feature/WNPT-307 #4

Merged
afra.khalaj merged 3 commits from feature/WNPT-307 into develop 2026-07-18 06:49:16 -04:00
9 changed files with 368 additions and 180 deletions

View file

@ -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
View file

@ -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:

View file

@ -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}

View file

@ -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">
<>
<div
className="relative h-[35vh] w-full cursor-pointer bg-gray-200"
onClick={() => setLightboxOpen(true)}
>
<img
src={images[index]}
alt=""
className="h-full w-full object-cover"
/>
{slideCount > 1 && (
<>
<button
type="button"
onClick={() => setIndex((i) => (i - 1 + slideCount) % slideCount)}
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"
onClick={() => setIndex((i) => (i + 1) % slideCount)}
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">
{Array.from({ length: slideCount }).map((_, i) => (
{images.map((_, i) => (
<span
key={i}
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-[#223D5D]" : "bg-white/70"}`}
className={`h-1.5 w-1.5 rounded-full ${i === index ? "bg-white" : "bg-white/50"}`}
/>
))}
</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>
);
}

View file

@ -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

View file

@ -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) => ({
const newPhotos: UploadedPhoto[] = await Promise.all(
files.map(async (file) => ({
id: crypto.randomUUID(),
file,
localUrl: URL.createObjectURL(file),
status: "uploading",
}));
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}

View file

@ -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 (

View file

@ -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,25 +125,27 @@ function CitySelectorRow({
/>
</button>
{open && (
{open &&
createPortal(
<>
<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">
<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)
}}
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,25 +197,27 @@ function MatchSelectorRow({
/>
</button>
{open && hasMatches && (
{open && hasMatches &&
createPortal(
<>
<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">
<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)
}}
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 && (

View file

@ -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)