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 (
+
+
+
+ {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 (
+
+
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: "#111",
+ }}
+ aria-expanded={open}
+ >
+ {q}
+ {open ? (
+
+ ) : (
+
+ )}
+
+ {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 (
-
-
- {SECTIONS.map((section) => (
-
-
+
+ GenDesign
+
+
+
+ Аналитика
+
+
+ Site Finder
+
+
setModalOpen(true)}
style={{
- fontSize: 13,
- textTransform: "uppercase",
- letterSpacing: 0.6,
- color: "#5b6066",
- margin: "0 0 8px",
+ padding: "7px 16px",
+ background: "#1d4ed8",
+ color: "#fff",
+ border: "none",
+ borderRadius: 8,
+ fontSize: 14,
+ fontWeight: 500,
+ cursor: "pointer",
}}
>
- {section.title}
-
+ Запросить пилот
+
+
+
+
+ {/* ── 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}
+
+
+ ))}
+
+
+
setModalOpen(true)}
+ style={{
+ width: "100%",
+ padding: "12px 24px",
+ background: "#1d4ed8",
+ color: "#fff",
+ border: "none",
+ borderRadius: 8,
+ fontSize: 15,
+ fontWeight: 600,
+ cursor: "pointer",
+ }}
+ >
+ Запросить пилот
+
+
+
+
+ {/* ── 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 */
+
+ )}
+
+ >
+ );
+}
+
+// ── Shared styles ──────────────────────────────────────────────────────────
+
+const labelStyle: React.CSSProperties = {
+ display: "block",
+ fontSize: 12,
+ fontWeight: 500,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.04em",
+ color: "#5b6066",
+ marginBottom: 6,
+};
+
+const inputStyle: React.CSSProperties = {
+ width: "100%",
+ padding: "9px 12px",
+ border: "1px solid #d1d5db",
+ borderRadius: 8,
+ fontSize: 14,
+ color: "#111",
+ background: "#fff",
+ outline: "none",
+ boxSizing: "border-box" as const,
+};
diff --git a/frontend/src/lib/api/landing.ts b/frontend/src/lib/api/landing.ts
new file mode 100644
index 00000000..31869eb5
--- /dev/null
+++ b/frontend/src/lib/api/landing.ts
@@ -0,0 +1,51 @@
+"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;
+}
+
+export interface PilotRequestPayload {
+ name: string;
+ phone?: string;
+ email?: string;
+ company?: string;
+ message?: string;
+ source: string;
+}
+
+export interface PilotRequestResponse {
+ id: number;
+ status: string;
+ tracking_id?: string;
+}
+
+// ── Hooks ──────────────────────────────────────────────────────────────────
+
+export function useLandingStats() {
+ return useQuery({
+ queryKey: ["landing", "stats"],
+ queryFn: () => apiFetch("/api/v1/landing/stats"),
+ staleTime: 60_000,
+ });
+}
+
+export function useSubmitPilotRequest() {
+ return useMutation({
+ mutationFn: (payload: PilotRequestPayload) =>
+ apiFetch("/api/v1/pilot/request", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ });
+}