{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;
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[] = 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 (
setCafeLocation({ lat, lng })}
/>
{cafeFeatures.map((feature) => (
toggleFeature(feature.uuid)}
/>
))}
{error && (
{error}
)}
);
}