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 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)

View file

@ -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 (
<div
style={{
borderBottom: "1px solid #eef0f3",
borderBottom: "1px solid var(--border-soft)",
paddingBottom: open ? 16 : 0,
}}
>
@ -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}
>
<span>{q}</span>
{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>
{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 (
<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: "#f6f7f9",
background: "var(--bg-app)",
minHeight: "100vh",
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
fontVariantNumeric: "tabular-nums",
@ -121,8 +145,8 @@ export default function HomePage() {
{/* ── Nav ────────────────────────────────────────────────────────── */}
<nav
style={{
background: "#fff",
borderBottom: "1px solid #e6e8ec",
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
padding: "0 24px",
height: 56,
display: "flex",
@ -133,19 +157,29 @@ export default function HomePage() {
zIndex: 100,
}}
>
<span style={{ fontWeight: 700, fontSize: 17, color: "#111" }}>
<span
style={{ fontWeight: 700, fontSize: 17, color: "var(--fg-primary)" }}
>
GenDesign
</span>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<a
href="/analytics"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }}
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Аналитика
</a>
<a
href="/site-finder"
style={{ fontSize: 14, color: "#5b6066", textDecoration: "none" }}
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Site Finder
</a>
@ -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() {
<h1
style={{
margin: 0,
fontSize: 32,
fontSize: 28,
fontWeight: 600,
color: "#111",
color: "var(--fg-primary)",
lineHeight: 1.25,
maxWidth: 640,
}}
@ -191,7 +225,7 @@ export default function HomePage() {
style={{
margin: "12px 0 0",
fontSize: 16,
color: "#5b6066",
color: "var(--fg-secondary)",
lineHeight: 1.6,
maxWidth: 600,
}}
@ -212,55 +246,63 @@ export default function HomePage() {
{/* zk_total */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<Building2 size={16} strokeWidth={1.5} color="#1d4ed8" />
<Building2 size={16} strokeWidth={1.5} color="var(--accent)" />
</div>
<div style={kpiLabel}>ЖК DOM.РФ в ЕКБ</div>
<div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.zk_total)}
{showSkeleton ? <KpiSkeleton /> : formatNumber(stats?.zk_total)}
</div>
</div>
{/* deals_total */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<BarChart3 size={16} strokeWidth={1.5} color="#1d4ed8" />
<BarChart3 size={16} strokeWidth={1.5} color="var(--accent)" />
</div>
<div style={kpiLabel}>ДДУ-сделки Росреестра</div>
<div style={kpiValue}>
{isLoading ? "—" : formatNumber(stats?.deals_total)}
{showSkeleton ? (
<KpiSkeleton />
) : (
formatNumber(stats?.deals_total)
)}
</div>
</div>
{/* price_coverage_pct */}
<div style={kpiCardStyle}>
<div style={kpiIconWrap}>
<Database size={16} strokeWidth={1.5} color="#1d4ed8" />
<Database size={16} strokeWidth={1.5} color="var(--accent)" />
</div>
<div style={kpiLabel}>Покрытие ценами Objective</div>
<div style={kpiValue}>
{isLoading
? "—"
: `${formatNumber(stats?.price_coverage_pct, 1)}%`}
{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="#1d4ed8" />
<MapPin size={16} strokeWidth={1.5} color="var(--accent)" />
</div>
<div style={kpiLabel}>Маппинг Objective DOM.РФ</div>
<div style={kpiValue}>
{isLoading
? "—"
: `${formatNumber(stats?.mapping_coverage_pct, 1)}%`}
{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="#1d4ed8" />
<RefreshCw size={16} strokeWidth={1.5} color="var(--accent)" />
</div>
<div style={kpiLabel}>Последнее обновление данных</div>
<div
@ -270,10 +312,26 @@ export default function HomePage() {
fontVariantNumeric: "tabular-nums",
}}
>
{isLoading ? "—" : (stats?.last_data_update ?? "—")}
{showSkeleton ? (
<KpiSkeleton />
) : (
(stats?.last_data_update ?? "—")
)}
</div>
</div>
</div>
{isStale && (
<p
style={{
marginTop: 12,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
Данные обновляются...
</p>
)}
</section>
{/* ── Парадокс портфеля ──────────────────────────────────────────── */}
@ -284,30 +342,23 @@ export default function HomePage() {
padding: "0 24px 48px",
}}
>
{/* Headline-bar */}
<div
style={{
background: "#0f172a",
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
? "Загрузка..."
<HeadlineBar
title="Парадокс портфеля"
subtitle={
showSkeleton
? "Данные обновляются..."
: (stats?.paradox ??
"из ЖК у большинства есть цены — но в публичном доступе единицы")}
</div>
</div>
"из ЖК у большинства есть цены — но в публичном доступе единицы")
}
/>
<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
покрывают лишь часть из них. Из тех, где цены есть, публично доступны
@ -326,8 +377,8 @@ export default function HomePage() {
>
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 16,
padding: "32px",
maxWidth: 480,
@ -338,7 +389,7 @@ export default function HomePage() {
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "#5b6066",
color: "var(--fg-secondary)",
fontWeight: 500,
marginBottom: 8,
}}
@ -357,13 +408,15 @@ export default function HomePage() {
style={{
fontSize: 36,
fontWeight: 700,
color: "#111",
color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
}}
>
30 000
</span>
<span style={{ fontSize: 16, color: "#5b6066" }}> / мес</span>
<span style={{ fontSize: 16, color: "var(--fg-secondary)" }}>
/ мес
</span>
</div>
<ul
@ -392,13 +445,17 @@ export default function HomePage() {
width: 4,
height: 4,
borderRadius: "50%",
background: "#1d4ed8",
background: "var(--accent)",
marginTop: 8,
flexShrink: 0,
}}
/>
<span
style={{ fontSize: 14, color: "#5b6066", lineHeight: 1.5 }}
style={{
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
{item}
</span>
@ -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)",
}}
>
Частые вопросы
</h2>
<p style={{ margin: "0 0 20px", fontSize: 14, color: "#5b6066" }}>
<p
style={{
margin: "0 0 20px",
fontSize: 14,
color: "var(--fg-secondary)",
}}
>
Об источниках данных, точности и поддержке.
</p>
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "0 20px",
maxWidth: 720,
@ -464,11 +527,11 @@ export default function HomePage() {
{/* ── Footer ─────────────────────────────────────────────────────── */}
<footer
style={{
borderTop: "1px solid #e6e8ec",
background: "#fff",
borderTop: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: "20px 24px",
fontSize: 12,
color: "#73767e",
color: "var(--fg-tertiary)",
display: "flex",
gap: 16,
flexWrap: "wrap",
@ -476,11 +539,14 @@ export default function HomePage() {
>
<a
href="/api/v1/docs"
style={{ color: "#1d4ed8", textDecoration: "none" }}
style={{ color: "var(--accent)", textDecoration: "none" }}
>
OpenAPI
</a>
<a href="/health" style={{ color: "#1d4ed8", textDecoration: "none" }}>
<a
href="/health"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
/health
</a>
<span>GenDesign {new Date().getFullYear()}</span>
@ -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",
};

View file

@ -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 */}
<Drawer open={open} onClose={handleClose} side="bottom">
{/* Header */}
<div
onClick={handleClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
zIndex: 1000,
}}
/>
{/* 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",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 0",
marginBottom: 20,
}}
>
{/* Header */}
<div
<h2
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
<h2
style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111" }}
>
Запросить пилотный доступ
</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>
Запросить пилотный доступ
</h2>
</div>
<div style={{ padding: "0 24px 32px" }}>
{/* Success state */}
{trackingId !== null ? (
<div
@ -169,13 +131,13 @@ export function PilotRequestModal({ open, onClose }: Props) {
padding: "24px 0",
}}
>
<CheckCircle size={40} color="#0a7a3a" strokeWidth={1.5} />
<CheckCircle size={40} color="var(--success)" strokeWidth={1.5} />
<p
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "#111",
color: "var(--fg-primary)",
}}
>
Заявка принята
@ -184,32 +146,26 @@ export function PilotRequestModal({ open, onClose }: Props) {
style={{
margin: 0,
fontSize: 14,
color: "#5b6066",
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
Мы свяжемся с вами в течение 12 рабочих дней.
{trackingId ? (
<>
{" "}
Номер заявки:{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
color: "#111",
}}
>
{trackingId}
</span>
</>
) : null}
Мы свяжемся с вами в течение 12 рабочих дней. Номер заявки:{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
color: "var(--fg-primary)",
}}
>
{trackingId}
</span>
</p>
<button
onClick={handleClose}
style={{
marginTop: 8,
padding: "8px 20px",
background: "#1d4ed8",
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 8,
@ -286,14 +242,16 @@ export function PilotRequestModal({ open, onClose }: Props) {
placeholder="alex@prinzip.ru"
style={{
...inputStyle,
borderColor: emailError ? "#b3261e" : "#d1d5db",
borderColor: emailError
? "var(--danger)"
: "var(--border-strong)",
}}
/>
{emailError ? (
<span
style={{
fontSize: 12,
color: "#b3261e",
color: "var(--danger)",
marginTop: 4,
display: "block",
}}
@ -326,7 +284,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
<span
style={{
fontSize: 12,
color: "#73767e",
color: "var(--fg-tertiary)",
marginTop: 2,
display: "block",
}}
@ -340,10 +298,10 @@ export function PilotRequestModal({ open, onClose }: Props) {
<div
style={{
padding: "10px 14px",
background: "#fee2e2",
background: "var(--danger-soft)",
borderRadius: 8,
fontSize: 13,
color: "#b3261e",
color: "var(--danger)",
}}
>
{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) {
</form>
)}
</div>
</>
</Drawer>
);
}
@ -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,
};

View file

@ -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 ──────────────────────────────────────────────────────────────────