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
5 changed files with 1135 additions and 148 deletions
Showing only changes of commit 014a98a79e - Show all commits

View file

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

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,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: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 515%.",
},
{
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 (
<div
style={{
borderBottom: "1px solid #eef0f3",
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: "#111",
}}
aria-expanded={open}
>
<span>{q}</span>
{open ? (
<ChevronUp size={18} strokeWidth={1.5} color="#5b6066" />
) : (
<ChevronDown size={18} strokeWidth={1.5} color="#5b6066" />
)}
</button>
{open ? (
<p
style={{
margin: "0 0 4px",
fontSize: 14,
color: "#5b6066",
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,
});
}
// ── Main component ─────────────────────────────────────────────────────────
export default function HomePage() {
const [modalOpen, setModalOpen] = useState(false);
const { data: stats, isLoading } = useLandingStats();
return (
<main
style={{
padding: 24,
maxWidth: 980,
margin: "0 auto",
fontFamily:
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
background: "#f6f7f9",
minHeight: "100vh",
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
fontVariantNumeric: "tabular-nums",
}}
>
<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
{/* ── Nav ────────────────────────────────────────────────────────── */}
<nav
style={{
background: "#fff",
borderBottom: "1px solid #e6e8ec",
padding: "0 24px",
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "sticky",
top: 0,
zIndex: 100,
}}
>
<span style={{ fontWeight: 700, fontSize: 17, color: "#111" }}>
GenDesign
</span>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<a
href="/analytics"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }}
>
Аналитика
</a>
<a
href="/site-finder"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }}
>
Site Finder
</a>
<button
onClick={() => 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}
</h2>
Запросить пилот
</button>
</div>
</nav>
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section
style={{
maxWidth: 1280,
margin: "0 auto",
padding: "64px 24px 48px",
}}
>
<h1
style={{
margin: 0,
fontSize: 32,
fontWeight: 600,
color: "#111",
lineHeight: 1.25,
maxWidth: 640,
}}
>
Цифры рынка перед фундаментом
</h1>
<p
style={{
margin: "12px 0 0",
fontSize: 16,
color: "#5b6066",
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="#1d4ed8" />
</div>
<div style={kpiLabel}>ЖК DOM.РФ в ЕКБ</div>
<div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.zk_total)}
</div>
</div>
{/* deals_total */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<BarChart3 size={16} strokeWidth={1.5} color="#1d4ed8" />
</div>
<div style={kpiLabel}>ДДУ-сделки Росреестра</div>
<div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.deals_total)}
</div>
</div>
{/* price_coverage_pct */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<Database size={16} strokeWidth={1.5} color="#1d4ed8" />
</div>
<div style={kpiLabel}>Покрытие ценами Objective</div>
<div style={kpiValue}>
{isLoading
? "—"
: `${formatNumber(stats?.price_coverage_pct, 1)}%`}
</div>
</div>
{/* mapping_coverage_pct */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<MapPin size={16} strokeWidth={1.5} color="#1d4ed8" />
</div>
<div style={kpiLabel}>Маппинг Objective DOM.РФ</div>
<div style={kpiValue}>
{isLoading
? "—"
: `${formatNumber(stats?.mapping_coverage_pct, 1)}%`}
</div>
</div>
{/* last_data_update */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<RefreshCw size={16} strokeWidth={1.5} color="#1d4ed8" />
</div>
<div style={kpiLabel}>Последнее обновление данных</div>
<div
style={{
...kpiValue,
fontSize: 18,
fontVariantNumeric: "tabular-nums",
}}
>
{isLoading ? "—" : (stats?.last_data_update ?? "—")}
</div>
</div>
</div>
</section>
{/* ── Парадокс портфеля ──────────────────────────────────────────── */}
<section
style={{
maxWidth: 1280,
margin: "0 auto 0",
padding: "0 24px 48px",
}}
>
{/* Headline-bar */}
<div
style={{
background: "#0f172a",
color: "#e2e8f0",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
marginBottom: 16,
}}
>
<div style={{ fontWeight: 600 }}>Парадокс портфеля</div>
<div style={{ marginTop: 6, fontSize: 14, color: "#94a3b8" }}>
{isLoading
? "Загрузка..."
: (stats?.paradox ??
"из ЖК у большинства есть цены — но в публичном доступе единицы")}
</div>
</div>
<p
style={{ margin: 0, fontSize: 14, color: "#5b6066", lineHeight: 1.6 }}
>
DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective
покрывают лишь часть из них. Из тех, где цены есть, публично доступны
единицы остальное требует прямого запроса или данных, агрегированных
GenDesign.
</p>
</section>
{/* ── Pricing ────────────────────────────────────────────────────── */}
<section
style={{
maxWidth: 1280,
margin: "0 auto",
padding: "0 24px 48px",
}}
>
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 16,
padding: "32px",
maxWidth: 480,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 12,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
marginBottom: 8,
}}
>
{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>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
marginBottom: 16,
}}
>
<span
style={{
fontSize: 36,
fontWeight: 700,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
30 000
</span>
<span style={{ fontSize: 16, color: "#5b6066" }}> / мес</span>
</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: "#1d4ed8",
marginTop: 8,
flexShrink: 0,
}}
/>
<span
style={{ fontSize: 14, color: "#5b6066", lineHeight: 1.5 }}
>
{item}
</span>
</li>
))}
</ul>
<button
onClick={() => setModalOpen(true)}
style={{
width: "100%",
padding: "12px 24px",
background: "#1d4ed8",
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: "#111",
}}
>
Частые вопросы
</h2>
<p style={{ margin: "0 0 20px", fontSize: 14, color: "#5b6066" }}>
Об источниках данных, точности и поддержке.
</p>
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "0 20px",
maxWidth: 720,
}}
>
{FAQ.map((item) => (
<FaqItem key={item.q} q={item.q} a={item.a} />
))}
</div>
</section>
{/* ── Footer ─────────────────────────────────────────────────────── */}
<footer
style={{
marginTop: 48,
paddingTop: 20,
borderTop: "1px solid #e6e8ec",
background: "#fff",
padding: "20px 24px",
fontSize: 12,
color: "#73767e",
display: "flex",
gap: 16,
flexWrap: "wrap",
}}
>
<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>
<a
href="https://github.com/lekss361/-gendesign"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
GitHub
OpenAPI
</a>
<a href="/health" style={{ color: "#1d4ed8", textDecoration: "none" }}>
/health
</a>
<span>GenDesign {new Date().getFullYear()}</span>
</footer>
{/* ── Modal ──────────────────────────────────────────────────────── */}
<PilotRequestModal open={modalOpen} onClose={() => setModalOpen(false)} />
</main>
);
}
// ── 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",
};

