Merge pull request 'fix(tradein): убрать basePath redirect-loop в frontend' (#371) from fix/tradein-frontend-basepath-redirect into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 16s

This commit is contained in:
lekss361 2026-05-20 22:06:15 +00:00
commit 7d8624072e
3 changed files with 190 additions and 200 deletions

View file

@ -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;

View file

@ -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">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>
</>
);
}

View file

@ -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">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>
</>
);
}