From 7fa93f825cea8ef3b1e72f0123aa2cec1512afb2 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 19:54:11 +0300 Subject: [PATCH] feat(trade-in): TI-3 frontend /trade-in page + 5 components + hooks - types/trade-in.ts: TradeInEstimateInput, AggregatedEstimate, AnalogLot - lib/trade-in-api.ts: useEstimateMutation (POST) + useEstimate (GET) - EstimateForm: controlled form with validation, required fields, optional fields - EstimateResult: hero price card, PriceRangeBar, 4 sections (cover/listings/deals/trade-in placeholder) - AnalogsTable: sortable table with photo fallback, safeUrl XSS guard - PriceRangeBar: horizontal scale with median marker - EstimateProgress: inline spinner - page.tsx: 2-col sticky layout, mobile-responsive, URL persistence via ?id= Backend uses mock data (realistic EKB prices). PDF button disabled (coming TI-2). Depends on TI-1 (PR #316). Closes #314 (TI-3 sub-task). --- frontend/src/app/trade-in/page.tsx | 183 +++++++ .../src/components/trade-in/AnalogsTable.tsx | 283 +++++++++++ .../src/components/trade-in/EstimateForm.tsx | 393 +++++++++++++++ .../components/trade-in/EstimateProgress.tsx | 44 ++ .../components/trade-in/EstimateResult.tsx | 471 ++++++++++++++++++ .../src/components/trade-in/PriceRangeBar.tsx | 78 +++ frontend/src/lib/trade-in-api.ts | 39 ++ frontend/src/types/trade-in.ts | 52 ++ 8 files changed, 1543 insertions(+) create mode 100644 frontend/src/app/trade-in/page.tsx create mode 100644 frontend/src/components/trade-in/AnalogsTable.tsx create mode 100644 frontend/src/components/trade-in/EstimateForm.tsx create mode 100644 frontend/src/components/trade-in/EstimateProgress.tsx create mode 100644 frontend/src/components/trade-in/EstimateResult.tsx create mode 100644 frontend/src/components/trade-in/PriceRangeBar.tsx create mode 100644 frontend/src/lib/trade-in-api.ts create mode 100644 frontend/src/types/trade-in.ts 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 ( +
+ {/* Page header */} +
+

+ Trade-In Estimator +

+

+ Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ +

+
+ + {/* Two-column layout */} +
+ {/* Left — sticky form */} +
+ + +
+ + {/* Right — result area */} +
+ {resultData ? ( + + ) : restoredEstimate.isLoading ? ( + + ) : ( + + )} +
+
+ + {/* Responsive: stack on mobile */} + +
+ ); +} + +function EmptyState() { + return ( +
+
🏠
+
+ Введите параметры квартиры +
+
+ Оценка появится здесь после нажатия «Оценить» +
+
+ ); +} diff --git a/frontend/src/components/trade-in/AnalogsTable.tsx b/frontend/src/components/trade-in/AnalogsTable.tsx new file mode 100644 index 00000000..b6ebc00e --- /dev/null +++ b/frontend/src/components/trade-in/AnalogsTable.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useState } from "react"; + +import type { AnalogLot } from "@/types/trade-in"; + +/** XSS prevention: only allow http/https/relative src */ +function safeImgSrc(url: string | null): string | null { + if (!url) return null; + if ( + url.startsWith("https://") || + url.startsWith("http://") || + url.startsWith("/") + ) { + return url; + } + return null; +} + +function fmtRub(value: number): string { + return value.toLocaleString("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }); +} + +function fmtDate(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "2-digit", + }); +} + +type SortKey = "price_rub" | "price_per_m2" | "days_on_market"; + +interface Props { + rows: AnalogLot[]; + emptyLabel?: string; +} + +export function AnalogsTable({ + rows, + emptyLabel = "Нет аналогов в радиусе 1 км", +}: Props) { + const [sortKey, setSortKey] = useState("price_rub"); + const [sortAsc, setSortAsc] = useState(true); + + if (rows.length === 0) { + return ( +
+ {emptyLabel} +
+ ); + } + + const sorted = [...rows].sort((a, b) => { + const av = a[sortKey] ?? 0; + const bv = b[sortKey] ?? 0; + return sortAsc + ? (av as number) - (bv as number) + : (bv as number) - (av as number); + }); + + function handleSort(key: SortKey) { + if (sortKey === key) { + setSortAsc((prev) => !prev); + } else { + setSortKey(key); + setSortAsc(true); + } + } + + function SortIcon({ col }: { col: SortKey }) { + if (sortKey !== col) return null; + return sortAsc ? ( + + ) : ( + + ); + } + + const thStyle: React.CSSProperties = { + padding: "7px 8px", + fontSize: 11, + color: "#5b6066", + textTransform: "uppercase", + letterSpacing: "0.04em", + fontWeight: 600, + borderBottom: "1px solid #e6e8ec", + textAlign: "left", + whiteSpace: "nowrap", + background: "#f9fafb", + }; + const tdStyle: React.CSSProperties = { + padding: "8px 8px", + fontSize: 13, + color: "#1a1d23", + borderBottom: "1px solid #f3f4f6", + verticalAlign: "middle", + }; + const numTd: React.CSSProperties = { + ...tdStyle, + fontVariantNumeric: "tabular-nums", + textAlign: "right", + }; + const sortableTh = (col: SortKey): React.CSSProperties => ({ + ...thStyle, + cursor: "pointer", + userSelect: "none", + color: sortKey === col ? "#1d4ed8" : "#5b6066", + }); + + return ( +
+ + + + + + + + + + + + + + + {sorted.map((row, i) => { + const imgSrc = safeImgSrc(row.photo_url); + return ( + + + + + + + + + + + ); + })} + +
ФотоАдресм²Комн.Этаж 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 : "—"} +
+
+ ); +} diff --git a/frontend/src/components/trade-in/EstimateForm.tsx b/frontend/src/components/trade-in/EstimateForm.tsx new file mode 100644 index 00000000..a1c5711b --- /dev/null +++ b/frontend/src/components/trade-in/EstimateForm.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useState } from "react"; + +import type { + HouseType, + RepairState, + TradeInEstimateInput, +} from "@/types/trade-in"; + +const HOUSE_TYPE_LABELS: Record = { + panel: "Панель", + brick: "Кирпич", + monolith: "Монолит", + monolith_brick: "Монолит-кирпич", + other: "Другое", +}; + +const REPAIR_STATE_LABELS: Record = { + needs_repair: "Требует ремонта", + standard: "Стандартный", + good: "Хороший", + excellent: "Евроремонт", +}; + +interface Props { + onSubmit: (input: TradeInEstimateInput) => void; + isPending: boolean; + error: string | null; +} + +interface FormState { + address: string; + area_m2: string; + rooms: string; + floor: string; + total_floors: string; + year_built: string; + house_type: HouseType | ""; + repair_state: RepairState | ""; + has_balcony: boolean; +} + +const INITIAL: FormState = { + address: "", + area_m2: "", + rooms: "", + floor: "", + total_floors: "", + year_built: "", + house_type: "", + repair_state: "", + has_balcony: false, +}; + +function validate(f: FormState): string | null { + if (f.address.trim().length < 3) return "Введите адрес (минимум 3 символа)"; + const area = Number(f.area_m2); + if (!f.area_m2 || isNaN(area) || area <= 10 || area >= 500) + return "Площадь: от 10 до 500 м²"; + const rooms = Number(f.rooms); + if (f.rooms === "" || isNaN(rooms) || rooms < 0 || rooms > 10) + return "Количество комнат: 0 (студия) — 10"; + const floor = Number(f.floor); + if (!f.floor || isNaN(floor) || floor < 1 || floor > 100) + return "Этаж: 1—100"; + const totalFloors = Number(f.total_floors); + if ( + !f.total_floors || + isNaN(totalFloors) || + totalFloors < 1 || + totalFloors > 100 + ) + return "Этажей в доме: 1—100"; + if (floor > totalFloors) return "Этаж не может быть больше этажей в доме"; + if (f.year_built !== "") { + const yr = Number(f.year_built); + if (isNaN(yr) || yr < 1800 || yr > 2100) return "Год постройки: 1800—2100"; + } + return null; +} + +function toInput(f: FormState): TradeInEstimateInput { + const input: TradeInEstimateInput = { + address: f.address.trim(), + area_m2: Number(f.area_m2), + rooms: Number(f.rooms), + floor: Number(f.floor), + total_floors: Number(f.total_floors), + has_balcony: f.has_balcony, + }; + if (f.year_built !== "") input.year_built = Number(f.year_built); + if (f.house_type !== "") input.house_type = f.house_type; + if (f.repair_state !== "") input.repair_state = f.repair_state; + return input; +} + +export function EstimateForm({ onSubmit, isPending, error }: Props) { + const [form, setForm] = useState(INITIAL); + const [touched, setTouched] = useState(false); + + const validationError = validate(form); + const canSubmit = !validationError && !isPending; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setTouched(true); + if (!canSubmit) return; + onSubmit(toInput(form)); + } + + function field(key: K) { + return (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [key]: e.target.value })); + }; + } + + return ( +
+

+ Параметры квартиры +

+ + {/* Address */} + + + {/* Area + Rooms row */} +
+ + +
+ + {/* Floor + Total floors row */} +
+ + +
+ + {/* Year built */} + + + {/* House type */} + + + {/* Repair state */} + + + {/* Balcony */} + + + {/* Validation error (shown after first submit attempt) */} + {touched && validationError && ( +
{validationError}
+ )} + + {/* API error */} + {error &&
{error}
} + + + +
+ ); +} + +function Required() { + return *; +} + +const labelStyle: React.CSSProperties = { + display: "block", + fontSize: 12, + color: "#5b6066", + marginBottom: 4, + textTransform: "uppercase", + letterSpacing: 0.4, + fontWeight: 600, +}; + +const inputStyle: React.CSSProperties = { + padding: "8px 10px", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + width: "100%", + boxSizing: "border-box", + color: "#1a1d23", + background: "#fff", +}; + +const errorBoxStyle: React.CSSProperties = { + padding: "10px 12px", + background: "#fef2f2", + border: "1px solid #fecaca", + borderRadius: 6, + color: "#991b1b", + fontSize: 13, +}; + +const primaryBtn = (enabled: boolean): React.CSSProperties => ({ + padding: "11px 18px", + background: enabled ? "#1d4ed8" : "#9ca3af", + color: "#fff", + border: "none", + borderRadius: 8, + fontSize: 15, + fontWeight: 600, + cursor: enabled ? "pointer" : "not-allowed", + marginTop: 4, + transition: "background 0.15s", +}); diff --git a/frontend/src/components/trade-in/EstimateProgress.tsx b/frontend/src/components/trade-in/EstimateProgress.tsx new file mode 100644 index 00000000..f4a8434d --- /dev/null +++ b/frontend/src/components/trade-in/EstimateProgress.tsx @@ -0,0 +1,44 @@ +"use client"; + +interface Props { + visible: boolean; +} + +export function EstimateProgress({ visible }: Props) { + if (!visible) return null; + + return ( +
+ {/* Spinning circle SVG */} + + Считаем оценку… + +
+ ); +} diff --git a/frontend/src/components/trade-in/EstimateResult.tsx b/frontend/src/components/trade-in/EstimateResult.tsx new file mode 100644 index 00000000..1e81a27b --- /dev/null +++ b/frontend/src/components/trade-in/EstimateResult.tsx @@ -0,0 +1,471 @@ +"use client"; + +import type { AggregatedEstimate, ConfidenceLevel } from "@/types/trade-in"; +import type { TradeInEstimateInput } from "@/types/trade-in"; +import { PriceRangeBar } from "./PriceRangeBar"; +import { AnalogsTable } from "./AnalogsTable"; + +// ── helpers ──────────────────────────────────────────────────────────────────── + +function fmtRub(value: number): string { + return value.toLocaleString("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }); +} + +function fmtRubM(value: number): string { + const m = value / 1_000_000; + return `${m.toFixed(2).replace(".", ",")} млн ₽`; +} + +const CONFIDENCE_COLOR: Record< + ConfidenceLevel, + { bg: string; fg: string; border: string; label: string } +> = { + high: { bg: "#dcfce7", fg: "#15803d", border: "#86efac", label: "Высокая" }, + medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a", label: "Средняя" }, + low: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5", label: "Низкая" }, +}; + +const HOUSE_TYPE_LABELS: Record = { + panel: "Панель", + brick: "Кирпич", + monolith: "Монолит", + monolith_brick: "Монолит-кирпич", + other: "Другое", +}; + +const REPAIR_STATE_LABELS: Record = { + needs_repair: "Требует ремонта", + standard: "Стандартный", + good: "Хороший", + excellent: "Евроремонт", +}; + +// ── sub-components ──────────────────────────────────────────────────────────── + +function SectionHeader({ + icon, + title, +}: { + icon: React.ReactNode; + title: string; +}) { + return ( +
+ {icon} +

