feature/WNPT-307 #4
9 changed files with 368 additions and 180 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.9.0",
|
||||
"axios": "^1.6.6",
|
||||
"heic2any": "^0.0.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
|
|
|||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -14,6 +14,9 @@ importers:
|
|||
axios:
|
||||
specifier: ^1.6.6
|
||||
version: 1.17.0
|
||||
heic2any:
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
|
|
@ -942,6 +945,9 @@ packages:
|
|||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
heic2any@0.0.4:
|
||||
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
|
||||
|
||||
hermes-estree@0.25.1:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
|
|
@ -2245,6 +2251,8 @@ snapshots:
|
|||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
heic2any@0.0.4: {}
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
hermes-parser@0.25.1:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ interface ReservationModalProps {
|
|||
|
||||
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
||||
// these are fixed test wallet ids given directly for now, replace once that exists
|
||||
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
|
||||
const WALLET_UUIDS = [
|
||||
"e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d",
|
||||
"af7d967f-30c0-409b-9066-2549f2da5e5e",
|
||||
];
|
||||
|
||||
function PaymentRow({
|
||||
label,
|
||||
|
|
@ -117,7 +120,9 @@ export default function ReservationModal({
|
|||
if (payableAmount > 0) {
|
||||
// reservation creation happens on /payment/success, only once the gateway confirms state=20
|
||||
setPendingReservation(payload);
|
||||
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
|
||||
const appBaseUrl = (
|
||||
import.meta.env.VITE_APP_BASE_URL || window.location.origin
|
||||
).replace(/\/$/, "");
|
||||
const { uuid } = await createCharge({
|
||||
amount: payableAmount,
|
||||
callback_url: `${appBaseUrl}/payment/success`,
|
||||
|
|
@ -129,7 +134,9 @@ export default function ReservationModal({
|
|||
navigate("/payment/success?state=20");
|
||||
}
|
||||
} catch {
|
||||
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
setError(
|
||||
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -229,14 +236,16 @@ export default function ReservationModal({
|
|||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||
<p className="text-right text-sm text-gray-500">
|
||||
توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
|
||||
کاربر است.
|
||||
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت
|
||||
تمایل به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
||||
{error && <p className="mb-2 text-center text-sm text-red-500">{error}</p>}
|
||||
{error && (
|
||||
<p className="mb-2 text-center text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting || !cafeMatchScheduleUuid}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
|
@ -17,56 +18,6 @@ import { fromE164 } from "../utils/phone";
|
|||
import Icon from "../components/Icon";
|
||||
import ReservationModal from "../components/ReservationModal";
|
||||
|
||||
function MatchPicker({
|
||||
value,
|
||||
options,
|
||||
now,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: CafeSchedule[];
|
||||
now: Date;
|
||||
onChange: (uuid: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((s) => s.match_event.uuid === value);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative mt-1 inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-right text-sm text-gray-500"
|
||||
>
|
||||
<span>{selected ? formatMatchLabel(selected.match_event, now) : "بازی را انتخاب کنید"}</span>
|
||||
<Icon name="chevronDown" className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-20 mt-2 max-h-60 w-64 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||
{options.map((s) => (
|
||||
<button
|
||||
key={s.match_event.uuid}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(s.match_event.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"
|
||||
>
|
||||
{formatMatchLabel(s.match_event, now)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
|
|
@ -86,50 +37,182 @@ function Pill({
|
|||
|
||||
function ImageCarousel({ images }: { images: string[] }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const slideCount = Math.max(images.length, 1);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const lightboxScrollRef = useRef<HTMLDivElement>(null);
|
||||
const slideCount = images.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
setLightboxIndex(index);
|
||||
requestAnimationFrame(() => {
|
||||
if (lightboxScrollRef.current) {
|
||||
lightboxScrollRef.current.scrollLeft =
|
||||
index * lightboxScrollRef.current.clientWidth;
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lightboxOpen]);
|
||||
|
||||
const handleLightboxScroll = () => {
|
||||
if (!lightboxScrollRef.current) return;
|
||||
const i = Math.round(
|
||||
lightboxScrollRef.current.scrollLeft /
|
||||
lightboxScrollRef.current.clientWidth,
|
||||
);
|
||||
setLightboxIndex(i);
|
||||
};
|
||||
|
||||
if (slideCount === 0) {
|
||||
return <div className="h-[35vh] w-full bg-gray-200" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-[35vh] w-full bg-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex((i) => (i - 1 + slideCount) % slideCount)}
|
||||
aria-label="قبلی"
|
||||
className="absolute left-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||
<>
|
||||
<div
|
||||
className="relative h-[35vh] w-full cursor-pointer bg-gray-200"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex((i) => (i + 1) % slideCount)}
|
||||
aria-label="بعدی"
|
||||
className="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||
>
|
||||
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: slideCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-[#223D5D]" : "bg-white/70"}`}
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
src={images[index]}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{slideCount > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="قبلی"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIndex((i) => (i - 1 + slideCount) % slideCount);
|
||||
}}
|
||||
className="absolute left-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||
>
|
||||
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بعدی"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIndex((i) => (i + 1) % slideCount);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white shadow"
|
||||
>
|
||||
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5">
|
||||
{images.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-white" : "bg-white/50"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-9999 flex flex-col bg-black">
|
||||
<div className="flex shrink-0 items-center justify-between px-4 py-3">
|
||||
<span className="text-sm text-white/60">
|
||||
{lightboxIndex + 1} / {slideCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-lg text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={lightboxScrollRef}
|
||||
dir="ltr"
|
||||
className="flex flex-1 snap-x snap-mandatory overflow-x-auto"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
onScroll={handleLightboxScroll}
|
||||
>
|
||||
{images.map((url, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-full w-full shrink-0 snap-start items-center justify-center p-2"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{slideCount > 1 && (
|
||||
<div className="flex shrink-0 items-center justify-center gap-2 py-4">
|
||||
{images.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === lightboxIndex ? "w-4 bg-white" : "w-1.5 bg-white/40"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function openNavigationPicker(name: string, lat: number, lng: number) {
|
||||
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
function NavigationSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
lat,
|
||||
lng,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: name, url: mapsUrl });
|
||||
} catch {
|
||||
// user dismissed the native share sheet
|
||||
}
|
||||
} else {
|
||||
window.open(mapsUrl, "_blank");
|
||||
}
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
const apps: { label: string; url: string }[] = [
|
||||
{ label: "نشان", url: `https://neshan.org/maps/@${lat},${lng},15z` },
|
||||
{ label: "بلد", url: `https://balad.ir/p?lat=${lat}&lng=${lng}` },
|
||||
{ label: "Waze", url: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes` },
|
||||
{ label: "Google Maps", url: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}` },
|
||||
...(isIOS ? [{ label: "Apple Maps", url: `maps://?daddr=${lat},${lng}` }] : []),
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-9998 bg-black/50" onClick={onClose} />
|
||||
<div className="fixed inset-x-0 bottom-0 z-9999 rounded-t-3xl bg-white px-5 pb-8 pt-4">
|
||||
<div className="mx-auto mb-4 h-1 w-10 rounded-full bg-gray-200" />
|
||||
<h3 className="mb-4 text-right font-semibold text-gray-900">مسیریابی با</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{apps.map((app) => (
|
||||
<button
|
||||
key={app.label}
|
||||
type="button"
|
||||
onClick={() => { window.open(app.url, "_blank"); onClose(); }}
|
||||
className="w-full rounded-2xl border border-gray-200 py-3 text-center text-gray-900 active:bg-gray-50"
|
||||
>
|
||||
{app.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function CafeDetail() {
|
||||
|
|
@ -137,19 +220,29 @@ export default function CafeDetail() {
|
|||
const navigate = useNavigate();
|
||||
const now = useNow();
|
||||
const [isReservationOpen, setIsReservationOpen] = useState(false);
|
||||
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
|
||||
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
|
||||
const [selectedEventUuid, setSelectedEventUuid] = useState("");
|
||||
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(undefined);
|
||||
const [selectedEventUuid] = useState("");
|
||||
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const state = location.state as { cafe?: Cafe } | null;
|
||||
const cafe = state?.cafe;
|
||||
|
||||
// only ever offer upcoming matches — picking a past one to "reserve" makes no sense
|
||||
const upcomingSchedules = schedules
|
||||
.filter((s) => new Date(s.match_event.match_datetime).getTime() > now.getTime())
|
||||
.sort((a, b) => new Date(a.match_event.match_datetime).getTime() - new Date(b.match_event.match_datetime).getTime());
|
||||
.filter(
|
||||
(s) => new Date(s.match_event.match_datetime).getTime() > now.getTime(),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.match_event.match_datetime).getTime() -
|
||||
new Date(b.match_event.match_datetime).getTime(),
|
||||
);
|
||||
// defaults to the closest upcoming match, but the user can override via MatchPicker
|
||||
const effectiveEventUuid = selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
|
||||
const effectiveEventUuid =
|
||||
selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!cafe) return;
|
||||
|
|
@ -173,9 +266,14 @@ export default function CafeDetail() {
|
|||
);
|
||||
}
|
||||
|
||||
const images = cafe.media.filter((m) => m.type === "image").map((m) => m.url);
|
||||
const images = (schedule?.cafe_media ?? cafe.media)
|
||||
.filter((m) => m.type === "image")
|
||||
.map((m) => m.url);
|
||||
const tags = cafe.features.map((f) => f.name);
|
||||
const mapPosition: [number, number] = [cafe.location.coordinates[1], cafe.location.coordinates[0]];
|
||||
const mapPosition: [number, number] = [
|
||||
cafe.location.coordinates[1],
|
||||
cafe.location.coordinates[0],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
|
|
@ -185,9 +283,9 @@ export default function CafeDetail() {
|
|||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="بازگشت"
|
||||
className="absolute left-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
|
||||
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow"
|
||||
>
|
||||
<Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
|
||||
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -195,21 +293,22 @@ export default function CafeDetail() {
|
|||
<h1 className="text-right text-xl font-bold text-gray-900">
|
||||
{cafe.name}
|
||||
</h1>
|
||||
<MatchPicker
|
||||
value={effectiveEventUuid}
|
||||
options={upcomingSchedules}
|
||||
now={now}
|
||||
onChange={setSelectedEventUuid}
|
||||
/>
|
||||
{upcomingSchedules[0] && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formatMatchLabel(upcomingSchedules[0].match_event, now)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 ">
|
||||
{schedule && (
|
||||
<>
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
|
||||
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))}{" "}
|
||||
تومان
|
||||
</Pill>
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
|
||||
ظرفیت باقی مانده:{" "}
|
||||
{formatPersianNumber(schedule.remaining_tickets)}نفر
|
||||
</Pill>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -220,6 +319,13 @@ export default function CafeDetail() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{schedule?.description && (
|
||||
<div className="mt-4">
|
||||
<h2 className="mb-2 text-right font-semibold text-gray-900">توضیحات</h2>
|
||||
<p className="text-right leading-relaxed text-gray-600">{schedule.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mt-4 mb-2 text-right font-semibold text-gray-900">
|
||||
آدرس
|
||||
</h2>
|
||||
|
|
@ -260,7 +366,7 @@ export default function CafeDetail() {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])}
|
||||
onClick={() => setIsNavigationOpen(true)}
|
||||
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
|
||||
>
|
||||
مسیریابی
|
||||
|
|
@ -270,8 +376,8 @@ export default function CafeDetail() {
|
|||
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||||
<p className="text-right text-sm text-gray-600">
|
||||
توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
|
||||
کاربر است.
|
||||
پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت تمایل
|
||||
به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,6 +400,12 @@ export default function CafeDetail() {
|
|||
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
||||
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
|
||||
/>
|
||||
<NavigationSheet
|
||||
isOpen={isNavigationOpen}
|
||||
onClose={() => setIsNavigationOpen(false)}
|
||||
lat={mapPosition[0]}
|
||||
lng={mapPosition[1]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default function CafeMapPage() {
|
|||
onClick={() => navigate(-1)}
|
||||
className="absolute right-4 top-4 z-[1000] flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-lg"
|
||||
>
|
||||
<Icon name="arrowRight" className="h-5 w-5 text-gray-700" />
|
||||
<Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
|
||||
</button>
|
||||
|
||||
<MapContainer
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
|
@ -34,12 +35,14 @@ function FormField({
|
|||
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">
|
||||
|
|
@ -48,6 +51,7 @@ function FormField({
|
|||
</label>
|
||||
<input
|
||||
type="text"
|
||||
dir={inputDir}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
|
|
@ -188,6 +192,23 @@ function CafeLocationMap({
|
|||
|
||||
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,
|
||||
|
|
@ -564,12 +585,14 @@ 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",
|
||||
}));
|
||||
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(
|
||||
|
|
@ -609,7 +632,7 @@ export default function CafeOwnerRegister() {
|
|||
const handleAddLicense = async (file: File) => {
|
||||
const newDoc: UploadedDocument = {
|
||||
file,
|
||||
localUrl: URL.createObjectURL(file),
|
||||
localUrl: await createPreviewUrl(file),
|
||||
status: "uploading",
|
||||
};
|
||||
setLicenseDoc(newDoc);
|
||||
|
|
@ -732,10 +755,11 @@ export default function CafeOwnerRegister() {
|
|||
/>
|
||||
<FormField
|
||||
label="شماره تماس کافه"
|
||||
placeholder="مثال: ۰۲۱-۱۲۳۴۵۶۷"
|
||||
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
|
||||
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
||||
value={cafePhone}
|
||||
onChange={setCafePhone}
|
||||
inputDir="ltr"
|
||||
/>
|
||||
<PhotoUpload
|
||||
photos={photos}
|
||||
|
|
|
|||
|
|
@ -116,11 +116,7 @@ export default function EventReservations() {
|
|||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const selectedReservations =
|
||||
selectedIds.length > 0
|
||||
? reservations.filter((r) => selectedIds.includes(r.uuid))
|
||||
: reservations;
|
||||
downloadReservationsCsv(selectedReservations);
|
||||
downloadReservationsCsv(reservations);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
|
@ -96,15 +97,26 @@ function CitySelectorRow({
|
|||
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 selectedCity = options.find((city) => city.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="relative px-4 mt-4">
|
||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||
<div className="mt-4 px-4">
|
||||
<p className="mb-1 text-sm text-gray-500">{label}</p>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
onClick={handleOpen}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
>
|
||||
<span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
|
||||
<Icon
|
||||
|
|
@ -113,26 +125,28 @@ function CitySelectorRow({
|
|||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{open &&
|
||||
createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -151,17 +165,28 @@ function MatchSelectorRow({
|
|||
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 selectedMatch = options.find((match) => match.uuid === value)
|
||||
const hasMatches = options.length > 0
|
||||
|
||||
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="relative px-4 mt-4">
|
||||
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||
<div className="mt-4 px-4">
|
||||
<p className="mb-1 text-sm text-gray-500">{label}</p>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={!hasMatches}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
|
||||
onClick={handleOpen}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3 disabled:opacity-50"
|
||||
>
|
||||
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
|
||||
|
|
@ -172,26 +197,28 @@ function MatchSelectorRow({
|
|||
/>
|
||||
</button>
|
||||
|
||||
{open && hasMatches && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||
{options.map((match) => (
|
||||
<button
|
||||
key={match.uuid}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(match.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"
|
||||
>
|
||||
{formatMatchLabel(match, now)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{open && hasMatches &&
|
||||
createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-9999 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg"
|
||||
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
||||
>
|
||||
{options.map((match) => (
|
||||
<button
|
||||
key={match.uuid}
|
||||
type="button"
|
||||
onClick={() => { onChange(match.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"
|
||||
>
|
||||
{formatMatchLabel(match, now)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -217,6 +244,7 @@ function MyReservationsRow() {
|
|||
function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||
const navigate = useNavigate()
|
||||
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
|
||||
const firstImage = cafe.media.find((m) => m.type === 'image')
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -224,7 +252,11 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
|||
onClick={() => navigate(`/cafe/${cafe.uuid}`, { state: { cafe } })}
|
||||
className="flex w-full items-center justify-start gap-4 rounded-2xl bg-white p-4 text-right shadow-sm"
|
||||
>
|
||||
<div className="h-32 w-36 shrink-0 rounded-xl bg-gray-200" />
|
||||
<div className="h-32 w-36 shrink-0 overflow-hidden rounded-xl bg-gray-200">
|
||||
{firstImage && (
|
||||
<img src={firstImage.url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{cafe.name}</h3>
|
||||
{remainingTickets !== undefined && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import Icon from '../components/Icon'
|
||||
import PaymentFailModal from '../components/PaymentFailModal'
|
||||
|
|
@ -30,11 +30,17 @@ export default function PaymentSuccess() {
|
|||
// both land on the same fail modal
|
||||
const isFail = !isSuccess || hasError
|
||||
|
||||
// React 18 StrictMode double-invokes effects; the ref persists across the simulated
|
||||
// remount so the second invocation sees it as true and exits early, preventing
|
||||
// takePendingReservation() from returning null on the second run and triggering hasError
|
||||
const reservationAttempted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return
|
||||
if (!isSuccess || reservationAttempted.current) return
|
||||
reservationAttempted.current = true
|
||||
const pending = takePendingReservation()
|
||||
if (!pending) {
|
||||
Promise.resolve().then(() => setHasError(true))
|
||||
setHasError(true)
|
||||
return
|
||||
}
|
||||
createReservation(pending)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue