fix(tradein-frontend): Мера estimate — hydration #418 + photos 422 (#1803 #1804) #1808

Merged
lekss361 merged 1 commit from fix/mera-estimate-ui-artifacts into main 2026-06-19 17:49:45 +00:00
2 changed files with 44 additions and 10 deletions

View file

@ -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<Date | null>(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;

View file

@ -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<HTMLInputElement>(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 (
<article className="card">
@ -94,7 +110,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
>
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Фото квартиры</h3>
<span style={{ color: "var(--muted)", fontSize: 13 }}>
{photos.length} / {MAX_PHOTOS}
{isPreview ? `— / ${MAX_PHOTOS}` : `${photos.length} / ${MAX_PHOTOS}`}
</span>
</div>
@ -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)}
/>