View file

@ -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<FormState>({
name: "",
phone: "",
email: "",
company: "",
message: "",
});
const [emailError, setEmailError] = useState<string>("");
const [trackingId, setTrackingId] = useState<string | null>(null);
const mutation = useSubmitPilotRequest();
if (!open) return null;
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(data.tracking_id ?? String(data.id));
},
});
}
function handleClose() {
setForm({ name: "", phone: "", email: "", company: "", message: "" });
setEmailError("");
setTrackingId(null);
mutation.reset();
onClose();
}
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
zIndex: 1000,
}}
/>
{/* Panel — bottom-sheet on mobile, centered modal on desktop */}
<div
role="dialog"
aria-modal="true"
aria-label="Запрос пилотного доступа"
style={{
position: "fixed",
zIndex: 1001,
background: "#fff",
borderRadius: 16,
// Mobile: bottom sheet
bottom: 0,
left: 0,
right: 0,
// Desktop override via media query workaround — inline style wins, so we
// use a wrapper approach: the outer element covers full screen on mobile,
// and a max-width container is constrained. We centre via transform.
maxWidth: 560,
margin: "0 auto",
padding: "24px 24px 32px",
boxShadow: "0 -4px 24px rgba(0,0,0,0.12)",
overflowY: "auto",
maxHeight: "90vh",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
}}
>
<h2
style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111" }}
>
Запросить пилотный доступ
</h2>
<button
onClick={handleClose}
aria-label="Закрыть"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
color: "#5b6066",
display: "flex",
alignItems: "center",
}}
>
<X size={20} strokeWidth={1.5} />
</button>
</div>
{/* Success state */}
{trackingId !== null ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 12,
padding: "24px 0",
}}
>
<CheckCircle size={40} color="#0a7a3a" strokeWidth={1.5} />
<p
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "#111",
}}
>
Заявка принята
</p>
<p
style={{
margin: 0,
fontSize: 14,
color: "#5b6066",
lineHeight: 1.5,
}}
>
Мы свяжемся с вами в течение 12 рабочих дней.
{trackingId ? (
<>
{" "}
Номер заявки:{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
color: "#111",
}}
>
{trackingId}
</span>
</>
) : null}
</p>
<button
onClick={handleClose}
style={{
marginTop: 8,
padding: "8px 20px",
background: "#1d4ed8",
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 ? "#b3261e" : "#d1d5db",
}}
/>
{emailError ? (
<span
style={{
fontSize: 12,
color: "#b3261e",
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: "#73767e",
marginTop: 2,
display: "block",
}}
>
{form.message.length} / 2000
</span>
</div>
{/* Error */}
{mutation.isError ? (
<div
style={{
padding: "10px 14px",
background: "#fee2e2",
borderRadius: 8,
fontSize: 13,
color: "#b3261e",
}}
>
{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()
? "#93c5fd"
: "#1d4ed8",
color: "#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>
</>
);
}
// ── 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,
};

View file

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