reservation-front/src/pages/Home.tsx
2026-07-15 14:05:20 +03:30

330 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import '../utils/leafletIcons'
import Icon from '../components/Icon'
import HomeBanner from '../components/HomeBanner'
import { Cafe, City, fetchCafes, fetchCities, MatchEvent } from '../services/campaignApi'
import { useMatchEvents } from '../hooks/useMatchEvents'
import { formatMatchLabel } from '../utils/matchCountdown'
import { formatPersianNumber } from '../utils/formatNumber'
function findTehran(cities: City[]): City | undefined {
return cities.find((c) => c.city_name === 'تهران' || c.city_name.toLowerCase() === 'tehran')
}
function MapIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.52734 5.186V11.6727C1.52734 12.9393 2.42734 13.4593 3.52068 12.8327L5.08734 11.9393C5.42734 11.746 5.99401 11.726 6.34734 11.906L9.84734 13.6593C10.2007 13.8327 10.7673 13.8193 11.1073 13.626L13.994 11.9727C14.3607 11.7593 14.6673 11.2393 14.6673 10.8127V4.326C14.6673 3.05933 13.7673 2.53933 12.674 3.166L11.1073 4.05933C10.7673 4.25267 10.2007 4.27267 9.84734 4.09267L6.34734 2.346C5.99401 2.17267 5.42734 2.186 5.08734 2.37933L2.20068 4.03267C1.82734 4.246 1.52734 4.766 1.52734 5.186Z"
stroke="currentColor"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M5.70703 2.66602V11.3327" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.4863 4.41406V13.3341" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ListIcon() {
return (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.33398 13H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 8.33398H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.33398 3.66602H14.0007" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 3.66732L2.66667 4.33398L4.66667 2.33398" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 8.33333L2.66667 9L4.66667 7" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
<path d="M2 12.9993L2.66667 13.666L4.66667 11.666" stroke="currentColor" strokeWidth={1.25} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function CafesMapPreview({ cafes }: { cafes: Cafe[] }) {
const navigate = useNavigate()
const center: [number, number] =
cafes.length > 0
? [cafes[0].location.coordinates[1], cafes[0].location.coordinates[0]]
: [35.6892, 51.389]
return (
<div className="relative mx-4 mt-3 h-52 overflow-hidden rounded-2xl">
<MapContainer
center={center}
zoom={12}
scrollWheelZoom={false}
dragging={false}
zoomControl={false}
doubleClickZoom={false}
keyboard={false}
className="h-full w-full"
>
<TileLayer
attribution="&copy; Addwin Maps"
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
maxZoom={19}
minZoom={8}
/>
{cafes.map((cafe) => (
<Marker
key={cafe.uuid}
position={[cafe.location.coordinates[1], cafe.location.coordinates[0]]}
/>
))}
</MapContainer>
{/* transparent overlay — captures all clicks and opens the full-page map */}
<div
className="absolute inset-0 z-[1000] cursor-pointer"
onClick={() => navigate('/map', { state: { cafes } })}
/>
</div>
)
}
function CitySelectorRow({
label,
value,
options,
onChange,
}: {
label: string
value: string
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">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<button
type="button"
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">{selectedCity?.city_name ?? ''}</span>
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${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 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
{options.map((city) => (
<button
key={city.uuid}
type="button"
onClick={() => {
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"
>
{city.city_name}
</button>
))}
</div>
</>
)}
</div>
)
}
function MatchSelectorRow({
label,
value,
options,
now,
onChange,
}: {
label: string
value: string
options: MatchEvent[]
now: Date
onChange: (uuid: string) => void
}) {
const [open, setOpen] = useState(false)
const selectedMatch = options.find((match) => match.uuid === value)
const hasMatches = options.length > 0
return (
<div className="relative px-4 mt-4">
<p className="text-sm text-gray-500 mb-1">{label}</p>
<button
type="button"
disabled={!hasMatches}
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 disabled:opacity-50"
>
<span className={selectedMatch ? 'text-gray-900' : 'text-gray-400'}>
{selectedMatch ? formatMatchLabel(selectedMatch, now) : !hasMatches ? 'بازی فعالی موجود نیست' : ''}
</span>
<Icon
name="chevronDown"
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && hasMatches && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<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((match) => (
<button
key={match.uuid}
type="button"
onClick={() => {
onChange(match.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(match, now)}
</button>
))}
</div>
</>
)}
</div>
)
}
function MyReservationsRow() {
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => navigate('/reservations')}
className="flex w-full items-center justify-between px-4 py-6"
>
<div className="flex items-center gap-2">
<Icon name="book" className="h-5 w-5 text-[#4D4D4D]" />
<span className="text-gray-900">رزروهای شما</span>
</div>
<Icon name="arrowRight" className="h-5 w-5 text-gray-400" />
</button>
)
}
function CafeCard({ cafe }: { cafe: Cafe }) {
const navigate = useNavigate()
const remainingTickets = cafe.schedules?.[0]?.remaining_tickets
return (
<button
type="button"
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>
{remainingTickets !== undefined && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
{formatPersianNumber(remainingTickets)} صندلی
</span>
)}
</div>
</button>
)
}
export default function Home() {
const [view, setView] = useState<'list' | 'map'>('list')
const [cities, setCities] = useState<City[]>([])
const [selectedCityUuid, setSelectedCityUuid] = useState('')
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
const { matches, now } = useMatchEvents()
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_id must always be sent together
if (!effectiveCityUuid || !effectiveMatchUuid) return
fetchCafes({ city_uuid: effectiveCityUuid, event_id: effectiveMatchUuid })
.then(setCafes)
.catch(() => {})
}, [effectiveCityUuid, effectiveMatchUuid])
return (
<div className="min-h-screen bg-white pb-24">
<HomeBanner />
<MyReservationsRow />
<CitySelectorRow label="شهر" value={effectiveCityUuid} options={cities} onChange={setSelectedCityUuid} />
<MatchSelectorRow
label="بازی انتخابی"
value={effectiveMatchUuid}
options={matches}
now={now}
onChange={setSelectedMatchUuid}
/>
<div className="mt-6 flex items-center justify-between px-4">
<h2 className="font-semibold text-gray-900">کافه‌ها</h2>
<div className="flex rounded-full bg-gray-100 p-1">
<button
type="button"
onClick={() => setView('map')}
className={`flex items-center gap-1 rounded-full px-3 py-1.5 text-sm ${
view === 'map' ? 'bg-[#223D5D] text-white' : 'text-gray-600'
}`}
>
<MapIcon />
نقشه
</button>
<button
type="button"
onClick={() => setView('list')}
className={`flex items-center gap-1 rounded-full px-3 py-1.5 text-sm ${
view === 'list' ? 'bg-[#223D5D] text-white' : 'text-gray-600'
}`}
>
<ListIcon />
لیست
</button>
</div>
</div>
{view === 'map' ? (
<CafesMapPreview cafes={cafes} />
) : (
<div className="mt-3 px-4">
<div className="space-y-3">
{cafes.map((cafe) => (
<CafeCard key={cafe.uuid} cafe={cafe} />
))}
</div>
</div>
)}
<button
type="button"
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
>
پشتیبانی
</button>
</div>
)
}