diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx index d372196a..fe54a325 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx @@ -17,6 +17,7 @@ * Тема — через brand-CSS-vars (--accent…), responsive, всё graceful при null-полях. */ +import { useState, useEffect } from "react"; import type { AggregatedEstimate } from "@/types/trade-in"; import { API_BASE_URL } from "@/lib/api"; @@ -63,7 +64,10 @@ function compsTier(explanation: string | null): string | null { return null; } -/** «27 мая 2026, 14:30» */ +/** + * «27 мая 2026, 14:30» — timeZone: "UTC" фиксирует вывод одинаково на SSR (Node UTC) + * и клиенте (любой TZ). Иначе «12:00» на сервере → «15:00» у клиента в Москве → React #418. + */ function formatRuDateTime(d: Date): string { return new Intl.DateTimeFormat("ru-RU", { day: "numeric", @@ -71,28 +75,42 @@ function formatRuDateTime(d: Date): string { year: "numeric", hour: "2-digit", minute: "2-digit", + timeZone: "UTC", }).format(d); } /** - * Момент актуальности данных: предпочитаем last_scraped_at (ISO), иначе - * now − data_freshness_minutes. Невалидно/нет данных → null (строку не рисуем). + * Момент актуальности данных из last_scraped_at (ISO). + * Возвращает Date или null. Не использует Date.now() — безопасна для SSR. */ -function freshnessMoment(estimate: AggregatedEstimate): Date | null { +function freshnessFromScraped(estimate: AggregatedEstimate): Date | null { if (estimate.last_scraped_at) { const d = new Date(estimate.last_scraped_at); if (!Number.isNaN(d.getTime())) return d; } - if (typeof estimate.data_freshness_minutes === "number" && estimate.data_freshness_minutes >= 0) { - return new Date(Date.now() - estimate.data_freshness_minutes * 60_000); - } return null; } export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low; const tier = compsTier(estimate.confidence_explanation); - const fresh = freshnessMoment(estimate); + + // SSR-safe: берём last_scraped_at (статичная ISO строка — нет мismatch). + // Если его нет, fallback на data_freshness_minutes через Date.now() — + // но Date.now() нельзя в render (SSR/client mismatch → React #418), поэтому + // вычисляем ТОЛЬКО на клиенте через useEffect. + const scrapedFresh = freshnessFromScraped(estimate); + const [fallbackFresh, setFallbackFresh] = useState(null); + useEffect(() => { + if (scrapedFresh !== null) return; // last_scraped_at уже есть — fallback не нужен + if ( + typeof estimate.data_freshness_minutes === "number" && + estimate.data_freshness_minutes >= 0 + ) { + setFallbackFresh(new Date(Date.now() - estimate.data_freshness_minutes * 60_000)); + } + }, [estimate.data_freshness_minutes, scrapedFresh]); + const fresh = scrapedFresh ?? fallbackFresh; // CTA показываем только при сконфигурированном лид-инбоксе (см. CONTACT_EMAIL) — review #689. const leadEnabled = CONTACT_EMAIL.length > 0; diff --git a/tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx b/tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx index c52ee16f..42517a90 100644 --- a/tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx @@ -11,6 +11,16 @@ const BASE = "/api/v1/trade-in"; const MAX_PHOTOS = 12; const MAX_FILE_BYTES = 10 * 1024 * 1024; // match backend `_MAX_PHOTO_BYTES` +/** + * Возвращает false для placeholder-ID вида «preview-0000-...» или любого + * значения, не похожего на реальный UUID4 (32 hex-цифры через дефисы). + * Позволяет избежать GET /photos с фейковым ID → 422 (#1804). + */ +function isRealEstimateId(id: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) && + !id.startsWith("preview-"); +} + interface PhotoMeta { id: string; filename: string | null; @@ -26,6 +36,8 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) { const inputRef = useRef(null); const refresh = useCallback(async () => { + // #1804: не слать запрос с фейковым/preview ID — backend отклоняет 422. + if (!isRealEstimateId(estimateId)) return; try { const r = await fetch(`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`); if (r.ok) setPhotos((await r.json()) as PhotoMeta[]); @@ -40,6 +52,8 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) { async function handleFiles(files: FileList | null) { if (!files || files.length === 0) return; + // #1804: guard — не слать upload с placeholder-ID (backend 422) + if (!isRealEstimateId(estimateId)) return; setUploading(true); setErrors([]); // clear stale errors on every new pick const sid = getOrCreateSessionId(); @@ -80,6 +94,8 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) { } const full = photos.length >= MAX_PHOTOS; + // #1804: upload недоступен для preview/placeholder ID — не слать 422. + const isPreview = !isRealEstimateId(estimateId); return (
@@ -94,7 +110,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) { >

Фото квартиры

- {photos.length} / {MAX_PHOTOS} + {isPreview ? `— / ${MAX_PHOTOS}` : `${photos.length} / ${MAX_PHOTOS}`} @@ -104,7 +120,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) { accept="image/*" multiple aria-label="Загрузить фото квартиры" - disabled={uploading || full} + disabled={uploading || full || isPreview} onChange={(e) => handleFiles(e.target.files)} />