feat(sf-fe-a12): Landing redesign + PilotCTA с real B3/B4 data (v2 rebased) #367
6 changed files with 1198 additions and 161 deletions
|
|
@ -7,6 +7,7 @@ from datetime import date
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -16,17 +17,34 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fallback defaults (на случай пустой dev БД)
|
# Response schema
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_FALLBACK: dict[str, Any] = {
|
|
||||||
"zk_total": 1500,
|
class LandingStatsOut(BaseModel):
|
||||||
"deals_total": 6830000,
|
zk_total: int
|
||||||
"price_coverage_pct": 81.4,
|
deals_total: int
|
||||||
"mapping_coverage_pct": 13.3,
|
price_coverage_pct: float
|
||||||
"last_data_update": "2026-05-17",
|
mapping_coverage_pct: float
|
||||||
"paradox": ("из 1500 ЖК у 81% есть цены — но в публичном доступе только 0.3%"),
|
last_data_update: str
|
||||||
|
paradox: str
|
||||||
|
stale: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fallback defaults (на случай пустой dev БД или ошибки запроса)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FALLBACK_DATA: dict[str, Any] = {
|
||||||
|
"zk_total": 0,
|
||||||
|
"deals_total": 0,
|
||||||
|
"price_coverage_pct": 0.0,
|
||||||
|
"mapping_coverage_pct": 0.0,
|
||||||
|
"last_data_update": "",
|
||||||
|
"paradox": "",
|
||||||
|
"stale": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -87,9 +105,9 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
||||||
else:
|
else:
|
||||||
last_data_update = str(date.today())
|
last_data_update = str(date.today())
|
||||||
|
|
||||||
# Если все KPI нули — скорее всего пустая БД, лучше вернуть None → fallback
|
# Если все KPI нули — скорее всего пустая БД → stale
|
||||||
if zk_total == 0 and deals_total == 0:
|
if zk_total == 0 and deals_total == 0:
|
||||||
logger.warning("landing/stats: all KPIs are zero — using fallback")
|
logger.warning("landing/stats: all KPIs are zero — returning stale=True")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -102,16 +120,17 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
||||||
f"из {zk_total} ЖК у {price_coverage_pct}% есть цены"
|
f"из {zk_total} ЖК у {price_coverage_pct}% есть цены"
|
||||||
" — но в публичном доступе только 0.3%"
|
" — но в публичном доступе только 0.3%"
|
||||||
),
|
),
|
||||||
|
"stale": False,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("landing/stats: DB query failed, returning fallback")
|
logger.exception("landing/stats: DB query failed, returning stale=True")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/landing/stats")
|
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||||||
def landing_stats(
|
def landing_stats(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
) -> dict[str, Any]:
|
) -> LandingStatsOut:
|
||||||
"""5 KPI для hero-секции лендинга.
|
"""5 KPI для hero-секции лендинга.
|
||||||
|
|
||||||
Поля ответа:
|
Поля ответа:
|
||||||
|
|
@ -121,8 +140,9 @@ def landing_stats(
|
||||||
- mapping_coverage_pct: % маппинга objective → domrf_kn_objects (ЕКБ)
|
- mapping_coverage_pct: % маппинга objective → domrf_kn_objects (ЕКБ)
|
||||||
- last_data_update: дата последнего обновления данных (YYYY-MM-DD)
|
- last_data_update: дата последнего обновления данных (YYYY-MM-DD)
|
||||||
- paradox: строка-парадокс портфеля для hero CTA
|
- paradox: строка-парадокс портфеля для hero CTA
|
||||||
|
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||||||
"""
|
"""
|
||||||
result = _query_stats(db)
|
result = _query_stats(db)
|
||||||
if result is None:
|
if result is None:
|
||||||
return _FALLBACK
|
return LandingStatsOut(**_FALLBACK_DATA)
|
||||||
return result
|
return LandingStatsOut(**result)
|
||||||
|
|
|
||||||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -4196,9 +4196,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.29",
|
"version": "2.10.30",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
|
||||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
"integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
|
|
||||||
185
frontend/src/app/_legacy/page-tabs.tsx
Normal file
185
frontend/src/app/_legacy/page-tabs.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// Legacy landing — navigation portal (pre-A12 redesign 2026-05-18).
|
||||||
|
// Kept for rollback reference. Not routed — use /app/page.tsx.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: { title: string; routes: Route[] }[] = [
|
||||||
|
{
|
||||||
|
title: "Аналитика",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
href: "/analytics",
|
||||||
|
label: "Свердл рынок",
|
||||||
|
desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/analytics/prinzip",
|
||||||
|
label: "PRINZIP drill-down",
|
||||||
|
desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/analytics/developers",
|
||||||
|
label: "Топ девелоперов",
|
||||||
|
desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/analytics/objects/60160",
|
||||||
|
label: "Карточка ЖК (пример: Парк Победы)",
|
||||||
|
desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/analytics/recommend",
|
||||||
|
label: "Рекомендатор квартирографии",
|
||||||
|
desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Site Finder",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
href: "/site-finder",
|
||||||
|
label: "Анализ участка по cad-номеру",
|
||||||
|
desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Adminка (Token-protected)",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
href: "/admin/scrape/all",
|
||||||
|
label: "Скрапер — единая панель",
|
||||||
|
desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/leads",
|
||||||
|
label: "Лиды PRINZIP CRM",
|
||||||
|
desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Generative (WIP)",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
href: "/concept",
|
||||||
|
label: "Concept (Stage 1)",
|
||||||
|
desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomePageLegacy() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
maxWidth: 980,
|
||||||
|
margin: "0 auto",
|
||||||
|
fontFamily:
|
||||||
|
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header style={{ marginBottom: 24 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 28 }}>GenDesign</h1>
|
||||||
|
<p style={{ margin: "6px 0 0", color: "#5b6066", fontSize: 14 }}>
|
||||||
|
Generative Design + Site Finder — Discovery MVP. Свердл рынок &
|
||||||
|
PRINZIP.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<section key={section.title} style={{ marginTop: 24 }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: "#5b6066",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.routes.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r.href}
|
||||||
|
href={r.href}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
padding: "14px 16px",
|
||||||
|
border: "1px solid #e6e8ec",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#fff",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "#1f2937",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#1d4ed8",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.href}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15 }}>{r.label}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#5b6066",
|
||||||
|
marginTop: 6,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.desc}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
marginTop: 48,
|
||||||
|
paddingTop: 20,
|
||||||
|
borderTop: "1px solid #e6e8ec",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#73767e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/api/v1/docs"
|
||||||
|
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
|
||||||
|
>
|
||||||
|
OpenAPI / Swagger
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/health"
|
||||||
|
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
|
||||||
|
>
|
||||||
|
/health
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,188 +1,595 @@
|
||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
interface Route {
|
import { useState } from "react";
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
desc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTIONS: { title: string; routes: Route[] }[] = [
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Building2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Database,
|
||||||
|
MapPin,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
|
||||||
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
|
import { useLandingStats } from "@/lib/api/landing";
|
||||||
|
|
||||||
|
// ── FAQ data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FAQ: { q: string; a: string }[] = [
|
||||||
{
|
{
|
||||||
title: "Аналитика",
|
q: "Откуда берутся данные?",
|
||||||
routes: [
|
a: "Три источника: Росреестр (ДДУ-сделки, >7 млн записей), DOM.РФ (портфель новостроек, ПЗЗ), Objective (цены предложения). Обновление еженедельное для DOM.РФ и цен, ежеквартальное для Росреестра.",
|
||||||
{
|
|
||||||
href: "/analytics",
|
|
||||||
label: "Свердл рынок",
|
|
||||||
desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/analytics/prinzip",
|
|
||||||
label: "PRINZIP drill-down",
|
|
||||||
desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/analytics/developers",
|
|
||||||
label: "Топ девелоперов",
|
|
||||||
desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/analytics/objects/60160",
|
|
||||||
label: "Карточка ЖК (пример: Парк Победы)",
|
|
||||||
desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/analytics/recommend",
|
|
||||||
label: "Рекомендатор квартирографии",
|
|
||||||
desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Site Finder",
|
q: "Насколько точны цены?",
|
||||||
routes: [
|
a: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 5–15%.",
|
||||||
{
|
|
||||||
href: "/site-finder",
|
|
||||||
label: "Анализ участка по cad-номеру",
|
|
||||||
desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Админка (Token-protected)",
|
q: "Какие районы и регионы охвачены?",
|
||||||
routes: [
|
a: "Сейчас — Екатеринбург и Свердловская область (ПЗЗ ЕКБ, МСК-66). Расширение на другие регионы УрФО в роудмапе.",
|
||||||
{
|
|
||||||
href: "/admin/scrape/all",
|
|
||||||
label: "Скрапер — единая панель",
|
|
||||||
desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/admin/leads",
|
|
||||||
label: "Лиды PRINZIP CRM",
|
|
||||||
desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Generative (WIP)",
|
q: "Что входит в пилотный доступ?",
|
||||||
routes: [
|
a: "Полный доступ к Site Finder (анализ участков по кадастровому номеру), аналитике рынка Свердловской области и рекомендатору квартирографии. Срок пилота — 30 дней. Поддержка по email.",
|
||||||
{
|
},
|
||||||
href: "/concept",
|
{
|
||||||
label: "Concept (Stage 1)",
|
q: "Как устроен маппинг Objective → DOM.РФ?",
|
||||||
desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.",
|
a: "Объекты Objective сопоставляются с портфелем DOM.РФ по нечёткому совпадению названия и геокоординатам. Текущее покрытие — около 11% (один DOM.РФ-объект может объединять несколько Objective-лотов).",
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Sub-components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FaqItem({ q, a }: { q: string; a: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border-soft)",
|
||||||
|
paddingBottom: open ? 16 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: "16px 0",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span>{q}</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp size={18} strokeWidth={1.5} color="var(--fg-secondary)" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color="var(--fg-secondary)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KPI formatting ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatNumber(n: number | undefined, decimals = 0): string {
|
||||||
|
if (n === undefined) return "—";
|
||||||
|
return n.toLocaleString("ru", {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton placeholder shown when stale=true (no fake data) */
|
||||||
|
function KpiSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label="Данные обновляются..."
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 80,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "var(--border-card)",
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const { data: stats, isLoading } = useLandingStats();
|
||||||
|
|
||||||
|
const isStale = stats?.stale === true;
|
||||||
|
const showSkeleton = isLoading || isStale;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
padding: 24,
|
background: "var(--bg-app)",
|
||||||
maxWidth: 980,
|
minHeight: "100vh",
|
||||||
margin: "0 auto",
|
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
||||||
fontFamily:
|
fontVariantNumeric: "tabular-nums",
|
||||||
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<header style={{ marginBottom: 24 }}>
|
{/* ── Nav ────────────────────────────────────────────────────────── */}
|
||||||
<h1 style={{ margin: 0, fontSize: 28 }}>GenDesign</h1>
|
<nav
|
||||||
<p style={{ margin: "6px 0 0", color: "#5b6066", fontSize: 14 }}>
|
style={{
|
||||||
Generative Design + Site Finder — Discovery MVP. Свердл рынок &
|
background: "var(--bg-card)",
|
||||||
PRINZIP.
|
borderBottom: "1px solid var(--border-card)",
|
||||||
</p>
|
padding: "0 24px",
|
||||||
</header>
|
height: 56,
|
||||||
|
display: "flex",
|
||||||
{SECTIONS.map((section) => (
|
alignItems: "center",
|
||||||
<section key={section.title} style={{ marginTop: 24 }}>
|
justifyContent: "space-between",
|
||||||
<h2
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ fontWeight: 700, fontSize: 17, color: "var(--fg-primary)" }}
|
||||||
|
>
|
||||||
|
GenDesign
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
|
||||||
|
<a
|
||||||
|
href="/analytics"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
textTransform: "uppercase",
|
color: "var(--fg-secondary)",
|
||||||
letterSpacing: 0.6,
|
textDecoration: "none",
|
||||||
color: "#5b6066",
|
|
||||||
margin: "0 0 8px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section.title}
|
Аналитика
|
||||||
</h2>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/site-finder"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site Finder
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: "7px 16px",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Запросить пилот
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── Hero ───────────────────────────────────────────────────────── */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: 1280,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "64px 24px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
lineHeight: 1.25,
|
||||||
|
maxWidth: 640,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Цифры рынка перед фундаментом
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "12px 0 0",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Аналитика новостроек Свердловской области — Росреестр, DOM.РФ,
|
||||||
|
Objective — в одном инструменте для девелоперов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── KPI cards ──────────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* zk_total */}
|
||||||
|
<div style={kpiCardStyle}>
|
||||||
|
<div style={kpiIconWrap}>
|
||||||
|
<Building2 size={16} strokeWidth={1.5} color="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div style={kpiLabel}>ЖК DOM.РФ в ЕКБ</div>
|
||||||
|
<div style={kpiValue}>
|
||||||
|
{showSkeleton ? <KpiSkeleton /> : formatNumber(stats?.zk_total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* deals_total */}
|
||||||
|
<div style={kpiCardStyle}>
|
||||||
|
<div style={kpiIconWrap}>
|
||||||
|
<BarChart3 size={16} strokeWidth={1.5} color="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div style={kpiLabel}>ДДУ-сделки Росреестра</div>
|
||||||
|
<div style={kpiValue}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<KpiSkeleton />
|
||||||
|
) : (
|
||||||
|
formatNumber(stats?.deals_total)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* price_coverage_pct */}
|
||||||
|
<div style={kpiCardStyle}>
|
||||||
|
<div style={kpiIconWrap}>
|
||||||
|
<Database size={16} strokeWidth={1.5} color="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div style={kpiLabel}>Покрытие ценами Objective</div>
|
||||||
|
<div style={kpiValue}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<KpiSkeleton />
|
||||||
|
) : (
|
||||||
|
`${formatNumber(stats?.price_coverage_pct, 1)}%`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* mapping_coverage_pct */}
|
||||||
|
<div style={kpiCardStyle}>
|
||||||
|
<div style={kpiIconWrap}>
|
||||||
|
<MapPin size={16} strokeWidth={1.5} color="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div style={kpiLabel}>Маппинг Objective → DOM.РФ</div>
|
||||||
|
<div style={kpiValue}>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<KpiSkeleton />
|
||||||
|
) : (
|
||||||
|
`${formatNumber(stats?.mapping_coverage_pct, 1)}%`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* last_data_update */}
|
||||||
|
<div style={kpiCardStyle}>
|
||||||
|
<div style={kpiIconWrap}>
|
||||||
|
<RefreshCw size={16} strokeWidth={1.5} color="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div style={kpiLabel}>Последнее обновление данных</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...kpiValue,
|
||||||
|
fontSize: 18,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<KpiSkeleton />
|
||||||
|
) : (
|
||||||
|
(stats?.last_data_update ?? "—")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStale && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Данные обновляются...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Парадокс портфеля ──────────────────────────────────────────── */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: 1280,
|
||||||
|
margin: "0 auto 0",
|
||||||
|
padding: "0 24px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeadlineBar
|
||||||
|
title="Парадокс портфеля"
|
||||||
|
subtitle={
|
||||||
|
showSkeleton
|
||||||
|
? "Данные обновляются..."
|
||||||
|
: (stats?.paradox ??
|
||||||
|
"из ЖК у большинства есть цены — но в публичном доступе единицы")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "16px 0 0",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective
|
||||||
|
покрывают лишь часть из них. Из тех, где цены есть, публично доступны
|
||||||
|
единицы — остальное требует прямого запроса или данных, агрегированных
|
||||||
|
GenDesign.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Pricing ────────────────────────────────────────────────────── */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: 1280,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "0 24px 48px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: "32px",
|
||||||
|
maxWidth: 480,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
fontSize: 12,
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
textTransform: "uppercase",
|
||||||
gap: 12,
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{section.routes.map((r) => (
|
Пилотная подписка
|
||||||
<Link
|
</div>
|
||||||
key={r.href}
|
<div
|
||||||
href={r.href}
|
style={{
|
||||||
style={{
|
display: "flex",
|
||||||
display: "block",
|
alignItems: "baseline",
|
||||||
padding: "14px 16px",
|
gap: 8,
|
||||||
border: "1px solid #e6e8ec",
|
marginBottom: 16,
|
||||||
borderRadius: 12,
|
}}
|
||||||
background: "#fff",
|
>
|
||||||
textDecoration: "none",
|
<span
|
||||||
color: "#1f2937",
|
style={{
|
||||||
transition: "all 0.15s",
|
fontSize: 36,
|
||||||
}}
|
fontWeight: 700,
|
||||||
>
|
color: "var(--fg-primary)",
|
||||||
<div
|
fontVariantNumeric: "tabular-nums",
|
||||||
style={{
|
}}
|
||||||
fontFamily: "monospace",
|
>
|
||||||
fontSize: 12,
|
30 000
|
||||||
color: "#1d4ed8",
|
</span>
|
||||||
marginBottom: 4,
|
<span style={{ fontSize: 16, color: "var(--fg-secondary)" }}>
|
||||||
}}
|
₽ / мес
|
||||||
>
|
</span>
|
||||||
{r.href}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>{r.label}</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#5b6066",
|
|
||||||
marginTop: 6,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.desc}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: "0 0 24px",
|
||||||
|
padding: 0,
|
||||||
|
listStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
"Site Finder — анализ участков по кадастру",
|
||||||
|
"Аналитика рынка Свердловской области",
|
||||||
|
"Рекомендатор квартирографии",
|
||||||
|
"Данные DOM.РФ, Росреестр, Objective",
|
||||||
|
"Email-поддержка в рабочие дни",
|
||||||
|
].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
style={{ display: "flex", gap: 8, alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--accent)",
|
||||||
|
marginTop: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 24px",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Запросить пилот
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── FAQ ────────────────────────────────────────────────────────── */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
maxWidth: 1280,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "0 24px 64px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Частые вопросы
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 20px",
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Об источниках данных, точности и поддержке.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "0 20px",
|
||||||
|
maxWidth: 720,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FAQ.map((item) => (
|
||||||
|
<FaqItem key={item.q} q={item.q} a={item.a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Footer ─────────────────────────────────────────────────────── */}
|
||||||
<footer
|
<footer
|
||||||
style={{
|
style={{
|
||||||
marginTop: 48,
|
borderTop: "1px solid var(--border-card)",
|
||||||
paddingTop: 20,
|
background: "var(--bg-card)",
|
||||||
borderTop: "1px solid #e6e8ec",
|
padding: "20px 24px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#73767e",
|
color: "var(--fg-tertiary)",
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/api/v1/docs"
|
href="/api/v1/docs"
|
||||||
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
|
style={{ color: "var(--accent)", textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
OpenAPI / Swagger
|
OpenAPI
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/health"
|
href="/health"
|
||||||
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
|
style={{ color: "var(--accent)", textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
/health
|
/health
|
||||||
</a>
|
</a>
|
||||||
<a
|
<span>GenDesign {new Date().getFullYear()}</span>
|
||||||
href="https://github.com/lekss361/-gendesign"
|
|
||||||
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* ── Modal ──────────────────────────────────────────────────────── */}
|
||||||
|
<PilotRequestModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared styles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const kpiCardStyle: React.CSSProperties = {
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "16px 18px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpiIconWrap: React.CSSProperties = {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: "var(--accent-soft)",
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpiLabel: React.CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpiValue: React.CSSProperties = {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
};
|
||||||
|
|
|
||||||
371
frontend/src/components/landing/PilotRequestModal.tsx
Normal file
371
frontend/src/components/landing/PilotRequestModal.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { CheckCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { Drawer } from "@/components/ui/Drawer";
|
||||||
|
import {
|
||||||
|
useSubmitPilotRequest,
|
||||||
|
type PilotRequestPayload,
|
||||||
|
} from "@/lib/api/landing";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
company: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Derives a human-readable tracking ID from the UUID string returned by backend.
|
||||||
|
* Example: "7f3a8c2e-..." → "GD-7F3A8C2E"
|
||||||
|
*/
|
||||||
|
function deriveTrackingId(id: string): string {
|
||||||
|
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PilotRequestModal({ open, onClose }: Props) {
|
||||||
|
const [form, setForm] = useState<FormState>({
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
company: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
const [emailError, setEmailError] = useState<string>("");
|
||||||
|
const [trackingId, setTrackingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useSubmitPilotRequest();
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function handleChange(
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
if (name === "email") {
|
||||||
|
setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
if (form.email && !EMAIL_RE.test(form.email)) {
|
||||||
|
setEmailError("Некорректный email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: PilotRequestPayload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
source: "landing",
|
||||||
|
...(form.phone.trim() ? { phone: form.phone.trim() } : {}),
|
||||||
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||||
|
...(form.company.trim() ? { company: form.company.trim() } : {}),
|
||||||
|
...(form.message.trim()
|
||||||
|
? { message: form.message.trim().slice(0, 2000) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
mutation.mutate(payload, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setTrackingId(deriveTrackingId(data.id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setForm({ name: "", phone: "", email: "", company: "", message: "" });
|
||||||
|
setEmailError("");
|
||||||
|
setTrackingId(null);
|
||||||
|
mutation.reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onClose={handleClose} side="bottom">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "20px 24px 0",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Запросить пилотный доступ
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "0 24px 32px" }}>
|
||||||
|
{/* Success state */}
|
||||||
|
{trackingId !== null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 12,
|
||||||
|
padding: "24px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle size={40} color="var(--success)" strokeWidth={1.5} />
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заявка принята
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trackingId}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: "8px 20px",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Form */
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pilot-name" style={labelStyle}>
|
||||||
|
Имя *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pilot-name"
|
||||||
|
name="name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="Алексей Кириллов"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pilot-company" style={labelStyle}>
|
||||||
|
Компания
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pilot-company"
|
||||||
|
name="company"
|
||||||
|
value={form.company}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="PRINZIP"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pilot-phone" style={labelStyle}>
|
||||||
|
Телефон
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pilot-phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+7 900 000-00-00"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pilot-email" style={labelStyle}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pilot-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="alex@prinzip.ru"
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
borderColor: emailError
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--border-strong)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{emailError ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--danger)",
|
||||||
|
marginTop: 4,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{emailError}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pilot-message" style={labelStyle}>
|
||||||
|
Сообщение
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="pilot-message"
|
||||||
|
name="message"
|
||||||
|
value={form.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Расскажите о вашем проекте или вопросе..."
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
resize: "vertical",
|
||||||
|
minHeight: 96,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.message.length} / 2000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{mutation.isError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--danger-soft)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.error instanceof Error
|
||||||
|
? mutation.error.message
|
||||||
|
: "Ошибка отправки. Попробуйте позже."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending || !form.name.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px",
|
||||||
|
background:
|
||||||
|
mutation.isPending || !form.name.trim()
|
||||||
|
? "var(--accent-soft)"
|
||||||
|
: "var(--accent)",
|
||||||
|
color:
|
||||||
|
mutation.isPending || !form.name.trim()
|
||||||
|
? "var(--accent)"
|
||||||
|
: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor:
|
||||||
|
mutation.isPending || !form.name.trim()
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
opacity: mutation.isPending || !form.name.trim() ? 0.7 : 1,
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Отправка..." : "Отправить заявку"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared styles ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "9px 12px",
|
||||||
|
border: "1px solid var(--border-strong)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box" as const,
|
||||||
|
};
|
||||||
54
frontend/src/lib/api/landing.ts
Normal file
54
frontend/src/lib/api/landing.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LandingStats {
|
||||||
|
zk_total: number;
|
||||||
|
deals_total: number;
|
||||||
|
price_coverage_pct: number;
|
||||||
|
mapping_coverage_pct: number;
|
||||||
|
last_data_update: string;
|
||||||
|
paradox: string;
|
||||||
|
/** true when DB is unavailable or all-zero — frontend shows skeleton */
|
||||||
|
stale?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PilotRequestPayload {
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
company?: string;
|
||||||
|
message?: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PilotRequestResponse {
|
||||||
|
/** UUID returned as string via CAST(id AS text) */
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useLandingStats() {
|
||||||
|
return useQuery<LandingStats>({
|
||||||
|
queryKey: ["landing", "stats"],
|
||||||
|
queryFn: () => apiFetch<LandingStats>("/api/v1/landing/stats"),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitPilotRequest() {
|
||||||
|
return useMutation<PilotRequestResponse, Error, PilotRequestPayload>({
|
||||||
|
mutationFn: (payload: PilotRequestPayload) =>
|
||||||
|
apiFetch<PilotRequestResponse>("/api/v1/pilot/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue