diff --git a/.gitignore b/.gitignore
index 36747a8c..1f805cdc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,9 +29,11 @@ frontend/.sentryclirc
.mcp.json
# креды-заметки — НЕ коммитить (см. issue #391)
sshkey.txt
-# Cian session cookies (browser-export) для local-sweep — НЕ коммитить
+# Session cookies (browser-export) для local-sweep — НЕ коммитить (#770)
tradein-mvp/scripts/.cian-cookies.json
tradein-mvp/scripts/*.cookies.json
+tradein-mvp/scripts/*cookies*.json
+tradein-mvp/scripts/.*cookies*.json
# IDE
.vscode/
diff --git a/tradein-mvp/frontend/src/app/global-error.tsx b/tradein-mvp/frontend/src/app/global-error.tsx
new file mode 100644
index 00000000..150e456b
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/global-error.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * Глобальный error-boundary (#770 finding #25). Ловит непойманные ошибки рендера
+ * корневого layout/страниц — без него Next.js показывает голый дефолтный экран.
+ * Должен сам рендерить /
(заменяет root layout при краше).
+ */
+export default function GlobalError({
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
+
+
+ Что-то пошло не так
+
+
+ Произошла непредвиденная ошибка. Попробуйте обновить страницу — если
+ повторится, вернитесь чуть позже.
+
+
+
+
+
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/history/page.tsx b/tradein-mvp/frontend/src/app/history/page.tsx
index 0b1c5237..f2c0621b 100644
--- a/tradein-mvp/frontend/src/app/history/page.tsx
+++ b/tradein-mvp/frontend/src/app/history/page.tsx
@@ -20,12 +20,18 @@ interface HistoryRow {
export default function HistoryPage() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
+ // #770 finding #25: не маскировать ошибку пустым списком — иначе «нет оценок»
+ // показывается и при реальном сбое загрузки.
+ const [error, setError] = useState(false);
useEffect(() => {
fetch(`${API_BASE_URL}/api/v1/trade-in/history?limit=100`)
- .then((r) => (r.ok ? r.json() : []))
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ })
.then((d) => setRows(d as HistoryRow[]))
- .catch(() => setRows([]))
+ .catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
@@ -38,6 +44,10 @@ export default function HistoryPage() {
{loading ? (
Загрузка…
+ ) : error ? (
+
+ Не удалось загрузить историю оценок. Обновите страницу или попробуйте позже.
+
) : rows.length === 0 ? (
Оценок пока нет.
) : (
diff --git a/tradein-mvp/frontend/src/app/providers.tsx b/tradein-mvp/frontend/src/app/providers.tsx
index 44cf575e..38c3cbe9 100644
--- a/tradein-mvp/frontend/src/app/providers.tsx
+++ b/tradein-mvp/frontend/src/app/providers.tsx
@@ -11,6 +11,16 @@ export function Providers({ children }: { children: React.ReactNode }) {
queries: {
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
+ // #770 finding #26: не ретраить 4xx (включая 429) — иначе на rate-limit
+ // дефолтные 3 ретрая превращаются в шторм. Ретраим только 5xx/сеть, ≤2 раза.
+ retry: (failureCount, error) => {
+ const status = (error as { status?: number } | null)?.status;
+ if (typeof status === "number" && status >= 400 && status < 500) return false;
+ return failureCount < 2;
+ },
+ },
+ mutations: {
+ retry: false,
},
},
}),
diff --git a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
index 3e83312e..600a1a92 100644
--- a/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
@@ -101,7 +101,7 @@ export function DealsCard({ estimate }: Props) {
млн
- 7 месяцев · разница к рынку
+ {estimate.period_months} мес · разница к рынку
@@ -133,7 +133,7 @@ export function DealsCard({ estimate }: Props) {
{deals.map((d, i) => (
-
+
))}
diff --git a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
index 84abec2d..59f5074a 100644
--- a/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
@@ -2,11 +2,8 @@
import { useState } from "react";
-import type {
- HouseType,
- RepairState,
- TradeInEstimateInput,
-} from "@/types/trade-in";
+import type { TradeInEstimateInput } from "@/types/trade-in";
+import { asHouseType, asRepairState } from "@/types/trade-in";
import { AddressInput } from "@/components/trade-in/AddressInput";
import { MapPicker } from "@/components/trade-in/MapPicker";
@@ -86,8 +83,10 @@ function toPayload(f: FormState): TradeInEstimateInput {
has_balcony: f.has_balcony,
};
if (f.year_built !== "") out.year_built = Number(f.year_built);
- if (f.house_type) out.house_type = f.house_type as HouseType;
- if (f.repair_state) out.repair_state = f.repair_state as RepairState;
+ const ht = asHouseType(f.house_type);
+ if (ht) out.house_type = ht;
+ const rs = asRepairState(f.repair_state);
+ if (rs) out.repair_state = rs;
if (f.ownership_type) out.ownership_type = f.ownership_type;
if (f.has_mortgage) out.has_mortgage = true;
if (f.client_name.trim()) out.client_name = f.client_name.trim();
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index c3a381b7..56f9e18c 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -8,6 +8,7 @@
*/
import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState } from "@/types/trade-in";
+import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { HeroTransparency } from "./HeroTransparency";
@@ -159,8 +160,10 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
function handleEnrichSubmit(e: React.FormEvent) {
e.preventDefault();
const patch: { house_type?: HouseType; repair_state?: RepairState } = {};
- if (enrichHouseType) patch.house_type = enrichHouseType as HouseType;
- if (enrichRepairState) patch.repair_state = enrichRepairState as RepairState;
+ const ht = asHouseType(enrichHouseType);
+ if (ht) patch.house_type = ht;
+ const rs = asRepairState(enrichRepairState);
+ if (rs) patch.repair_state = rs;
onResubmit(patch);
}
diff --git a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
index eb9d00d5..dfb5042d 100644
--- a/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
@@ -41,6 +41,14 @@ function fmtArea(v: number): string {
return v.toFixed(1);
}
+/** Гуманизация свежести данных (#770 finding #31): мин → ч → дн. */
+function humanizeAgo(min: number | null): string {
+ if (min === null || min < 0) return "—";
+ if (min < 60) return `${min} мин назад`;
+ if (min < 1440) return `${Math.round(min / 60)} ч назад`;
+ return `${Math.round(min / 1440)} дн назад`;
+}
+
/** Extract Cian numeric ID from a source_url like https://ekb.cian.ru/sale/flat/123456789/ */
function extractCianId(url: string | null): string | null {
if (!url) return null;
@@ -112,11 +120,7 @@ export function ListingsCard({ estimate, estimateId }: Props) {
Обновлено:{" "}
-
- {estimate.data_freshness_minutes !== null
- ? `${estimate.data_freshness_minutes} мин назад`
- : "—"}
-
+ {humanizeAgo(estimate.data_freshness_minutes)}
Источников:
{estimate.sources_used.length} / 7
@@ -259,7 +263,11 @@ export function ListingsCard({ estimate, estimateId }: Props) {
{lots.map((lot, i) => (
-
+
))}
diff --git a/tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
index 69a9d480..bf53197f 100644
--- a/tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
@@ -117,6 +117,9 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
// Ключ последнего ОТПРАВЛЕННОГО драфта — чтобы не дёргать бэкенд на тех же значениях.
const lastSentKeyRef = useRef
(baseKey);
+ // #770 finding #27: монотонный счётчик запросов — фильтрует out-of-order ответы
+ // (медленный старый пересчёт мог перетереть свежий результат).
+ const reqSeqRef = useRef(0);
useEffect(() => {
lastSentKeyRef.current = baseKey;
}, [baseKey]);
@@ -126,8 +129,12 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
const key = draftKey(d);
if (key === lastSentKeyRef.current) return; // ничего не изменилось
lastSentKeyRef.current = key;
+ const seq = ++reqSeqRef.current;
mutation.mutate(applyDraft(baseInput, d), {
- onSuccess: (est) => setWhatIfEstimate(est),
+ onSuccess: (est) => {
+ // Применяем только результат последнего отправленного пересчёта.
+ if (seq === reqSeqRef.current) setWhatIfEstimate(est);
+ },
});
},
[baseInput, mutation],
@@ -316,6 +323,7 @@ export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
<>
0 ? " up" : delta < 0 ? " down" : ""}`}
+ title="Пересчёт ходит к источникам заново — дельта включает естественный разброс свежих объявлений, а не только эффект изменённого параметра."
>
{formatDeltaMln(delta)}
{deltaPct !== null && Math.abs(deltaPct) >= 0.1
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 9b75662e..39a1acf5 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -18,6 +18,28 @@ export type HouseType =
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
+// Runtime-валидаторы enum-значений (#770 finding #30): защищают `as`-cast от
+// произвольной строки (битый ?id=-restore / подменённый payload).
+export const HOUSE_TYPES: readonly HouseType[] = [
+ "panel",
+ "brick",
+ "monolith",
+ "monolith_brick",
+ "other",
+];
+export const REPAIR_STATES: readonly RepairState[] = [
+ "needs_repair",
+ "standard",
+ "good",
+ "excellent",
+];
+export function asHouseType(v: string | null | undefined): HouseType | undefined {
+ return v && (HOUSE_TYPES as readonly string[]).includes(v) ? (v as HouseType) : undefined;
+}
+export function asRepairState(v: string | null | undefined): RepairState | undefined {
+ return v && (REPAIR_STATES as readonly string[]).includes(v) ? (v as RepairState) : undefined;
+}
+
export type ConfidenceLevel = "low" | "medium" | "high";
// Точность гео-привязки адреса (из DaData qc_geo): house=0, street=1, approximate≥2.