gendesign/frontend/src/app/page.tsx
Light1YT 8107c23f18
Some checks failed
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 1m3s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 50s
Deploy / changes (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
feat(frontend): mobile responsiveness (#66) + data-sources страница (#80)
#66: responsive CSS-layer в globals.css (3-контекста <768/768-1280/>1280, не Tailwind
sm/md/lg) + ResizeObserver в ChartShell (единственный echarts импортёр → все charts
рефлоу) + class-применение (grid-collapse, nav horizontal-scroll, map-stack) на
site-finder/analytics. overflow-x guard.
#80: /data-sources attribution-страница (OSM ODbL/ДОМ.РФ/Объектив/Open-Meteo/Росреестр/
НСПД, server component, tokens) + footer-link.

Closes #66
Closes #80
2026-06-13 23:12:31 +05:00

759 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import {
BarChart3,
Building2,
Check,
ChevronDown,
ChevronUp,
Database,
MapPin,
Minus,
RefreshCw,
} from "lucide-react";
import { UserMenu } from "@/components/auth/UserMenu";
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
import { TopNav } from "@/components/landing/TopNav";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { useLandingStats } from "@/lib/api/landing";
// ── FAQ data ───────────────────────────────────────────────────────────────
const FAQ: { q: string; a: string }[] = [
{
q: "Откуда берутся данные?",
a: "Три источника: Росреестр (ДДУ-сделки, >7 млн записей), DOM.РФ (портфель новостроек, ПЗЗ), Objective (цены предложения). Обновление еженедельное для DOM.РФ и цен, ежеквартальное для Росреестра.",
},
{
q: "Насколько точны цены?",
a: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 515%.",
},
{
q: "Какие районы и регионы охвачены?",
a: "Сейчас — Екатеринбург и Свердловская область (ПЗЗ ЕКБ, МСК-66). Расширение на другие регионы УрФО в роудмапе.",
},
{
q: "Что входит в пилотный доступ?",
a: "Полный доступ к Site Finder (анализ участков по кадастровому номеру), аналитике рынка Свердловской области и рекомендатору квартирографии. Срок пилота — 30 дней. Поддержка по email.",
},
{
q: "Как устроен маппинг Objective → DOM.РФ?",
a: "Объекты Objective сопоставляются с портфелем DOM.РФ по нечёткому совпадению названия и геокоординатам. Текущее покрытие — около 11% (один DOM.РФ-объект может объединять несколько Objective-лотов).",
},
];
// ── GenDesign vs НСПД — value-prop differentiation (#100) ────────────────────
// НСПД (nspd.gov.ru) — бесплатная госкарта пространственных данных. GenDesign
// не заменяет её, а считает поверх: рынок, прогноз, инвест-вердикт. Таблица
// объясняет, за что девелопер платит сверх бесплатного источника.
interface ComparisonRow {
feature: string;
/** null = нет совсем; string = есть, но в каком виде. */
nspd: string | null;
gendesign: string;
}
const COMPARISON_ROWS: ComparisonRow[] = [
{
feature: "Кадастр, ПЗЗ, ЗОУИТ, границы участка",
nspd: "Карта и атрибуты",
gendesign: "То же + сверка одним кликом",
},
{
feature: "Цены сделок ДДУ (Росреестр)",
nspd: null,
gendesign: "Медиана по району и классу",
},
{
feature: "Остатки и скорость продаж новостроек",
nspd: null,
gendesign: "DOM.РФ + Objective, еженедельно",
},
{
feature: "Инвест-балл участка и объяснение",
nspd: null,
gendesign: "Score 0100 с разбором факторов",
},
{
feature: "Прогноз цены и горизонты выхода",
nspd: null,
gendesign: "Прогноз с доверительным интервалом",
},
{
feature: "Рекомендация квартирографии",
nspd: null,
gendesign: "Mix планировок под спрос района",
},
{
feature: "Экспорт в PDF / Excel / DXF",
nspd: null,
gendesign: "Отчёт по участку в один клик",
},
];
// ── Sub-components ─────────────────────────────────────────────────────────
function ComparisonCell({ value }: { value: string | null }) {
if (value === null) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--fg-tertiary)",
}}
>
<Minus size={16} strokeWidth={1.5} color="var(--fg-tertiary)" />
нет
</span>
);
}
return (
<span
style={{
display: "inline-flex",
alignItems: "flex-start",
gap: 6,
fontSize: 13,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
<Check
size={16}
strokeWidth={1.5}
color="var(--success)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
{value}
</span>
);
}
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() {
const [modalOpen, setModalOpen] = useState(false);
const { data: stats, isLoading } = useLandingStats();
const isStale = stats?.stale === true;
const showSkeleton = isLoading || isStale;
return (
<main
style={{
background: "var(--bg-app)",
minHeight: "100vh",
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
fontVariantNumeric: "tabular-nums",
}}
>
{/* ── Nav ────────────────────────────────────────────────────────── */}
{/* UserMenu — личный кабинет (avatar + dropdown) для залогиненного юзера.
В dev (401) или до загрузки /me — UserMenu сам рендерит null. */}
<TopNav rightSlot={<UserMenu />} />
{/* ── 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>
{/* ── GenDesign vs НСПД (#100) ───────────────────────────────────── */}
<section
style={{
maxWidth: 1280,
margin: "0 auto",
padding: "0 24px 48px",
}}
>
<h2
style={{
margin: "0 0 4px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
GenDesign и НСПД
</h2>
<p
style={{
margin: "0 0 20px",
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
maxWidth: 720,
}}
>
НСПД (nspd.gov.ru) бесплатная государственная карта: кадастр, ПЗЗ,
границы. GenDesign не заменяет её, а считает поверх добавляет рынок,
прогноз и инвест-вердикт. Любую цифру можно сверить с первоисточником
из карточки участка.
</p>
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
maxWidth: 880,
}}
>
{/* Header row */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 200px 240px",
gap: 16,
padding: "12px 20px",
borderBottom: "1px solid var(--border-card)",
background: "var(--bg-card-alt)",
}}
>
<div style={comparisonHeadCell}>Возможность</div>
<div style={comparisonHeadCell}>НСПД (бесплатно)</div>
<div style={{ ...comparisonHeadCell, color: "var(--accent)" }}>
GenDesign
</div>
</div>
{/* Data rows */}
{COMPARISON_ROWS.map((row, i) => (
<div
key={row.feature}
style={{
display: "grid",
gridTemplateColumns: "1fr 200px 240px",
gap: 16,
padding: "12px 20px",
alignItems: "start",
borderBottom:
i < COMPARISON_ROWS.length - 1
? "1px solid var(--border-soft)"
: "none",
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
lineHeight: 1.5,
}}
>
{row.feature}
</div>
<ComparisonCell value={row.nspd} />
<ComparisonCell value={row.gendesign} />
</div>
))}
</div>
</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
style={{
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
fontWeight: 500,
marginBottom: 8,
}}
>
Пилотная подписка
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
marginBottom: 16,
}}
>
<span
style={{
fontSize: 36,
fontWeight: 700,
color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
}}
>
30 000
</span>
<span style={{ fontSize: 16, color: "var(--fg-secondary)" }}>
/ мес
</span>
</div>
<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
style={{
borderTop: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: "20px 24px",
fontSize: 12,
color: "var(--fg-tertiary)",
display: "flex",
gap: 16,
flexWrap: "wrap",
}}
>
<a
href="/api/v1/docs"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
OpenAPI
</a>
<a
href="/health"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
/health
</a>
<a
href="https://nspd.gov.ru"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
НСПД
</a>
<a
href="https://pkk.rosreestr.ru"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
ПКК Росреестра
</a>
<a
href="/data-sources"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
Источники данных
</a>
<span>GenDesign {new Date().getFullYear()}</span>
</footer>
{/* ── Modal ──────────────────────────────────────────────────────── */}
<PilotRequestModal open={modalOpen} onClose={() => setModalOpen(false)} />
</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",
};
const comparisonHeadCell: React.CSSProperties = {
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
};