feat(trade-in): TI-3 frontend /trade-in page + 5 components + hooks #317
8 changed files with 1543 additions and 0 deletions
183
frontend/src/app/trade-in/page.tsx
Normal file
183
frontend/src/app/trade-in/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/trade-in/AnalogsTable.tsx
Normal file
283
frontend/src/components/trade-in/AnalogsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
frontend/src/components/trade-in/EstimateForm.tsx
Normal file
393
frontend/src/components/trade-in/EstimateForm.tsx
Normal 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",
|
||||
});
|
||||
44
frontend/src/components/trade-in/EstimateProgress.tsx
Normal file
44
frontend/src/components/trade-in/EstimateProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
471
frontend/src/components/trade-in/EstimateResult.tsx
Normal file
471
frontend/src/components/trade-in/EstimateResult.tsx
Normal 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 "ов";
|
||||
}
|
||||
78
frontend/src/components/trade-in/PriceRangeBar.tsx
Normal file
78
frontend/src/components/trade-in/PriceRangeBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/lib/trade-in-api.ts
Normal file
39
frontend/src/lib/trade-in-api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
52
frontend/src/types/trade-in.ts
Normal file
52
frontend/src/types/trade-in.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue