import { useEffect, useMemo, useRef, useState } from "react";
import heic2any from "heic2any";
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,
inputDir = "ltr",
}: {
label: string;
placeholder: string;
hint?: string;
value: string;
onChange: (value: string) => void;
inputDir?: "ltr" | "rtl";
}) {
return (
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 && (
{hint}
)}
);
}
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(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 (
{open &&
createPortal(
<>
setOpen(false)} />
{options.map((city) => (
))}
>,
document.body,
)}
);
}
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 (
);
}
const MAX_PHOTOS = 10;
async function createPreviewUrl(file: File): Promise
{
const isHeic =
/\.(heic|heif)$/i.test(file.name) ||
file.type === "image/heic" ||
file.type === "image/heif";
if (isHeic) {
try {
const converted = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.85 });
const blob = Array.isArray(converted) ? converted[0] : converted;
return URL.createObjectURL(blob);
} catch {
// fall back to original blob URL if conversion fails
}
}
return URL.createObjectURL(file);
}
function ImgWithHeicFallback({
src,
className,
fallbackClassName,
}: {
src: string;
className: string;
fallbackClassName: string;
}) {
const [error, setError] = useState(false);
if (error) {
return (
);
}
return
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(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 (
{photos.length > 0 && (
{photos.map((photo) => (
{photo.status === "uploading" && (
آپلود...
)}
{photo.status === "error" && (
خطا
)}
))}
)}
{canAdd && (
)}
{
if (!e.target.files) return;
const files = Array.from(e.target.files).slice(0, remaining);
if (files.length > 0) onAdd(files);
e.target.value = "";
}}
/>
);
}
function DocumentUpload({
doc,
onAdd,
onRemove,
}: {
doc: UploadedDocument | null;
onAdd: (file: File) => void;
onRemove: () => void;
}) {
const inputRef = useRef(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 (
{doc ? (
{doc.status === "uploading" && (
آپلود...
)}
{doc.status === "error" && (
خطا در آپلود
)}
) : (
)}
{
const file = e.target.files?.[0];
if (file) onAdd(file);
e.target.value = "";
}}
/>
);
}
function FeatureChip({
label,
checked,
onToggle,
}: {
label: string;
checked: boolean;
onToggle: () => void;
}) {
return (
);
}
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([]);
const [cities, setCities] = useState([]);
// explicitly selected in edit mode (pre-filled from existing cafe); empty = auto-select Tehran below
const [selectedCityUuid, setSelectedCityUuid] = useState("");
const [cafeFeatures, setCafeFeatures] = useState([]);
const [photos, setPhotos] = useState([]);
const [licenseDoc, setLicenseDoc] = useState(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[] = await Promise.all(
files.map(async (file) => ({
id: crypto.randomUUID(),
file,
localUrl: await createPreviewUrl(file),
status: "uploading" as const,
})),
);
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: await createPreviewUrl(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 (
setCafeLocation({ lat, lng })}
/>
{cafeFeatures.map((feature) => (
toggleFeature(feature.uuid)}
/>
))}
{error && (
{error}
)}
);
}