"use client"; /** * Trade-In страница в стиле gendsgn.ru/tradein.html mockup. * С basePath=/trade-in user видит её на gendsgn.ru/trade-in/. * Использует CSS из /components/trade-in/trade-in.css. */ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import "@/components/trade-in/trade-in.css"; import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in"; import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api"; import { HTTPError } from "@/lib/api"; import { EstimateForm } from "@/components/trade-in/EstimateForm"; import { Topbar } from "@/components/trade-in/Topbar"; import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; import { HeroSummary } from "@/components/trade-in/HeroSummary"; import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark"; import { CianValuationCard } from "@/components/trade-in/CianValuationCard"; import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard"; import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard"; import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSection"; import { PhotoUpload } from "@/components/trade-in/PhotoUpload"; import { ListingsCard } from "@/components/trade-in/ListingsCard"; import { DealsCard } from "@/components/trade-in/DealsCard"; import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard"; import { MapCard } from "@/components/trade-in/MapCard"; import { DistributionCard } from "@/components/trade-in/DistributionCard"; import { PriceTrendCard } from "@/components/trade-in/PriceTrendCard"; import { ExposureCard } from "@/components/trade-in/ExposureCard"; import { OfferCard } from "@/components/trade-in/OfferCard"; import { WhatIfPanel } from "@/components/trade-in/WhatIfPanel"; import { TestPresets } from "@/components/trade-in/TestPresets"; import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api"; import { useQuota } from "@/lib/useQuota"; import { useBrand, useActiveBrandSlug } from "@/lib/useBrand"; function useEstimateId() { if (typeof window === "undefined") return null; const params = new URLSearchParams(window.location.search); return params.get("id"); } export default function TradeInPage() { const router = useRouter(); const queryClient = useQueryClient(); const quota = useQuota(); // #657 white-label: «отдельная страничка под Практику» = re-skin того же // UI при ?brand=praktika. Бренд резолвится из ?brand= slug (см. useBrand.ts). const { data: brand } = useBrand(); const activeBrandSlug = useActiveBrandSlug(); // Переопределяем CSS-переменные палитры в рантайме + вешаем .brand-active // (переключает --font-sans на Manrope, см. trade-in.css). Cleanup снимает // overrides → без ?brand= дефолтный UI полностью нетронут (no-op). useEffect(() => { const root = document.documentElement; if (!brand) return; // Бренд-цвета идут в CSS custom-property без доверия (#766: подменённый бренд // → CSS-инъекция). Применяем только валидный hex / rgb()/hsl(); иначе дефолт. const COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^(rgb|hsl)a?\([\d.,%\s/]+\)$/; if (COLOR_RE.test(brand.primary_color)) { root.style.setProperty("--accent", brand.primary_color); // зелёный (бренд) } if (COLOR_RE.test(brand.accent_color)) { root.style.setProperty("--accent-2", brand.accent_color); // циан (акцент) } root.classList.add("brand-active"); return () => { root.style.removeProperty("--accent"); root.style.removeProperty("--accent-2"); root.classList.remove("brand-active"); }; }, [brand]); const blocked = quota.data ? !quota.data.unlimited && quota.data.remaining <= 0 : false; const remainingQuota = quota.data && !quota.data.unlimited ? quota.data.remaining : null; const limitQuota = quota.data && !quota.data.unlimited ? quota.data.limit : null; const [freshResult, setFreshResult] = useState<{ estimate: AggregatedEstimate; input: TradeInEstimateInput; } | null>(null); const urlEstimateId = useEstimateId(); const restoredEstimate = useEstimate( freshResult === null ? urlEstimateId : null, ); const mutation = useEstimateMutation(); const currentEstimateId = freshResult?.estimate.estimate_id ?? urlEstimateId; const imvBenchmark = useEstimateImvBenchmark(currentEstimateId); const houses = useEstimateHouses(currentEstimateId); function handleSubmit(input: TradeInEstimateInput) { mutation.mutate(input, { onSuccess: (estimate) => { setFreshResult({ estimate, input }); // basePath auto-prefixes — passing "/" returns user to /trade-in/?id=... router.replace(`/?id=${estimate.estimate_id}`, { scroll: false }); void queryClient.invalidateQueries({ queryKey: ["trade-in", "quota"] }); }, }); } const apiError = mutation.error?.message ?? null; const resultData = freshResult ?? (restoredEstimate.data ? { estimate: restoredEstimate.data, input: { address: restoredEstimate.data.target_address ?? "—", area_m2: 0, rooms: restoredEstimate.data.analogs[0]?.rooms ?? 0, floor: 0, total_floors: 0, } as TradeInEstimateInput, } : null); // #822: restore по ?id= — отражаем загрузку/ошибку вместо молчаливого // empty-state. Backend отдаёт 404 на missing/owner-mismatch/expired. const restoreActive = urlEstimateId !== null && freshResult === null; const restoreError = restoreActive && restoredEstimate.isError; const restoreLoading = restoreActive && restoredEstimate.isPending; const restoreNotFound = restoreError && restoredEstimate.error instanceof HTTPError && restoredEstimate.error.status === 404; // Надёжный input для «Что-если»: предпочитаем поля самой оценки (есть и при // restore по ?id=, где resultData.input — stub с нулями), откатываемся на input. // address/rooms обязательны для payload — берём из estimate, иначе из input. const whatIfInput: TradeInEstimateInput | null = resultData ? { ...resultData.input, address: resultData.estimate.target_address ?? resultData.input.address, rooms: resultData.estimate.rooms ?? resultData.input.rooms, area_m2: resultData.estimate.area_m2 ?? resultData.input.area_m2, floor: resultData.estimate.floor ?? resultData.input.floor, total_floors: resultData.estimate.total_floors ?? resultData.input.total_floors, year_built: resultData.estimate.year_built ?? resultData.input.year_built, house_type: resultData.estimate.house_type ?? resultData.input.house_type, repair_state: resultData.estimate.repair_state ?? resultData.input.repair_state, has_balcony: resultData.estimate.has_balcony ?? resultData.input.has_balcony, } : null; // Гейтим панель: нужен валидный адрес + площадь, иначе recompute-payload невалиден. const whatIfReady = whatIfInput !== null && whatIfInput.address.trim().length >= 3 && whatIfInput.area_m2 > 10; function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) { if (!resultData) return; const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch }; handleSubmit(enrichedInput); } const isPending = mutation.isPending; return ( <>
Главная Мера Новая оценка

Оценка квартиры на вторичке

Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра. Время сбора — 10–30 сек.

Отчёт{" "} {resultData?.estimate.estimate_id.slice(0, 8) ?? "—"} Дата{" "} {new Date().toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", })} {resultData && ( Действителен до{" "} {new Date(resultData.estimate.expires_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", })} )}
{/* Sticky form left */} {/* Result column */}
{/* Тестовые пресеты — показываем только когда нет результата */} {!resultData && !isPending && ( handleSubmit(data)} /> )} {resultData ? ( <> {whatIfReady && whatIfInput && ( )} {currentEstimateId && ( )} {currentEstimateId && ( )} {/* ANALYTICS cards — каждая с graceful guard «render only if data present» */} ) : restoreError ? (

{restoreNotFound ? "Отчёт не найден или недоступен" : "Не удалось загрузить отчёт"}

{restoreNotFound ? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа." : "Произошла ошибка при загрузке отчёта. Попробуйте ещё раз."}

) : restoreLoading ? (

Загрузка отчёта…

) : (
🏠

Введите параметры квартиры

Заполните форму слева или выберите тестовую квартиру выше.

)}
); }