feat(tradein): фронтенд загрузки фото квартиры (#394) #408
2 changed files with 132 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
|
||||||
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
import { EstimateForm } from "@/components/trade-in/EstimateForm";
|
||||||
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||||
|
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||||||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||||
|
|
@ -155,6 +156,7 @@ export default function TradeInPage() {
|
||||||
{resultData ? (
|
{resultData ? (
|
||||||
<>
|
<>
|
||||||
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
|
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
|
||||||
|
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||||
<ListingsCard estimate={resultData.estimate} />
|
<ListingsCard estimate={resultData.estimate} />
|
||||||
<DealsCard estimate={resultData.estimate} />
|
<DealsCard estimate={resultData.estimate} />
|
||||||
<OfferCard estimate={resultData.estimate} />
|
<OfferCard estimate={resultData.estimate} />
|
||||||
|
|
|
||||||
130
tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx
Normal file
130
tradein-mvp/frontend/src/components/trade-in/PhotoUpload.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue