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}
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}
([]);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [reservations, setReservations] = useState([]);
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() {
{filteredReservations.map((reservation) => (
toggleSelected(reservation.id)}
+ selected={selectedIds.includes(reservation.uuid)}
+ onToggle={() => toggleSelected(reservation.uuid)}
/>
))}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 2ea9f1e..f8d95cd 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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 (
@@ -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"
>
-
{value}
+
{selectedCity?.city_name ?? ''}
{open && (
<>
setOpen(false)} />
-
- {options.map((option) => (
+
+ {options.map((city) => (
{
- 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}
))}
@@ -188,17 +191,22 @@ function MyReservationsRow() {
function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate()
+ const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
return (
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"
>
{cafe.name}
- {cafe.remainingCapacity} صندلی
+ {remainingTickets !== undefined && (
+
+ {formatPersianNumber(remainingTickets)} صندلی
+
+ )}
)
@@ -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
+}