پنل کافه
diff --git a/src/pages/CafeOwnerRegister.tsx b/src/pages/CafeOwnerRegister.tsx
index 5f095f0..066bd72 100644
--- a/src/pages/CafeOwnerRegister.tsx
+++ b/src/pages/CafeOwnerRegister.tsx
@@ -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
(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,
}: {
@@ -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 (
+
+ );
+ }
+ 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 = "";
+ }}
+ />
);
}
@@ -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([]);
+ 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()) {
@@ -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 (
-
+
+
-
-
+
+