gendesign/tradein-mvp/frontend/src/app/trade-in/page.tsx
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

190 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* Trade-In страница в стиле gendsgn.ru/tradein.html mockup.
* Использует 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 { 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 });
router.replace(`/trade-in?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">1030 сек</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} />
<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>
</>
);
}