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).
This commit is contained in:
lekss361 2026-05-17 19:54:11 +03:00
parent fce48a8924
commit 7fa93f825c
8 changed files with 1543 additions and 0 deletions

View file

@ -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 (
<main
style={{
minHeight: "100vh",
background: "#f8f9fb",
padding: "24px 16px",
boxSizing: "border-box",
}}
>
{/* Page header */}
<div style={{ maxWidth: 1280, margin: "0 auto 20px" }}>
<h1
style={{
margin: "0 0 4px",
fontSize: 22,
fontWeight: 800,
color: "#1a1d23",
}}
>
Trade-In Estimator
</h1>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ
</p>
</div>
{/* Two-column layout */}
<div
style={{
maxWidth: 1280,
margin: "0 auto",
display: "grid",
gridTemplateColumns: "360px 1fr",
gap: 20,
alignItems: "start",
}}
className="trade-in-grid"
>
{/* Left — sticky form */}
<div style={{ position: "sticky", top: 24 }}>
<EstimateForm
onSubmit={handleSubmit}
isPending={mutation.isPending}
error={apiError}
/>
<EstimateProgress visible={mutation.isPending} />
</div>
{/* Right — result area */}
<div>
{resultData ? (
<EstimateResult
estimate={resultData.estimate}
input={resultData.input}
/>
) : restoredEstimate.isLoading ? (
<EstimateProgress visible />
) : (
<EmptyState />
)}
</div>
</div>
{/* Responsive: stack on mobile */}
<style>{`
@media (max-width: 767px) {
.trade-in-grid {
grid-template-columns: 1fr !important;
}
.trade-in-grid > div:first-child {
position: static !important;
}
}
`}</style>
</main>
);
}
function EmptyState() {
return (
<div
style={{
padding: "48px 24px",
textAlign: "center",
color: "#9ca3af",
background: "#fff",
border: "1px dashed #e6e8ec",
borderRadius: 12,
}}
>
<div style={{ fontSize: 40, marginBottom: 12 }}>🏠</div>
<div
style={{
fontSize: 16,
fontWeight: 600,
color: "#374151",
marginBottom: 6,
}}
>
Введите параметры квартиры
</div>
<div style={{ fontSize: 14 }}>
Оценка появится здесь после нажатия «Оценить»
</div>
</div>
);
}

View file

@ -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<SortKey>("price_rub");
const [sortAsc, setSortAsc] = useState(true);
if (rows.length === 0) {
return (
<div
style={{
padding: "20px 0",
color: "#9ca3af",
fontSize: 14,
textAlign: "center",
}}
>
{emptyLabel}
</div>
);
}
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 ? (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: "inline", marginLeft: 2, verticalAlign: "middle" }}
aria-hidden="true"
>
<polyline points="18 15 12 9 6 15" />
</svg>
) : (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: "inline", marginLeft: 2, verticalAlign: "middle" }}
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
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 (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr>
<th style={thStyle}>Фото</th>
<th style={thStyle}>Адрес</th>
<th style={{ ...thStyle, textAlign: "right" }}>м²</th>
<th style={{ ...thStyle, textAlign: "right" }}>Комн.</th>
<th style={{ ...thStyle, textAlign: "right" }}>Этаж</th>
<th
style={sortableTh("price_rub")}
onClick={() => handleSort("price_rub")}
>
Цена <SortIcon col="price_rub" />
</th>
<th
style={sortableTh("price_per_m2")}
onClick={() => handleSort("price_per_m2")}
>
/м² <SortIcon col="price_per_m2" />
</th>
<th
style={sortableTh("days_on_market")}
onClick={() => handleSort("days_on_market")}
>
Дней <SortIcon col="days_on_market" />
</th>
</tr>
</thead>
<tbody>
{sorted.map((row, i) => {
const imgSrc = safeImgSrc(row.photo_url);
return (
<tr
key={i}
style={{ background: i % 2 === 0 ? "#fff" : "#fafafa" }}
>
<td style={tdStyle}>
{imgSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imgSrc}
alt="фото"
width={48}
height={36}
style={{
objectFit: "cover",
borderRadius: 4,
display: "block",
}}
/>
) : (
<div
style={{
width: 48,
height: 36,
background: "#f3f4f6",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#9ca3af"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
ry="2"
/>
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
)}
</td>
<td style={{ ...tdStyle, maxWidth: 200 }}>
<span
title={row.address}
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.address}
</span>
{row.listing_date && (
<span style={{ fontSize: 11, color: "#9ca3af" }}>
{fmtDate(row.listing_date)}
</span>
)}
</td>
<td style={{ ...numTd }}>{row.area_m2.toFixed(1)}</td>
<td style={{ ...numTd }}>
{row.rooms === 0 ? "ст." : row.rooms}
</td>
<td style={{ ...numTd }}>
{row.floor !== null && row.total_floors !== null
? `${row.floor}/${row.total_floors}`
: (row.floor ?? "—")}
</td>
<td style={{ ...numTd }}>{fmtRub(row.price_rub)}</td>
<td style={{ ...numTd }}>{fmtRub(row.price_per_m2)}</td>
<td style={{ ...numTd }}>
{row.days_on_market !== null ? row.days_on_market : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,393 @@
"use client";
import { useState } from "react";
import type {
HouseType,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
const HOUSE_TYPE_LABELS: Record<HouseType, string> = {
panel: "Панель",
brick: "Кирпич",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_STATE_LABELS: Record<RepairState, string> = {
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<FormState>(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<K extends keyof FormState>(key: K) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [key]: e.target.value }));
};
}
return (
<form
onSubmit={handleSubmit}
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<h2 style={{ margin: 0, fontSize: 18, color: "#1a1d23" }}>
Параметры квартиры
</h2>
{/* Address */}
<label>
<span style={labelStyle}>
Адрес <Required />
</span>
<input
type="text"
placeholder="ул. Ленина, 1, кв. 5, Екатеринбург"
value={form.address}
onChange={field("address")}
minLength={3}
maxLength={500}
required
style={inputStyle}
/>
</label>
{/* Area + Rooms row */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<label>
<span style={labelStyle}>
Площадь, м² <Required />
</span>
<input
type="number"
inputMode="decimal"
placeholder="54"
min={11}
max={499}
step={0.1}
value={form.area_m2}
onChange={field("area_m2")}
required
style={inputStyle}
/>
</label>
<label>
<span style={labelStyle}>
Комнат <Required />
</span>
<select
value={form.rooms}
onChange={field("rooms")}
required
style={inputStyle}
>
<option value=""> выбрать </option>
<option value="0">Студия</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5+</option>
</select>
</label>
</div>
{/* Floor + Total floors row */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<label>
<span style={labelStyle}>
Этаж <Required />
</span>
<input
type="number"
inputMode="numeric"
placeholder="5"
min={1}
max={100}
step={1}
value={form.floor}
onChange={field("floor")}
required
style={inputStyle}
/>
</label>
<label>
<span style={labelStyle}>
Этажей в доме <Required />
</span>
<input
type="number"
inputMode="numeric"
placeholder="17"
min={1}
max={100}
step={1}
value={form.total_floors}
onChange={field("total_floors")}
required
style={inputStyle}
/>
</label>
</div>
{/* Year built */}
<label>
<span style={labelStyle}>Год постройки (опц.)</span>
<input
type="number"
inputMode="numeric"
placeholder="1985"
min={1800}
max={2100}
step={1}
value={form.year_built}
onChange={field("year_built")}
style={inputStyle}
/>
</label>
{/* House type */}
<label>
<span style={labelStyle}>Тип дома (опц.)</span>
<select
value={form.house_type}
onChange={field("house_type")}
style={inputStyle}
>
<option value=""> не указан </option>
{(Object.entries(HOUSE_TYPE_LABELS) as [HouseType, string][]).map(
([val, label]) => (
<option key={val} value={val}>
{label}
</option>
),
)}
</select>
</label>
{/* Repair state */}
<label>
<span style={labelStyle}>Состояние ремонта (опц.)</span>
<select
value={form.repair_state}
onChange={field("repair_state")}
style={inputStyle}
>
<option value=""> не указано </option>
{(Object.entries(REPAIR_STATE_LABELS) as [RepairState, string][]).map(
([val, label]) => (
<option key={val} value={val}>
{label}
</option>
),
)}
</select>
</label>
{/* Balcony */}
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={form.has_balcony}
onChange={(e) =>
setForm((prev) => ({ ...prev, has_balcony: e.target.checked }))
}
style={{ width: 16, height: 16, cursor: "pointer" }}
/>
<span style={{ fontSize: 14, color: "#374151" }}>
Есть балкон / лоджия
</span>
</label>
{/* Validation error (shown after first submit attempt) */}
{touched && validationError && (
<div style={errorBoxStyle}>{validationError}</div>
)}
{/* API error */}
{error && <div style={errorBoxStyle}>{error}</div>}
<button type="submit" disabled={!canSubmit} style={primaryBtn(canSubmit)}>
{isPending ? (
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
justifyContent: "center",
}}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Считаем
</span>
) : (
"Оценить"
)}
</button>
<style>{`@keyframes ti-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</form>
);
}
function Required() {
return <span style={{ color: "#ef4444", marginLeft: 2 }}>*</span>;
}
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",
});

View file

@ -0,0 +1,44 @@
"use client";
interface Props {
visible: boolean;
}
export function EstimateProgress({ visible }: Props) {
if (!visible) return null;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 16px",
background: "#eff6ff",
border: "1px solid #bfdbfe",
borderRadius: 8,
color: "#1d4ed8",
fontSize: 14,
fontWeight: 500,
marginTop: 12,
}}
>
{/* Spinning circle SVG */}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite", flexShrink: 0 }}
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span>Считаем оценку</span>
<style>{`@keyframes ti-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</div>
);
}

View file

@ -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<string, string> = {
panel: "Панель",
brick: "Кирпич",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_STATE_LABELS: Record<string, string> = {
needs_repair: "Требует ремонта",
standard: "Стандартный",
good: "Хороший",
excellent: "Евроремонт",
};
// ── sub-components ────────────────────────────────────────────────────────────
function SectionHeader({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
paddingBottom: 8,
borderBottom: "1px solid #e6e8ec",
}}
>
<span style={{ color: "#1d4ed8" }}>{icon}</span>
<h3
style={{ margin: 0, fontSize: 15, fontWeight: 700, color: "#1a1d23" }}
>
{title}
</h3>
</div>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
}}
>
{children}
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
}
export function EstimateResult({ estimate, input }: Props) {
const conf = CONFIDENCE_COLOR[estimate.confidence];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Hero card */}
<Card>
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<div>
<div
style={{
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.4,
fontWeight: 600,
}}
>
Оценочная стоимость
</div>
<div
style={{
fontSize: 32,
fontWeight: 800,
color: "#1a1d23",
fontVariantNumeric: "tabular-nums",
lineHeight: 1.1,
}}
>
{fmtRubM(estimate.median_price_rub)}
</div>
<div
style={{
fontSize: 14,
color: "#5b6066",
marginTop: 4,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtRub(estimate.median_price_per_m2)} / м²
</div>
</div>
{/* Confidence badge */}
<div
style={{
background: conf.bg,
border: `1px solid ${conf.border}`,
borderRadius: 8,
padding: "6px 12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
}}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
color: conf.fg,
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
Достоверность
</span>
<span style={{ fontSize: 16, fontWeight: 800, color: conf.fg }}>
{conf.label}
</span>
<span style={{ fontSize: 11, color: conf.fg }}>
{estimate.n_analogs} аналог{ending(estimate.n_analogs)}
</span>
</div>
</div>
{/* Price range bar */}
<PriceRangeBar
rangeLow={estimate.range_low_rub}
rangeHigh={estimate.range_high_rub}
median={estimate.median_price_rub}
/>
<div
style={{
marginTop: 10,
fontSize: 12,
color: "#9ca3af",
}}
>
Диапазон: {fmtRubM(estimate.range_low_rub)} {" "}
{fmtRubM(estimate.range_high_rub)} · данные за{" "}
{estimate.period_months} мес.
</div>
</Card>
{/* Section 1 — Cover / input snapshot */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
}
title="Параметры объекта"
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 10,
}}
>
{[
{ label: "Адрес", value: input.address },
{ label: "Площадь", value: `${input.area_m2} м²` },
{
label: "Комнат",
value: input.rooms === 0 ? "Студия" : String(input.rooms),
},
{
label: "Этаж",
value: `${input.floor} из ${input.total_floors}`,
},
...(input.year_built
? [{ label: "Год постройки", value: String(input.year_built) }]
: []),
...(input.house_type
? [
{
label: "Тип дома",
value:
HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type,
},
]
: []),
...(input.repair_state
? [
{
label: "Ремонт",
value:
REPAIR_STATE_LABELS[input.repair_state] ??
input.repair_state,
},
]
: []),
{
label: "Балкон",
value: input.has_balcony ? "Есть" : "Нет",
},
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontSize: 11,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: 0.3,
marginBottom: 2,
}}
>
{label}
</div>
<div
style={{
fontSize: 13,
color: "#1a1d23",
fontWeight: 500,
wordBreak: "break-word",
}}
>
{value}
</div>
</div>
))}
</div>
</Card>
{/* Section 2 — Listings (analogs) */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
}
title={`Объявления-аналоги (${estimate.analogs.length})`}
/>
<AnalogsTable
rows={estimate.analogs}
emptyLabel="Нет объявлений-аналогов в выборке"
/>
</Card>
{/* Section 3 — Actual deals */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
}
title={`Фактические сделки (${estimate.actual_deals.length})`}
/>
<AnalogsTable
rows={estimate.actual_deals}
emptyLabel="Нет фактических сделок в выборке"
/>
</Card>
{/* Section 4 — Trade-in cost placeholder */}
<Card>
<SectionHeader
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="12" y2="14" />
</svg>
}
title="Выкупная стоимость (trade-in)"
/>
<div
style={{
padding: "20px 0",
textAlign: "center",
color: "#9ca3af",
fontSize: 14,
}}
>
<div
style={{
marginBottom: 8,
display: "flex",
justifyContent: "center",
}}
>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="#d1d5db"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="12" y2="14" />
</svg>
</div>
Подробный расчёт в PDF-отчёте.
<br />
<span style={{ fontSize: 12 }}>
Выкупная цена, таблица self-sale vs trade-in, 4 преимущества будут
в TI-2.
</span>
</div>
</Card>
{/* PDF button (disabled) */}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
title="Генерация PDF появится в TI-2"
style={{ display: "inline-block", cursor: "not-allowed" }}
>
<button
type="button"
disabled
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 18px",
background: "#e5e7eb",
color: "#9ca3af",
border: "1px solid #d1d5db",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: "not-allowed",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Скачать PDF
</button>
</div>
</div>
</div>
);
}
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 "ов";
}

View file

@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* Bar */}
<div
style={{
position: "relative",
height: 10,
borderRadius: 999,
background: "linear-gradient(to right, #bfdbfe, #1d4ed8)",
}}
>
{/* Median marker */}
<div
title={`Медиана: ${fmtRub(median)}`}
style={{
position: "absolute",
top: "50%",
left: `${medianPct}%`,
transform: "translate(-50%, -50%)",
width: 14,
height: 14,
borderRadius: "50%",
background: "#1d4ed8",
border: "2px solid #fff",
boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
}}
/>
</div>
{/* Labels */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#5b6066",
fontVariantNumeric: "tabular-nums",
}}
>
<span>{fmtRub(rangeLow)}</span>
<span
style={{
fontWeight: 700,
color: "#1d4ed8",
fontSize: 13,
}}
>
{fmtRub(median)}
</span>
<span>{fmtRub(rangeHigh)}</span>
</div>
</div>
);
}

View file

@ -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<AggregatedEstimate, Error, TradeInEstimateInput>({
mutationFn: (input) =>
apiFetch<AggregatedEstimate>(`${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<AggregatedEstimate>({
queryKey: ["trade-in", "estimate", estimate_id],
queryFn: () =>
apiFetch<AggregatedEstimate>(`${BASE}/estimate/${estimate_id}`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway
});
}

View file

@ -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
}