Merge pull request 'fix(tradein): убрать basePath redirect-loop в frontend' (#371) from fix/tradein-frontend-basepath-redirect into main
This commit is contained in:
commit
7d8624072e
3 changed files with 190 additions and 200 deletions
|
|
@ -25,13 +25,6 @@ const nextConfig: NextConfig = {
|
|||
{ source: "/health", destination: `${backend}/health` },
|
||||
];
|
||||
},
|
||||
|
||||
async redirects() {
|
||||
// basePath применяется автоматически к source — НЕ дублируем.
|
||||
return [
|
||||
{ source: "/", destination: "/trade-in", permanent: false },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,192 @@
|
|||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/trade-in");
|
||||
/**
|
||||
* 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 { 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} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
"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">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} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue