330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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="© 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>
|
||
)
|
||
}
|