gendesign/tradein-mvp/frontend/src/app/page.tsx
bot-backend f1b8ae47a2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/ux): honest source list — drop dead domklik/restate, remove fake per-source median, sync hero/footer/card (#1968)
2026-06-27 13:58:16 +03:00

349 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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">1030 сек</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>
</>
);
}