gendesign/tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx
TradeIn Deploy 0a1df4d13a feat(tradein): фронтенд загрузки фото квартиры (#394)
Компонент PhotoUpload на result-view: выбор image/* (multiple),
загрузка в POST /estimate/{id}/photos, превью загруженных фото.
Multipart напрямую через fetch — apiFetch форсит JSON content-type.

Closes #394
2026-05-22 11:10:50 +05:00

130 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/* Загрузка фото квартиры к оценке (#394). */
/* eslint-disable @next/next/no-img-element -- фото отдаёт API, next/image тут лишний */
import { useCallback, useEffect, useRef, useState } from "react";
import { API_BASE_URL } from "@/lib/api";
import { getOrCreateSessionId } from "@/lib/sessionId";
const BASE = "/api/v1/trade-in";
const MAX_PHOTOS = 12;
interface PhotoMeta {
id: string;
filename: string | null;
content_type: string;
size_bytes: number | null;
uploaded_at: string;
}
export function PhotoUpload({ estimateId }: { estimateId: string }) {
const [photos, setPhotos] = useState<PhotoMeta[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
try {
const r = await fetch(`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`);
if (r.ok) setPhotos((await r.json()) as PhotoMeta[]);
} catch {
/* список фото некритичен — молча игнорируем */
}
}, [estimateId]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setUploading(true);
setError(null);
const sid = getOrCreateSessionId();
for (const file of Array.from(files)) {
const form = new FormData();
form.append("file", file);
try {
const r = await fetch(
`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos`,
{
method: "POST",
body: form,
headers: sid ? { "X-Session-Id": sid } : {},
},
);
if (!r.ok) setError(`${file.name}: ошибка загрузки (${r.status})`);
} catch {
setError(`${file.name}: сеть недоступна`);
}
}
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
await refresh();
}
const full = photos.length >= MAX_PHOTOS;
return (
<article className="card">
<div className="card-body">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Фото квартиры</h3>
<span style={{ color: "var(--muted)", fontSize: 13 }}>
{photos.length} / {MAX_PHOTOS}
</span>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
disabled={uploading || full}
onChange={(e) => handleFiles(e.target.files)}
/>
{uploading && (
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 8 }}>
Загрузка
</p>
)}
{full && !uploading && (
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 8 }}>
Достигнут лимит {MAX_PHOTOS} фото.
</p>
)}
{error && (
<p style={{ color: "#c0392b", fontSize: 13, marginTop: 8 }}>{error}</p>
)}
{photos.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
{photos.map((p) => (
<img
key={p.id}
src={`${API_BASE_URL}${BASE}/estimate/${estimateId}/photos/${p.id}`}
alt={p.filename ?? "фото квартиры"}
style={{
width: 96,
height: 96,
objectFit: "cover",
borderRadius: 8,
border: "1px solid var(--border, #e2e2e2)",
}}
/>
))}
</div>
)}
</div>
</article>
);
}