From 87e6009f06d5177ef6043b45141b8bea436cf249 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 18 May 2026 00:57:11 +0300 Subject: [PATCH] feat(sf-fe-a1): routes shell + _legacy/ backup + mock toggle wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move tab-based page.tsx to _legacy/site-finder-page-tabs.tsx (reference for Wave 2-4 section refactor) - New /site-finder/page.tsx: карта-first entry shell with MapPlaceholder + SidebarPlaceholder (TODO A2: EntryMap / MapFilterBar / RecentParcels / ParcelLegend / ParcelDrawer) - New /site-finder/analysis/[cad]/page.tsx: analysis shell with React 19 use(params), AnalysisSidebar placeholder, 5 SectionPlaceholder blocks (TODO A4-A11) - New src/lib/mock-toggle.ts: USE_MOCKS master switch + per-endpoint flags (MOCK_PARCELS_BBOX / MOCK_RECENT_PARCELS / MOCK_LANDING_STATS / MOCK_ANALYZE / MOCK_POI_SCORE) for B1-B6 progressive rollout --- .../src/app/_legacy/site-finder-page-tabs.tsx | 570 +++++++++++++++ .../app/site-finder/analysis/[cad]/page.tsx | 241 +++++++ frontend/src/app/site-finder/page.tsx | 672 ++++-------------- frontend/src/lib/mock-toggle.ts | 39 + 4 files changed, 991 insertions(+), 531 deletions(-) create mode 100644 frontend/src/app/_legacy/site-finder-page-tabs.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/page.tsx create mode 100644 frontend/src/lib/mock-toggle.ts diff --git a/frontend/src/app/_legacy/site-finder-page-tabs.tsx b/frontend/src/app/_legacy/site-finder-page-tabs.tsx new file mode 100644 index 00000000..568b146f --- /dev/null +++ b/frontend/src/app/_legacy/site-finder-page-tabs.tsx @@ -0,0 +1,570 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { FeatureCollection } from "geojson"; + +import { CadInput } from "@/components/site-finder/CadInput"; +import { FetchingState } from "@/components/site-finder/FetchingState"; +import { KpiCard } from "@/components/site-finder/KpiCard"; +import { OverviewTab } from "@/components/site-finder/OverviewTab"; +import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; +import { LandTab } from "@/components/site-finder/LandTab"; +import { MarketTab } from "@/components/site-finder/MarketTab"; +import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; +import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { useConnectionPoints } from "@/hooks/useConnectionPoints"; +import { useCustomPois } from "@/hooks/useCustomPois"; +import { + POI_DEFAULT_WEIGHTS, + type PoiCategoryKey, +} from "@/lib/api/weightProfiles"; + +// SiteMap imports Leaflet which requires browser APIs — load without SSR +const SiteMap = dynamic( + () => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap), + { + ssr: false, + loading: () => ( +
+ Загрузка карты… +
+ ), + }, +); + +type TabId = "overview" | "env" | "land" | "market"; + +const TABS: Array<{ id: TabId; label: string }> = [ + { id: "overview", label: "Обзор" }, + { id: "env", label: "Окружение" }, + { id: "land", label: "Земля" }, + { id: "market", label: "Рынок" }, +]; + +const TAB_IDS = TABS.map((t) => t.id); + +function isTabId(v: string | null | undefined): v is TabId { + return !!v && (TAB_IDS as readonly string[]).includes(v); +} + +// ── KPI helpers ─────────────────────────────────────────────────────────────── + +type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; + +function trendColor(pct: number): KpiColor { + if (pct > 5) return "green"; + if (pct > 0) return "blue"; + if (pct > -5) return "amber"; + return "red"; +} + +function noiseColor(db: number): KpiColor { + if (db < 45) return "green"; + if (db < 60) return "amber"; + return "red"; +} + +function scoreKpiColor(score: number, max: number): KpiColor { + const ratio = score / max; + if (ratio >= 0.65) return "green"; + if (ratio >= 0.35) return "amber"; + return "red"; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +function SiteFinderContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const { mutate, data, isPending, error, isIdle, fetchingState, cancel } = + useSiteAnalysis(); + const [isochrones, setIsochrones] = useState( + undefined, + ); + + // Fetch connection points whenever a parcel is loaded + const { data: connectionPoints } = useConnectionPoints(data?.cad_num); + // Fetch custom POIs for current parcel + const { data: customPois } = useCustomPois(data?.cad_num); + + // Weight profile state — lifted here so it survives tab switches. + // userId + adminToken allow the panel to load/save named profiles. + const [currentWeights, setCurrentWeights] = useState< + Record + >(() => ({ ...POI_DEFAULT_WEIGHTS })); + // Profile selection for the analyze call (null = system defaults). + const [activeProfileId, setActiveProfileId] = useState(null); + + // Pending weights change — set immediately when user clicks "Применить". + // Debounced to 300ms before triggering re-analyze, so rapid clicks collapse + // into a single request (#201 Phase 2). + const [pendingWeightsChange, setPendingWeightsChange] = useState<{ + weights: Record; + profileId: number | null; + } | null>(null); + const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300); + // Ref to skip the initial mount effect (we only re-analyze on actual changes). + const weightsChangeInitializedRef = useRef(false); + + const [profileUserId, setProfileUserId] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_user_id") ?? ""), + ); + const [adminToken] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_token") ?? ""), + ); + // Lazy init: считаем initialTab один раз на mount (useState всё равно + // игнорирует initializer после первого render — не тратим CPU). + const [tab, setTabState] = useState(() => { + const t = searchParams.get("tab"); + return isTabId(t) ? t : "overview"; + }); + + // Sync tab → URL query (?tab=env). overview = default, не пишем в URL. + function setTab(next: TabId) { + setTabState(next); + const params = new URLSearchParams(searchParams.toString()); + if (next === "overview") { + params.delete("tab"); + } else { + params.set("tab", next); + } + const qs = params.toString(); + // Используем pathname вместо голого "?" — Next 15 App Router не всегда + // чисто чистит query при `router.replace("?")` (dangling `?` остаётся). + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + } + + // Listen to back/forward navigation — reflect URL tab in state. + // setState с тем же значением React сам бэйлит через Object.is, поэтому + // guard по tab не нужен (и не вводит stale-closure через eslint-disable). + useEffect(() => { + const urlTab = searchParams.get("tab"); + setTabState(isTabId(urlTab) ? urlTab : "overview"); + }, [searchParams]); + + // Debounced re-analyze trigger: fires 300ms after the last "Применить" click. + // Skip initial mount (weightsChangeInitializedRef guards the first effect run). + // dataRef lets the effect read current data without listing it as a dep + // (avoids re-registering the timeout on every analyze completion). + const dataRef = useRef(data); + dataRef.current = data; + const profileUserIdRef = useRef(profileUserId); + profileUserIdRef.current = profileUserId; + + useEffect(() => { + if (!weightsChangeInitializedRef.current) { + weightsChangeInitializedRef.current = true; + return; + } + if (!debouncedWeightsChange) return; + const currentData = dataRef.current; + if (!currentData?.cad_num) return; + + const { weights, profileId } = debouncedWeightsChange; + const currentProfileUserId = profileUserIdRef.current; + setIsochrones(undefined); + mutate({ + cad: currentData.cad_num, + options: + profileId != null + ? { profileId } + : currentProfileUserId + ? { profileUserId: currentProfileUserId, weights } + : { weights }, + }); + // mutate is stable from useMutation — safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedWeightsChange]); + + function handleAnalyze(cadNum: string) { + setIsochrones(undefined); + setTab("overview"); + // Priority: named profile → user default profile → inline draft weights. + // When activeProfileId is set, backend uses that profile (ignores inline). + // When no profile is selected, pass currentWeights as inline so draft + // slider values are always respected even without a saved profile (#201). + mutate({ + cad: cadNum, + options: + activeProfileId != null + ? { profileId: activeProfileId } + : profileUserId + ? { profileUserId, weights: currentWeights } + : { weights: currentWeights }, + }); + } + + function handleWeightsChange( + weights: Record, + profileId: number | null, + ) { + setCurrentWeights(weights); + setActiveProfileId(profileId); + // Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). + // The actual mutate fires after 300ms of silence via debouncedWeightsChange. + if (data?.cad_num) { + setPendingWeightsChange({ weights, profileId }); + } + } + + // Derive KPI values from data + const scoreMax = data?.score_max_reference ?? 40; + const scoreValue = data + ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` + : "—"; + const scoreLabel = data?.score_label + ? `Score · ${data.score_label}` + : "Score"; + const scoreCardColor: KpiColor = data + ? scoreKpiColor(data.score, scoreMax) + : "neutral"; + + const districtValue = data?.district ? data.district.district_name : "—"; + const districtLabel = data?.district + ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + + ` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` + : "Район"; + + const trendPct = data?.market_trend?.delta_6m_pct; + const trendValue = + trendPct != null + ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` + : "—"; + const trendLabel = data?.market_trend + ? `Тренд за 6 мес · ${data.market_trend.label}` + : "Тренд рынка"; + const trendKpiColor: KpiColor = + trendPct != null ? trendColor(trendPct) : "neutral"; + + const noiseDb = data?.noise?.estimated_db; + const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; + const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; + const noiseKpiColor: KpiColor = + noiseDb != null ? noiseColor(noiseDb) : "neutral"; + + return ( +
+ {/* Breadcrumb + header */} +
+ + ← Главная + +
+ +
+
+

+ Site Finder +

+

+ Введите кадастровый номер участка или квартала для анализа локации +

+
+
+ +
+
+ + {/* Weight profile panel — collapsible, below header */} +
+ {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} + {!!adminToken && ( +
+ + { + setProfileUserId(e.target.value); + if (typeof window !== "undefined") { + localStorage.setItem("admin_user_id", e.target.value); + } + }} + /> +
+ )} + + {/* Recalculation indicator — shown while re-analyze is in-flight after + weights change (data already loaded, pendingWeightsChange set). */} + {isPending && !!data && ( +

+ Пересчёт… +

+ )} +
+ + {/* Error state */} + {error && ( +
+ {error instanceof Error ? error.message : "Ошибка анализа участка"} +
+ )} + + {/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */} + {fetchingState && ( + + )} + + {/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */} + {isPending && !fetchingState && ( +
+ Анализируем участок… +
+ )} + + {/* Empty initial state */} + {isIdle && !error && ( +
+ Введите кадастровый номер и нажмите «Анализировать» +
+ )} + + {/* Results */} + {data && ( + <> + {/* Cad label */} +
+ {data.cad_num} +
+ + {/* Hero band — sticky KPI cards */} +
+ + + + +
+ + {/* Tab buttons */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* 2-column grid: tab content + sticky map */} +
+ {/* Tab content */} +
+ {tab === "overview" && ( + + )} + {tab === "env" && } + {tab === "land" && } + {tab === "market" && } +
+ + {/* Sticky right sidebar — map always visible */} + +
+ + )} +
+ ); +} + +// useSearchParams() в Next 15 App Router throw'ит при static rendering без +// boundary — поэтому оборачиваем page-level. +export default function SiteFinderPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx new file mode 100644 index 00000000..0573e29a --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { LayoutDashboard } from "lucide-react"; + +// ── Placeholder components ───────────────────────────────────────────────── +// Replaced in Wave 2–4 with real sections + +function SidebarPlaceholder() { + return ( + + ); +} + +interface SectionPlaceholderProps { + id: string; + title: string; + note?: string; +} + +function SectionPlaceholder({ id, title, note }: SectionPlaceholderProps) { + return ( +
+

+ {title} +

+ {note && ( +

+ {note} +

+ )} +
+ + Заполняется в Wave 2–4 (A5–A11) +
+
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +interface PageProps { + params: Promise<{ cad: string }>; +} + +export default function AnalysisPage({ params }: PageProps) { + // React 19: use() unwraps Promise params (app router pattern) + const { cad } = use(params); + + return ( +
+ {/* Header / breadcrumb */} +
+ {/* TODO A4: replace with */} + + ← К карте + + · + + {cad} + + · + + TODO A4: район · площадь · анализ свежий N мин + + {/* TODO A4: справа */} +
+ + {/* Body: sidebar + scrollable sections */} +
+ {/* TODO A4: replace with */} + + + {/* Main scrollable content */} +
+ {/* TODO A5: Section 1 — HeadlineBar + 4 KPI + mini-map + EgrnPropertyTable + PoiList2Gis */} + + + {/* TODO A6: Section 2 — NspdEngineeringNearbyBlock */} + + + {/* TODO A7/A8/A9: Section 3 — sub-sections 3.1 / 3.2 / 3.3 + CompetitorTable */} + + + {/* TODO A10: Section 4 — ScoreBreakdownPanel + GateVerdictBanner + geology/hydro/risk */} + + + {/* TODO A11: Section 5 — SeasonalWeatherBlock + air quality */} + +
+
+
+ ); +} diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index 568b146f..54ed66b4 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,570 +1,180 @@ "use client"; -import { Suspense, useEffect, useRef, useState } from "react"; -import dynamic from "next/dynamic"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import type { FeatureCollection } from "geojson"; +import { MapPin } from "lucide-react"; -import { CadInput } from "@/components/site-finder/CadInput"; -import { FetchingState } from "@/components/site-finder/FetchingState"; -import { KpiCard } from "@/components/site-finder/KpiCard"; -import { OverviewTab } from "@/components/site-finder/OverviewTab"; -import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; -import { LandTab } from "@/components/site-finder/LandTab"; -import { MarketTab } from "@/components/site-finder/MarketTab"; -import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; -import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { useConnectionPoints } from "@/hooks/useConnectionPoints"; -import { useCustomPois } from "@/hooks/useCustomPois"; -import { - POI_DEFAULT_WEIGHTS, - type PoiCategoryKey, -} from "@/lib/api/weightProfiles"; +// ── Placeholder components ───────────────────────────────────────────────── +// Replaced in A2 with , , -// SiteMap imports Leaflet which requires browser APIs — load without SSR -const SiteMap = dynamic( - () => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap), - { - ssr: false, - loading: () => ( +function MapPlaceholder() { + return ( +
+ + TODO A2: EntryMap — Leaflet full-screen ЕКБ + parcel layers + + Данные: GET /api/v1/parcels/by-bbox (B1 ready) + +
+ ); +} + +function SidebarPlaceholder() { + return ( + + ); } // ── Page ────────────────────────────────────────────────────────────────────── -function SiteFinderContent() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const { mutate, data, isPending, error, isIdle, fetchingState, cancel } = - useSiteAnalysis(); - const [isochrones, setIsochrones] = useState( - undefined, - ); - - // Fetch connection points whenever a parcel is loaded - const { data: connectionPoints } = useConnectionPoints(data?.cad_num); - // Fetch custom POIs for current parcel - const { data: customPois } = useCustomPois(data?.cad_num); - - // Weight profile state — lifted here so it survives tab switches. - // userId + adminToken allow the panel to load/save named profiles. - const [currentWeights, setCurrentWeights] = useState< - Record - >(() => ({ ...POI_DEFAULT_WEIGHTS })); - // Profile selection for the analyze call (null = system defaults). - const [activeProfileId, setActiveProfileId] = useState(null); - - // Pending weights change — set immediately when user clicks "Применить". - // Debounced to 300ms before triggering re-analyze, so rapid clicks collapse - // into a single request (#201 Phase 2). - const [pendingWeightsChange, setPendingWeightsChange] = useState<{ - weights: Record; - profileId: number | null; - } | null>(null); - const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300); - // Ref to skip the initial mount effect (we only re-analyze on actual changes). - const weightsChangeInitializedRef = useRef(false); - - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); - // Lazy init: считаем initialTab один раз на mount (useState всё равно - // игнорирует initializer после первого render — не тратим CPU). - const [tab, setTabState] = useState(() => { - const t = searchParams.get("tab"); - return isTabId(t) ? t : "overview"; - }); - - // Sync tab → URL query (?tab=env). overview = default, не пишем в URL. - function setTab(next: TabId) { - setTabState(next); - const params = new URLSearchParams(searchParams.toString()); - if (next === "overview") { - params.delete("tab"); - } else { - params.set("tab", next); - } - const qs = params.toString(); - // Используем pathname вместо голого "?" — Next 15 App Router не всегда - // чисто чистит query при `router.replace("?")` (dangling `?` остаётся). - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); - } - - // Listen to back/forward navigation — reflect URL tab in state. - // setState с тем же значением React сам бэйлит через Object.is, поэтому - // guard по tab не нужен (и не вводит stale-closure через eslint-disable). - useEffect(() => { - const urlTab = searchParams.get("tab"); - setTabState(isTabId(urlTab) ? urlTab : "overview"); - }, [searchParams]); - - // Debounced re-analyze trigger: fires 300ms after the last "Применить" click. - // Skip initial mount (weightsChangeInitializedRef guards the first effect run). - // dataRef lets the effect read current data without listing it as a dep - // (avoids re-registering the timeout on every analyze completion). - const dataRef = useRef(data); - dataRef.current = data; - const profileUserIdRef = useRef(profileUserId); - profileUserIdRef.current = profileUserId; - - useEffect(() => { - if (!weightsChangeInitializedRef.current) { - weightsChangeInitializedRef.current = true; - return; - } - if (!debouncedWeightsChange) return; - const currentData = dataRef.current; - if (!currentData?.cad_num) return; - - const { weights, profileId } = debouncedWeightsChange; - const currentProfileUserId = profileUserIdRef.current; - setIsochrones(undefined); - mutate({ - cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, - }); - // mutate is stable from useMutation — safe to omit from deps. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedWeightsChange]); - - function handleAnalyze(cadNum: string) { - setIsochrones(undefined); - setTab("overview"); - // Priority: named profile → user default profile → inline draft weights. - // When activeProfileId is set, backend uses that profile (ignores inline). - // When no profile is selected, pass currentWeights as inline so draft - // slider values are always respected even without a saved profile (#201). - mutate({ - cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, - }); - } - - function handleWeightsChange( - weights: Record, - profileId: number | null, - ) { - setCurrentWeights(weights); - setActiveProfileId(profileId); - // Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). - // The actual mutate fires after 300ms of silence via debouncedWeightsChange. - if (data?.cad_num) { - setPendingWeightsChange({ weights, profileId }); - } - } - - // Derive KPI values from data - const scoreMax = data?.score_max_reference ?? 40; - const scoreValue = data - ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` - : "—"; - const scoreLabel = data?.score_label - ? `Score · ${data.score_label}` - : "Score"; - const scoreCardColor: KpiColor = data - ? scoreKpiColor(data.score, scoreMax) - : "neutral"; - - const districtValue = data?.district ? data.district.district_name : "—"; - const districtLabel = data?.district - ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + - ` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` - : "Район"; - - const trendPct = data?.market_trend?.delta_6m_pct; - const trendValue = - trendPct != null - ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` - : "—"; - const trendLabel = data?.market_trend - ? `Тренд за 6 мес · ${data.market_trend.label}` - : "Тренд рынка"; - const trendKpiColor: KpiColor = - trendPct != null ? trendColor(trendPct) : "neutral"; - - const noiseDb = data?.noise?.estimated_db; - const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; - const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; - const noiseKpiColor: KpiColor = - noiseDb != null ? noiseColor(noiseDb) : "neutral"; - +export default function SiteFinderPage() { return ( -
- {/* Breadcrumb + header */} -
+
+ {/* Header */} +
← Главная -
+ · +

