821 lines
25 KiB
TypeScript
821 lines
25 KiB
TypeScript
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 (
|
||
<div className="mt-5">
|
||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||
{label}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
dir={inputDir}
|
||
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="© 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;
|
||
|
||
async function createPreviewUrl(file: File): Promise<string> {
|
||
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 (
|
||
<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[] = 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 (
|
||
<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={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
|
||
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
||
value={cafePhone}
|
||
onChange={setCafePhone}
|
||
inputDir="ltr"
|
||
/>
|
||
<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>
|
||
);
|
||
}
|