From 212f7109e695564b54e71f14cc79a9c89b07eb89 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 08:11:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(sf-fe-a12):=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?B3=20UUID=20contract=20+=20B4=20stale=20flag=20+=20Drawer=20a11?= =?UTF-8?q?y=20+=20tokens=20+=20HeadlineBar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B3 type contract: PilotRequestResponse.id is string (UUID via CAST), drop tracking_id; derive display ID as GD-{first8hexUppercase} from UUID - B4 stale flag: LandingStatsOut Pydantic schema adds stale:bool=False; fallback returns stale=True with zeroed fields (no fake numbers); frontend shows skeleton placeholders when stale=true - PilotRequestModal: replaced manual backdrop+panel+ESC logic with (focus trap, ESC, scroll-lock, focus restore for free); removed ~80 LoC - Design tokens: all #hex literals in page.tsx and PilotRequestModal.tsx replaced with var(--token) from globals.css; h1 fontSize 32 -> 28 per display spec - HeadlineBar: hand-rolled dark div in page.tsx replaced with component --- backend/app/api/v1/landing.py | 50 +++-- frontend/src/app/page.tsx | 210 ++++++++++++------ .../components/landing/PilotRequestModal.tsx | 157 +++++-------- frontend/src/lib/api/landing.ts | 7 +- 4 files changed, 237 insertions(+), 187 deletions(-) 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} +

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