From 5bad637b1ff1cf6cbca831d7aa1a4701242cbebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Wed, 24 Jun 2026 19:06:24 +0330 Subject: [PATCH] feat: user panel api calls fixed --- src/components/ReservationModal.tsx | 8 +- src/data/mockCafes.ts | 55 ---------- src/data/mockReservations.ts | 13 --- src/pages/CafeDetail.tsx | 164 +++++++++++++++++++++++----- src/pages/EventReservations.tsx | 56 ++++++---- src/pages/Home.tsx | 70 ++++++++---- src/services/campaignApi.ts | 58 ++++++++++ 7 files changed, 282 insertions(+), 142 deletions(-) delete mode 100644 src/data/mockCafes.ts delete mode 100644 src/data/mockReservations.ts diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx index 57f5890..9c1630b 100644 --- a/src/components/ReservationModal.tsx +++ b/src/components/ReservationModal.tsx @@ -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}

- {cafe.match} + {cafe.address}

diff --git a/src/data/mockCafes.ts b/src/data/mockCafes.ts deleted file mode 100644 index 6f720da..0000000 --- a/src/data/mockCafes.ts +++ /dev/null @@ -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: [], - }, -] diff --git a/src/data/mockReservations.ts b/src/data/mockReservations.ts deleted file mode 100644 index 6e17337..0000000 --- a/src/data/mockReservations.ts +++ /dev/null @@ -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: '۰۹۱۲۳۴۵۶۷۸۹' }, -] diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx index 882168a..c8e63d0 100644 --- a/src/pages/CafeDetail.tsx +++ b/src/pages/CafeDetail.tsx @@ -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 ( + + + + ); +} + +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 ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {options.map((s) => ( + + ))} +
+ + )} +
+ ); +} + 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([]); + const [selectedEventUuid, setSelectedEventUuid] = useState(""); + const [schedule, setSchedule] = useState(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 (
- +
); diff --git a/src/pages/EventReservations.tsx b/src/pages/EventReservations.tsx index d3b7500..75390cb 100644 --- a/src/pages/EventReservations.tsx +++ b/src/pages/EventReservations.tsx @@ -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({
- {reservation.code} + {reservation.reserve_code} - {formatPersianNumber(reservation.count)} نفر + {formatPersianNumber(reservation.people_count)} نفر
-

{reservation.name}

-

{reservation.phone}

+

{reservation.full_name}

+

{reservation.phone_number}

{open && ( <>
setOpen(false)} /> -
- {options.map((option) => ( +
+ {options.map((city) => ( ))}
@@ -188,17 +191,22 @@ function MyReservationsRow() { function CafeCard({ cafe }: { cafe: Cafe }) { const navigate = useNavigate() + const remainingTickets = cafe.schedules?.[0]?.remaining_tickets return ( ) @@ -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([]) + const [selectedCityUuid, setSelectedCityUuid] = useState('') const [selectedMatchUuid, setSelectedMatchUuid] = useState('') const { matches, now } = useMatchEvents() - const effectiveMatchUuid = selectedMatchUuid || matches[0]?.uuid || '' + const [cafes, setCafes] = useState([]) + + 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 (
@@ -217,7 +247,7 @@ export default function Home() { - + {view === 'list' ? (
- {mockCafes.map((cafe) => ( - + {cafes.map((cafe) => ( + ))}
) : ( diff --git a/src/services/campaignApi.ts b/src/services/campaignApi.ts index 6cd6e11..874abc0 100644 --- a/src/services/campaignApi.ts +++ b/src/services/campaignApi.ts @@ -122,6 +122,24 @@ export async function fetchMyCafes(): Promise { 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 { + const { data } = await api.get('/campaign/cafes/', { params }) + return data +} + export interface CafeSchedule { uuid: string cafe_uuid: string @@ -148,3 +166,43 @@ export async function fetchCafeSchedules(cafeUuid: string): Promise(`/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 { + // bare single object, NOT a list — unlike most other endpoints in this file + const { data } = await api.get(`/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 { + const { data } = await api.get(`/campaign/cafes/${cafeUuid}/events/${eventUuid}/reservations/`) + return data +}