gendesign/tradein-mvp/frontend/src/app/page.tsx
lekss361 bd3e5c8216
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy Trade-In / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 29s
Deploy / build-frontend (push) Successful in 28s
Deploy / build-worker (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 46s
Deploy / deploy (push) Successful in 58s
Deploy Trade-In / build-frontend (push) Successful in 1m40s
Deploy Trade-In / deploy (push) Successful in 39s
feat(tradein): аккаунт praktika + лимит 15 оценок/мес, чистка PRINZIP в UI (#635)
2026-05-28 22:04:58 +00:00

240 lines
9.1 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.
* С 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 { useQueryClient } from "@tanstack/react-query";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { Topbar } from "@/components/trade-in/Topbar";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
import { CianValuationCard } from "@/components/trade-in/CianValuationCard";
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard";
import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSection";
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
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 queryClient = useQueryClient();
const quota = useQuota();
const blocked = quota.data
? !quota.data.unlimited && quota.data.remaining <= 0
: false;
const remainingQuota =
quota.data && !quota.data.unlimited ? quota.data.remaining : null;
const limitQuota =
quota.data && !quota.data.unlimited ? quota.data.limit : null;
const [freshResult, setFreshResult] = useState<{
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
} | null>(null);
const urlEstimateId = useEstimateId();
const restoredEstimate = useEstimate(
freshResult === null ? urlEstimateId : null,
);
const mutation = useEstimateMutation();
const currentEstimateId =
freshResult?.estimate.estimate_id ?? urlEstimateId;
const imvBenchmark = useEstimateImvBenchmark(currentEstimateId);
const houses = useEstimateHouses(currentEstimateId);
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 });
void queryClient.invalidateQueries({ queryKey: ["trade-in", "quota"] });
},
});
}
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);
function handleResubmit(patch: { house_type?: HouseType; repair_state?: RepairState }) {
if (!resultData) return;
const enrichedInput: TradeInEstimateInput = { ...resultData.input, ...patch };
handleSubmit(enrichedInput);
}
const isPending = mutation.isPending;
return (
<>
<Topbar active="estimate" />
<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}
blocked={blocked}
remaining={remainingQuota}
limit={limitQuota}
/>
</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}
onResubmit={handleResubmit}
isResubmitting={isPending}
/>
<IMVBenchmark
benchmark={imvBenchmark.data}
isLoading={imvBenchmark.isPending}
/>
<CianValuationCard data={resultData.estimate.cian_valuation} />
<HouseInfoCard
houses={houses.data}
isLoading={houses.isPending}
/>
{currentEstimateId && (
<PlacementHistoryCard estimateId={currentEstimateId} />
)}
{currentEstimateId && (
<HouseAnalyticsSection estimateId={currentEstimateId} />
)}
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
<DealsCard estimate={resultData.estimate} />
<StreetDealsCard 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>
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>
</>
);
}