Merge pull request 'fix(tradein-frontend): Мера estimate — hydration #418 + photos 422 (#1803 #1804)' (#1808) from fix/mera-estimate-ui-artifacts into main
Some checks failed
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m45s
Deploy Trade-In / deploy (push) Has been cancelled

Reviewed-on: #1808
This commit is contained in:
lekss361 2026-06-19 17:49:44 +00:00
commit c88170b680
2 changed files with 44 additions and 10 deletions

View file

@ -17,6 +17,7 @@
* Тема через brand-CSS-vars (--accent), responsive, всё graceful при null-полях. * Тема через brand-CSS-vars (--accent), responsive, всё graceful при null-полях.
*/ */
import { useState, useEffect } from "react";
import type { AggregatedEstimate } from "@/types/trade-in"; import type { AggregatedEstimate } from "@/types/trade-in";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
@ -63,7 +64,10 @@ function compsTier(explanation: string | null): string | null {
return 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 { function formatRuDateTime(d: Date): string {
return new Intl.DateTimeFormat("ru-RU", { return new Intl.DateTimeFormat("ru-RU", {
day: "numeric", day: "numeric",
@ -71,28 +75,42 @@ function formatRuDateTime(d: Date): string {
year: "numeric", year: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone: "UTC",
}).format(d); }).format(d);
} }
/** /**
* Момент актуальности данных: предпочитаем last_scraped_at (ISO), иначе * Момент актуальности данных из last_scraped_at (ISO).
* now data_freshness_minutes. Невалидно/нет данных null (строку не рисуем). * Возвращает Date или null. Не использует Date.now() безопасна для SSR.
*/ */
function freshnessMoment(estimate: AggregatedEstimate): Date | null { function freshnessFromScraped(estimate: AggregatedEstimate): Date | null {
if (estimate.last_scraped_at) { if (estimate.last_scraped_at) {
const d = new Date(estimate.last_scraped_at); const d = new Date(estimate.last_scraped_at);
if (!Number.isNaN(d.getTime())) return d; 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; return null;
} }
export function HeroTransparency({ estimate, brandSlug, brandName }: Props) { export function HeroTransparency({ estimate, brandSlug, brandName }: Props) {
const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low; const band = CONF_BAND[estimate.confidence] ?? CONF_BAND.low;
const tier = compsTier(estimate.confidence_explanation); 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. // CTA показываем только при сконфигурированном лид-инбоксе (см. CONTACT_EMAIL) — review #689.
const leadEnabled = CONTACT_EMAIL.length > 0; const leadEnabled = CONTACT_EMAIL.length > 0;

View file

@ -11,6 +11,16 @@ const BASE = "/api/v1/trade-in";
const MAX_PHOTOS = 12; const MAX_PHOTOS = 12;
const MAX_FILE_BYTES = 10 * 1024 * 1024; // match backend `_MAX_PHOTO_BYTES` 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 { interface PhotoMeta {
id: string; id: string;
filename: string | null; filename: string | null;
@ -26,6 +36,8 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
// #1804: не слать запрос с фейковым/preview ID — backend отклоняет 422.
if (!isRealEstimateId(estimateId)) return;
try { try {
const r = await fetch(`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`); const r = await fetch(`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`);
if (r.ok) setPhotos((await r.json()) as PhotoMeta[]); 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) { async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
// #1804: guard — не слать upload с placeholder-ID (backend 422)
if (!isRealEstimateId(estimateId)) return;
setUploading(true); setUploading(true);
setErrors([]); // clear stale errors on every new pick setErrors([]); // clear stale errors on every new pick
const sid = getOrCreateSessionId(); const sid = getOrCreateSessionId();
@ -80,6 +94,8 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
} }
const full = photos.length >= MAX_PHOTOS; const full = photos.length >= MAX_PHOTOS;
// #1804: upload недоступен для preview/placeholder ID — не слать 422.
const isPreview = !isRealEstimateId(estimateId);
return ( return (
<article className="card"> <article className="card">
@ -94,7 +110,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
> >
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Фото квартиры</h3> <h3 style={{ fontSize: 15, fontWeight: 600 }}>Фото квартиры</h3>
<span style={{ color: "var(--muted)", fontSize: 13 }}> <span style={{ color: "var(--muted)", fontSize: 13 }}>
{photos.length} / {MAX_PHOTOS} {isPreview ? `— / ${MAX_PHOTOS}` : `${photos.length} / ${MAX_PHOTOS}`}
</span> </span>
</div> </div>
@ -104,7 +120,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
accept="image/*" accept="image/*"
multiple multiple
aria-label="Загрузить фото квартиры" aria-label="Загрузить фото квартиры"
disabled={uploading || full} disabled={uploading || full || isPreview}
onChange={(e) => handleFiles(e.target.files)} onChange={(e) => handleFiles(e.target.files)}
/> />