From 5b7f904dbeb6db4d6194ba46aa37fc4ef33e4161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Wed, 15 Jul 2026 16:31:59 +0330 Subject: [PATCH 1/3] perf: some modifications applied to app --- package.json | 1 + pnpm-lock.yaml | 8 + src/components/ReservationModal.tsx | 21 ++- src/pages/CafeDetail.tsx | 266 ++++++++++++++++++---------- src/pages/CafeMapPage.tsx | 2 +- src/pages/CafeOwnerRegister.tsx | 34 +++- src/pages/EventReservations.tsx | 6 +- src/pages/Home.tsx | 125 ++++++++----- src/pages/PaymentSuccess.tsx | 12 +- 9 files changed, 308 insertions(+), 167 deletions(-) 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}

+ )} - - {open && ( - <> -
setOpen(false)} /> -
- {options.map((s) => ( - - ))} -
- - )} -
- ); -} 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 ( -
- - -
- {Array.from({ length: slideCount }).map((_, i) => ( - - ))} + + {slideCount > 1 && ( + <> + + +
+ {images.map((_, i) => ( + + ))} +
+ + )}
-
+ + {lightboxOpen && + createPortal( +
+
+ + {lightboxIndex + 1} / {slideCount} + + +
+
+ {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() {
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}

- {open && ( - <> -
setOpen(false)} /> -
- {options.map((city) => ( - - ))} -
- - )} + {open && + createPortal( + <> +
setOpen(false)} /> +
+ {options.map((city) => ( + + ))} +
+ , + 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}

- {open && hasMatches && ( - <> -
setOpen(false)} /> -
- {options.map((match) => ( - - ))} -
- - )} + {open && hasMatches && + createPortal( + <> +
setOpen(false)} /> +
+ {options.map((match) => ( + + ))} +
+ , + 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) -- 2.45.3 From 165c7fd0ebf086e1185294a9b17840e440af256d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 18 Jul 2026 13:56:22 +0330 Subject: [PATCH 2/3] perf: modified routing button in cafe detail page --- src/pages/CafeDetail.tsx | 66 +++++++++++++++++++++++++++++++--------- src/pages/Home.tsx | 7 ++++- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx index f9e228a..ecc2466 100644 --- a/src/pages/CafeDetail.tsx +++ b/src/pages/CafeDetail.tsx @@ -168,18 +168,51 @@ function ImageCarousel({ images }: { images: string[] }) { ); } -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( + <> +
+
+
+

مسیریابی با

+
+ {apps.map((app) => ( + + ))} +
+
+ , + document.body, + ); } export default function CafeDetail() { @@ -187,6 +220,7 @@ export default function CafeDetail() { const navigate = useNavigate(); const now = useNow(); const [isReservationOpen, setIsReservationOpen] = useState(false); + const [isNavigationOpen, setIsNavigationOpen] = useState(false); const [schedules, setSchedules] = useState([]); const [selectedEventUuid] = useState(""); const [schedule, setSchedule] = useState( @@ -332,9 +366,7 @@ export default function CafeDetail() {
); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 256ca82..ddbd183 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -244,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 (