feat(sf-fe-a12): Landing redesign + PilotCTA с real B3/B4 data (v2 rebased) #367

Merged
lekss361 merged 2 commits from feat/sf-fe-a12-landing-redesign-v2 into main 2026-05-18 05:16:58 +00:00
4 changed files with 237 additions and 187 deletions
Showing only changes of commit 212f7109e6 - Show all commits

View file

@ -7,6 +7,7 @@ from datetime import date
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -16,17 +17,34 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fallback defaults (на случай пустой dev БД) # Response schema
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_FALLBACK: dict[str, Any] = {
"zk_total": 1500, class LandingStatsOut(BaseModel):
"deals_total": 6830000, zk_total: int
"price_coverage_pct": 81.4, deals_total: int
"mapping_coverage_pct": 13.3, price_coverage_pct: float
"last_data_update": "2026-05-17", mapping_coverage_pct: float
"paradox": ("из 1500 ЖК у 81% есть цены — но в публичном доступе только 0.3%"), 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: else:
last_data_update = str(date.today()) last_data_update = str(date.today())
# Если все KPI нули — скорее всего пустая БД, лучше вернуть None → fallback # Если все KPI нули — скорее всего пустая БД → stale
if zk_total == 0 and deals_total == 0: 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 None
return { return {
@ -102,16 +120,17 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
f"из {zk_total} ЖК у {price_coverage_pct}% есть цены" f"из {zk_total} ЖК у {price_coverage_pct}% есть цены"
" — но в публичном доступе только 0.3%" " — но в публичном доступе только 0.3%"
), ),
"stale": False,
} }
except Exception: except Exception:
logger.exception("landing/stats: DB query failed, returning fallback") logger.exception("landing/stats: DB query failed, returning stale=True")
return None return None
@router.get("/landing/stats") @router.get("/landing/stats", response_model=LandingStatsOut)
def landing_stats( def landing_stats(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]: ) -> LandingStatsOut:
"""5 KPI для hero-секции лендинга. """5 KPI для hero-секции лендинга.
Поля ответа: Поля ответа:
@ -121,8 +140,9 @@ def landing_stats(
- mapping_coverage_pct: % маппинга objective domrf_kn_objects (ЕКБ) - mapping_coverage_pct: % маппинга objective domrf_kn_objects (ЕКБ)
- last_data_update: дата последнего обновления данных (YYYY-MM-DD) - last_data_update: дата последнего обновления данных (YYYY-MM-DD)
- paradox: строка-парадокс портфеля для hero CTA - paradox: строка-парадокс портфеля для hero CTA
- stale: true если данные недоступны (DB ошибка или пустая БД)
""" """
result = _query_stats(db) result = _query_stats(db)
if result is None: if result is None:
return _FALLBACK return LandingStatsOut(**_FALLBACK_DATA)
return result return LandingStatsOut(**result)

View file

@ -13,6 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { PilotRequestModal } from "@/components/landing/PilotRequestModal"; import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { useLandingStats } from "@/lib/api/landing"; import { useLandingStats } from "@/lib/api/landing";
// ── FAQ data ─────────────────────────────────────────────────────────────── // ── FAQ data ───────────────────────────────────────────────────────────────
@ -47,7 +48,7 @@ function FaqItem({ q, a }: { q: string; a: string }) {
return ( return (
<div <div
style={{ style={{
borderBottom: "1px solid #eef0f3", borderBottom: "1px solid var(--border-soft)",
paddingBottom: open ? 16 : 0, paddingBottom: open ? 16 : 0,
}} }}
> >
@ -66,15 +67,19 @@ function FaqItem({ q, a }: { q: string; a: string }) {
gap: 12, gap: 12,
fontSize: 15, fontSize: 15,
fontWeight: 500, fontWeight: 500,
color: "#111", color: "var(--fg-primary)",
}} }}
aria-expanded={open} aria-expanded={open}
> >
<span>{q}</span> <span>{q}</span>
{open ? ( {open ? (
<ChevronUp size={18} strokeWidth={1.5} color="#5b6066" /> <ChevronUp size={18} strokeWidth={1.5} color="var(--fg-secondary)" />
) : ( ) : (
<ChevronDown size={18} strokeWidth={1.5} color="#5b6066" /> <ChevronDown
size={18}
strokeWidth={1.5}
color="var(--fg-secondary)"
/>
)} )}
</button> </button>
{open ? ( {open ? (
@ -82,7 +87,7 @@ function FaqItem({ q, a }: { q: string; a: string }) {
style={{ style={{
margin: "0 0 4px", margin: "0 0 4px",
fontSize: 14, fontSize: 14,
color: "#5b6066", color: "var(--fg-secondary)",
lineHeight: 1.6, 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 (
<div
aria-label="Данные обновляются..."
style={{
height: 28,
width: 80,
borderRadius: 6,
background: "var(--border-card)",
opacity: 0.6,
}}
/>
);
}
// ── Main component ───────────────────────────────────────────────────────── // ── Main component ─────────────────────────────────────────────────────────
export default function HomePage() { export default function HomePage() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const { data: stats, isLoading } = useLandingStats(); const { data: stats, isLoading } = useLandingStats();
const isStale = stats?.stale === true;
const showSkeleton = isLoading || isStale;
return ( return (
<main <main
style={{ style={{
background: "#f6f7f9", background: "var(--bg-app)",
minHeight: "100vh", minHeight: "100vh",
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif", fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
@ -121,8 +145,8 @@ export default function HomePage() {
{/* ── Nav ────────────────────────────────────────────────────────── */} {/* ── Nav ────────────────────────────────────────────────────────── */}
<nav <nav
style={{ style={{
background: "#fff", background: "var(--bg-card)",
borderBottom: "1px solid #e6e8ec", borderBottom: "1px solid var(--border-card)",
padding: "0 24px", padding: "0 24px",
height: 56, height: 56,
display: "flex", display: "flex",
@ -133,19 +157,29 @@ export default function HomePage() {
zIndex: 100, zIndex: 100,
}} }}
> >
<span style={{ fontWeight: 700, fontSize: 17, color: "#111" }}> <span
style={{ fontWeight: 700, fontSize: 17, color: "var(--fg-primary)" }}
>
GenDesign GenDesign
</span> </span>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}> <div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<a <a
href="/analytics" href="/analytics"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }} style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
> >
Аналитика Аналитика
</a> </a>
<a <a
href="/site-finder" href="/site-finder"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }} style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
> >
Site Finder Site Finder
</a> </a>
@ -153,7 +187,7 @@ export default function HomePage() {
onClick={() => setModalOpen(true)} onClick={() => setModalOpen(true)}
style={{ style={{
padding: "7px 16px", padding: "7px 16px",
background: "#1d4ed8", background: "var(--accent)",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
@ -178,9 +212,9 @@ export default function HomePage() {
<h1 <h1
style={{ style={{
margin: 0, margin: 0,
fontSize: 32, fontSize: 28,
fontWeight: 600, fontWeight: 600,
color: "#111", color: "var(--fg-primary)",
lineHeight: 1.25, lineHeight: 1.25,
maxWidth: 640, maxWidth: 640,
}} }}
@ -191,7 +225,7 @@ export default function HomePage() {
style={{ style={{
margin: "12px 0 0", margin: "12px 0 0",
fontSize: 16, fontSize: 16,
color: "#5b6066", color: "var(--fg-secondary)",
lineHeight: 1.6, lineHeight: 1.6,
maxWidth: 600, maxWidth: 600,
}} }}
@ -212,55 +246,63 @@ export default function HomePage() {
{/* zk_total */} {/* zk_total */}
<div style={kpiCardStyle}> <div style={kpiCardStyle}>
<div style={kpiIconWrap}> <div style={kpiIconWrap}>
<Building2 size={16} strokeWidth={1.5} color="#1d4ed8" /> <Building2 size={16} strokeWidth={1.5} color="var(--accent)" />
</div> </div>
<div style={kpiLabel}>ЖК DOM.РФ в ЕКБ</div> <div style={kpiLabel}>ЖК DOM.РФ в ЕКБ</div>
<div style={kpiValue}> <div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.zk_total)} {showSkeleton ? <KpiSkeleton /> : formatNumber(stats?.zk_total)}
</div> </div>
</div> </div>
{/* deals_total */} {/* deals_total */}
<div style={kpiCardStyle}> <div style={kpiCardStyle}>
<div style={kpiIconWrap}> <div style={kpiIconWrap}>
<BarChart3 size={16} strokeWidth={1.5} color="#1d4ed8" /> <BarChart3 size={16} strokeWidth={1.5} color="var(--accent)" />
</div> </div>
<div style={kpiLabel}>ДДУ-сделки Росреестра</div> <div style={kpiLabel}>ДДУ-сделки Росреестра</div>
<div style={kpiValue}> <div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.deals_total)} {showSkeleton ? (
<KpiSkeleton />
) : (
formatNumber(stats?.deals_total)
)}
</div> </div>
</div> </div>
{/* price_coverage_pct */} {/* price_coverage_pct */}
<div style={kpiCardStyle}> <div style={kpiCardStyle}>
<div style={kpiIconWrap}> <div style={kpiIconWrap}>
<Database size={16} strokeWidth={1.5} color="#1d4ed8" /> <Database size={16} strokeWidth={1.5} color="var(--accent)" />
</div> </div>
<div style={kpiLabel}>Покрытие ценами Objective</div> <div style={kpiLabel}>Покрытие ценами Objective</div>
<div style={kpiValue}> <div style={kpiValue}>
{isLoading {showSkeleton ? (
? "—" <KpiSkeleton />
: `${formatNumber(stats?.price_coverage_pct, 1)}%`} ) : (
`${formatNumber(stats?.price_coverage_pct, 1)}%`
)}
</div> </div>
</div> </div>
{/* mapping_coverage_pct */} {/* mapping_coverage_pct */}
<div style={kpiCardStyle}> <div style={kpiCardStyle}>
<div style={kpiIconWrap}> <div style={kpiIconWrap}>
<MapPin size={16} strokeWidth={1.5} color="#1d4ed8" /> <MapPin size={16} strokeWidth={1.5} color="var(--accent)" />
</div> </div>
<div style={kpiLabel}>Маппинг Objective DOM.РФ</div> <div style={kpiLabel}>Маппинг Objective DOM.РФ</div>
<div style={kpiValue}> <div style={kpiValue}>
{isLoading {showSkeleton ? (
? "—" <KpiSkeleton />
: `${formatNumber(stats?.mapping_coverage_pct, 1)}%`} ) : (
`${formatNumber(stats?.mapping_coverage_pct, 1)}%`
)}
</div> </div>
</div> </div>
{/* last_data_update */} {/* last_data_update */}
<div style={kpiCardStyle}> <div style={kpiCardStyle}>
<div style={kpiIconWrap}> <div style={kpiIconWrap}>
<RefreshCw size={16} strokeWidth={1.5} color="#1d4ed8" /> <RefreshCw size={16} strokeWidth={1.5} color="var(--accent)" />
</div> </div>
<div style={kpiLabel}>Последнее обновление данных</div> <div style={kpiLabel}>Последнее обновление данных</div>
<div <div
@ -270,10 +312,26 @@ export default function HomePage() {
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
{isLoading ? "—" : (stats?.last_data_update ?? "—")} {showSkeleton ? (
<KpiSkeleton />
) : (
(stats?.last_data_update ?? "—")
)}
</div> </div>
</div> </div>
</div> </div>
{isStale && (
<p
style={{
marginTop: 12,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Данные обновляются...
</p>
)}
</section> </section>
{/* ── Парадокс портфеля ──────────────────────────────────────────── */} {/* ── Парадокс портфеля ──────────────────────────────────────────── */}
@ -284,30 +342,23 @@ export default function HomePage() {
padding: "0 24px 48px", padding: "0 24px 48px",
}} }}
> >
{/* Headline-bar */} <HeadlineBar
<div title="Парадокс портфеля"
style={{ subtitle={
background: "#0f172a", showSkeleton
color: "#e2e8f0", ? "Данные обновляются..."
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
marginBottom: 16,
}}
>
<div style={{ fontWeight: 600 }}>Парадокс портфеля</div>
<div style={{ marginTop: 6, fontSize: 14, color: "#94a3b8" }}>
{isLoading
? "Загрузка..."
: (stats?.paradox ?? : (stats?.paradox ??
"из ЖК у большинства есть цены — но в публичном доступе единицы")} "из ЖК у большинства есть цены — но в публичном доступе единицы")
</div> }
</div> />
<p <p
style={{ margin: 0, fontSize: 14, color: "#5b6066", lineHeight: 1.6 }} style={{
margin: "16px 0 0",
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
> >
DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective
покрывают лишь часть из них. Из тех, где цены есть, публично доступны покрывают лишь часть из них. Из тех, где цены есть, публично доступны
@ -326,8 +377,8 @@ export default function HomePage() {
> >
<div <div
style={{ style={{
background: "#fff", background: "var(--bg-card)",
border: "1px solid #e6e8ec", border: "1px solid var(--border-card)",
borderRadius: 16, borderRadius: 16,
padding: "32px", padding: "32px",
maxWidth: 480, maxWidth: 480,
@ -338,7 +389,7 @@ export default function HomePage() {
fontSize: 12, fontSize: 12,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
color: "#5b6066", color: "var(--fg-secondary)",
fontWeight: 500, fontWeight: 500,
marginBottom: 8, marginBottom: 8,
}} }}
@ -357,13 +408,15 @@ export default function HomePage() {
style={{ style={{
fontSize: 36, fontSize: 36,
fontWeight: 700, fontWeight: 700,
color: "#111", color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}} }}
> >
30 000 30 000
</span> </span>
<span style={{ fontSize: 16, color: "#5b6066" }}> / мес</span> <span style={{ fontSize: 16, color: "var(--fg-secondary)" }}>
/ мес
</span>
</div> </div>
<ul <ul
@ -392,13 +445,17 @@ export default function HomePage() {
width: 4, width: 4,
height: 4, height: 4,
borderRadius: "50%", borderRadius: "50%",
background: "#1d4ed8", background: "var(--accent)",
marginTop: 8, marginTop: 8,
flexShrink: 0, flexShrink: 0,
}} }}
/> />
<span <span
style={{ fontSize: 14, color: "#5b6066", lineHeight: 1.5 }} style={{
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
> >
{item} {item}
</span> </span>
@ -411,7 +468,7 @@ export default function HomePage() {
style={{ style={{
width: "100%", width: "100%",
padding: "12px 24px", padding: "12px 24px",
background: "#1d4ed8", background: "var(--accent)",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
@ -438,18 +495,24 @@ export default function HomePage() {
margin: "0 0 4px", margin: "0 0 4px",
fontSize: 18, fontSize: 18,
fontWeight: 600, fontWeight: 600,
color: "#111", color: "var(--fg-primary)",
}} }}
> >
Частые вопросы Частые вопросы
</h2> </h2>
<p style={{ margin: "0 0 20px", fontSize: 14, color: "#5b6066" }}> <p
style={{
margin: "0 0 20px",
fontSize: 14,
color: "var(--fg-secondary)",
}}
>
Об источниках данных, точности и поддержке. Об источниках данных, точности и поддержке.
</p> </p>
<div <div
style={{ style={{
background: "#fff", background: "var(--bg-card)",
border: "1px solid #e6e8ec", border: "1px solid var(--border-card)",
borderRadius: 12, borderRadius: 12,
padding: "0 20px", padding: "0 20px",
maxWidth: 720, maxWidth: 720,
@ -464,11 +527,11 @@ export default function HomePage() {
{/* ── Footer ─────────────────────────────────────────────────────── */} {/* ── Footer ─────────────────────────────────────────────────────── */}
<footer <footer
style={{ style={{
borderTop: "1px solid #e6e8ec", borderTop: "1px solid var(--border-card)",
background: "#fff", background: "var(--bg-card)",
padding: "20px 24px", padding: "20px 24px",
fontSize: 12, fontSize: 12,
color: "#73767e", color: "var(--fg-tertiary)",
display: "flex", display: "flex",
gap: 16, gap: 16,
flexWrap: "wrap", flexWrap: "wrap",
@ -476,11 +539,14 @@ export default function HomePage() {
> >
<a <a
href="/api/v1/docs" href="/api/v1/docs"
style={{ color: "#1d4ed8", textDecoration: "none" }} style={{ color: "var(--accent)", textDecoration: "none" }}
> >
OpenAPI OpenAPI
</a> </a>
<a href="/health" style={{ color: "#1d4ed8", textDecoration: "none" }}> <a
href="/health"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
/health /health
</a> </a>
<span>GenDesign {new Date().getFullYear()}</span> <span>GenDesign {new Date().getFullYear()}</span>
@ -495,8 +561,8 @@ export default function HomePage() {
// ── Shared styles ────────────────────────────────────────────────────────── // ── Shared styles ──────────────────────────────────────────────────────────
const kpiCardStyle: React.CSSProperties = { const kpiCardStyle: React.CSSProperties = {
background: "#fff", background: "var(--bg-card)",
border: "1px solid #e6e8ec", border: "1px solid var(--border-card)",
borderRadius: 12, borderRadius: 12,
padding: "16px 18px", padding: "16px 18px",
}; };
@ -504,7 +570,7 @@ const kpiCardStyle: React.CSSProperties = {
const kpiIconWrap: React.CSSProperties = { const kpiIconWrap: React.CSSProperties = {
width: 28, width: 28,
height: 28, height: 28,
background: "#dbeafe", background: "var(--accent-soft)",
borderRadius: 6, borderRadius: 6,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -516,7 +582,7 @@ const kpiLabel: React.CSSProperties = {
fontSize: 12, fontSize: 12,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.04em", letterSpacing: "0.04em",
color: "#5b6066", color: "var(--fg-secondary)",
fontWeight: 500, fontWeight: 500,
marginBottom: 6, marginBottom: 6,
}; };
@ -524,6 +590,6 @@ const kpiLabel: React.CSSProperties = {
const kpiValue: React.CSSProperties = { const kpiValue: React.CSSProperties = {
fontSize: 28, fontSize: 28,
fontWeight: 600, fontWeight: 600,
color: "#111", color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
}; };

View file

@ -2,8 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { CheckCircle, X } from "lucide-react"; import { CheckCircle } from "lucide-react";
import { Drawer } from "@/components/ui/Drawer";
import { import {
useSubmitPilotRequest, useSubmitPilotRequest,
type PilotRequestPayload, type PilotRequestPayload,
@ -24,6 +25,15 @@ interface FormState {
message: string; 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 ────────────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────────────
export function PilotRequestModal({ open, onClose }: Props) { export function PilotRequestModal({ open, onClose }: Props) {
@ -39,8 +49,6 @@ export function PilotRequestModal({ open, onClose }: Props) {
const mutation = useSubmitPilotRequest(); const mutation = useSubmitPilotRequest();
if (!open) return null;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function handleChange( function handleChange(
@ -74,7 +82,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
mutation.mutate(payload, { mutation.mutate(payload, {
onSuccess: (data) => { onSuccess: (data) => {
setTrackingId(data.tracking_id ?? String(data.id)); setTrackingId(deriveTrackingId(data.id));
}, },
}); });
} }
@ -88,76 +96,30 @@ export function PilotRequestModal({ open, onClose }: Props) {
} }
return ( return (
<> <Drawer open={open} onClose={handleClose} side="bottom">
{/* Backdrop */} {/* Header */}
<div <div
onClick={handleClose}
style={{ style={{
position: "fixed", display: "flex",
inset: 0, alignItems: "center",
background: "rgba(0,0,0,0.45)", justifyContent: "space-between",
zIndex: 1000, padding: "20px 24px 0",
}} marginBottom: 20,
/>
{/* Panel — bottom-sheet on mobile, centered modal on desktop */}
<div
role="dialog"
aria-modal="true"
aria-label="Запрос пилотного доступа"
style={{
position: "fixed",
zIndex: 1001,
background: "#fff",
borderRadius: 16,
// Mobile: bottom sheet
bottom: 0,
left: 0,
right: 0,
// Desktop override via media query workaround — inline style wins, so we
// use a wrapper approach: the outer element covers full screen on mobile,
// and a max-width container is constrained. We centre via transform.
maxWidth: 560,
margin: "0 auto",
padding: "24px 24px 32px",
boxShadow: "0 -4px 24px rgba(0,0,0,0.12)",
overflowY: "auto",
maxHeight: "90vh",
}} }}
> >
{/* Header */} <h2
<div
style={{ style={{
display: "flex", margin: 0,
alignItems: "center", fontSize: 18,
justifyContent: "space-between", fontWeight: 600,
marginBottom: 20, color: "var(--fg-primary)",
}} }}
> >
<h2 Запросить пилотный доступ
style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111" }} </h2>
> </div>
Запросить пилотный доступ
</h2>
<button
onClick={handleClose}
aria-label="Закрыть"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
color: "#5b6066",
display: "flex",
alignItems: "center",
}}
>
<X size={20} strokeWidth={1.5} />
</button>
</div>
<div style={{ padding: "0 24px 32px" }}>
{/* Success state */} {/* Success state */}
{trackingId !== null ? ( {trackingId !== null ? (
<div <div
@ -169,13 +131,13 @@ export function PilotRequestModal({ open, onClose }: Props) {
padding: "24px 0", padding: "24px 0",
}} }}
> >
<CheckCircle size={40} color="#0a7a3a" strokeWidth={1.5} /> <CheckCircle size={40} color="var(--success)" strokeWidth={1.5} />
<p <p
style={{ style={{
margin: 0, margin: 0,
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
color: "#111", color: "var(--fg-primary)",
}} }}
> >
Заявка принята Заявка принята
@ -184,32 +146,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
style={{ style={{
margin: 0, margin: 0,
fontSize: 14, fontSize: 14,
color: "#5b6066", color: "var(--fg-secondary)",
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
Мы свяжемся с вами в течение 12 рабочих дней. Мы свяжемся с вами в течение 12 рабочих дней. Номер заявки:{" "}
{trackingId ? ( <span
<> style={{
{" "} fontVariantNumeric: "tabular-nums",
Номер заявки:{" "} color: "var(--fg-primary)",
<span }}
style={{ >
fontVariantNumeric: "tabular-nums", {trackingId}
color: "#111", </span>
}}
>
{trackingId}
</span>
</>
) : null}
</p> </p>
<button <button
onClick={handleClose} onClick={handleClose}
style={{ style={{
marginTop: 8, marginTop: 8,
padding: "8px 20px", padding: "8px 20px",
background: "#1d4ed8", background: "var(--accent)",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
@ -286,14 +242,16 @@ export function PilotRequestModal({ open, onClose }: Props) {
placeholder="alex@prinzip.ru" placeholder="alex@prinzip.ru"
style={{ style={{
...inputStyle, ...inputStyle,
borderColor: emailError ? "#b3261e" : "#d1d5db", borderColor: emailError
? "var(--danger)"
: "var(--border-strong)",
}} }}
/> />
{emailError ? ( {emailError ? (
<span <span
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#b3261e", color: "var(--danger)",
marginTop: 4, marginTop: 4,
display: "block", display: "block",
}} }}
@ -326,7 +284,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
<span <span
style={{ style={{
fontSize: 12, fontSize: 12,
color: "#73767e", color: "var(--fg-tertiary)",
marginTop: 2, marginTop: 2,
display: "block", display: "block",
}} }}
@ -340,10 +298,10 @@ export function PilotRequestModal({ open, onClose }: Props) {
<div <div
style={{ style={{
padding: "10px 14px", padding: "10px 14px",
background: "#fee2e2", background: "var(--danger-soft)",
borderRadius: 8, borderRadius: 8,
fontSize: 13, fontSize: 13,
color: "#b3261e", color: "var(--danger)",
}} }}
> >
{mutation.error instanceof Error {mutation.error instanceof Error
@ -360,9 +318,12 @@ export function PilotRequestModal({ open, onClose }: Props) {
padding: "12px 24px", padding: "12px 24px",
background: background:
mutation.isPending || !form.name.trim() mutation.isPending || !form.name.trim()
? "#93c5fd" ? "var(--accent-soft)"
: "#1d4ed8", : "var(--accent)",
color: "#fff", color:
mutation.isPending || !form.name.trim()
? "var(--accent)"
: "#fff",
border: "none", border: "none",
borderRadius: 8, borderRadius: 8,
fontSize: 15, fontSize: 15,
@ -381,7 +342,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
</form> </form>
)} )}
</div> </div>
</> </Drawer>
); );
} }
@ -393,18 +354,18 @@ const labelStyle: React.CSSProperties = {
fontWeight: 500, fontWeight: 500,
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
letterSpacing: "0.04em", letterSpacing: "0.04em",
color: "#5b6066", color: "var(--fg-secondary)",
marginBottom: 6, marginBottom: 6,
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: "100%", width: "100%",
padding: "9px 12px", padding: "9px 12px",
border: "1px solid #d1d5db", border: "1px solid var(--border-strong)",
borderRadius: 8, borderRadius: 8,
fontSize: 14, fontSize: 14,
color: "#111", color: "var(--fg-primary)",
background: "#fff", background: "var(--bg-card)",
outline: "none", outline: "none",
boxSizing: "border-box" as const, boxSizing: "border-box" as const,
}; };

View file

@ -13,6 +13,8 @@ export interface LandingStats {
mapping_coverage_pct: number; mapping_coverage_pct: number;
last_data_update: string; last_data_update: string;
paradox: string; paradox: string;
/** true when DB is unavailable or all-zero — frontend shows skeleton */
stale?: boolean;
} }
export interface PilotRequestPayload { export interface PilotRequestPayload {
@ -25,9 +27,10 @@ export interface PilotRequestPayload {
} }
export interface PilotRequestResponse { export interface PilotRequestResponse {
id: number; /** UUID returned as string via CAST(id AS text) */
id: string;
status: string; status: string;
tracking_id?: string; created_at: string;
} }
// ── Hooks ────────────────────────────────────────────────────────────────── // ── Hooks ──────────────────────────────────────────────────────────────────