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,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(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, + )} + ); } -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() { @@ -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([]); - 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 +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 (
@@ -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" > - +
@@ -195,21 +293,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 +319,13 @@ export default function CafeDetail() { ))}
+ {schedule?.description && ( +
+

توضیحات

+

{schedule.description}

+
+ )} +

آدرس

@@ -260,7 +366,7 @@ export default function CafeDetail() {
@@ -294,6 +400,12 @@ export default function CafeDetail() { ticketPrice={schedule ? Number(schedule.ticket_price) : 0} cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""} /> + setIsNavigationOpen(false)} + lat={mapPosition[0]} + lng={mapPosition[1]} + />
); } 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" > - + void; + inputDir?: "ltr" | "rtl"; }) { return (
@@ -48,6 +51,7 @@ function FormField({ onChange(e.target.value)} placeholder={placeholder} @@ -188,6 +192,23 @@ function CafeLocationMap({ const MAX_PHOTOS = 10; +async function createPreviewUrl(file: File): Promise { + 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() { /> { - 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..ddbd183 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, + )}
) } @@ -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 (