fix(tradein): убрать basePath redirect-loop в frontend #371
3 changed files with 190 additions and 200 deletions
|
|
@ -25,13 +25,6 @@ const nextConfig: NextConfig = {
|
||||||
{ source: "/health", destination: `${backend}/health` },
|
{ source: "/health", destination: `${backend}/health` },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
async redirects() {
|
|
||||||
// basePath применяется автоматически к source — НЕ дублируем.
|
|
||||||
return [
|
|
||||||
{ source: "/", destination: "/trade-in", permanent: false },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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