349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
"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 (
|
||
<>
|
||
<Topbar active="estimate" />
|
||
|
||
<main className="page">
|
||
<div className="crumbs">
|
||
<em>Главная</em> <span>›</span> <em>Мера</em> <span>›</span> Новая оценка
|
||
</div>
|
||
|
||
<div className="page-title">
|
||
<div>
|
||
<h1>Оценка квартиры на вторичке</h1>
|
||
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
||
Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра.
|
||
Время сбора — <span className="num">10–30 сек</span>.
|
||
</p>
|
||
</div>
|
||
<div className="page-meta">
|
||
<span className="pill">
|
||
<span className="dot" />
|
||
<span>Отчёт</span>{" "}
|
||
<span className="mono">
|
||
{resultData?.estimate.estimate_id.slice(0, 8) ?? "—"}
|
||
</span>
|
||
</span>
|
||
<span className="pill">
|
||
<span className="dot muted" />
|
||
<span>Дата</span>{" "}
|
||
<span className="mono">
|
||
{new Date().toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
})}
|
||
</span>
|
||
</span>
|
||
{resultData && (
|
||
<span className="pill">
|
||
<span className="dot warn" />
|
||
<span>Действителен до</span>{" "}
|
||
<span className="mono">
|
||
{new Date(resultData.estimate.expires_at).toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
})}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="layout">
|
||
{/* Sticky form left */}
|
||
<aside className="form-card">
|
||
<EstimateForm
|
||
onSubmit={handleSubmit}
|
||
isPending={isPending}
|
||
error={apiError}
|
||
blocked={blocked}
|
||
remaining={remainingQuota}
|
||
limit={limitQuota}
|
||
/>
|
||
</aside>
|
||
|
||
{/* Result column */}
|
||
<section className="result-col">
|
||
<SourcesProgress
|
||
estimate={resultData?.estimate ?? null}
|
||
isPending={isPending}
|
||
/>
|
||
|
||
{/* Тестовые пресеты — показываем только когда нет результата */}
|
||
{!resultData && !isPending && (
|
||
<TestPresets onPick={(data) => handleSubmit(data)} />
|
||
)}
|
||
|
||
{resultData ? (
|
||
<>
|
||
{whatIfReady && whatIfInput && (
|
||
<WhatIfPanel
|
||
baseEstimate={resultData.estimate}
|
||
baseInput={whatIfInput}
|
||
/>
|
||
)}
|
||
<HeroSummary
|
||
estimate={resultData.estimate}
|
||
input={resultData.input}
|
||
onResubmit={handleResubmit}
|
||
isResubmitting={isPending}
|
||
/>
|
||
<IMVBenchmark
|
||
benchmark={imvBenchmark.data}
|
||
isLoading={imvBenchmark.isPending}
|
||
/>
|
||
<CianValuationCard data={resultData.estimate.cian_valuation} />
|
||
<HouseInfoCard
|
||
houses={houses.data}
|
||
isLoading={houses.isPending}
|
||
/>
|
||
{currentEstimateId && (
|
||
<PlacementHistoryCard estimateId={currentEstimateId} />
|
||
)}
|
||
{currentEstimateId && (
|
||
<HouseAnalyticsSection estimateId={currentEstimateId} />
|
||
)}
|
||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||
<DealsCard estimate={resultData.estimate} />
|
||
<StreetDealsCard estimate={resultData.estimate} />
|
||
{/* ANALYTICS cards — каждая с graceful guard «render only if data present» */}
|
||
<MapCard estimate={resultData.estimate} />
|
||
<DistributionCard estimate={resultData.estimate} />
|
||
<PriceTrendCard estimate={resultData.estimate} />
|
||
<ExposureCard estimate={resultData.estimate} />
|
||
<OfferCard estimate={resultData.estimate} brandSlug={activeBrandSlug} />
|
||
</>
|
||
) : restoreError ? (
|
||
<article className="card">
|
||
<div className="card-body" style={{ padding: "48px 24px", textAlign: "center" }}>
|
||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
|
||
{restoreNotFound
|
||
? "Отчёт не найден или недоступен"
|
||
: "Не удалось загрузить отчёт"}
|
||
</h3>
|
||
<p style={{ color: "var(--muted)", fontSize: 14, marginBottom: 16 }}>
|
||
{restoreNotFound
|
||
? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа."
|
||
: "Произошла ошибка при загрузке отчёта. Попробуйте ещё раз."}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={() => router.replace("/")}
|
||
>
|
||
Новая оценка
|
||
</button>
|
||
</div>
|
||
</article>
|
||
) : restoreLoading ? (
|
||
<article className="card">
|
||
<div className="card-body" style={{ padding: "48px 24px", textAlign: "center" }}>
|
||
<p style={{ color: "var(--muted)", fontSize: 14 }}>
|
||
Загрузка отчёта…
|
||
</p>
|
||
</div>
|
||
</article>
|
||
) : (
|
||
<article className="card">
|
||
<div className="card-body" style={{ padding: "48px 24px", textAlign: "center" }}>
|
||
<div style={{ fontSize: 40, marginBottom: 12 }}>🏠</div>
|
||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
|
||
Введите параметры квартиры
|
||
</h3>
|
||
<p style={{ color: "var(--muted)", fontSize: 14 }}>
|
||
Заполните форму слева или выберите тестовую квартиру выше.
|
||
</p>
|
||
</div>
|
||
</article>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="page-foot">
|
||
<div>
|
||
Мера · MVP ·{" "}
|
||
<span className="mono">data: Avito + Cian + Yandex + N1 + Росреестр</span>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 18 }}>
|
||
<a href="#">Документация</a>
|
||
<a href="#">API</a>
|
||
<a href="#">Источники данных</a>
|
||
</div>
|
||
</footer>
|
||
</>
|
||
);
|
||
}
|