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.