feat: media upload

This commit is contained in:
Afra ‌ 2026-07-11 22:38:51 +08:00
parent fa34064828
commit ab213ab8d2
10 changed files with 489 additions and 26 deletions

View file

@ -6,6 +6,7 @@ import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
import { getCafeUuid } from '../utils/cafe'
import Icon from '../components/Icon'
import HostBanner from '../assets/images/banners/Host-banner.jpg'
const ENTRY_FEE_STEP = 10000
const DESCRIPTION_MAX_LENGTH = 200
@ -190,7 +191,7 @@ export default function AddEvent() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />

View file

@ -6,7 +6,6 @@ export default function AddEventFail() {
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="h-16 w-full bg-gray-200" />
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="fail" className="mx-auto h-28 w-28" />

View file

@ -6,7 +6,6 @@ export default function AddEventSuccess() {
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="h-16 w-full bg-gray-200" />
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="success" className="mx-auto h-28 w-28" />

View file

@ -4,6 +4,7 @@ import Icon from '../components/Icon'
import { useNow } from '../hooks/useNow'
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
import { getCafeUuid } from '../utils/cafe'
import HostBanner from '../assets/images/banners/Host-banner.jpg'
import { formatPersianNumber } from '../utils/formatNumber'
import { formatMatchLabel } from '../utils/matchCountdown'
@ -34,7 +35,7 @@ export default function CafeOwnerPanel() {
return (
<div className="min-h-screen bg-white pb-20">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<div className="px-4">
<h1 className="mt-4 text-right text-lg font-bold text-gray-900">پنل کافه</h1>

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css";
@ -6,15 +7,18 @@ import "../utils/leafletIcons";
import Icon from "../components/Icon";
import {
CafeFeature,
CafeMediaItem,
City,
fetchCafeFeatures,
fetchCities,
fetchMyCafes,
presignMedia,
registerCafe,
updateCafe,
} from "../services/campaignApi";
import { getCafeUuid, setCafeUuid } from "../utils/cafe";
import { fromE164, toE164 } from "../utils/phone";
import { toPersianDigits } from "../utils/formatNumber";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
@ -56,6 +60,74 @@ function FormField({
);
}
function CitySelectField({
value,
options,
onChange,
}: {
value: string;
options: City[];
onChange: (uuid: string) => void;
}) {
const [open, setOpen] = useState(false);
const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 });
const buttonRef = useRef<HTMLButtonElement>(null);
const selected = options.find((c) => c.uuid === value);
const handleOpen = () => {
if (!open && buttonRef.current) {
const r = buttonRef.current.getBoundingClientRect();
setDropPos({ top: r.bottom + 8, left: r.left, width: r.width });
}
setOpen((o) => !o);
};
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
شهر
</label>
<button
ref={buttonRef}
type="button"
onClick={handleOpen}
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
>
<span className={selected ? "text-gray-900" : "font-light text-[#999999]"}>
{selected ? selected.city_name : "شهر کافه را انتخاب کنید"}
</span>
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open &&
createPortal(
<>
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
<div
className="fixed z-9999 max-h-60 overflow-y-auto rounded-2xl bg-white shadow-lg"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
>
{options.map((city) => (
<button
key={city.uuid}
type="button"
onClick={() => { onChange(city.uuid); setOpen(false); }}
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
>
{city.city_name}
</button>
))}
</div>
</>,
document.body,
)}
</div>
);
}
function LocationPicker({
onSelect,
}: {
@ -114,24 +186,241 @@ function CafeLocationMap({
);
}
function UploadBox({
label,
buttonLabel,
const MAX_PHOTOS = 10;
function ImgWithHeicFallback({
src,
className,
fallbackClassName,
}: {
label: string;
buttonLabel: string;
src: string;
className: string;
fallbackClassName: string;
}) {
const [error, setError] = useState(false);
if (error) {
return (
<div className={fallbackClassName}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-8 w-8 text-gray-400">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span className="mt-1 text-xs text-gray-400">HEIC</span>
</div>
);
}
return <img src={src} alt="" onError={() => setError(true)} className={className} />;
}
interface UploadedPhoto {
id: string;
file?: File;
localUrl: string;
status: "uploading" | "done" | "error";
objectKey?: string;
}
interface UploadedDocument {
file?: File;
localUrl: string;
status: "uploading" | "done" | "error";
objectKey?: string;
}
function PhotoUpload({
photos,
onAdd,
onRemove,
}: {
photos: UploadedPhoto[];
onAdd: (files: File[]) => void;
onRemove: (id: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const remaining = MAX_PHOTOS - photos.length;
const canAdd = remaining > 0;
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, remaining);
if (files.length > 0) onAdd(files);
};
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
{label}
تصاویر
</label>
<button
type="button"
className="flex h-36 w-full items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-[#F8F8F8] text-[#666666]"
>
{buttonLabel}
</button>
{photos.length > 0 && (
<div className="mb-3 grid grid-cols-3 gap-2">
{photos.map((photo) => (
<div key={photo.id} className="relative aspect-square">
<ImgWithHeicFallback
src={photo.localUrl}
className="h-full w-full rounded-xl object-cover"
fallbackClassName="flex h-full w-full flex-col items-center justify-center rounded-xl bg-gray-100"
/>
{photo.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40">
<span className="text-xs text-white">آپلود...</span>
</div>
)}
{photo.status === "error" && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-red-500/50">
<span className="text-xs text-white">خطا</span>
</div>
)}
<button
type="button"
onClick={() => onRemove(photo.id)}
className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-sm leading-none text-white"
>
×
</button>
</div>
))}
</div>
)}
{canAdd && (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node))
setIsDragging(false);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className={`flex h-36 w-full flex-col items-center justify-center gap-1 rounded-2xl border border-dashed transition-colors ${
isDragging
? "border-[#223D5D] bg-[#ECF2F8] text-[#223D5D]"
: "border-gray-300 bg-[#F8F8F8] text-[#666666]"
}`}
>
<span>{isDragging ? "رها کنید" : "افزودن تصویر"}</span>
{!isDragging && photos.length > 0 && (
<span className="text-sm text-gray-400">
{toPersianDigits(String(photos.length))} از ۱۰
</span>
)}
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (!e.target.files) return;
const files = Array.from(e.target.files).slice(0, remaining);
if (files.length > 0) onAdd(files);
e.target.value = "";
}}
/>
</div>
);
}
function DocumentUpload({
doc,
onAdd,
onRemove,
}: {
doc: UploadedDocument | null;
onAdd: (file: File) => void;
onRemove: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = Array.from(e.dataTransfer.files).find((f) =>
f.type.startsWith("image/"),
);
if (file) onAdd(file);
};
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
مجوز کسب و کار
</label>
{doc ? (
<div className="relative aspect-video w-full">
<ImgWithHeicFallback
src={doc.localUrl}
className="h-full w-full rounded-2xl object-cover"
fallbackClassName="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-gray-100"
/>
{doc.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/40">
<span className="text-sm text-white">آپلود...</span>
</div>
)}
{doc.status === "error" && (
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-red-500/50">
<span className="text-sm text-white">خطا در آپلود</span>
</div>
)}
<button
type="button"
onClick={onRemove}
className="absolute left-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white"
>
×
</button>
</div>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node))
setIsDragging(false);
}}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className={`flex h-36 w-full items-center justify-center rounded-2xl border border-dashed transition-colors ${
isDragging
? "border-[#223D5D] bg-[#ECF2F8] text-[#223D5D]"
: "border-gray-300 bg-[#F8F8F8] text-[#666666]"
}`}
>
{isDragging ? "رها کنید" : "افزودن تصویر مجوز"}
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onAdd(file);
e.target.value = "";
}}
/>
</div>
);
}
@ -181,9 +470,30 @@ export default function CafeOwnerRegister() {
// explicitly selected in edit mode (pre-filled from existing cafe); empty = auto-select Tehran below
const [selectedCityUuid, setSelectedCityUuid] = useState("");
const [cafeFeatures, setCafeFeatures] = useState<CafeFeature[]>([]);
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// keep refs so the unmount cleanup can revoke any remaining object URLs
const photosRef = useRef(photos);
useEffect(() => {
photosRef.current = photos;
}, [photos]);
const licenseDocRef = useRef(licenseDoc);
useEffect(() => {
licenseDocRef.current = licenseDoc;
}, [licenseDoc]);
useEffect(() => {
return () => {
photosRef.current
.filter((p) => p.localUrl.startsWith("blob:"))
.forEach((p) => URL.revokeObjectURL(p.localUrl));
const doc = licenseDocRef.current;
if (doc?.localUrl.startsWith("blob:")) URL.revokeObjectURL(doc.localUrl);
};
}, []);
// redirect owners who already have a cafe away from fresh registration
useEffect(() => {
if (!isEditMode && getCafeUuid()) {
@ -216,12 +526,33 @@ export default function CafeOwnerRegister() {
setCafeName(cafe.name);
setSelectedCityUuid(cafe.city.uuid);
setAddress(cafe.address);
setCafePhone(fromE164(cafe.phone_number));
setCafePhone(cafe.phone_number);
setCafeLocation({
lat: cafe.location.coordinates[1],
lng: cafe.location.coordinates[0],
});
setSelectedFeatures(cafe.features.map((f) => f.uuid));
const imageMedia = cafe.media.filter((m) => m.type === "image");
const docMedia = cafe.media.find((m) => m.type === "document");
if (imageMedia.length > 0) {
setPhotos(
imageMedia.map((m) => ({
id: m.uuid,
localUrl: m.url,
status: "done",
objectKey: m.url,
})),
);
}
if (docMedia) {
setLicenseDoc({
localUrl: docMedia.url,
status: "done",
objectKey: docMedia.url,
});
}
})
.catch(() => {});
}, [isEditMode]);
@ -232,14 +563,115 @@ export default function CafeOwnerRegister() {
);
};
const handleAddPhotos = async (files: File[]) => {
const newPhotos: UploadedPhoto[] = files.map((file) => ({
id: crypto.randomUUID(),
file,
localUrl: URL.createObjectURL(file),
status: "uploading",
}));
setPhotos((prev) => [...prev, ...newPhotos]);
await Promise.all(
newPhotos.map(async (photo) => {
try {
const { upload_url, object_key } = await presignMedia(photo.file!.name);
const res = await fetch(upload_url, {
method: "PUT",
body: photo.file!,
headers: { "Content-Type": photo.file!.type },
});
if (!res.ok) throw new Error("upload failed");
setPhotos((prev) =>
prev.map((p) =>
p.id === photo.id ? { ...p, status: "done", objectKey: object_key } : p,
),
);
} catch {
setPhotos((prev) =>
prev.map((p) =>
p.id === photo.id ? { ...p, status: "error" } : p,
),
);
}
}),
);
};
const handleRemovePhoto = (id: string) => {
setPhotos((prev) => {
const photo = prev.find((p) => p.id === id);
if (photo) URL.revokeObjectURL(photo.localUrl);
return prev.filter((p) => p.id !== id);
});
};
const handleAddLicense = async (file: File) => {
const newDoc: UploadedDocument = {
file,
localUrl: URL.createObjectURL(file),
status: "uploading",
};
setLicenseDoc(newDoc);
try {
const { upload_url, object_key } = await presignMedia(file.name);
const res = await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!res.ok) throw new Error("upload failed");
setLicenseDoc((prev) =>
prev ? { ...prev, status: "done", objectKey: object_key } : null,
);
} catch {
setLicenseDoc((prev) =>
prev ? { ...prev, status: "error" } : null,
);
}
};
const handleRemoveLicense = () => {
setLicenseDoc((prev) => {
if (prev) URL.revokeObjectURL(prev.localUrl);
return null;
});
};
const handleContinue = async () => {
const stillUploading =
photos.some((p) => p.status === "uploading") ||
licenseDoc?.status === "uploading";
if (stillUploading) {
setError("لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود.");
return;
}
if (!isEditMode) {
if (!photos.some((p) => p.status === "done")) {
setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید.");
return;
}
if (!licenseDoc || licenseDoc.status !== "done") {
setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید.");
return;
}
}
setError("");
setIsSubmitting(true);
try {
const mediaItems: CafeMediaItem[] = [
...photos
.filter((p) => p.status === "done" && p.objectKey)
.map((p) => ({ object_key: p.objectKey!, type: "image" as const })),
...(licenseDoc?.status === "done" && licenseDoc.objectKey
? [{ object_key: licenseDoc.objectKey, type: "document" as const }]
: []),
];
const payload = {
city_uuid: effectiveCityUuid,
name: cafeName,
phone_number: cafePhone ? toE164(cafePhone) : "",
phone_number: cafePhone,
location: {
type: "Point" as const,
coordinates: [cafeLocation.lng, cafeLocation.lat] as [
@ -249,6 +681,7 @@ export default function CafeOwnerRegister() {
},
address,
feature_uuids: selectedFeatures,
media: mediaItems,
};
if (isEditMode) {
const cafeUuid = getCafeUuid() ?? "";
@ -272,7 +705,7 @@ export default function CafeOwnerRegister() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<div className="px-4">
<FormField
@ -281,6 +714,11 @@ export default function CafeOwnerRegister() {
value={cafeName}
onChange={setCafeName}
/>
<CitySelectField
value={effectiveCityUuid}
options={cities}
onChange={setSelectedCityUuid}
/>
<FormField
label="آدرس"
placeholder="موقعیت کافه را با جزئیات وارد کنید"
@ -299,8 +737,16 @@ export default function CafeOwnerRegister() {
value={cafePhone}
onChange={setCafePhone}
/>
<UploadBox label="تصاویر" buttonLabel="افزودن تصویر" />
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
<PhotoUpload
photos={photos}
onAdd={handleAddPhotos}
onRemove={handleRemovePhoto}
/>
<DocumentUpload
doc={licenseDoc}
onAdd={handleAddLicense}
onRemove={handleRemoveLicense}
/>
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">

View file

@ -6,7 +6,6 @@ export default function CafeRegisterFail() {
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="h-16 w-full bg-gray-200" />
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="fail" className="mx-auto h-28 w-28" />

View file

@ -6,7 +6,6 @@ export default function CafeRegisterSuccess() {
return (
<div className="flex min-h-screen flex-col bg-white pb-32">
<div className="h-16 w-full bg-gray-200" />
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<Icon name="success" className="mx-auto h-28 w-28" />

View file

@ -4,6 +4,7 @@ import Icon from "../components/Icon";
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
import { getCafeUuid } from "../utils/cafe";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
import { formatPersianNumber } from "../utils/formatNumber";
function toEnglishDigits(value: string) {
@ -124,7 +125,7 @@ export default function EventReservations() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={HostBanner} alt="" className="w-full" />
<button
type="button"

View file

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon'
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
import { formatPersianNumber } from '../utils/formatNumber'
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
function formatReservationDateTime(iso: string): string {
return new Intl.DateTimeFormat('fa-IR', {
@ -112,7 +113,7 @@ export default function MyReservations() {
return (
<div className="min-h-screen bg-white pb-24">
<div className="h-16 w-full bg-gray-200" />
<img src={GuestBanner} alt="" className="w-full" />
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />

View file

@ -61,6 +61,11 @@ export interface GeoPoint {
coordinates: [number, number]
}
export interface CafeMediaItem {
object_key: string
type: 'image' | 'document'
}
export interface RegisterCafePayload {
owner_uuid: string
city_uuid: string
@ -69,6 +74,7 @@ export interface RegisterCafePayload {
location: GeoPoint
address: string
feature_uuids: string[]
media: CafeMediaItem[]
}
export interface CafeResponse extends RegisterCafePayload {
@ -88,6 +94,7 @@ export interface UpdateCafePayload {
location: GeoPoint
address: string
feature_uuids: string[]
media: CafeMediaItem[]
}
export async function updateCafe(cafeUuid: string, payload: UpdateCafePayload): Promise<CafeResponse> {
@ -253,3 +260,13 @@ export async function createReservation(payload: CreateReservationPayload): Prom
const { data } = await api.post<ReservationResult>('/campaign/reservations/', payload)
return data
}
export interface PresignResponse {
upload_url: string
object_key: string
}
export async function presignMedia(filename: string): Promise<PresignResponse> {
const { data } = await api.post<PresignResponse>('/campaign/media/presign/', { filename })
return data
}