From 014a98a79eccfb1a37950a8a5dddbd17d43297cf Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 07:43:31 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(sf-fe-a12):=20Landing=20redesign=20+?= =?UTF-8?q?=20PilotCTA=20=D1=81=20real=20data=20B3/B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero + 5 KPI cards из GET /api/v1/landing/stats (zk_total, deals_total, price_coverage_pct, mapping_coverage_pct, last_data_update). Парадокс портфеля из stats.paradox. Pricing card 30 000 руб/мес. FAQ accordion. PilotRequestModal -> POST /api/v1/pilot/request, success state с trackingId. Admin links убраны из public nav. lucide-react добавлен в deps. --- frontend/package-lock.json | 6 +- frontend/src/app/_legacy/page-tabs.tsx | 185 +++++ frontend/src/app/page.tsx | 631 ++++++++++++++---- .../components/landing/PilotRequestModal.tsx | 410 ++++++++++++ frontend/src/lib/api/landing.ts | 51 ++ 5 files changed, 1135 insertions(+), 148 deletions(-) create mode 100644 frontend/src/app/_legacy/page-tabs.tsx create mode 100644 frontend/src/components/landing/PilotRequestModal.tsx create mode 100644 frontend/src/lib/api/landing.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ce0a14f..7c330e20 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4196,9 +4196,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" diff --git a/frontend/src/app/_legacy/page-tabs.tsx b/frontend/src/app/_legacy/page-tabs.tsx new file mode 100644 index 00000000..75bff5fc --- /dev/null +++ b/frontend/src/app/_legacy/page-tabs.tsx @@ -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 ( +
+
+

GenDesign

+

+ Generative Design + Site Finder — Discovery MVP. Свердл рынок & + PRINZIP. +

+
+ + {SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.routes.map((r) => ( + +
+ {r.href} +
+
{r.label}
+
+ {r.desc} +
+ + ))} +
+
+ ))} + + +
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6e25ccae..c118484e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,188 +1,529 @@ -import Link from "next/link"; +"use client"; -interface Route { - href: string; - label: string; - desc: string; -} +import { useState } from "react"; -const SECTIONS: { title: string; routes: Route[] }[] = [ +import { + BarChart3, + Building2, + ChevronDown, + ChevronUp, + Database, + MapPin, + RefreshCw, +} from "lucide-react"; + +import { PilotRequestModal } from "@/components/landing/PilotRequestModal"; +import { useLandingStats } from "@/lib/api/landing"; + +// ── FAQ data ─────────────────────────────────────────────────────────────── + +const FAQ: { q: string; a: string }[] = [ { - 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 ЖК. Слайдеры под проект.", - }, - ], + q: "Откуда берутся данные?", + a: "Три источника: Росреестр (ДДУ-сделки, >7 млн записей), DOM.РФ (портфель новостроек, ПЗЗ), Objective (цены предложения). Обновление еженедельное для DOM.РФ и цен, ежеквартальное для Росреестра.", }, { - title: "Site Finder", - routes: [ - { - href: "/site-finder", - label: "Анализ участка по cad-номеру", - desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.", - }, - ], + q: "Насколько точны цены?", + a: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 5–15%.", }, { - title: "Админка (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 · таблица лидов с фильтрами.", - }, - ], + q: "Какие районы и регионы охвачены?", + a: "Сейчас — Екатеринбург и Свердловская область (ПЗЗ ЕКБ, МСК-66). Расширение на другие регионы УрФО в роудмапе.", }, { - title: "Generative (WIP)", - routes: [ - { - href: "/concept", - label: "Concept (Stage 1)", - desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.", - }, - ], + q: "Что входит в пилотный доступ?", + a: "Полный доступ к Site Finder (анализ участков по кадастровому номеру), аналитике рынка Свердловской области и рекомендатору квартирографии. Срок пилота — 30 дней. Поддержка по email.", + }, + { + q: "Как устроен маппинг Objective → DOM.РФ?", + a: "Объекты Objective сопоставляются с портфелем DOM.РФ по нечёткому совпадению названия и геокоординатам. Текущее покрытие — около 11% (один DOM.РФ-объект может объединять несколько Objective-лотов).", }, ]; +// ── Sub-components ───────────────────────────────────────────────────────── + +function FaqItem({ q, a }: { q: string; a: string }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +

+ {a} +

+ ) : null} +
+ ); +} + +// ── KPI formatting ───────────────────────────────────────────────────────── + +function formatNumber(n: number | undefined, decimals = 0): string { + if (n === undefined) return "—"; + return n.toLocaleString("ru", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +// ── Main component ───────────────────────────────────────────────────────── + export default function HomePage() { + const [modalOpen, setModalOpen] = useState(false); + const { data: stats, isLoading } = useLandingStats(); + return (
-
-

GenDesign

-

- Generative Design + Site Finder — Discovery MVP. Свердл рынок & - PRINZIP. -

