feat: user panel api calls fixed
This commit is contained in:
parent
74fbdfdf7d
commit
5bad637b1f
7 changed files with 282 additions and 142 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Cafe } from "../data/mockCafes";
|
||||
import { Cafe } from "../services/campaignApi";
|
||||
import { formatPersianNumber } from "../utils/formatNumber";
|
||||
import InfoIcon from "./InfoIcon";
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ interface ReservationModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
cafe: Cafe;
|
||||
ticketPrice: number;
|
||||
}
|
||||
|
||||
const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹";
|
||||
|
|
@ -75,6 +76,7 @@ export default function ReservationModal({
|
|||
isOpen,
|
||||
onClose,
|
||||
cafe,
|
||||
ticketPrice,
|
||||
}: ReservationModalProps) {
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [count, setCount] = useState(1);
|
||||
|
|
@ -91,7 +93,7 @@ export default function ReservationModal({
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const reservationAmount = cafe.entryFeeAmount * count;
|
||||
const reservationAmount = ticketPrice * count;
|
||||
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
||||
|
||||
return createPortal(
|
||||
|
|
@ -105,7 +107,7 @@ export default function ReservationModal({
|
|||
{cafe.name}
|
||||
</h2>
|
||||
<p className="mt-3 mb-5 text-right text-sm text-gray-500">
|
||||
{cafe.match}
|
||||
{cafe.address}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
]
|
||||
|
|
@ -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: '۰۹۱۲۳۴۵۶۷۸۹' },
|
||||
]
|
||||
|
|
@ -1,9 +1,19 @@
|
|||
import { ReactNode, useState } from "react";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
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 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({
|
||||
children,
|
||||
className = "bg-gray-100 text-gray-700",
|
||||
|
|
@ -117,17 +192,12 @@ function ImageCarousel({ images }: { images: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function toEnglishDigits(value: string) {
|
||||
const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
|
||||
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}`;
|
||||
async function openNavigationPicker(name: string, lat: number, lng: number) {
|
||||
const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: cafe.name, url: mapsUrl });
|
||||
await navigator.share({ title: name, url: mapsUrl });
|
||||
} catch {
|
||||
// user dismissed the native share sheet
|
||||
}
|
||||
|
|
@ -137,14 +207,37 @@ async function openNavigationPicker(cafe: Cafe) {
|
|||
}
|
||||
|
||||
export default function CafeDetail() {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const now = useNow();
|
||||
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 =
|
||||
(location.state as { cafe?: Cafe } | null)?.cafe ??
|
||||
mockCafes.find((c) => c.id === Number(id));
|
||||
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());
|
||||
// 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) {
|
||||
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 (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="relative">
|
||||
<ImageCarousel images={cafe.images} />
|
||||
<ImageCarousel images={images} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
|
|
@ -172,15 +269,25 @@ export default function CafeDetail() {
|
|||
<h1 className="text-right text-xl font-bold text-gray-900">
|
||||
{cafe.name}
|
||||
</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 ">
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">ورودی: نفری {cafe.entryFee}</Pill>
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">ظرفیت باقی مانده: {cafe.remainingCapacity}نفر</Pill>
|
||||
{/* </div> */}
|
||||
|
||||
{/* <div className="mt-2 flex flex-wrap gap-2"> */}
|
||||
{cafe.tags.map((tag) => (
|
||||
{schedule && (
|
||||
<>
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||
ورودی: نفری {formatPersianNumber(Number(schedule.ticket_price))} تومان
|
||||
</Pill>
|
||||
<Pill className="bg-[#ECF2F8] text-gray-700">
|
||||
ظرفیت باقی مانده: {formatPersianNumber(schedule.remaining_tickets)}نفر
|
||||
</Pill>
|
||||
</>
|
||||
)}
|
||||
{tags.map((tag) => (
|
||||
<Pill key={tag} className="bg-[#ECF2F8] text-gray-700">
|
||||
{tag}
|
||||
</Pill>
|
||||
|
|
@ -191,7 +298,7 @@ export default function CafeDetail() {
|
|||
آدرس
|
||||
</h2>
|
||||
<MapContainer
|
||||
center={[cafe.location.lat, cafe.location.lng]}
|
||||
center={mapPosition}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
|
|
@ -203,7 +310,7 @@ export default function CafeDetail() {
|
|||
maxZoom={19}
|
||||
minZoom={8}
|
||||
/>
|
||||
<Marker position={[cafe.location.lat, cafe.location.lng]} />
|
||||
<Marker position={mapPosition} />
|
||||
<ZoomControl position="topright" />
|
||||
</MapContainer>
|
||||
|
||||
|
|
@ -212,8 +319,8 @@ export default function CafeDetail() {
|
|||
<PhoneIcon />
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p>شماره تماس</p>
|
||||
<a href={`tel:${toEnglishDigits(cafe.phone)}`} className="text-gray-900">
|
||||
{cafe.phone}
|
||||
<a href={`tel:${cafe.phone_number}`} className="text-gray-900">
|
||||
{fromE164(cafe.phone_number)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -227,7 +334,7 @@ export default function CafeDetail() {
|
|||
|
||||
<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"
|
||||
>
|
||||
مسیریابی
|
||||
|
|
@ -257,6 +364,7 @@ export default function CafeDetail() {
|
|||
isOpen={isReservationOpen}
|
||||
onClose={() => setIsReservationOpen(false)}
|
||||
cafe={cafe}
|
||||
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
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";
|
||||
|
||||
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;
|
||||
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();
|
||||
return haystack.includes(toEnglishDigits(query).toLowerCase());
|
||||
}
|
||||
|
||||
function downloadReservationsCsv(reservations: Reservation[]) {
|
||||
function downloadReservationsCsv(reservations: EventReservation[]) {
|
||||
const header = ["کد رزرو", "تعداد", "نام", "شماره تماس"];
|
||||
const rows = reservations.map((r) => [
|
||||
r.code,
|
||||
String(r.count),
|
||||
r.name,
|
||||
toEnglishDigits(r.phone),
|
||||
r.reserve_code,
|
||||
String(r.people_count),
|
||||
r.full_name,
|
||||
toEnglishDigits(r.phone_number),
|
||||
]);
|
||||
const csvContent = [header, ...rows]
|
||||
.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
|
||||
|
|
@ -96,7 +97,7 @@ function ReservationCard({
|
|||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
reservation: Reservation;
|
||||
reservation: EventReservation;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
|
|
@ -105,14 +106,14 @@ function ReservationCard({
|
|||
<div className="flex flex-col items-start gap-2 text-right">
|
||||
<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">
|
||||
{reservation.code}
|
||||
{reservation.reserve_code}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-gray-900">{reservation.name}</p>
|
||||
<p className="text-gray-500">{reservation.phone}</p>
|
||||
<p className="text-gray-900">{reservation.full_name}</p>
|
||||
<p className="text-gray-500">{reservation.phone_number}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -130,21 +131,30 @@ export default function EventReservations() {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [reservations, setReservations] = useState<EventReservation[]>([]);
|
||||
|
||||
const event: CafeEvent | undefined =
|
||||
(location.state as { event?: CafeEvent } | null)?.event ??
|
||||
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(
|
||||
() =>
|
||||
mockReservations.filter((reservation) =>
|
||||
reservations.filter((reservation) =>
|
||||
reservationMatches(reservation, query),
|
||||
),
|
||||
[query],
|
||||
[reservations, query],
|
||||
);
|
||||
|
||||
const toggleSelected = (reservationId: number) => {
|
||||
const toggleSelected = (reservationId: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(reservationId)
|
||||
? prev.filter((id) => id !== reservationId)
|
||||
|
|
@ -155,8 +165,8 @@ export default function EventReservations() {
|
|||
const handleDownload = () => {
|
||||
const selectedReservations =
|
||||
selectedIds.length > 0
|
||||
? mockReservations.filter((r) => selectedIds.includes(r.id))
|
||||
: mockReservations;
|
||||
? reservations.filter((r) => selectedIds.includes(r.uuid))
|
||||
: reservations;
|
||||
downloadReservationsCsv(selectedReservations);
|
||||
};
|
||||
|
||||
|
|
@ -189,10 +199,10 @@ export default function EventReservations() {
|
|||
<div className="mt-4 flex flex-col gap-3 px-4">
|
||||
{filteredReservations.map((reservation) => (
|
||||
<ReservationCard
|
||||
key={reservation.id}
|
||||
key={reservation.uuid}
|
||||
reservation={reservation}
|
||||
selected={selectedIds.includes(reservation.id)}
|
||||
onToggle={() => toggleSelected(reservation.id)}
|
||||
selected={selectedIds.includes(reservation.uuid)}
|
||||
onToggle={() => toggleSelected(reservation.uuid)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import BookIcon from '../components/BookIcon'
|
||||
import { mockCafes, Cafe } from '../data/mockCafes'
|
||||
import { MatchEvent } from '../services/campaignApi'
|
||||
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
|
||||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||
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 }) {
|
||||
return (
|
||||
|
|
@ -67,7 +69,7 @@ function MapPlaceholder() {
|
|||
)
|
||||
}
|
||||
|
||||
function SelectorRow({
|
||||
function CitySelectorRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
|
|
@ -75,10 +77,11 @@ function SelectorRow({
|
|||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (value: string) => void
|
||||
options: City[]
|
||||
onChange: (uuid: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedCity = options.find((city) => city.uuid === value)
|
||||
|
||||
return (
|
||||
<div className="relative px-4 mt-4">
|
||||
|
|
@ -88,25 +91,25 @@ function SelectorRow({
|
|||
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"
|
||||
>
|
||||
<span className="text-gray-900">{value}</span>
|
||||
<span className="text-gray-900">{selectedCity?.city_name ?? ''}</span>
|
||||
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<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">
|
||||
{options.map((option) => (
|
||||
<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((city) => (
|
||||
<button
|
||||
key={option}
|
||||
key={city.uuid}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option)
|
||||
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"
|
||||
>
|
||||
{option}
|
||||
{city.city_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -188,17 +191,22 @@ function MyReservationsRow() {
|
|||
|
||||
function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||
const navigate = useNavigate()
|
||||
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<div className="h-32 w-36 shrink-0 rounded-xl bg-gray-200" />
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -206,10 +214,32 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
|||
|
||||
export default function Home() {
|
||||
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 { 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 (
|
||||
<div className="min-h-screen bg-white pb-24">
|
||||
|
|
@ -217,7 +247,7 @@ export default function Home() {
|
|||
|
||||
<MyReservationsRow />
|
||||
|
||||
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
|
||||
<CitySelectorRow label="شهر" value={effectiveCityUuid} options={cities} onChange={setSelectedCityUuid} />
|
||||
<MatchSelectorRow
|
||||
label="بازی انتخابی"
|
||||
value={effectiveMatchUuid}
|
||||
|
|
@ -255,8 +285,8 @@ export default function Home() {
|
|||
<div className="mt-3 px-4">
|
||||
{view === 'list' ? (
|
||||
<div className="space-y-3">
|
||||
{mockCafes.map((cafe) => (
|
||||
<CafeCard key={cafe.id} cafe={cafe} />
|
||||
{cafes.map((cafe) => (
|
||||
<CafeCard key={cafe.uuid} cafe={cafe} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -122,6 +122,24 @@ export async function fetchMyCafes(): Promise<MyCafe[]> {
|
|||
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 {
|
||||
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/`)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue