+ Trade-In Estimator +
++ Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ +
+diff --git a/frontend/src/app/trade-in/page.tsx b/frontend/src/app/trade-in/page.tsx
new file mode 100644
index 00000000..02192dbc
--- /dev/null
+++ b/frontend/src/app/trade-in/page.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+
+import type {
+ AggregatedEstimate,
+ TradeInEstimateInput,
+} from "@/types/trade-in";
+import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
+import { EstimateForm } from "@/components/trade-in/EstimateForm";
+import { EstimateResult } from "@/components/trade-in/EstimateResult";
+import { EstimateProgress } from "@/components/trade-in/EstimateProgress";
+
+// ── URL persistence helpers ────────────────────────────────────────────────────
+
+function useEstimateId() {
+ // SSR guard: searchParams is only available in client
+ if (typeof window === "undefined") return null;
+ const params = new URLSearchParams(window.location.search);
+ return params.get("id");
+}
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export default function TradeInPage() {
+ const router = useRouter();
+
+ // Result from mutation (fresh) or from URL (restored)
+ const [freshResult, setFreshResult] = useState<{
+ estimate: AggregatedEstimate;
+ input: TradeInEstimateInput;
+ } | null>(null);
+
+ const urlEstimateId = useEstimateId();
+ // Fetch from URL if no fresh result yet
+ const restoredEstimate = useEstimate(
+ freshResult === null ? urlEstimateId : null,
+ );
+
+ const mutation = useEstimateMutation();
+
+ function handleSubmit(input: TradeInEstimateInput) {
+ mutation.mutate(input, {
+ onSuccess: (estimate) => {
+ setFreshResult({ estimate, input });
+ // Persist estimate_id in URL for shareable link
+ router.replace(`/trade-in?id=${estimate.estimate_id}`, {
+ scroll: false,
+ });
+ },
+ });
+ }
+
+ const apiError = mutation.error?.message ?? null;
+
+ // Determine what to render on the right side
+ const resultData: {
+ estimate: AggregatedEstimate;
+ input: TradeInEstimateInput;
+ } | null =
+ freshResult ??
+ (restoredEstimate.data
+ ? {
+ estimate: restoredEstimate.data,
+ // When restoring from URL we don't have the original input — synthesise a minimal one
+ input: {
+ address: restoredEstimate.data.analogs[0]?.address ?? "—",
+ area_m2: 0,
+ rooms: 0,
+ floor: 0,
+ total_floors: 0,
+ },
+ }
+ : null);
+
+ return (
+
+ Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ
+
+ Trade-In Estimator
+
+
| Фото | +Адрес | +м² | +Комн. | +Этаж | + handleSort("price_rub")}
+ >
+ Цена ₽ |
+ handleSort("price_per_m2")}
+ >
+ ₽/м² |
+ handleSort("days_on_market")}
+ >
+ Дней |
+
|---|---|---|---|---|---|---|---|
|
+ {imgSrc ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+
+
+ )}
+ |
+ + + {row.address} + + {row.listing_date && ( + + {fmtDate(row.listing_date)} + + )} + | +{row.area_m2.toFixed(1)} | ++ {row.rooms === 0 ? "ст." : row.rooms} + | ++ {row.floor !== null && row.total_floors !== null + ? `${row.floor}/${row.total_floors}` + : (row.floor ?? "—")} + | +{fmtRub(row.price_rub)} | +{fmtRub(row.price_per_m2)} | ++ {row.days_on_market !== null ? row.days_on_market : "—"} + | +