+ SiteFinder · карта участков +

+ {/* TODO A4: справа */} + + {/* TODO A2: MapFilterBar top-bar с chips (free/area/vri/district) + counter «148 / 34 подходят» */} + + {/* Main layout: карта слева, sidebar справа */}
-
-

- Site Finder -

-

- Введите кадастровый номер участка или квартала для анализа локации -

-
+ {/* Map area */}
- + {/* TODO A2: replace with */} + +
+ + {/* Right sidebar */} +
+ {/* TODO A2: + + */} +
- {/* Weight profile panel — collapsible, below header */} -
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} - - {/* Recalculation indicator — shown while re-analyze is in-flight after - weights change (data already loaded, pendingWeightsChange set). */} - {isPending && !!data && ( -

- Пересчёт… -

- )} -
- - {/* Error state */} - {error && ( -
- {error instanceof Error ? error.message : "Ошибка анализа участка"} -
- )} - - {/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */} - {fetchingState && ( - - )} - - {/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */} - {isPending && !fetchingState && ( -
- Анализируем участок… -
- )} - - {/* Empty initial state */} - {isIdle && !error && ( -
- Введите кадастровый номер и нажмите «Анализировать» -
- )} - - {/* Results */} - {data && ( - <> - {/* Cad label */} -
- {data.cad_num} -
- - {/* Hero band — sticky KPI cards */} -
- - - - -
- - {/* Tab buttons */} -
- {TABS.map((t) => ( - - ))} -
- - {/* 2-column grid: tab content + sticky map */} -
- {/* Tab content */} -
- {tab === "overview" && ( - - )} - {tab === "env" && } - {tab === "land" && } - {tab === "market" && } -
- - {/* Sticky right sidebar — map always visible */} - -
- - )} + {/* TODO A2: — slide-in справа при клике на парцель (query ?selected=cad) */}
); } - -// useSearchParams() в Next 15 App Router throw'ит при static rendering без -// boundary — поэтому оборачиваем page-level. -export default function SiteFinderPage() { - return ( - - - - ); -} diff --git a/frontend/src/lib/mock-toggle.ts b/frontend/src/lib/mock-toggle.ts new file mode 100644 index 00000000..acedfe09 --- /dev/null +++ b/frontend/src/lib/mock-toggle.ts @@ -0,0 +1,39 @@ +/** + * Mock toggle — controls whether hooks return fixture data or call real backend. + * + * Usage in hooks: + * import { USE_MOCKS, MOCK_PARCELS_BBOX } from "@/lib/mock-toggle"; + * + * // Coarse toggle: all mocks on/off + * if (USE_MOCKS) return fixtureParcels; + * + * // Fine-grained: per-endpoint (disable individually as B1-B6 ship to prod) + * if (MOCK_PARCELS_BBOX) return fixtureParcels; + * + * .env.local (dev): + * NEXT_PUBLIC_USE_MOCKS=true + * + * Production: unset or NEXT_PUBLIC_USE_MOCKS=false + * + * TODO: per-hook fine-grained flags below — flip to false as each backend + * endpoint (B1-B6) is confirmed deployed and smoke-tested in prod. + */ + +/** Master switch — set NEXT_PUBLIC_USE_MOCKS=true in .env.local for dev */ +export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true"; + +/** + * Fine-grained feature flags (all derive from USE_MOCKS initially). + * Flip each to `false` once the corresponding backend endpoint is live. + * + * B1 — GET /api/v1/parcels/by-bbox → used by A2 EntryMap + * B2 — GET /api/v1/users/me/recent-parcels → used by A2 RecentParcels + * B4 — GET /api/v1/landing/stats → used by A12 Landing + * B5 — POST /api/v1/parcels/{cad}/analyze → used by A5–A11 sections + * B6 — GET /api/v1/parcels/{cad}/poi-score → used by A5 PoiList2Gis + */ +export const MOCK_PARCELS_BBOX = USE_MOCKS; // B1 +export const MOCK_RECENT_PARCELS = USE_MOCKS; // B2 +export const MOCK_LANDING_STATS = USE_MOCKS; // B4 +export const MOCK_ANALYZE = USE_MOCKS; // B5 +export const MOCK_POI_SCORE = USE_MOCKS; // B6 -- 2.45.3