+ {title} +

+
+ ); +} + +function Card({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── + +interface Props { + estimate: AggregatedEstimate; + input: TradeInEstimateInput; +} + +export function EstimateResult({ estimate, input }: Props) { + const conf = CONFIDENCE_COLOR[estimate.confidence]; + + return ( +
+ {/* Hero card */} + +
+
+
+ Оценочная стоимость +
+
+ {fmtRubM(estimate.median_price_rub)} +
+
+ {fmtRub(estimate.median_price_per_m2)} / м² +
+
+ + {/* Confidence badge */} +
+ + Достоверность + + + {conf.label} + + + {estimate.n_analogs} аналог{ending(estimate.n_analogs)} + +
+
+ + {/* Price range bar */} + + +
+ Диапазон: {fmtRubM(estimate.range_low_rub)} —{" "} + {fmtRubM(estimate.range_high_rub)} · данные за{" "} + {estimate.period_months} мес. +
+
+ + {/* Section 1 — Cover / input snapshot */} + + + + {/* Section 2 — Listings (analogs) */} + + + + {/* Section 3 — Actual deals */} + + + + {/* Section 4 — Trade-in cost placeholder */} + + + + {/* PDF button (disabled) */} +
+
+ +
+
+
+ ); +} + +function ending(n: number): string { + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return ""; + if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return "а"; + return "ов"; +} diff --git a/frontend/src/components/trade-in/PriceRangeBar.tsx b/frontend/src/components/trade-in/PriceRangeBar.tsx new file mode 100644 index 00000000..9381c1b1 --- /dev/null +++ b/frontend/src/components/trade-in/PriceRangeBar.tsx @@ -0,0 +1,78 @@ +"use client"; + +interface Props { + rangeLow: number; + rangeHigh: number; + median: number; +} + +function fmtRub(value: number): string { + return value.toLocaleString("ru-RU", { + style: "currency", + currency: "RUB", + maximumFractionDigits: 0, + }); +} + +export function PriceRangeBar({ rangeLow, rangeHigh, median }: Props) { + const span = rangeHigh - rangeLow; + // Guard against degenerate case + const medianPct = + span > 0 + ? Math.max(0, Math.min(100, ((median - rangeLow) / span) * 100)) + : 50; + + return ( +
+ {/* Bar */} +
+ {/* Median marker */} +
+
+ + {/* Labels */} +
+ {fmtRub(rangeLow)} + + {fmtRub(median)} + + {fmtRub(rangeHigh)} +
+
+ ); +} diff --git a/frontend/src/lib/trade-in-api.ts b/frontend/src/lib/trade-in-api.ts new file mode 100644 index 00000000..88c3138c --- /dev/null +++ b/frontend/src/lib/trade-in-api.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "./api"; +import type { + AggregatedEstimate, + TradeInEstimateInput, +} from "@/types/trade-in"; + +const BASE = "/api/v1/trade-in"; + +/** + * POST /api/v1/trade-in/estimate + * Sends apartment parameters and returns AggregatedEstimate (mock data for now). + */ +export function useEstimateMutation() { + return useMutation({ + mutationFn: (input) => + apiFetch(`${BASE}/estimate`, { + method: "POST", + body: JSON.stringify(input), + }), + }); +} + +/** + * GET /api/v1/trade-in/estimate/{id} + * Fetches a previously computed estimate by UUID (for shareable links / PDF). + */ +export function useEstimate(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id], + queryFn: () => + apiFetch(`${BASE}/estimate/${estimate_id}`), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway + }); +} diff --git a/frontend/src/types/trade-in.ts b/frontend/src/types/trade-in.ts new file mode 100644 index 00000000..b3262e40 --- /dev/null +++ b/frontend/src/types/trade-in.ts @@ -0,0 +1,52 @@ +// Trade-In Estimator — TypeScript types +// Mirrors Pydantic schemas from backend/app/api/v1/trade_in.py (TI-1) + +export type HouseType = + | "panel" + | "brick" + | "monolith" + | "monolith_brick" + | "other"; + +export type RepairState = "needs_repair" | "standard" | "good" | "excellent"; + +export type ConfidenceLevel = "low" | "medium" | "high"; + +export interface TradeInEstimateInput { + address: string; // min 3, max 500 + area_m2: number; // 10 < x < 500 + rooms: number; // 0-10 (0 = студия) + floor: number; // 1-100 + total_floors: number; // 1-100 + year_built?: number; // 1800-2100 + house_type?: HouseType; + repair_state?: RepairState; + has_balcony?: boolean; +} + +export interface AnalogLot { + address: string; + area_m2: number; + rooms: number; + floor: number | null; + total_floors: number | null; + price_rub: number; + price_per_m2: number; + listing_date: string | null; // ISO date + days_on_market: number | null; + photo_url: string | null; +} + +export interface AggregatedEstimate { + estimate_id: string; // UUID + median_price_rub: number; + range_low_rub: number; + range_high_rub: number; + median_price_per_m2: number; + confidence: ConfidenceLevel; + n_analogs: number; + period_months: number; // 24 + analogs: AnalogLot[]; // top 5-10 + actual_deals: AnalogLot[]; // last 12 mo + expires_at: string; // ISO datetime +}