#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-полях.
|
* Тема — через 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue