feat: user panel api calls fixed

This commit is contained in:
Afra ‌ 2026-06-24 19:06:24 +03:30
parent 74fbdfdf7d
commit 5bad637b1f
7 changed files with 282 additions and 142 deletions

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Cafe } from "../data/mockCafes"; import { Cafe } from "../services/campaignApi";
import { formatPersianNumber } from "../utils/formatNumber"; import { formatPersianNumber } from "../utils/formatNumber";
import InfoIcon from "./InfoIcon"; import InfoIcon from "./InfoIcon";
@ -8,6 +8,7 @@ interface ReservationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
cafe: Cafe; cafe: Cafe;
ticketPrice: number;
} }
const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹"; const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹";
@ -75,6 +76,7 @@ export default function ReservationModal({
isOpen, isOpen,
onClose, onClose,
cafe, cafe,
ticketPrice,
}: ReservationModalProps) { }: ReservationModalProps) {
const [fullName, setFullName] = useState(""); const [fullName, setFullName] = useState("");
const [count, setCount] = useState(1); const [count, setCount] = useState(1);
@ -91,7 +93,7 @@ export default function ReservationModal({
if (!isOpen) return null; if (!isOpen) return null;
const reservationAmount = cafe.entryFeeAmount * count; const reservationAmount = ticketPrice * count;
const payableAmount = Math.max(0, reservationAmount - walletBalance); const payableAmount = Math.max(0, reservationAmount - walletBalance);
return createPortal( return createPortal(
@ -105,7 +107,7 @@ export default function ReservationModal({
{cafe.name} {cafe.name}
</h2> </h2>
<p className="mt-3 mb-5 text-right text-sm text-gray-500"> <p className="mt-3 mb-5 text-right text-sm text-gray-500">
{cafe.match} {cafe.address}
</p> </p>
<div> <div>

View file

@ -1,55 +0,0 @@
export interface Cafe {
id: number
name: string
match: string
entryFee: string
entryFeeAmount: number
remainingCapacity: number
tags: string[]
location: { lat: number; lng: number }
phone: string
address: string
images: string[] // empty array for now, will show gray placeholder
}
export const mockCafes: Cafe[] = [
{
id: 1,
name: 'کافه بی‌بای',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: '۲,۰۰۰,۰۰۰ تومان',
entryFeeAmount: 2000000,
remainingCapacity: 3,
tags: ['پارکینگ دارد', 'فضای سیگار', 'امکان سفارش'],
location: { lat: 35.7219, lng: 51.3347 },
phone: '۰۲۱۹۱۰۲۰۲۱۲۰۲',
address: 'ونک، خدامی، نبش نیروی انتظامی، پلاک ۲',
images: [],
},
{
id: 2,
name: 'کافه بی‌بای شعبه ۲',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: '۱,۵۰۰,۰۰۰ تومان',
entryFeeAmount: 1500000,
remainingCapacity: 10,
tags: ['فضای سیگار', 'امکان سفارش'],
location: { lat: 35.755, lng: 51.4165 },
phone: '۰۲۱۸۸۴۴۵۵۶۶',
address: 'جردن، خیابان آفریقا، پلاک ۱۲',
images: [],
},
{
id: 3,
name: 'کافه ستاره',
match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
entryFee: 'رایگان',
entryFeeAmount: 0,
remainingCapacity: 15,
tags: ['پارکینگ دارد'],
location: { lat: 35.6892, lng: 51.389 },
phone: '۰۲۱۷۷۳۳۲۲۱۱',
address: 'انقلاب، خیابان ۱۶ آذر، پلاک ۵',
images: [],
},
]

View file

@ -1,13 +0,0 @@
export interface Reservation {
id: number
code: string
count: number
name: string
phone: string
}
export const mockReservations: Reservation[] = [
{ id: 1, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 2, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
{ id: 3, code: 'QC-۸۸۸۷', count: 3, name: 'وینسو وینسوزاده', phone: '۰۹۱۲۳۴۵۶۷۸۹' },
]

View file

@ -1,9 +1,19 @@
import { ReactNode, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { useNavigate, useParams, 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";
import "../utils/leafletIcons"; import "../utils/leafletIcons";
import { mockCafes, Cafe } from "../data/mockCafes"; import {
Cafe,
CafeEventSchedule,
CafeSchedule,
fetchCafeEventSchedule,
fetchCafeSchedules,
} from "../services/campaignApi";
import { useNow } from "../hooks/useNow";
import { formatMatchLabel } from "../utils/matchCountdown";
import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from "../utils/phone";
import InfoIcon from "../components/InfoIcon"; import InfoIcon from "../components/InfoIcon";
import ReservationModal from "../components/ReservationModal"; import ReservationModal from "../components/ReservationModal";
@ -67,6 +77,71 @@ function MapPinIcon() {
); );
} }
function ChevronDownIcon({ className = "" }: { className?: string }) {
return (
<svg
className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${className}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
</svg>
);
}
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>
<ChevronDownIcon className={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,
className = "bg-gray-100 text-gray-700", className = "bg-gray-100 text-gray-700",
@ -117,17 +192,12 @@ function ImageCarousel({ images }: { images: string[] }) {
); );
} }
function toEnglishDigits(value: string) { async function openNavigationPicker(name: string, lat: number, lng: number) {
const persianDigits = "۰۱۲۳۴۵۶۷۸۹"; const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)));
}
async function openNavigationPicker(cafe: Cafe) {
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${cafe.location.lat},${cafe.location.lng}`;
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ title: cafe.name, url: mapsUrl }); await navigator.share({ title: name, url: mapsUrl });
} catch { } catch {
// user dismissed the native share sheet // user dismissed the native share sheet
} }
@ -137,14 +207,37 @@ async function openNavigationPicker(cafe: Cafe) {
} }
export default function CafeDetail() { export default function CafeDetail() {
const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const now = useNow();
const [isReservationOpen, setIsReservationOpen] = useState(false); const [isReservationOpen, setIsReservationOpen] = useState(false);
const [schedules, setSchedules] = useState<CafeSchedule[]>([]);
const [selectedEventUuid, setSelectedEventUuid] = useState("");
const [schedule, setSchedule] = useState<CafeEventSchedule | undefined>(undefined);
const cafe: Cafe | undefined = const state = location.state as { cafe?: Cafe } | null;
(location.state as { cafe?: Cafe } | null)?.cafe ?? const cafe = state?.cafe;
mockCafes.find((c) => c.id === Number(id));
// 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());
// defaults to the closest upcoming match, but the user can override via MatchPicker
const effectiveEventUuid = selectedEventUuid || upcomingSchedules[0]?.match_event.uuid || "";
useEffect(() => {
if (!cafe) return;
fetchCafeSchedules(cafe.uuid)
.then(setSchedules)
.catch(() => {});
}, [cafe]);
useEffect(() => {
if (!cafe || !effectiveEventUuid) return;
fetchCafeEventSchedule(cafe.uuid, effectiveEventUuid)
.then(setSchedule)
.catch(() => {});
}, [cafe, effectiveEventUuid]);
if (!cafe) { if (!cafe) {
return ( return (
@ -154,10 +247,14 @@ export default function CafeDetail() {
); );
} }
const images = 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]];
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<div className="relative"> <div className="relative">
<ImageCarousel images={cafe.images} /> <ImageCarousel images={images} />
<button <button
type="button" type="button"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
@ -172,15 +269,25 @@ 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>
<p className="mt-1 text-right text-sm text-gray-500">{cafe.match}</p> <MatchPicker
value={effectiveEventUuid}
options={upcomingSchedules}
now={now}
onChange={setSelectedEventUuid}
/>
<div className="mt-3 flex flex-wrap gap-2 "> <div className="mt-3 flex flex-wrap gap-2 ">
<Pill className="bg-[#ECF2F8] text-gray-700">ورودی: نفری {cafe.entryFee}</Pill> {schedule && (
<Pill className="bg-[#ECF2F8] text-gray-700">ظرفیت باقی مانده: {cafe.remainingCapacity}نفر</Pill> <>
{/* </div> */} <Pill className="bg-[#ECF2F8] text-gray-700">
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
{/* <div className="mt-2 flex flex-wrap gap-2"> */} </Pill>
{cafe.tags.map((tag) => ( <Pill className="bg-[#ECF2F8] text-gray-700">
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
</Pill>
</>
)}
{tags.map((tag) => (
<Pill key={tag} className="bg-[#ECF2F8] text-gray-700"> <Pill key={tag} className="bg-[#ECF2F8] text-gray-700">
{tag} {tag}
</Pill> </Pill>
@ -191,7 +298,7 @@ export default function CafeDetail() {
آدرس آدرس
</h2> </h2>
<MapContainer <MapContainer
center={[cafe.location.lat, cafe.location.lng]} center={mapPosition}
zoom={15} zoom={15}
scrollWheelZoom={false} scrollWheelZoom={false}
zoomControl={false} zoomControl={false}
@ -203,7 +310,7 @@ export default function CafeDetail() {
maxZoom={19} maxZoom={19}
minZoom={8} minZoom={8}
/> />
<Marker position={[cafe.location.lat, cafe.location.lng]} /> <Marker position={mapPosition} />
<ZoomControl position="topright" /> <ZoomControl position="topright" />
</MapContainer> </MapContainer>
@ -212,8 +319,8 @@ export default function CafeDetail() {
<PhoneIcon /> <PhoneIcon />
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<p>شماره تماس</p> <p>شماره تماس</p>
<a href={`tel:${toEnglishDigits(cafe.phone)}`} className="text-gray-900"> <a href={`tel:${cafe.phone_number}`} className="text-gray-900">
{cafe.phone} {fromE164(cafe.phone_number)}
</a> </a>
</div> </div>
</div> </div>
@ -227,7 +334,7 @@ export default function CafeDetail() {
<button <button
type="button" type="button"
onClick={() => openNavigationPicker(cafe)} 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"
> >
مسیریابی مسیریابی
@ -257,6 +364,7 @@ export default function CafeDetail() {
isOpen={isReservationOpen} isOpen={isReservationOpen}
onClose={() => setIsReservationOpen(false)} onClose={() => setIsReservationOpen(false)}
cafe={cafe} cafe={cafe}
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
/> />
</div> </div>
); );

View file

@ -1,7 +1,8 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents"; import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
import { mockReservations, Reservation } from "../data/mockReservations"; import { EventReservation, fetchEventReservations } from "../services/campaignApi";
import { getCafeUuid } from "../utils/cafe";
import { formatPersianNumber } from "../utils/formatNumber"; import { formatPersianNumber } from "../utils/formatNumber";
function ChevronIcon({ className = "" }: { className?: string }) { function ChevronIcon({ className = "" }: { className?: string }) {
@ -60,21 +61,21 @@ function toEnglishDigits(value: string) {
); );
} }
function reservationMatches(reservation: Reservation, query: string) { function reservationMatches(reservation: EventReservation, query: string) {
if (!query) return true; if (!query) return true;
const haystack = toEnglishDigits( const haystack = toEnglishDigits(
`${reservation.name} ${reservation.phone} ${reservation.count} ${formatPersianNumber(reservation.count)} ${reservation.code}`, `${reservation.full_name} ${reservation.phone_number} ${reservation.people_count} ${formatPersianNumber(reservation.people_count)} ${reservation.reserve_code}`,
).toLowerCase(); ).toLowerCase();
return haystack.includes(toEnglishDigits(query).toLowerCase()); return haystack.includes(toEnglishDigits(query).toLowerCase());
} }
function downloadReservationsCsv(reservations: Reservation[]) { function downloadReservationsCsv(reservations: EventReservation[]) {
const header = ["کد رزرو", "تعداد", "نام", "شماره تماس"]; const header = ["کد رزرو", "تعداد", "نام", "شماره تماس"];
const rows = reservations.map((r) => [ const rows = reservations.map((r) => [
r.code, r.reserve_code,
String(r.count), String(r.people_count),
r.name, r.full_name,
toEnglishDigits(r.phone), toEnglishDigits(r.phone_number),
]); ]);
const csvContent = [header, ...rows] const csvContent = [header, ...rows]
.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) .map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
@ -96,7 +97,7 @@ function ReservationCard({
selected, selected,
onToggle, onToggle,
}: { }: {
reservation: Reservation; reservation: EventReservation;
selected: boolean; selected: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
@ -105,14 +106,14 @@ function ReservationCard({
<div className="flex flex-col items-start gap-2 text-right"> <div className="flex flex-col items-start gap-2 text-right">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center"> <span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
{reservation.code} {reservation.reserve_code}
</span> </span>
<span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center"> <span className="font-medium rounded-full bg-[#ECF2F8] px-3 py-1 text-sm text-gray-700 h-9 flex items-center">
{formatPersianNumber(reservation.count)} نفر {formatPersianNumber(reservation.people_count)} نفر
</span> </span>
</div> </div>
<p className="text-gray-900">{reservation.name}</p> <p className="text-gray-900">{reservation.full_name}</p>
<p className="text-gray-500">{reservation.phone}</p> <p className="text-gray-500">{reservation.phone_number}</p>
</div> </div>
<button <button
type="button" type="button"
@ -130,21 +131,30 @@ export default function EventReservations() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [reservations, setReservations] = useState<EventReservation[]>([]);
const event: CafeEvent | undefined = const event: CafeEvent | undefined =
(location.state as { event?: CafeEvent } | null)?.event ?? (location.state as { event?: CafeEvent } | null)?.event ??
mockCafeEvents.find((e) => e.id === Number(id)); mockCafeEvents.find((e) => e.id === Number(id));
useEffect(() => {
const cafeUuid = getCafeUuid();
if (!cafeUuid || !id) return;
fetchEventReservations(cafeUuid, id)
.then(setReservations)
.catch(() => {});
}, [id]);
const filteredReservations = useMemo( const filteredReservations = useMemo(
() => () =>
mockReservations.filter((reservation) => reservations.filter((reservation) =>
reservationMatches(reservation, query), reservationMatches(reservation, query),
), ),
[query], [reservations, query],
); );
const toggleSelected = (reservationId: number) => { const toggleSelected = (reservationId: string) => {
setSelectedIds((prev) => setSelectedIds((prev) =>
prev.includes(reservationId) prev.includes(reservationId)
? prev.filter((id) => id !== reservationId) ? prev.filter((id) => id !== reservationId)
@ -155,8 +165,8 @@ export default function EventReservations() {
const handleDownload = () => { const handleDownload = () => {
const selectedReservations = const selectedReservations =
selectedIds.length > 0 selectedIds.length > 0
? mockReservations.filter((r) => selectedIds.includes(r.id)) ? reservations.filter((r) => selectedIds.includes(r.uuid))
: mockReservations; : reservations;
downloadReservationsCsv(selectedReservations); downloadReservationsCsv(selectedReservations);
}; };
@ -189,10 +199,10 @@ export default function EventReservations() {
<div className="mt-4 flex flex-col gap-3 px-4"> <div className="mt-4 flex flex-col gap-3 px-4">
{filteredReservations.map((reservation) => ( {filteredReservations.map((reservation) => (
<ReservationCard <ReservationCard
key={reservation.id} key={reservation.uuid}
reservation={reservation} reservation={reservation}
selected={selectedIds.includes(reservation.id)} selected={selectedIds.includes(reservation.uuid)}
onToggle={() => toggleSelected(reservation.id)} onToggle={() => toggleSelected(reservation.uuid)}
/> />
))} ))}
</div> </div>

View file

@ -1,12 +1,14 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import BookIcon from '../components/BookIcon' import BookIcon from '../components/BookIcon'
import { mockCafes, Cafe } from '../data/mockCafes' import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
import { MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents' import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown' import { formatMatchLabel } from '../utils/matchCountdown'
import { formatPersianNumber } from '../utils/formatNumber'
const cityOptions = ['تهران', 'اصفهان', 'مشهد'] function findTehran(cities: City[]): City | undefined {
return cities.find((c) => c.city_name === 'تهران' || c.city_name.toLowerCase() === 'tehran')
}
function ChevronIcon({ className = '' }: { className?: string }) { function ChevronIcon({ className = '' }: { className?: string }) {
return ( return (
@ -67,7 +69,7 @@ function MapPlaceholder() {
) )
} }
function SelectorRow({ function CitySelectorRow({
label, label,
value, value,
options, options,
@ -75,10 +77,11 @@ function SelectorRow({
}: { }: {
label: string label: string
value: string value: string
options: string[] options: City[]
onChange: (value: string) => void onChange: (uuid: string) => void
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const selectedCity = options.find((city) => city.uuid === value)
return ( return (
<div className="relative px-4 mt-4"> <div className="relative px-4 mt-4">
@ -88,25 +91,25 @@ function SelectorRow({
onClick={() => setOpen((o) => !o)} 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" className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
> >
<span className="text-gray-900">{value}</span> <span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
<ChevronDownIcon className={open ? 'rotate-180' : ''} /> <ChevronDownIcon className={open ? 'rotate-180' : ''} />
</button> </button>
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute inset-x-4 top-full z-20 mt-2 overflow-hidden rounded-xl bg-white shadow-lg"> <div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
{options.map((option) => ( {options.map((city) => (
<button <button
key={option} key={city.uuid}
type="button" type="button"
onClick={() => { onClick={() => {
onChange(option) onChange(city.uuid)
setOpen(false) 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"
> >
{option} {city.city_name}
</button> </button>
))} ))}
</div> </div>
@ -188,17 +191,22 @@ function MyReservationsRow() {
function CafeCard({ cafe }: { cafe: Cafe }) { function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate() const navigate = useNavigate()
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
return ( return (
<button <button
type="button" type="button"
onClick={() => navigate(`/cafe/${cafe.id}`, { state: { 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" 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 rounded-xl bg-gray-200" />
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<h3 className="font-semibold text-gray-900">{cafe.name}</h3> <h3 className="font-semibold text-gray-900">{cafe.name}</h3>
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">{cafe.remainingCapacity} صندلی</span> {remainingTickets !== undefined && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
{formatPersianNumber(remainingTickets)} صندلی
</span>
)}
</div> </div>
</button> </button>
) )
@ -206,10 +214,32 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
export default function Home() { export default function Home() {
const [view, setView] = useState<'list' | 'map'>('list') const [view, setView] = useState<'list' | 'map'>('list')
const [city, setCity] = useState(cityOptions[0]) const [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('') const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
const { matches, now } = useMatchEvents() const { matches, now } = useMatchEvents()
const effectiveMatchUuid = selectedMatchUuid || matches[0]?.uuid || '' const [cafes, setCafes] = useState<Cafe[]>([])
const nearestMatch = [...matches].sort(
(a, b) => new Date(a.match_datetime).getTime() - new Date(b.match_datetime).getTime(),
)[0]
// defaults to Tehran + the nearest upcoming match, but the user can override either
const effectiveCityUuid = selectedCityUuid || findTehran(cities)?.uuid || ''
const effectiveMatchUuid = selectedMatchUuid || nearestMatch?.uuid || ''
useEffect(() => {
fetchCities()
.then(setCities)
.catch(() => {})
}, [])
useEffect(() => {
// wait until both real defaults are known — city_uuid/event_uuid must always be sent together
if (!effectiveCityUuid || !effectiveMatchUuid) return
fetchCafes({ city_uuid: effectiveCityUuid, event_uuid: effectiveMatchUuid })
.then(setCafes)
.catch(() => {})
}, [effectiveCityUuid, effectiveMatchUuid])
return ( return (
<div className="min-h-screen bg-white pb-24"> <div className="min-h-screen bg-white pb-24">
@ -217,7 +247,7 @@ export default function Home() {
<MyReservationsRow /> <MyReservationsRow />
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} /> <CitySelectorRow label="شهر" value={effectiveCityUuid} options={cities} onChange={setSelectedCityUuid} />
<MatchSelectorRow <MatchSelectorRow
label="بازی انتخابی" label="بازی انتخابی"
value={effectiveMatchUuid} value={effectiveMatchUuid}
@ -255,8 +285,8 @@ export default function Home() {
<div className="mt-3 px-4"> <div className="mt-3 px-4">
{view === 'list' ? ( {view === 'list' ? (
<div className="space-y-3"> <div className="space-y-3">
{mockCafes.map((cafe) => ( {cafes.map((cafe) => (
<CafeCard key={cafe.id} cafe={cafe} /> <CafeCard key={cafe.uuid} cafe={cafe} />
))} ))}
</div> </div>
) : ( ) : (

View file

@ -122,6 +122,24 @@ export async function fetchMyCafes(): Promise<MyCafe[]> {
return data return data
} }
// the public cafe-listing response matches MyCafe plus an embedded schedule for
// whichever event_uuid was queried — NOT present on /campaign/cafes/my/, don't merge these back into one type.
// confirmed via a real response that this field can be entirely absent (not even an
// empty array) when the cafe has no schedule for the queried event — always guard with `?.`
export interface Cafe extends MyCafe {
schedules?: CafeSchedule[]
}
export interface FetchCafesParams {
city_uuid: string
event_uuid: string
}
export async function fetchCafes(params: FetchCafesParams): Promise<Cafe[]> {
const { data } = await api.get<Cafe[]>('/campaign/cafes/', { params })
return data
}
export interface CafeSchedule { export interface CafeSchedule {
uuid: string uuid: string
cafe_uuid: string cafe_uuid: string
@ -148,3 +166,43 @@ export async function fetchCafeSchedules(cafeUuid: string): Promise<CafeSchedule
const { data } = await api.get<CafeSchedule[]>(`/campaign/cafes/${cafeUuid}/schedules/`) const { data } = await api.get<CafeSchedule[]>(`/campaign/cafes/${cafeUuid}/schedules/`)
return data return data
} }
export interface CafeEventSchedule {
cafe_uuid: string
cafe_name: string
cafe_phone_number: string
cafe_address: string
cafe_location: GeoPoint
// confirmed via a real response: CafeFeature[], not the "string" shown in the spec
cafe_features: CafeFeature[]
cafe_media: CafeMedia[]
schedule_uuid: string
match_event: MatchEvent
total_tickets: number
remaining_tickets: number
ticket_price: string
description: string
}
export async function fetchCafeEventSchedule(cafeUuid: string, eventUuid: string): Promise<CafeEventSchedule> {
// bare single object, NOT a list — unlike most other endpoints in this file
const { data } = await api.get<CafeEventSchedule>(`/campaign/cafes/${cafeUuid}/events/${eventUuid}/`)
return data
}
export interface EventReservation {
uuid: string
user_uuid: string
cafe_match_schedule_uuid: string
reserve_code: string
people_count: number
full_name: string
phone_number: string
status: string
created_at: string
}
export async function fetchEventReservations(cafeUuid: string, eventUuid: string): Promise<EventReservation[]> {
const { data } = await api.get<EventReservation[]>(`/campaign/cafes/${cafeUuid}/events/${eventUuid}/reservations/`)
return data
}