reservation-front/src/pages/CafeOwnerRegister.tsx
2026-07-11 22:38:51 +08:00

797 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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";
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 { toPersianDigits } from "../utils/formatNumber";
import HostBanner from "../assets/images/banners/Host-banner.jpg";
const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
function findTehran(cities: City[]): City | undefined {
return cities.find(
(c) => c.city_name === "تهران" || c.city_name.toLowerCase() === "tehran",
);
}
function FormField({
label,
placeholder,
hint,
value,
onChange,
}: {
label: string;
placeholder: string;
hint?: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
{label}
</label>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]"
/>
{hint && (
<p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
)}
</div>
);
}
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,
}: {
onSelect: (lat: number, lng: number) => void;
}) {
const onSelectRef = useRef(onSelect);
useEffect(() => {
onSelectRef.current = onSelect;
}, [onSelect]);
const handlers = useMemo(
() => ({
click(e: { latlng: { lat: number; lng: number } }) {
onSelectRef.current(e.latlng.lat, e.latlng.lng);
},
}),
[],
);
useMapEvents(handlers);
return null;
}
function CafeLocationMap({
label,
location,
onChange,
}: {
label: string;
location: { lat: number; lng: number };
onChange: (lat: number, lng: number) => void;
}) {
return (
<div className="mt-5">
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
{label}
</label>
<MapContainer
center={[location.lat, location.lng]}
zoom={15}
scrollWheelZoom={false}
zoomControl={false}
className="h-48 w-full overflow-hidden rounded-2xl"
>
<TileLayer
attribution="&copy; Addwin Maps"
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
maxZoom={19}
minZoom={8}
/>
<Marker position={[location.lat, location.lng]} />
<ZoomControl position="topright" />
<LocationPicker onSelect={onChange} />
</MapContainer>
</div>
);
}
const MAX_PHOTOS = 10;
function ImgWithHeicFallback({
src,
className,
fallbackClassName,
}: {
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>
{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>
);
}
function FeatureChip({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className={`flex items-center gap-2 whitespace-nowrap rounded-2xl border border-[#F2F2F2] px-3 py-2 text-sm text-gray-900 ${
checked ? "bg-[#ECF2F8]" : "bg-transparent"
}`}
>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[#223D5D] ${
checked ? "bg-[#223D5D]" : "bg-transparent"
}`}
>
{checked && <Icon name="check" className="h-3 w-3 text-white" />}
</span>
<span>{label}</span>
</button>
);
}
export default function CafeOwnerRegister() {
const navigate = useNavigate();
const location = useLocation();
// panel's "اطلاعات کافه" link navigates here with state:{step:2} to signal edit mode
const isEditMode =
(location.state as { step?: number } | null)?.step === 2;
const [cafeName, setCafeName] = useState("");
const [address, setAddress] = useState("");
const [cafePhone, setCafePhone] = useState("");
const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION);
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
const [cities, setCities] = useState<City[]>([]);
// 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()) {
navigate("/owner/panel", { replace: true });
}
}, [isEditMode, navigate]);
useEffect(() => {
fetchCities()
.then(setCities)
.catch(() => {});
fetchCafeFeatures()
.then(setCafeFeatures)
.catch(() => {});
}, []);
// fall back to Tehran when no city has been explicitly selected yet
const effectiveCityUuid =
selectedCityUuid ||
findTehran(cities)?.uuid ||
cities[0]?.uuid ||
"";
useEffect(() => {
if (!isEditMode) return;
fetchMyCafes()
.then((cafes) => {
const cafe = cafes[0];
if (!cafe) return;
setCafeName(cafe.name);
setSelectedCityUuid(cafe.city.uuid);
setAddress(cafe.address);
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]);
const toggleFeature = (uuid: string) => {
setSelectedFeatures((prev) =>
prev.includes(uuid) ? prev.filter((f) => f !== uuid) : [...prev, uuid],
);
};
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,
location: {
type: "Point" as const,
coordinates: [cafeLocation.lng, cafeLocation.lat] as [
number,
number,
],
},
address,
feature_uuids: selectedFeatures,
media: mediaItems,
};
if (isEditMode) {
const cafeUuid = getCafeUuid() ?? "";
await updateCafe(cafeUuid, payload);
navigate("/owner/panel");
} else {
const cafe = await registerCafe({ owner_uuid: "", ...payload });
setCafeUuid(cafe.uuid);
navigate("/owner/register/success");
}
} catch {
if (!isEditMode) {
navigate("/owner/register/fail");
} else {
setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-white pb-24">
<img src={HostBanner} alt="" className="w-full" />
<div className="px-4">
<FormField
label="نام کافه"
placeholder="نام کافه را وارد کنید"
value={cafeName}
onChange={setCafeName}
/>
<CitySelectField
value={effectiveCityUuid}
options={cities}
onChange={setSelectedCityUuid}
/>
<FormField
label="آدرس"
placeholder="موقعیت کافه را با جزئیات وارد کنید"
value={address}
onChange={setAddress}
/>
<CafeLocationMap
label="موقعیت روی نقشه"
location={cafeLocation}
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
/>
<FormField
label="شماره تماس کافه"
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
hint="شماره را همراه با پیش‌شماره شهر وارد کنید."
value={cafePhone}
onChange={setCafePhone}
/>
<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">
ویژگی‌های کافه
</label>
<div className="grid grid-cols-2 gap-3">
{cafeFeatures.map((feature) => (
<FeatureChip
key={feature.uuid}
label={feature.name}
checked={selectedFeatures.includes(feature.uuid)}
onToggle={() => toggleFeature(feature.uuid)}
/>
))}
</div>
</div>
{error && (
<p className="mt-4 text-right text-sm text-red-500">{error}</p>
)}
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
<button
type="button"
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
>
پشتیبانی
</button>
<button
type="button"
disabled={isSubmitting}
onClick={handleContinue}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
>
{isSubmitting
? "در حال ارسال..."
: isEditMode
? "ذخیره تغییرات"
: "ادامه"}
{!isSubmitting && !isEditMode && (
<Icon name="chevronLeft" className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}