Компонент PhotoUpload на result-view: выбор image/* (multiple),
загрузка в POST /estimate/{id}/photos, превью загруженных фото.
Multipart напрямую через fetch — apiFetch форсит JSON content-type.
Closes #394
194 lines
7.1 KiB
TypeScript
194 lines
7.1 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Trade-In страница в стиле gendsgn.ru/tradein.html mockup.
|
||
* С basePath=/trade-in user видит её на gendsgn.ru/trade-in/.
|
||
* Использует CSS из /components/trade-in/trade-in.css.
|
||
*/
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
import "@/components/trade-in/trade-in.css";
|
||
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
|
||
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||
|
||
function useEstimateId() {
|
||
if (typeof window === "undefined") return null;
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get("id");
|
||
}
|
||
|
||
export default function TradeInPage() {
|
||
const router = useRouter();
|
||
const [freshResult, setFreshResult] = useState<{
|
||
estimate: AggregatedEstimate;
|
||
input: TradeInEstimateInput;
|
||
} | null>(null);
|
||
|
||
const urlEstimateId = useEstimateId();
|
||
const restoredEstimate = useEstimate(
|
||
freshResult === null ? urlEstimateId : null,
|
||
);
|
||
|
||
const mutation = useEstimateMutation();
|
||
|
||
function handleSubmit(input: TradeInEstimateInput) {
|
||
mutation.mutate(input, {
|
||
onSuccess: (estimate) => {
|
||
setFreshResult({ estimate, input });
|
||
// basePath auto-prefixes — passing "/" returns user to /trade-in/?id=...
|
||
router.replace(`/?id=${estimate.estimate_id}`, { scroll: false });
|
||
},
|
||
});
|
||
}
|
||
|
||
const apiError = mutation.error?.message ?? null;
|
||
const resultData =
|
||
freshResult ??
|
||
(restoredEstimate.data
|
||
? {
|
||
estimate: restoredEstimate.data,
|
||
input: {
|
||
address: restoredEstimate.data.target_address ?? "—",
|
||
area_m2: 0,
|
||
rooms: restoredEstimate.data.analogs[0]?.rooms ?? 0,
|
||
floor: 0,
|
||
total_floors: 0,
|
||
} as TradeInEstimateInput,
|
||
}
|
||
: null);
|
||
|
||
const isPending = mutation.isPending;
|
||
|
||
return (
|
||
<>
|
||
{/* Topbar — белый-лейбл бренд */}
|
||
<header className="topbar">
|
||
<div className="topbar-inner">
|
||
<div className="brand">
|
||
<span className="brand-mark">TI</span>
|
||
<span className="brand-name">PRINZIP</span>
|
||
<span className="brand-sep" />
|
||
<span className="brand-product">Trade-In</span>
|
||
</div>
|
||
<nav className="top-nav">
|
||
<a href="#" className="is-active">Оценка</a>
|
||
<a href="#">История</a>
|
||
<a href="#">Кэш</a>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="page">
|
||
<div className="crumbs">
|
||
<em>Главная</em> <span>›</span> <em>Trade-In</em> <span>›</span> Новая оценка
|
||
</div>
|
||
|
||
<div className="page-title">
|
||
<div>
|
||
<h1>Оценка квартиры на вторичке</h1>
|
||
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
||
Агрегируем данные из 7 источников + аналоги в продаже + фактические сделки.
|
||
Время сбора — <span className="num">10–30 сек</span>.
|
||
</p>
|
||
</div>
|
||
<div className="page-meta">
|
||
<span className="pill">
|
||
<span className="dot" />
|
||
<span>Отчёт</span>{" "}
|
||
<span className="mono">
|
||
{resultData?.estimate.estimate_id.slice(0, 8) ?? "—"}
|
||
</span>
|
||
</span>
|
||
<span className="pill">
|
||
<span className="dot muted" />
|
||
<span>Дата</span>{" "}
|
||
<span className="mono">
|
||
{new Date().toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
})}
|
||
</span>
|
||
</span>
|
||
{resultData && (
|
||
<span className="pill">
|
||
<span className="dot warn" />
|
||
<span>Действителен до</span>{" "}
|
||
<span className="mono">
|
||
{new Date(resultData.estimate.expires_at).toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
})}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="layout">
|
||
{/* Sticky form left */}
|
||
<aside className="form-card">
|
||
<EstimateForm onSubmit={handleSubmit} isPending={isPending} error={apiError} />
|
||
</aside>
|
||
|
||
{/* Result column */}
|
||
<section className="result-col">
|
||
<SourcesProgress
|
||
estimate={resultData?.estimate ?? null}
|
||
isPending={isPending}
|
||
/>
|
||
|
||
{/* Тестовые пресеты — показываем только когда нет результата */}
|
||
{!resultData && !isPending && (
|
||
<TestPresets onPick={(data) => handleSubmit(data)} />
|
||
)}
|
||
|
||
{resultData ? (
|
||
<>
|
||
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
|
||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||
<ListingsCard estimate={resultData.estimate} />
|
||
<DealsCard estimate={resultData.estimate} />
|
||
<OfferCard estimate={resultData.estimate} />
|
||
</>
|
||
) : (
|
||
<article className="card">
|
||
<div className="card-body" style={{ padding: "48px 24px", textAlign: "center" }}>
|
||
<div style={{ fontSize: 40, marginBottom: 12 }}>🏠</div>
|
||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
|
||
Введите параметры квартиры
|
||
</h3>
|
||
<p style={{ color: "var(--muted)", fontSize: 14 }}>
|
||
Заполните форму слева или выберите тестовую квартиру выше.
|
||
</p>
|
||
</div>
|
||
</article>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="page-foot">
|
||
<div>
|
||
PRINZIP Trade-In · MVP ·{" "}
|
||
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 18 }}>
|
||
<a href="#">Документация</a>
|
||
<a href="#">API</a>
|
||
<a href="#">Источники данных</a>
|
||
</div>
|
||
</footer>
|
||
</>
|
||
);
|
||
}
|