Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv). Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538 Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498 Требуют cross-file (3, не тронуты): #1338, #1363, #1421 Пропущено (1): #1539 Не входило в партию: 22 needs-Leha issue (нужны решения владельца). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
233 lines
7 KiB
TypeScript
233 lines
7 KiB
TypeScript
"use client";
|
||
|
||
import { Suspense, useState } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
|
||
import type {
|
||
AggregatedEstimate,
|
||
TradeInEstimateInput,
|
||
} from "@/types/trade-in";
|
||
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||
import { EstimateResult } from "@/components/trade-in/EstimateResult";
|
||
import { EstimateProgress } from "@/components/trade-in/EstimateProgress";
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
function TradeInInner() {
|
||
const router = useRouter();
|
||
// App Router hook: reactive to URL changes and SSR-safe (no hydration mismatch).
|
||
const urlEstimateId = useSearchParams().get("id");
|
||
|
||
// Result from mutation (fresh) or from URL (restored)
|
||
const [freshResult, setFreshResult] = useState<{
|
||
estimate: AggregatedEstimate;
|
||
input: TradeInEstimateInput;
|
||
} | null>(null);
|
||
|
||
// Drop the fresh result when the URL points at a different estimate so a
|
||
// soft-navigation to another shared link doesn't keep showing the old one.
|
||
const [freshFor, setFreshFor] = useState<string | null>(null);
|
||
const activeFreshResult = freshFor === urlEstimateId ? freshResult : null;
|
||
|
||
// Fetch from URL if no fresh result for the current id yet
|
||
const restoredEstimate = useEstimate(
|
||
activeFreshResult === null ? urlEstimateId : null,
|
||
);
|
||
|
||
const mutation = useEstimateMutation();
|
||
|
||
function handleSubmit(input: TradeInEstimateInput) {
|
||
mutation.mutate(input, {
|
||
onSuccess: (estimate) => {
|
||
setFreshResult({ estimate, input });
|
||
// Bind the fresh result to its id so it's only shown while the URL
|
||
// still points at this estimate (see activeFreshResult).
|
||
setFreshFor(estimate.estimate_id);
|
||
// Persist estimate_id in URL for shareable link
|
||
router.replace(`/trade-in?id=${estimate.estimate_id}`, {
|
||
scroll: false,
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
const apiError = mutation.error?.message ?? null;
|
||
|
||
// Determine what to render on the right side
|
||
const restoreError = restoredEstimate.error?.message ?? null;
|
||
|
||
const resultData: {
|
||
estimate: AggregatedEstimate;
|
||
input: TradeInEstimateInput;
|
||
} | null =
|
||
activeFreshResult ??
|
||
(restoredEstimate.data
|
||
? {
|
||
estimate: restoredEstimate.data,
|
||
// When restoring from URL we don't have the original object parameters
|
||
// (GET /estimate/{id} returns only AggregatedEstimate, no input snapshot).
|
||
// Don't borrow the first analog's address or fabricate 0 m²/floor — those
|
||
// are someone else's listing. Pass an empty input; EstimateResult must
|
||
// hide the "Параметры объекта" block when the object params are unknown.
|
||
// TODO(TI): expose the stored input on the GET endpoint + render real params.
|
||
input: {
|
||
address: "",
|
||
area_m2: 0,
|
||
rooms: 0,
|
||
floor: 0,
|
||
total_floors: 0,
|
||
},
|
||
}
|
||
: null);
|
||
|
||
return (
|
||
<main
|
||
style={{
|
||
minHeight: "100vh",
|
||
background: "#f8f9fb",
|
||
padding: "24px 16px",
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{/* Page header */}
|
||
<div style={{ maxWidth: 1280, margin: "0 auto 20px" }}>
|
||
<h1
|
||
style={{
|
||
margin: "0 0 4px",
|
||
fontSize: 22,
|
||
fontWeight: 800,
|
||
color: "#1a1d23",
|
||
}}
|
||
>
|
||
Trade-In Estimator
|
||
</h1>
|
||
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
|
||
Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ
|
||
</p>
|
||
</div>
|
||
|
||
{/* Two-column layout */}
|
||
<div
|
||
style={{
|
||
maxWidth: 1280,
|
||
margin: "0 auto",
|
||
display: "grid",
|
||
gridTemplateColumns: "360px 1fr",
|
||
gap: 20,
|
||
alignItems: "start",
|
||
}}
|
||
className="trade-in-grid"
|
||
>
|
||
{/* Left — sticky form */}
|
||
<div style={{ position: "sticky", top: 24 }}>
|
||
<EstimateForm
|
||
onSubmit={handleSubmit}
|
||
isPending={mutation.isPending}
|
||
error={apiError}
|
||
/>
|
||
<EstimateProgress visible={mutation.isPending} />
|
||
</div>
|
||
|
||
{/* Right — result area */}
|
||
<div>
|
||
{resultData ? (
|
||
<EstimateResult
|
||
estimate={resultData.estimate}
|
||
input={resultData.input}
|
||
/>
|
||
) : restoredEstimate.isLoading ? (
|
||
<EstimateProgress visible />
|
||
) : restoreError ? (
|
||
<RestoreError />
|
||
) : (
|
||
<EmptyState />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Responsive: stack on mobile */}
|
||
<style>{`
|
||
@media (max-width: 767px) {
|
||
.trade-in-grid {
|
||
grid-template-columns: 1fr !important;
|
||
}
|
||
.trade-in-grid > div:first-child {
|
||
position: static !important;
|
||
}
|
||
}
|
||
`}</style>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
// useSearchParams() в Next 15 App Router throw'ит при static rendering без
|
||
// <Suspense> boundary — поэтому оборачиваем page-level.
|
||
export default function TradeInPage() {
|
||
return (
|
||
<Suspense fallback={null}>
|
||
<TradeInInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
function EmptyState() {
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: "48px 24px",
|
||
textAlign: "center",
|
||
color: "#9ca3af",
|
||
background: "#fff",
|
||
border: "1px dashed #e6e8ec",
|
||
borderRadius: 12,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 40, marginBottom: 12 }}>🏠</div>
|
||
<div
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "#374151",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
Введите параметры квартиры
|
||
</div>
|
||
<div style={{ fontSize: 14 }}>
|
||
Оценка появится здесь после нажатия «Оценить»
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RestoreError() {
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: "48px 24px",
|
||
textAlign: "center",
|
||
color: "#b91c1c",
|
||
background: "#fff",
|
||
border: "1px solid #fca5a5",
|
||
borderRadius: 12,
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 40, marginBottom: 12 }}>⚠️</div>
|
||
<div
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "#991b1b",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
Ссылка устарела или не найдена
|
||
</div>
|
||
<div style={{ fontSize: 14, color: "#6b7280" }}>
|
||
Оценка по этой ссылке недоступна — срок её хранения истёк. Введите
|
||
параметры квартиры слева, чтобы рассчитать новую.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|