#1803 HeroTransparency: formatRuDateTime добавляет timeZone:"UTC" (SSR Node UTC vs клиент Europe/Moscow давали разный час → React minified error #418). Date.now() fallback (data_freshness_minutes без last_scraped_at) вынесен в useEffect/useState — не выполняется при SSR. #1804 PhotoUpload: guard isRealEstimateId() проверяет UUID4 regex и отклоняет preview-0000-... — GET/POST /photos с фейковым ID больше не слаются → нет 422. Счётчик показывает «— / 12» вместо «0 / 12», input disabled в preview-режиме.
This commit is contained in:
parent
a534dece06
commit
cbc20cfe54
2 changed files with 44 additions and 10 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue