Compare commits
No commits in common. "c88170b6808cde5b748fc5ea9fd1a844ba84552e" and "abbe0b871dadf1bd3c9d9974a1114fc989564f4a" have entirely different histories.
c88170b680
...
abbe0b871d
2 changed files with 10 additions and 44 deletions
|
|
@ -17,7 +17,6 @@
|
||||||
* Тема — через 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";
|
||||||
|
|
||||||
|
|
@ -64,10 +63,7 @@ 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",
|
||||||
|
|
@ -75,42 +71,28 @@ 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), иначе
|
||||||
* Возвращает Date или null. Не использует Date.now() — безопасна для SSR.
|
* now − data_freshness_minutes. Невалидно/нет данных → null (строку не рисуем).
|
||||||
*/
|
*/
|
||||||
function freshnessFromScraped(estimate: AggregatedEstimate): Date | null {
|
function freshnessMoment(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,16 +11,6 @@ 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;
|
||||||
|
|
@ -36,8 +26,6 @@ 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[]);
|
||||||
|
|
@ -52,8 +40,6 @@ 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();
|
||||||
|
|
@ -94,8 +80,6 @@ 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">
|
||||||
|
|
@ -110,7 +94,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 }}>
|
||||||
{isPreview ? `— / ${MAX_PHOTOS}` : `${photos.length} / ${MAX_PHOTOS}`}
|
{photos.length} / {MAX_PHOTOS}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -120,7 +104,7 @@ export function PhotoUpload({ estimateId }: { estimateId: string }) {
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
aria-label="Загрузить фото квартиры"
|
aria-label="Загрузить фото квартиры"
|
||||||
disabled={uploading || full || isPreview}
|
disabled={uploading || full}
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue