Два 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
759 lines
25 KiB
TypeScript
759 lines
25 KiB
TypeScript
"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: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 5–15%.",
|
||
},
|
||
{
|
||
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-скор 0–40 с разбором факторов",
|
||
},
|
||
{
|
||
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)",
|
||
};
|