gendesign/frontend/src/app/page.tsx
Light1YT 589b3bfdc3
All checks were successful
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 9m59s
CI / changes (pull_request) Successful in 6s
fix(site-finder): honest POI-score label + drop duplicate MOI column (audit #1871 P2)
Два disclosure-фикса + 3 consistency follow-up (по code-review).

invest_score = взвешенная POI-сумма (distance-decay + center-bonus, max-ref
40), НЕ калиброванный инвест-рейтинг (scorer.py — пустой стаб). Был мислейбл
«Инвест-балл». Формулу/SCORE_MAX_REFERENCE не трогаем — только честная подпись.
- backend parcels.py: score_explanation → «POI-скор (0–40, оценочный)...
  Не инвест-рейтинг...»
- KpiCard: новый optional caption prop (backward-compat).
- Section1ParcelInfo: KPI «Инвест-балл» → «POI-скор · 0–40» + caption «оценочный».
- legacy site-finder: scoreLabel → «POI-скор · {label} · оценочный».
- CompareTable: «Инвест-балл» → «POI-скор · 0–40» (тот же score, был мислейбл).
- landing app/page.tsx: «Score 0–100» → «POI-скор 0–40» (реальная шкала).
- Section1 scoreColor: пороги 0–100 → 0–40 калиброванные (_score_label:
  <5 плохо · 5–15 средне · 15–25 хорошо · ≥25 отлично) — раньше хороший
  ~30/40 участок рисовался red рядом с честным «0–40» лейблом.

MOI (months_of_inventory) математически КОРРЕКТЕН — по определению не зависит
от горизонта проекции (MOI = сток / месячный темп). Не баг. Но дублировался
колонкой на 4 горизонта в ForecastHorizonsBlock (116.6/116.6/117.6/117.6 —
выглядело сломанным). Убрал колонку; MOI остаётся 1 раз в Section6 key_numbers.
deficit_index (варьируется) сохранён. Подчищены unused balanced/DEFICIT_BALANCE_EPS.

138 frontend vitest passed, ruff/tsc/lint clean.

Refs #1871
2026-06-23 12:29:02 +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: "POI-скор участка и объяснение",
nspd: null,
gendesign: "POI-скор 040 с разбором факторов",
},
{
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)",
};