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/package-lock.json b/frontend/package-lock.json
index 6ce0a14f..7c330e20 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -4196,9 +4196,9 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.29",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
- "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "version": "2.10.30",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
+ "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
diff --git a/frontend/src/app/_legacy/page-tabs.tsx b/frontend/src/app/_legacy/page-tabs.tsx
new file mode 100644
index 00000000..75bff5fc
--- /dev/null
+++ b/frontend/src/app/_legacy/page-tabs.tsx
@@ -0,0 +1,185 @@
+// Legacy landing — navigation portal (pre-A12 redesign 2026-05-18).
+// Kept for rollback reference. Not routed — use /app/page.tsx.
+
+import Link from "next/link";
+
+interface Route {
+ href: string;
+ label: string;
+ desc: string;
+}
+
+const SECTIONS: { title: string; routes: Route[] }[] = [
+ {
+ title: "Аналитика",
+ routes: [
+ {
+ href: "/analytics",
+ label: "Свердл рынок",
+ desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.",
+ },
+ {
+ href: "/analytics/prinzip",
+ label: "PRINZIP drill-down",
+ desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.",
+ },
+ {
+ href: "/analytics/developers",
+ label: "Топ девелоперов",
+ desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.",
+ },
+ {
+ href: "/analytics/objects/60160",
+ label: "Карточка ЖК (пример: Парк Победы)",
+ desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
+ },
+ {
+ href: "/analytics/recommend",
+ label: "Рекомендатор квартирографии",
+ desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
+ },
+ ],
+ },
+ {
+ title: "Site Finder",
+ routes: [
+ {
+ href: "/site-finder",
+ label: "Анализ участка по cad-номеру",
+ desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.",
+ },
+ ],
+ },
+ {
+ title: "Adminка (Token-protected)",
+ routes: [
+ {
+ href: "/admin/scrape/all",
+ label: "Скрапер — единая панель",
+ desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.",
+ },
+ {
+ href: "/admin/leads",
+ label: "Лиды PRINZIP CRM",
+ desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.",
+ },
+ ],
+ },
+ {
+ title: "Generative (WIP)",
+ routes: [
+ {
+ href: "/concept",
+ label: "Concept (Stage 1)",
+ desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.",
+ },
+ ],
+ },
+];
+
+export default function HomePageLegacy() {
+ return (
+
+
+
+ {SECTIONS.map((section) => (
+
+
+ {section.title}
+
+
+ {section.routes.map((r) => (
+
+
+ {r.href}
+
+
{r.label}
+
+ {r.desc}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 6e25ccae..e2bd47c0 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,188 +1,595 @@
-import Link from "next/link";
+"use client";
-interface Route {
- href: string;
- label: string;
- desc: string;
-}
+import { useState } from "react";
-const SECTIONS: { title: string; routes: Route[] }[] = [
+import {
+ BarChart3,
+ Building2,
+ ChevronDown,
+ ChevronUp,
+ Database,
+ MapPin,
+ RefreshCw,
+} from "lucide-react";
+
+import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
+import { HeadlineBar } from "@/components/ui/HeadlineBar";
+import { useLandingStats } from "@/lib/api/landing";
+
+// ── FAQ data ───────────────────────────────────────────────────────────────
+
+const FAQ: { q: string; a: string }[] = [
{
- title: "Аналитика",
- routes: [
- {
- href: "/analytics",
- label: "Свердл рынок",
- desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.",
- },
- {
- href: "/analytics/prinzip",
- label: "PRINZIP drill-down",
- desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.",
- },
- {
- href: "/analytics/developers",
- label: "Топ девелоперов",
- desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.",
- },
- {
- href: "/analytics/objects/60160",
- label: "Карточка ЖК (пример: Парк Победы)",
- desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
- },
- {
- href: "/analytics/recommend",
- label: "Рекомендатор квартирографии",
- desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
- },
- ],
+ q: "Откуда берутся данные?",
+ a: "Три источника: Росреестр (ДДУ-сделки, >7 млн записей), DOM.РФ (портфель новостроек, ПЗЗ), Objective (цены предложения). Обновление еженедельное для DOM.РФ и цен, ежеквартальное для Росреестра.",
},
{
- title: "Site Finder",
- routes: [
- {
- href: "/site-finder",
- label: "Анализ участка по cad-номеру",
- desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.",
- },
- ],
+ q: "Насколько точны цены?",
+ a: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 5–15%.",
},
{
- title: "Админка (Token-protected)",
- routes: [
- {
- href: "/admin/scrape/all",
- label: "Скрапер — единая панель",
- desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.",
- },
- {
- href: "/admin/leads",
- label: "Лиды PRINZIP CRM",
- desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.",
- },
- ],
+ q: "Какие районы и регионы охвачены?",
+ a: "Сейчас — Екатеринбург и Свердловская область (ПЗЗ ЕКБ, МСК-66). Расширение на другие регионы УрФО в роудмапе.",
},
{
- title: "Generative (WIP)",
- routes: [
- {
- href: "/concept",
- label: "Concept (Stage 1)",
- desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.",
- },
- ],
+ q: "Что входит в пилотный доступ?",
+ a: "Полный доступ к Site Finder (анализ участков по кадастровому номеру), аналитике рынка Свердловской области и рекомендатору квартирографии. Срок пилота — 30 дней. Поддержка по email.",
+ },
+ {
+ q: "Как устроен маппинг Objective → DOM.РФ?",
+ a: "Объекты Objective сопоставляются с портфелем DOM.РФ по нечёткому совпадению названия и геокоординатам. Текущее покрытие — около 11% (один DOM.РФ-объект может объединять несколько Objective-лотов).",
},
];
+// ── Sub-components ─────────────────────────────────────────────────────────
+
+function FaqItem({ q, a }: { q: string; a: string }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
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}
+ >
+ {q}
+ {open ? (
+
+ ) : (
+
+ )}
+
+ {open ? (
+
+ {a}
+
+ ) : null}
+
+ );
+}
+
+// ── 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 (
+
+ );
+}
+
+// ── 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 (
-
-
- {SECTIONS.map((section) => (
-
-
+
+ GenDesign
+
+
+
- {section.title}
-
+ Аналитика
+
+
+ Site Finder
+
+
setModalOpen(true)}
+ style={{
+ padding: "7px 16px",
+ background: "var(--accent)",
+ color: "#fff",
+ border: "none",
+ borderRadius: 8,
+ fontSize: 14,
+ fontWeight: 500,
+ cursor: "pointer",
+ }}
+ >
+ Запросить пилот
+
+
+
+
+ {/* ── Hero ───────────────────────────────────────────────────────── */}
+
+
+ Цифры рынка перед фундаментом
+
+
+ Аналитика новостроек Свердловской области — Росреестр, DOM.РФ,
+ Objective — в одном инструменте для девелоперов.
+
+
+ {/* ── KPI cards ──────────────────────────────────────────────── */}
+
+ {/* zk_total */}
+
+
+
+
+
ЖК DOM.РФ в ЕКБ
+
+ {showSkeleton ? : formatNumber(stats?.zk_total)}
+
+
+
+ {/* deals_total */}
+
+
+
+
+
ДДУ-сделки Росреестра
+
+ {showSkeleton ? (
+
+ ) : (
+ formatNumber(stats?.deals_total)
+ )}
+
+
+
+ {/* price_coverage_pct */}
+
+
+
+
+
Покрытие ценами Objective
+
+ {showSkeleton ? (
+
+ ) : (
+ `${formatNumber(stats?.price_coverage_pct, 1)}%`
+ )}
+
+
+
+ {/* mapping_coverage_pct */}
+
+
+
+
+
Маппинг Objective → DOM.РФ
+
+ {showSkeleton ? (
+
+ ) : (
+ `${formatNumber(stats?.mapping_coverage_pct, 1)}%`
+ )}
+
+
+
+ {/* last_data_update */}
+
+
+
+
+
Последнее обновление данных
+
+ {showSkeleton ? (
+
+ ) : (
+ (stats?.last_data_update ?? "—")
+ )}
+
+
+
+
+ {isStale && (
+
+ Данные обновляются...
+
+ )}
+
+
+ {/* ── Парадокс портфеля ──────────────────────────────────────────── */}
+
+
+
+
+ DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective
+ покрывают лишь часть из них. Из тех, где цены есть, публично доступны
+ единицы — остальное требует прямого запроса или данных, агрегированных
+ GenDesign.
+
+
+
+ {/* ── Pricing ────────────────────────────────────────────────────── */}
+
+
- {section.routes.map((r) => (
-
-
- {r.href}
-
-
{r.label}
-
- {r.desc}
-
-
- ))}
+ Пилотная подписка
+
+
+
+ 30 000
+
+
+ ₽ / мес
+
-
- ))}
+
+ {[
+ "Site Finder — анализ участков по кадастру",
+ "Аналитика рынка Свердловской области",
+ "Рекомендатор квартирографии",
+ "Данные DOM.РФ, Росреестр, Objective",
+ "Email-поддержка в рабочие дни",
+ ].map((item) => (
+
+
+
+ {item}
+
+
+ ))}
+
+
+
setModalOpen(true)}
+ style={{
+ width: "100%",
+ padding: "12px 24px",
+ background: "var(--accent)",
+ color: "#fff",
+ border: "none",
+ borderRadius: 8,
+ fontSize: 15,
+ fontWeight: 600,
+ cursor: "pointer",
+ }}
+ >
+ Запросить пилот
+
+
+
+
+ {/* ── FAQ ────────────────────────────────────────────────────────── */}
+
+
+ Частые вопросы
+
+
+ Об источниках данных, точности и поддержке.
+
+
+ {FAQ.map((item) => (
+
+ ))}
+
+
+
+ {/* ── Footer ─────────────────────────────────────────────────────── */}
+
+ {/* ── Modal ──────────────────────────────────────────────────────── */}
+ setModalOpen(false)} />
);
}
+
+// ── 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",
+};
diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx
new file mode 100644
index 00000000..d8710d64
--- /dev/null
+++ b/frontend/src/components/landing/PilotRequestModal.tsx
@@ -0,0 +1,371 @@
+"use client";
+
+import { useState } from "react";
+
+import { CheckCircle } from "lucide-react";
+
+import { Drawer } from "@/components/ui/Drawer";
+import {
+ useSubmitPilotRequest,
+ type PilotRequestPayload,
+} from "@/lib/api/landing";
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+}
+
+interface FormState {
+ name: string;
+ phone: string;
+ email: string;
+ company: 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 ──────────────────────────────────────────────────────────────
+
+export function PilotRequestModal({ open, onClose }: Props) {
+ const [form, setForm] = useState({
+ name: "",
+ phone: "",
+ email: "",
+ company: "",
+ message: "",
+ });
+ const [emailError, setEmailError] = useState("");
+ const [trackingId, setTrackingId] = useState(null);
+
+ const mutation = useSubmitPilotRequest();
+
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+ function handleChange(
+ e: React.ChangeEvent,
+ ) {
+ const { name, value } = e.target;
+ setForm((prev) => ({ ...prev, [name]: value }));
+ if (name === "email") {
+ setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : "");
+ }
+ }
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!form.name.trim()) return;
+ if (form.email && !EMAIL_RE.test(form.email)) {
+ setEmailError("Некорректный email");
+ return;
+ }
+
+ const payload: PilotRequestPayload = {
+ name: form.name.trim(),
+ source: "landing",
+ ...(form.phone.trim() ? { phone: form.phone.trim() } : {}),
+ ...(form.email.trim() ? { email: form.email.trim() } : {}),
+ ...(form.company.trim() ? { company: form.company.trim() } : {}),
+ ...(form.message.trim()
+ ? { message: form.message.trim().slice(0, 2000) }
+ : {}),
+ };
+
+ mutation.mutate(payload, {
+ onSuccess: (data) => {
+ setTrackingId(deriveTrackingId(data.id));
+ },
+ });
+ }
+
+ function handleClose() {
+ setForm({ name: "", phone: "", email: "", company: "", message: "" });
+ setEmailError("");
+ setTrackingId(null);
+ mutation.reset();
+ onClose();
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Запросить пилотный доступ
+
+
+
+
+ {/* Success state */}
+ {trackingId !== null ? (
+
+
+
+ Заявка принята
+
+
+ Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "}
+
+ {trackingId}
+
+
+
+ Закрыть
+
+
+ ) : (
+ /* Form */
+
+ )}
+
+
+ );
+}
+
+// ── Shared styles ──────────────────────────────────────────────────────────
+
+const labelStyle: React.CSSProperties = {
+ display: "block",
+ fontSize: 12,
+ fontWeight: 500,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.04em",
+ color: "var(--fg-secondary)",
+ marginBottom: 6,
+};
+
+const inputStyle: React.CSSProperties = {
+ width: "100%",
+ padding: "9px 12px",
+ border: "1px solid var(--border-strong)",
+ borderRadius: 8,
+ fontSize: 14,
+ 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
new file mode 100644
index 00000000..77f2dc6a
--- /dev/null
+++ b/frontend/src/lib/api/landing.ts
@@ -0,0 +1,54 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+
+import { apiFetch } from "@/lib/api";
+
+// ── Types ──────────────────────────────────────────────────────────────────
+
+export interface LandingStats {
+ zk_total: number;
+ deals_total: number;
+ price_coverage_pct: number;
+ 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 {
+ name: string;
+ phone?: string;
+ email?: string;
+ company?: string;
+ message?: string;
+ source: string;
+}
+
+export interface PilotRequestResponse {
+ /** UUID returned as string via CAST(id AS text) */
+ id: string;
+ status: string;
+ created_at: string;
+}
+
+// ── Hooks ──────────────────────────────────────────────────────────────────
+
+export function useLandingStats() {
+ return useQuery({
+ queryKey: ["landing", "stats"],
+ queryFn: () => apiFetch("/api/v1/landing/stats"),
+ staleTime: 60_000,
+ });
+}
+
+export function useSubmitPilotRequest() {
+ return useMutation({
+ mutationFn: (payload: PilotRequestPayload) =>
+ apiFetch("/api/v1/pilot/request", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ });
+}