-
- - {SECTIONS.map((section) => ( -
-

+ + GenDesign + +
+ + Аналитика + + + Site Finder + +

+ Запросить пилот + + + + + {/* ── Hero ───────────────────────────────────────────────────────── */} +
+

+ Цифры рынка перед фундаментом +

+

+ Аналитика новостроек Свердловской области — Росреестр, DOM.РФ, + Objective — в одном инструменте для девелоперов. +

+ + {/* ── KPI cards ──────────────────────────────────────────────── */} +
+ {/* zk_total */} +
+
+ +
+
ЖК DOM.РФ в ЕКБ
+
+ {isLoading ? "—" : formatNumber(stats?.zk_total)} +
+
+ + {/* deals_total */} +
+
+ +
+
ДДУ-сделки Росреестра
+
+ {isLoading ? "—" : formatNumber(stats?.deals_total)} +
+
+ + {/* price_coverage_pct */} +
+
+ +
+
Покрытие ценами Objective
+
+ {isLoading + ? "—" + : `${formatNumber(stats?.price_coverage_pct, 1)}%`} +
+
+ + {/* mapping_coverage_pct */} +
+
+ +
+
Маппинг Objective → DOM.РФ
+
+ {isLoading + ? "—" + : `${formatNumber(stats?.mapping_coverage_pct, 1)}%`} +
+
+ + {/* last_data_update */} +
+
+ +
+
Последнее обновление данных
+
+ {isLoading ? "—" : (stats?.last_data_update ?? "—")} +
+
+
+
+ + {/* ── Парадокс портфеля ──────────────────────────────────────────── */} +
+ {/* Headline-bar */} +
+
Парадокс портфеля
+
+ {isLoading + ? "Загрузка..." + : (stats?.paradox ?? + "из ЖК у большинства есть цены — но в публичном доступе единицы")} +
+
+ +

+ DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective + покрывают лишь часть из них. Из тех, где цены есть, публично доступны + единицы — остальное требует прямого запроса или данных, агрегированных + GenDesign. +

+
+ + {/* ── Pricing ────────────────────────────────────────────────────── */} +
+
- {section.routes.map((r) => ( - -
- {r.href} -
-
{r.label}
-
- {r.desc} -
- - ))} + Пилотная подписка +
+
+ + 30 000 + + ₽ / мес
-
- ))} +
    + {[ + "Site Finder — анализ участков по кадастру", + "Аналитика рынка Свердловской области", + "Рекомендатор квартирографии", + "Данные DOM.РФ, Росреестр, Objective", + "Email-поддержка в рабочие дни", + ].map((item) => ( +
  • + + + {item} + +
  • + ))} +
+ + + +
+ + {/* ── FAQ ────────────────────────────────────────────────────────── */} +
+

+ Частые вопросы +

+

+ Об источниках данных, точности и поддержке. +

+
+ {FAQ.map((item) => ( + + ))} +
+
+ + {/* ── Footer ─────────────────────────────────────────────────────── */} + + {/* ── Modal ──────────────────────────────────────────────────────── */} + setModalOpen(false)} />
); } + +// ── Shared styles ────────────────────────────────────────────────────────── + +const kpiCardStyle: React.CSSProperties = { + background: "#fff", + border: "1px solid #e6e8ec", + borderRadius: 12, + padding: "16px 18px", +}; + +const kpiIconWrap: React.CSSProperties = { + width: 28, + height: 28, + background: "#dbeafe", + borderRadius: 6, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 8, +}; + +const kpiLabel: React.CSSProperties = { + fontSize: 12, + textTransform: "uppercase", + letterSpacing: "0.04em", + color: "#5b6066", + fontWeight: 500, + marginBottom: 6, +}; + +const kpiValue: React.CSSProperties = { + fontSize: 28, + fontWeight: 600, + color: "#111", + fontVariantNumeric: "tabular-nums", +}; diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx new file mode 100644 index 00000000..9af21f06 --- /dev/null +++ b/frontend/src/components/landing/PilotRequestModal.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { useState } from "react"; + +import { CheckCircle, X } from "lucide-react"; + +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; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function PilotRequestModal({ open, onClose }: Props) { + const [form, setForm] = useState({ + name: "", + phone: "", + email: "", + company: "", + message: "", + }); + const [emailError, setEmailError] = useState(""); + const [trackingId, setTrackingId] = useState(null); + + const mutation = useSubmitPilotRequest(); + + if (!open) return null; + + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + function handleChange( + e: React.ChangeEvent, + ) { + 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) { + 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(data.tracking_id ?? String(data.id)); + }, + }); + } + + function handleClose() { + setForm({ name: "", phone: "", email: "", company: "", message: "" }); + setEmailError(""); + setTrackingId(null); + mutation.reset(); + onClose(); + } + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel — bottom-sheet on mobile, centered modal on desktop */} +
+ {/* Header */} +
+

+ Запросить пилотный доступ +

+ +
+ + {/* Success state */} + {trackingId !== null ? ( +
+ +

+ Заявка принята +

+

+ Мы свяжемся с вами в течение 1–2 рабочих дней. + {trackingId ? ( + <> + {" "} + Номер заявки:{" "} + + {trackingId} + + + ) : null} +

+ +
+ ) : ( + /* Form */ +
+
+ {/* Name */} +
+ + +
+ + {/* Company */} +
+ + +
+ + {/* Phone */} +
+ + +
+ + {/* Email */} +
+ + + {emailError ? ( + + {emailError} + + ) : null} +
+ + {/* Message */} +
+ +