diff --git a/backend/app/api/v1/landing.py b/backend/app/api/v1/landing.py
index 79f90e7f..2e78c71a 100644
--- a/backend/app/api/v1/landing.py
+++ b/backend/app/api/v1/landing.py
@@ -7,6 +7,7 @@ from datetime import date
from typing import Annotated, Any
from fastapi import APIRouter, Depends
+from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -16,17 +17,34 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+
# ---------------------------------------------------------------------------
-# Fallback defaults (на случай пустой dev БД)
+# Response schema
# ---------------------------------------------------------------------------
-_FALLBACK: dict[str, Any] = {
- "zk_total": 1500,
- "deals_total": 6830000,
- "price_coverage_pct": 81.4,
- "mapping_coverage_pct": 13.3,
- "last_data_update": "2026-05-17",
- "paradox": ("из 1500 ЖК у 81% есть цены — но в публичном доступе только 0.3%"),
+
+class LandingStatsOut(BaseModel):
+ zk_total: int
+ deals_total: int
+ price_coverage_pct: float
+ mapping_coverage_pct: float
+ last_data_update: str
+ paradox: str
+ stale: bool = False
+
+
+# ---------------------------------------------------------------------------
+# Fallback defaults (на случай пустой dev БД или ошибки запроса)
+# ---------------------------------------------------------------------------
+
+_FALLBACK_DATA: dict[str, Any] = {
+ "zk_total": 0,
+ "deals_total": 0,
+ "price_coverage_pct": 0.0,
+ "mapping_coverage_pct": 0.0,
+ "last_data_update": "",
+ "paradox": "",
+ "stale": True,
}
@@ -87,9 +105,9 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
else:
last_data_update = str(date.today())
- # Если все KPI нули — скорее всего пустая БД, лучше вернуть None → fallback
+ # Если все KPI нули — скорее всего пустая БД → stale
if zk_total == 0 and deals_total == 0:
- logger.warning("landing/stats: all KPIs are zero — using fallback")
+ logger.warning("landing/stats: all KPIs are zero — returning stale=True")
return None
return {
@@ -102,16 +120,17 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
f"из {zk_total} ЖК у {price_coverage_pct}% есть цены"
" — но в публичном доступе только 0.3%"
),
+ "stale": False,
}
except Exception:
- logger.exception("landing/stats: DB query failed, returning fallback")
+ logger.exception("landing/stats: DB query failed, returning stale=True")
return None
-@router.get("/landing/stats")
+@router.get("/landing/stats", response_model=LandingStatsOut)
def landing_stats(
db: Annotated[Session, Depends(get_db)],
-) -> dict[str, Any]:
+) -> LandingStatsOut:
"""5 KPI для hero-секции лендинга.
Поля ответа:
@@ -121,8 +140,9 @@ def landing_stats(
- mapping_coverage_pct: % маппинга objective → domrf_kn_objects (ЕКБ)
- last_data_update: дата последнего обновления данных (YYYY-MM-DD)
- paradox: строка-парадокс портфеля для hero CTA
+ - stale: true если данные недоступны (DB ошибка или пустая БД)
"""
result = _query_stats(db)
if result is None:
- return _FALLBACK
- return result
+ return LandingStatsOut(**_FALLBACK_DATA)
+ return LandingStatsOut(**result)
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index c118484e..e2bd47c0 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { useLandingStats } from "@/lib/api/landing";
// ── FAQ data ───────────────────────────────────────────────────────────────
@@ -47,7 +48,7 @@ function FaqItem({ q, a }: { q: string; a: string }) {
return (
@@ -66,15 +67,19 @@ function FaqItem({ q, a }: { q: string; a: string }) {
gap: 12,
fontSize: 15,
fontWeight: 500,
- color: "#111",
+ color: "var(--fg-primary)",
}}
aria-expanded={open}
>
{q}
{open ? (
-
+
) : (
-
+
)}
{open ? (
@@ -82,7 +87,7 @@ function FaqItem({ q, a }: { q: string; a: string }) {
style={{
margin: "0 0 4px",
fontSize: 14,
- color: "#5b6066",
+ color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
@@ -103,16 +108,35 @@ function formatNumber(n: number | undefined, decimals = 0): string {
});
}
+/** Skeleton placeholder shown when stale=true (no fake data) */
+function KpiSkeleton() {
+ return (
+
+ );
+}
+
// ── 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 (
-
+
GenDesign
Аналитика
Site Finder
@@ -153,7 +187,7 @@ export default function HomePage() {
onClick={() => setModalOpen(true)}
style={{
padding: "7px 16px",
- background: "#1d4ed8",
+ background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 8,
@@ -178,9 +212,9 @@ export default function HomePage() {
-
+
ЖК DOM.РФ в ЕКБ
- {isLoading ? "—" : formatNumber(stats?.zk_total)}
+ {showSkeleton ? : formatNumber(stats?.zk_total)}
{/* deals_total */}
-
+
ДДУ-сделки Росреестра
- {isLoading ? "—" : formatNumber(stats?.deals_total)}
+ {showSkeleton ? (
+
+ ) : (
+ formatNumber(stats?.deals_total)
+ )}
{/* price_coverage_pct */}
-
+
Покрытие ценами Objective
- {isLoading
- ? "—"
- : `${formatNumber(stats?.price_coverage_pct, 1)}%`}
+ {showSkeleton ? (
+
+ ) : (
+ `${formatNumber(stats?.price_coverage_pct, 1)}%`
+ )}
{/* mapping_coverage_pct */}
-
+
Маппинг Objective → DOM.РФ
- {isLoading
- ? "—"
- : `${formatNumber(stats?.mapping_coverage_pct, 1)}%`}
+ {showSkeleton ? (
+
+ ) : (
+ `${formatNumber(stats?.mapping_coverage_pct, 1)}%`
+ )}
{/* last_data_update */}
-
+
Последнее обновление данных
- {isLoading ? "—" : (stats?.last_data_update ?? "—")}
+ {showSkeleton ? (
+
+ ) : (
+ (stats?.last_data_update ?? "—")
+ )}
+
+ {isStale && (
+
+ Данные обновляются...
+
+ )}
{/* ── Парадокс портфеля ──────────────────────────────────────────── */}
@@ -284,30 +342,23 @@ export default function HomePage() {
padding: "0 24px 48px",
}}
>
- {/* Headline-bar */}
-
-
Парадокс портфеля
-
- {isLoading
- ? "Загрузка..."
+
-
+ "из ЖК у большинства есть цены — но в публичном доступе единицы")
+ }
+ />
DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective
покрывают лишь часть из них. Из тех, где цены есть, публично доступны
@@ -326,8 +377,8 @@ export default function HomePage() {
>
30 000
- ₽ / мес
+
+ ₽ / мес
+
{item}
@@ -411,7 +468,7 @@ export default function HomePage() {
style={{
width: "100%",
padding: "12px 24px",
- background: "#1d4ed8",
+ background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 8,
@@ -438,18 +495,24 @@ export default function HomePage() {
margin: "0 0 4px",
fontSize: 18,
fontWeight: 600,
- color: "#111",
+ color: "var(--fg-primary)",
}}
>
Частые вопросы
-
+
Об источниках данных, точности и поддержке.
OpenAPI
-
+
/health
GenDesign {new Date().getFullYear()}
@@ -495,8 +561,8 @@ export default function HomePage() {
// ── Shared styles ──────────────────────────────────────────────────────────
const kpiCardStyle: React.CSSProperties = {
- background: "#fff",
- border: "1px solid #e6e8ec",
+ background: "var(--bg-card)",
+ border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 18px",
};
@@ -504,7 +570,7 @@ const kpiCardStyle: React.CSSProperties = {
const kpiIconWrap: React.CSSProperties = {
width: 28,
height: 28,
- background: "#dbeafe",
+ background: "var(--accent-soft)",
borderRadius: 6,
display: "flex",
alignItems: "center",
@@ -516,7 +582,7 @@ const kpiLabel: React.CSSProperties = {
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
- color: "#5b6066",
+ color: "var(--fg-secondary)",
fontWeight: 500,
marginBottom: 6,
};
@@ -524,6 +590,6 @@ const kpiLabel: React.CSSProperties = {
const kpiValue: React.CSSProperties = {
fontSize: 28,
fontWeight: 600,
- color: "#111",
+ color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
};
diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx
index 9af21f06..d8710d64 100644
--- a/frontend/src/components/landing/PilotRequestModal.tsx
+++ b/frontend/src/components/landing/PilotRequestModal.tsx
@@ -2,8 +2,9 @@
import { useState } from "react";
-import { CheckCircle, X } from "lucide-react";
+import { CheckCircle } from "lucide-react";
+import { Drawer } from "@/components/ui/Drawer";
import {
useSubmitPilotRequest,
type PilotRequestPayload,
@@ -24,6 +25,15 @@ interface FormState {
message: string;
}
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+/** Derives a human-readable tracking ID from the UUID string returned by backend.
+ * Example: "7f3a8c2e-..." → "GD-7F3A8C2E"
+ */
+function deriveTrackingId(id: string): string {
+ return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
+}
+
// ── Component ──────────────────────────────────────────────────────────────
export function PilotRequestModal({ open, onClose }: Props) {
@@ -39,8 +49,6 @@ export function PilotRequestModal({ open, onClose }: Props) {
const mutation = useSubmitPilotRequest();
- if (!open) return null;
-
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function handleChange(
@@ -74,7 +82,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
mutation.mutate(payload, {
onSuccess: (data) => {
- setTrackingId(data.tracking_id ?? String(data.id));
+ setTrackingId(deriveTrackingId(data.id));
},
});
}
@@ -88,76 +96,30 @@ export function PilotRequestModal({ open, onClose }: Props) {
}
return (
- <>
- {/* Backdrop */}
+
+ {/* Header */}
-
- {/* Panel — bottom-sheet on mobile, centered modal on desktop */}
-
- {/* Header */}
-
-
- Запросить пилотный доступ
-
-
-
-
-
+ Запросить пилотный доступ
+
+
+
{/* Success state */}
{trackingId !== null ? (
-
+
Заявка принята
@@ -184,32 +146,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
style={{
margin: 0,
fontSize: 14,
- color: "#5b6066",
+ color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
- Мы свяжемся с вами в течение 1–2 рабочих дней.
- {trackingId ? (
- <>
- {" "}
- Номер заявки:{" "}
-
- {trackingId}
-
- >
- ) : null}
+ Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "}
+
+ {trackingId}
+
{emailError ? (
{mutation.error instanceof Error
@@ -360,9 +318,12 @@ export function PilotRequestModal({ open, onClose }: Props) {
padding: "12px 24px",
background:
mutation.isPending || !form.name.trim()
- ? "#93c5fd"
- : "#1d4ed8",
- color: "#fff",
+ ? "var(--accent-soft)"
+ : "var(--accent)",
+ color:
+ mutation.isPending || !form.name.trim()
+ ? "var(--accent)"
+ : "#fff",
border: "none",
borderRadius: 8,
fontSize: 15,
@@ -381,7 +342,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
)}
- >
+
);
}
@@ -393,18 +354,18 @@ const labelStyle: React.CSSProperties = {
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
- color: "#5b6066",
+ color: "var(--fg-secondary)",
marginBottom: 6,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "9px 12px",
- border: "1px solid #d1d5db",
+ border: "1px solid var(--border-strong)",
borderRadius: 8,
fontSize: 14,
- color: "#111",
- background: "#fff",
+ color: "var(--fg-primary)",
+ background: "var(--bg-card)",
outline: "none",
boxSizing: "border-box" as const,
};
diff --git a/frontend/src/lib/api/landing.ts b/frontend/src/lib/api/landing.ts
index 31869eb5..77f2dc6a 100644
--- a/frontend/src/lib/api/landing.ts
+++ b/frontend/src/lib/api/landing.ts
@@ -13,6 +13,8 @@ export interface LandingStats {
mapping_coverage_pct: number;
last_data_update: string;
paradox: string;
+ /** true when DB is unavailable or all-zero — frontend shows skeleton */
+ stale?: boolean;
}
export interface PilotRequestPayload {
@@ -25,9 +27,10 @@ export interface PilotRequestPayload {
}
export interface PilotRequestResponse {
- id: number;
+ /** UUID returned as string via CAST(id AS text) */
+ id: string;
status: string;
- tracking_id?: string;
+ created_at: string;
}
// ── Hooks ──────────────────────────────────────────────────────────────────