diff --git a/package.json b/package.json
index e133dfe..c42d0be 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 74b5131..7ecf485 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx
index 463cf23..b968512 100644
--- a/src/components/ReservationModal.tsx
+++ b/src/components/ReservationModal.tsx
@@ -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({
- توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
- کاربر است.
+ پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت
+ تمایل به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
- {error &&
{error}
}
+ {error && (
+
{error}
+ )}
void;
-}) {
- const [open, setOpen] = useState(false);
- const selected = options.find((s) => s.match_event.uuid === value);
-
- if (options.length === 0) return null;
-
- return (
-
-
setOpen((o) => !o)}
- className="flex items-center gap-1 text-right text-sm text-gray-500"
- >
- {selected ? formatMatchLabel(selected.match_event, now) : "بازی را انتخاب کنید"}
-
-
-
- {open && (
- <>
-
setOpen(false)} />
-
- {options.map((s) => (
- {
- 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)}
-
- ))}
-
- >
- )}
-
- );
-}
function Pill({
children,
@@ -86,35 +37,134 @@ 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
(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
;
+ }
return (
-
-
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"
+ <>
+ setLightboxOpen(true)}
>
-
-
-
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"
- >
-
-
-
- {Array.from({ length: slideCount }).map((_, i) => (
-
- ))}
+
+ {slideCount > 1 && (
+ <>
+
{
+ 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"
+ >
+
+
+
{
+ 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"
+ >
+
+
+
+ {images.map((_, i) => (
+
+ ))}
+
+ >
+ )}
-
+
+ {lightboxOpen &&
+ createPortal(
+
+
+
+ {lightboxIndex + 1} / {slideCount}
+
+ setLightboxOpen(false)}
+ className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-lg text-white"
+ >
+ ✕
+
+
+
+ {images.map((url, i) => (
+
+
+
+ ))}
+
+ {slideCount > 1 && (
+
+ {images.map((_, i) => (
+
+ ))}
+
+ )}
+
,
+ document.body,
+ )}
+ >
);
}
@@ -138,18 +188,27 @@ export default function CafeDetail() {
const now = useNow();
const [isReservationOpen, setIsReservationOpen] = useState(false);
const [schedules, setSchedules] = useState([]);
- const [selectedEventUuid, setSelectedEventUuid] = useState("");
- const [schedule, setSchedule] = useState(undefined);
+ const [selectedEventUuid] = useState("");
+ const [schedule, setSchedule] = useState(
+ 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 +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 mapPosition: [number, number] = [cafe.location.coordinates[1], cafe.location.coordinates[0]];
+ const mapPosition: [number, number] = [
+ cafe.location.coordinates[1],
+ cafe.location.coordinates[0],
+ ];
return (
@@ -185,9 +249,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"
>
-
+
@@ -195,21 +259,22 @@ export default function CafeDetail() {
{cafe.name}
-
+ {upcomingSchedules[0] && (
+
+ {formatMatchLabel(upcomingSchedules[0].match_event, now)}
+
+ )}
{schedule && (
<>
- ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
+ ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))}{" "}
+ تومان
- ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
+ ظرفیت باقی مانده:{" "}
+ {formatPersianNumber(schedule.remaining_tickets)}نفر
>
)}
@@ -220,6 +285,13 @@ export default function CafeDetail() {
))}
+ {schedule?.description && (
+
+
توضیحات
+
{schedule.description}
+
+ )}
+
آدرس
@@ -260,7 +332,9 @@ export default function CafeDetail() {
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"
>
مسیریابی
@@ -270,8 +344,8 @@ export default function CafeDetail() {
- توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
- کاربر است.
+ پس از ثبت رزرو، امکان لغو آن از طریق وینسو وجود ندارد. در صورت تمایل
+ به لغو، لازم است مستقیماً با کافه هماهنگ کنید.
diff --git a/src/pages/CafeMapPage.tsx b/src/pages/CafeMapPage.tsx
index 37b143d..27016c0 100644
--- a/src/pages/CafeMapPage.tsx
+++ b/src/pages/CafeMapPage.tsx
@@ -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"
>
-
+
{
+ 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 +582,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 +629,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);
diff --git a/src/pages/EventReservations.tsx b/src/pages/EventReservations.tsx
index 2c69733..1be086d 100644
--- a/src/pages/EventReservations.tsx
+++ b/src/pages/EventReservations.tsx
@@ -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 (
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 2da0a6c..256ca82 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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(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 (
-
-
{label}
+
+
{label}
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"
>
{selectedCity?.city_name ?? ''}
- {open && (
- <>
-
setOpen(false)} />
-
- {options.map((city) => (
- {
- 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}
-
- ))}
-
- >
- )}
+ {open &&
+ createPortal(
+ <>
+
setOpen(false)} />
+
+ {options.map((city) => (
+ { 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}
+
+ ))}
+
+ >,
+ document.body,
+ )}
)
}
@@ -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
(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 (
-
-
{label}
+
+
{label}
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"
>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
@@ -172,26 +197,28 @@ function MatchSelectorRow({
/>
- {open && hasMatches && (
- <>
-
setOpen(false)} />
-
- {options.map((match) => (
- {
- 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)}
-
- ))}
-
- >
- )}
+ {open && hasMatches &&
+ createPortal(
+ <>
+
setOpen(false)} />
+
+ {options.map((match) => (
+ { 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)}
+
+ ))}
+
+ >,
+ document.body,
+ )}
)
}
diff --git a/src/pages/PaymentSuccess.tsx b/src/pages/PaymentSuccess.tsx
index 20e7467..64b9866 100644
--- a/src/pages/PaymentSuccess.tsx
+++ b/src/pages/PaymentSuccess.tsx
@@ -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)