feature/WNPT-247 #2
10 changed files with 489 additions and 26 deletions
|
|
@ -6,6 +6,7 @@ import { useMatchEvents } from '../hooks/useMatchEvents'
|
|||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
import Icon from '../components/Icon'
|
||||
import HostBanner from '../assets/images/banners/Host-banner.jpg'
|
||||
|
||||
const ENTRY_FEE_STEP = 10000
|
||||
const DESCRIPTION_MAX_LENGTH = 200
|
||||
|
|
@ -190,7 +191,7 @@ export default function AddEvent() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
<img src={HostBanner} alt="" className="w-full" />
|
||||
|
||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
||||
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export default function AddEventFail() {
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<Icon name="fail" className="mx-auto h-28 w-28" />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export default function AddEventSuccess() {
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<Icon name="success" className="mx-auto h-28 w-28" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Icon from '../components/Icon'
|
|||
import { useNow } from '../hooks/useNow'
|
||||
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
|
||||
import { getCafeUuid } from '../utils/cafe'
|
||||
import HostBanner from '../assets/images/banners/Host-banner.jpg'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ export default function CafeOwnerPanel() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-20">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
<img src={HostBanner} alt="" className="w-full" />
|
||||
|
||||
<div className="px-4">
|
||||
<h1 className="mt-4 text-right text-lg font-bold text-gray-900">پنل کافه</h1>
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
}: {
|
||||
|
|
@ -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 (
|
||||
<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}
|
||||
تصاویر
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-36 w-full items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-[#F8F8F8] text-[#666666]"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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()) {
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
<img src={HostBanner} alt="" className="w-full" />
|
||||
|
||||
<div className="px-4">
|
||||
<FormField
|
||||
|
|
@ -281,6 +714,11 @@ export default function CafeOwnerRegister() {
|
|||
value={cafeName}
|
||||
onChange={setCafeName}
|
||||
/>
|
||||
<CitySelectField
|
||||
value={effectiveCityUuid}
|
||||
options={cities}
|
||||
onChange={setSelectedCityUuid}
|
||||
/>
|
||||
<FormField
|
||||
label="آدرس"
|
||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||
|
|
@ -299,8 +737,16 @@ export default function CafeOwnerRegister() {
|
|||
value={cafePhone}
|
||||
onChange={setCafePhone}
|
||||
/>
|
||||
<UploadBox label="تصاویر" buttonLabel="افزودن تصویر" />
|
||||
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export default function CafeRegisterFail() {
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<Icon name="fail" className="mx-auto h-28 w-28" />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export default function CafeRegisterSuccess() {
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white pb-32">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<Icon name="success" className="mx-auto h-28 w-28" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Icon from "../components/Icon";
|
|||
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
|
||||
import { EventReservation, fetchEventReservations } from "../services/campaignApi";
|
||||
import { getCafeUuid } from "../utils/cafe";
|
||||
import HostBanner from "../assets/images/banners/Host-banner.jpg";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
|
||||
function toEnglishDigits(value: string) {
|
||||
|
|
@ -124,7 +125,7 @@ export default function EventReservations() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
<img src={HostBanner} alt="" className="w-full" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import Icon from '../components/Icon'
|
||||
import { fetchMyReservationByUuid, fetchMyReservations, MyReservation } from '../services/campaignApi'
|
||||
import { formatPersianNumber } from '../utils/formatNumber'
|
||||
import GuestBanner from '../assets/images/banners/Guest-banner.jpg'
|
||||
|
||||
function formatReservationDateTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
|
|
@ -112,7 +113,7 @@ export default function MyReservations() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
<div className="h-16 w-full bg-gray-200" />
|
||||
<img src={GuestBanner} alt="" className="w-full" />
|
||||
|
||||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
||||
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ export interface GeoPoint {
|
|||
coordinates: [number, number]
|
||||
}
|
||||
|
||||
export interface CafeMediaItem {
|
||||
object_key: string
|
||||
type: 'image' | 'document'
|
||||
}
|
||||
|
||||
export interface RegisterCafePayload {
|
||||
owner_uuid: string
|
||||
city_uuid: string
|
||||
|
|
@ -69,6 +74,7 @@ export interface RegisterCafePayload {
|
|||
location: GeoPoint
|
||||
address: string
|
||||
feature_uuids: string[]
|
||||
media: CafeMediaItem[]
|
||||
}
|
||||
|
||||
export interface CafeResponse extends RegisterCafePayload {
|
||||
|
|
@ -88,6 +94,7 @@ export interface UpdateCafePayload {
|
|||
location: GeoPoint
|
||||
address: string
|
||||
feature_uuids: string[]
|
||||
media: CafeMediaItem[]
|
||||
}
|
||||
|
||||
export async function updateCafe(cafeUuid: string, payload: UpdateCafePayload): Promise<CafeResponse> {
|
||||
|
|
@ -253,3 +260,13 @@ export async function createReservation(payload: CreateReservationPayload): Prom
|
|||
const { data } = await api.post<ReservationResult>('/campaign/reservations/', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface PresignResponse {
|
||||
upload_url: string
|
||||
object_key: string
|
||||
}
|
||||
|
||||
export async function presignMedia(filename: string): Promise<PresignResponse> {
|
||||
const { data } = await api.post<PresignResponse>('/campaign/media/presign/', { filename })
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue