perf: some modifications applied to app

This commit is contained in:
Afra ‌ 2026-07-15 16:31:59 +03:30
parent ca7ccaa930
commit 5b7f904dbe
9 changed files with 308 additions and 167 deletions

View file

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.9.0", "@tanstack/react-query": "^5.9.0",
"axios": "^1.6.6", "axios": "^1.6.6",
"heic2any": "^0.0.4",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",

8
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
axios: axios:
specifier: ^1.6.6 specifier: ^1.6.6
version: 1.17.0 version: 1.17.0
heic2any:
specifier: ^0.0.4
version: 0.0.4
leaflet: leaflet:
specifier: ^1.9.4 specifier: ^1.9.4
version: 1.9.4 version: 1.9.4
@ -942,6 +945,9 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
heic2any@0.0.4:
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
hermes-estree@0.25.1: hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@ -2245,6 +2251,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
heic2any@0.0.4: {}
hermes-estree@0.25.1: {} hermes-estree@0.25.1: {}
hermes-parser@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 — // 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 // 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({ function PaymentRow({
label, label,
@ -117,7 +120,9 @@ export default function ReservationModal({
if (payableAmount > 0) { if (payableAmount > 0) {
// reservation creation happens on /payment/success, only once the gateway confirms state=20 // reservation creation happens on /payment/success, only once the gateway confirms state=20
setPendingReservation(payload); 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({ const { uuid } = await createCharge({
amount: payableAmount, amount: payableAmount,
callback_url: `${appBaseUrl}/payment/success`, callback_url: `${appBaseUrl}/payment/success`,
@ -129,7 +134,9 @@ export default function ReservationModal({
navigate("/payment/success?state=20"); navigate("/payment/success?state=20");
} }
} catch { } catch {
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید."); setError(
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
);
setIsSubmitting(false); 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"> <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" /> <Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-500"> <p className="text-right text-sm text-gray-500">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت
کاربر است. تمایل به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
</p> </p>
</div> </div>
</div> </div>
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6"> <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 <button
type="button" type="button"
disabled={isSubmitting || !cafeMatchScheduleUuid} 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 { useNavigate, useLocation } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet"; import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
@ -17,56 +18,6 @@ import { fromE164 } from "../utils/phone";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import ReservationModal from "../components/ReservationModal"; 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({ function Pill({
children, children,
@ -86,35 +37,134 @@ function Pill({
function ImageCarousel({ images }: { images: string[] }) { function ImageCarousel({ images }: { images: string[] }) {
const [index, setIndex] = useState(0); 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 ( 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 <button
type="button" type="button"
onClick={() => setIndex((i) => (i - 1 + slideCount) % slideCount)}
aria-label="قبلی" 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" 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" /> <Icon name="chevronLeft" className="h-5 w-5 text-gray-700" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIndex((i) => (i + 1) % slideCount)}
aria-label="بعدی" 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" 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" /> <Icon name="chevronRight" className="h-5 w-5 text-gray-700" />
</button> </button>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5"> <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 <span
key={i} 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>
</>
)}
</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,
)}
</>
); );
} }
@ -138,18 +188,27 @@ export default function CafeDetail() {
const now = useNow(); const now = useNow();
const [isReservationOpen, setIsReservationOpen] = useState(false); const [isReservationOpen, setIsReservationOpen] = useState(false);
const [schedules, setSchedules] = useState<CafeSchedule[]>([]); const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
const [selectedEventUuid, setSelectedEventUuid] = useState(""); const [selectedEventUuid] = useState("");
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(undefined); const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(
undefined,
);
const state = location.state as { cafe?: Cafe } | null; const state = location.state as { cafe?: Cafe } | null;
const cafe = state?.cafe; const cafe = state?.cafe;
// only ever offer upcoming matches — picking a past one to "reserve" makes no sense // only ever offer upcoming matches — picking a past one to "reserve" makes no sense
const upcomingSchedules = schedules const upcomingSchedules = schedules
.filter((s) => new Date(s.match_event.match_datetime).getTime() > now.getTime()) .filter(
.sort((a, b) => new Date(a.match_event.match_datetime).getTime() - new Date(b.match_event.match_datetime).getTime()); (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 // 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(() => { useEffect(() => {
if (!cafe) return; if (!cafe) return;
@ -173,9 +232,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 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 ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
@ -185,9 +249,9 @@ export default function CafeDetail() {
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
aria-label="بازگشت" 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> </button>
</div> </div>
@ -195,21 +259,22 @@ export default function CafeDetail() {
<h1 className="text-right text-xl font-bold text-gray-900"> <h1 className="text-right text-xl font-bold text-gray-900">
{cafe.name} {cafe.name}
</h1> </h1>
<MatchPicker {upcomingSchedules[0] && (
value={effectiveEventUuid} <p className="mt-1 text-sm text-gray-500">
options={upcomingSchedules} {formatMatchLabel(upcomingSchedules[0].match_event, now)}
now={now} </p>
onChange={setSelectedEventUuid} )}
/>
<div className="mt-3 flex flex-wrap gap-2 "> <div className="mt-3 flex flex-wrap gap-2 ">
{schedule && ( {schedule && (
<> <>
<Pill className="bg-[#ECF2F8] text-gray-700"> <Pill className="bg-[#ECF2F8] text-gray-700">
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))}{" "}
تومان
</Pill> </Pill>
<Pill className="bg-[#ECF2F8] text-gray-700"> <Pill className="bg-[#ECF2F8] text-gray-700">
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر ظرفیت باقی مانده:{" "}
{formatPersianNumber(schedule.remaining_tickets)}نفر
</Pill> </Pill>
</> </>
)} )}
@ -220,6 +285,13 @@ export default function CafeDetail() {
))} ))}
</div> </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 className="mt-4 mb-2 text-right font-semibold text-gray-900">
آدرس آدرس
</h2> </h2>
@ -260,7 +332,9 @@ export default function CafeDetail() {
<button <button
type="button" type="button"
onClick={() => openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])} onClick={() =>
openNavigationPicker(cafe.name, mapPosition[0], mapPosition[1])
}
className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900" className="mt-4 w-full rounded-2xl border border-[#223D5D] py-3 text-center text-gray-900"
> >
مسیریابی مسیریابی
@ -270,8 +344,8 @@ export default function CafeDetail() {
<div className="mt-4 flex items-center justify-start gap-3 rounded-2xl bg-[#ECF2F8] px-4 py-3"> <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" /> <Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-right text-sm text-gray-600"> <p className="text-right text-sm text-gray-600">
توجه داشته باشید که مسئولیت رزرو و حضور به‌موقع در کافه بر عهده پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت تمایل
کاربر است. به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
</p> </p>
</div> </div>
</div> </div>

View file

@ -41,7 +41,7 @@ export default function CafeMapPage() {
onClick={() => navigate(-1)} 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" 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> </button>
<MapContainer <MapContainer

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import heic2any from "heic2any";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet"; import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
@ -188,6 +189,23 @@ function CafeLocationMap({
const MAX_PHOTOS = 10; 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({ function ImgWithHeicFallback({
src, src,
className, className,
@ -564,12 +582,14 @@ export default function CafeOwnerRegister() {
}; };
const handleAddPhotos = async (files: File[]) => { const handleAddPhotos = async (files: File[]) => {
const newPhotos: UploadedPhoto[] = files.map((file) => ({ const newPhotos: UploadedPhoto[] = await Promise.all(
files.map(async (file) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
file, file,
localUrl: URL.createObjectURL(file), localUrl: await createPreviewUrl(file),
status: "uploading", status: "uploading" as const,
})); })),
);
setPhotos((prev) => [...prev, ...newPhotos]); setPhotos((prev) => [...prev, ...newPhotos]);
await Promise.all( await Promise.all(
@ -609,7 +629,7 @@ export default function CafeOwnerRegister() {
const handleAddLicense = async (file: File) => { const handleAddLicense = async (file: File) => {
const newDoc: UploadedDocument = { const newDoc: UploadedDocument = {
file, file,
localUrl: URL.createObjectURL(file), localUrl: await createPreviewUrl(file),
status: "uploading", status: "uploading",
}; };
setLicenseDoc(newDoc); setLicenseDoc(newDoc);

View file

@ -116,11 +116,7 @@ export default function EventReservations() {
}; };
const handleDownload = () => { const handleDownload = () => {
const selectedReservations = downloadReservationsCsv(reservations);
selectedIds.length > 0
? reservations.filter((r) => selectedIds.includes(r.uuid))
: reservations;
downloadReservationsCsv(selectedReservations);
}; };
return ( 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 { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet' import { MapContainer, TileLayer, Marker } from 'react-leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
@ -96,15 +97,26 @@ function CitySelectorRow({
onChange: (uuid: string) => void onChange: (uuid: string) => void
}) { }) {
const [open, setOpen] = useState(false) 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 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 ( return (
<div className="relative px-4 mt-4"> <div className="mt-4 px-4">
<p className="text-sm text-gray-500 mb-1">{label}</p> <p className="mb-1 text-sm text-gray-500">{label}</p>
<button <button
ref={buttonRef}
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={handleOpen}
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3" 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> <span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
<Icon <Icon
@ -113,25 +125,27 @@ function CitySelectorRow({
/> />
</button> </button>
{open && ( {open &&
createPortal(
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-9998" 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 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) => ( {options.map((city) => (
<button <button
key={city.uuid} key={city.uuid}
type="button" type="button"
onClick={() => { onClick={() => { onChange(city.uuid); setOpen(false) }}
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" 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} {city.city_name}
</button> </button>
))} ))}
</div> </div>
</> </>,
document.body,
)} )}
</div> </div>
) )
@ -151,17 +165,28 @@ function MatchSelectorRow({
onChange: (uuid: string) => void onChange: (uuid: string) => void
}) { }) {
const [open, setOpen] = useState(false) 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 selectedMatch = options.find((match) => match.uuid === value)
const hasMatches = options.length > 0 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 ( return (
<div className="relative px-4 mt-4"> <div className="mt-4 px-4">
<p className="text-sm text-gray-500 mb-1">{label}</p> <p className="mb-1 text-sm text-gray-500">{label}</p>
<button <button
ref={buttonRef}
type="button" type="button"
disabled={!hasMatches} disabled={!hasMatches}
onClick={() => setOpen((o) => !o)} onClick={handleOpen}
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" 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'}> <span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''} {selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
@ -172,25 +197,27 @@ function MatchSelectorRow({
/> />
</button> </button>
{open && hasMatches && ( {open && hasMatches &&
createPortal(
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-9998" 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 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) => ( {options.map((match) => (
<button <button
key={match.uuid} key={match.uuid}
type="button" type="button"
onClick={() => { onClick={() => { onChange(match.uuid); setOpen(false) }}
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" 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)} {formatMatchLabel(match, now)}
</button> </button>
))} ))}
</div> </div>
</> </>,
document.body,
)} )}
</div> </div>
) )

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 { useNavigate, useSearchParams } from 'react-router-dom'
import Icon from '../components/Icon' import Icon from '../components/Icon'
import PaymentFailModal from '../components/PaymentFailModal' import PaymentFailModal from '../components/PaymentFailModal'
@ -30,11 +30,17 @@ export default function PaymentSuccess() {
// both land on the same fail modal // both land on the same fail modal
const isFail = !isSuccess || hasError 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(() => { useEffect(() => {
if (!isSuccess) return if (!isSuccess || reservationAttempted.current) return
reservationAttempted.current = true
const pending = takePendingReservation() const pending = takePendingReservation()
if (!pending) { if (!pending) {
Promise.resolve().then(() => setHasError(true)) setHasError(true)
return return
} }
createReservation(pending) createReservation(pending)