feat(sf-fe-a12): Landing redesign + PilotCTA с real B3/B4 data (v2 rebased) #367

Merged
lekss361 merged 2 commits from feat/sf-fe-a12-landing-redesign-v2 into main 2026-05-18 05:16:58 +00:00
6 changed files with 1198 additions and 161 deletions

View file

@ -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)

View file

@ -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"

View 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. Свердл рынок &amp;
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>
);
}

View file

@ -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: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 515%.",
{
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. Свердл рынок &amp; 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",
};

View 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,
}}
>
Мы свяжемся с вами в течение 12 рабочих дней. Номер заявки:{" "}
<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,
};

View 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),
}),
});
}