- Updated LiveEventsList to use live data from the live store and improved event rendering with links to event details. - Enhanced MatchDetailView to accept API sections for dynamic market rendering and improved state management for expanded sections. - Modified SportsNav to utilize search parameters for active sport highlighting. - Refactored TopMatches to fetch live match data and odds from the API, replacing static fallback data. - Improved UI elements for better responsiveness and user experience across components.
468 lines
21 KiB
TypeScript
468 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import Link from "next/link"
|
|
import { useSearchParams } from "next/navigation"
|
|
import { useBetslipStore } from "@/lib/store/betslip-store"
|
|
import { mockEvents, popularLeagues, type Event } from "@/lib/mock-data"
|
|
import { useBettingStore } from "@/lib/store/betting-store"
|
|
import type { AppEvent } from "@/lib/store/betting-types"
|
|
import { SPORT_SLUG_TO_ID, getMarketsForTab, type ApiOdds, type MarketTabKey } from "@/lib/store/betting-api"
|
|
import { cn } from "@/lib/utils"
|
|
import { ChevronDown, BarChart2, TrendingUp, Plus, Loader2 } from "lucide-react"
|
|
|
|
function OddsButton({ odds, onClick, isSelected }: {
|
|
odds: number
|
|
onClick: () => void
|
|
isSelected: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
"flex items-center justify-center py-2 px-0.5 border-r border-border/10 text-[10px] transition-all min-w-0 bg-brand-surface hover:bg-white/5 h-full",
|
|
isSelected && "bg-brand-primary text-black font-bold border-none"
|
|
)}
|
|
>
|
|
<span className={cn("font-bold tracking-tighter", isSelected ? "text-black" : "text-brand-primary")}>{odds.toFixed(2)}</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function EventRow({ event }: { event: Event | AppEvent }) {
|
|
const { bets, addBet } = useBetslipStore()
|
|
|
|
return (
|
|
<div className="bg-brand-surface border-b border-border/20 hover:bg-white/5 transition-colors h-[38px] flex items-center">
|
|
{/* Small Icons & ID Column */}
|
|
<div className="flex items-center gap-1.5 px-2 w-[80px] shrink-0 border-r border-border/10 h-full">
|
|
<BarChart2 className="size-3 text-muted-foreground hover:text-primary cursor-pointer shrink-0" />
|
|
<TrendingUp className="size-3 text-muted-foreground hover:text-primary cursor-pointer shrink-0" />
|
|
<span className="text-[9.5px] text-brand-primary font-bold tabular-nums italic ml-0.5">{event.id || "01682"}</span>
|
|
</div>
|
|
|
|
{/* Time & Team Column */}
|
|
<div className="flex items-center gap-3 px-3 w-[240px] shrink-0 border-r border-border/10 h-full">
|
|
<div className="flex flex-col text-[9.5px] font-bold text-white leading-tight italic shrink-0 w-[45px]">
|
|
<span>11:00</span>
|
|
<span className="text-white/40 uppercase font-medium">PM</span>
|
|
</div>
|
|
<span className="text-[10px] font-bold text-white truncate max-w-[180px]">{event.homeTeam} - {event.awayTeam}</span>
|
|
</div>
|
|
|
|
{/* Market Columns Grid (10 Markets) */}
|
|
<div className="flex-1 grid grid-cols-10 h-full shrink-0">
|
|
{event.markets.slice(0, 10).map((market) => {
|
|
const betId = `${event.id}-${market.id}`
|
|
const isSelected = bets.some((b) => b.id === betId)
|
|
return (
|
|
<OddsButton
|
|
key={market.id}
|
|
odds={market.odds}
|
|
isSelected={isSelected}
|
|
onClick={() => addBet({
|
|
id: betId,
|
|
event: `${event.homeTeam} - ${event.awayTeam}`,
|
|
league: `${event.sport} - ${event.country} - ${event.league}`,
|
|
market: "1X2",
|
|
selection: market.label,
|
|
odds: market.odds,
|
|
})}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* More Markets Button -> match detail page */}
|
|
<Link
|
|
href={`/event/${event.id}`}
|
|
className="w-10 flex items-center justify-center h-full hover:bg-white/5 transition-colors border-l border-border/10 group"
|
|
aria-label="View all markets"
|
|
>
|
|
<Plus className="size-3 text-white group-hover:scale-110 transition-transform" />
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MARKET_HEADERS = ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"]
|
|
|
|
const ROW1_TABS: { key: MarketTabKey; label: string }[] = [
|
|
{ key: "main", label: "Main" },
|
|
{ key: "goals", label: "Goals" },
|
|
{ key: "handicap", label: "Handicap" },
|
|
{ key: "half_time", label: "Half Time / Full Time" },
|
|
{ key: "correct_score", label: "Correct Score" },
|
|
]
|
|
const ROW2_TABS: { key: MarketTabKey; label: string }[] = [
|
|
{ key: "1st_half", label: "1st Half" },
|
|
{ key: "2nd_half", label: "2nd Half" },
|
|
{ key: "combo", label: "Combo" },
|
|
{ key: "chance_mix", label: "Chance Mix" },
|
|
{ key: "home", label: "Home" },
|
|
]
|
|
|
|
export function EventsList({ filter = "All", sport: sportProp = "all", search = "" }: {
|
|
filter?: string
|
|
sport?: string
|
|
search?: string
|
|
}) {
|
|
const searchParams = useSearchParams()
|
|
const leagueQuery = searchParams.get("league")
|
|
const sportQuery = searchParams.get("sport") ?? sportProp
|
|
const [selectedLeague, setSelectedLeague] = useState<string | null>(leagueQuery)
|
|
const [activeTab, setActiveTab] = useState<MarketTabKey>("main")
|
|
const { bets, addBet } = useBetslipStore()
|
|
|
|
const sportId = sportQuery === "all" ? null : (SPORT_SLUG_TO_ID[sportQuery] ?? null)
|
|
const leagueId = selectedLeague && !Number.isNaN(Number(selectedLeague)) ? selectedLeague : null
|
|
const { events: apiEvents, loading, error, hasMore, loadMore, setFilters } = useBettingStore()
|
|
|
|
useEffect(() => {
|
|
setSelectedLeague(leagueQuery)
|
|
}, [leagueQuery])
|
|
|
|
useEffect(() => {
|
|
setFilters(sportId, leagueId)
|
|
}, [sportId, leagueId, setFilters])
|
|
|
|
const handleClose = () => {
|
|
const url = new URL(window.location.href)
|
|
url.searchParams.delete("league")
|
|
window.history.pushState({}, "", url)
|
|
setSelectedLeague(null)
|
|
}
|
|
|
|
const useApi = !(error && apiEvents.length === 0)
|
|
const events = useApi
|
|
? (filter === "Live" ? apiEvents.filter((e) => e.isLive) : apiEvents)
|
|
: selectedLeague
|
|
? mockEvents.filter((e) => e.league.toLowerCase() === selectedLeague.toLowerCase())
|
|
: mockEvents.filter((e) => {
|
|
if (filter === "Live" && !e.isLive) return false
|
|
if (sportProp !== "all" && e.sport.toLowerCase() !== sportProp.toLowerCase()) return false
|
|
return true
|
|
})
|
|
const showLoadMore = useApi && hasMore && events.length > 0
|
|
|
|
// Common Header Rendering
|
|
const renderTableHeaders = () => (
|
|
<>
|
|
{/* Table Header Categories */}
|
|
<div className="bg-brand-surface border-b border-border/40 grid grid-cols-5 text-[11px] font-bold text-white uppercase text-center items-center h-9">
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">Main</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">Goals</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">Handicap</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20 text-[9px] leading-tight">Half Time / Full Time</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer">Correct Score</div>
|
|
</div>
|
|
{/* Sub Headers */}
|
|
<div className="bg-brand-surface border-b border-border/40 grid grid-cols-5 text-[10px] font-bold text-white uppercase text-center items-center h-8">
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">1st Half</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">2nd Half</div>
|
|
<div className="h-full flex items-center justify-center bg-brand-primary text-black border-r border-border/10">Combo</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer border-r border-border/20">Chance Mix</div>
|
|
<div className="h-full flex items-center justify-center hover:bg-white/5 transition-colors cursor-pointer">Home</div>
|
|
</div>
|
|
</>
|
|
)
|
|
|
|
const getHeadersForTab = (tab: MarketTabKey) => {
|
|
const first = events[0]
|
|
const rawOdds: ApiOdds[] = first && "rawOdds" in first && Array.isArray((first as AppEvent).rawOdds) ? (first as AppEvent).rawOdds! : []
|
|
return getMarketsForTab(rawOdds, tab).headers
|
|
}
|
|
|
|
const renderColumnHeaders = (tab: MarketTabKey, eventList: (Event | AppEvent)[]) => {
|
|
let headers = eventList.length ? getHeadersForTab(tab) : getMarketsForTab([], tab).headers
|
|
if (!headers.length) headers = getMarketsForTab([], "main").headers
|
|
const n = Math.max(headers.length, 1)
|
|
return (
|
|
<div className="bg-brand-surface border-b border-white/5 flex flex-col">
|
|
<div className="h-8 flex items-center text-[9px] font-black text-white/40 uppercase">
|
|
<div className="w-[35px] shrink-0 px-1 border-r border-border/10 h-full flex items-center justify-center" />
|
|
<div className="w-[45px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">ID</div>
|
|
<div className="w-[50px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">Time</div>
|
|
<div className="flex-1 min-w-0 px-3 border-r border-border/10 h-full flex items-center">Event</div>
|
|
<div
|
|
className="flex-1 grid text-center h-full items-center tracking-tighter border-r border-border/10"
|
|
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
|
|
>
|
|
{headers.map((h, i) => (
|
|
<span key={i} className="border-r border-white/5 last:border-r-0 px-0.5 truncate">
|
|
{h}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="w-10 shrink-0 border-l border-border/10 h-full" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderEventItem = (event: Event | AppEvent, tab: MarketTabKey) => {
|
|
const hasRawOdds = event && "rawOdds" in event && Array.isArray((event as AppEvent).rawOdds)
|
|
const rawOdds = hasRawOdds ? (event as AppEvent).rawOdds! : []
|
|
const { cells } = getMarketsForTab(rawOdds, tab)
|
|
const useMainMarkets = !hasRawOdds && event.markets?.length && (tab === "main" || tab === "combo" || tab === "chance_mix" || tab === "home")
|
|
const displayCells = useMainMarkets
|
|
? event.markets.slice(0, 10).map((m, i) => ({ id: m.id, label: MARKET_HEADERS[i] ?? m.label, odds: m.odds }))
|
|
: cells
|
|
const n = Math.max(displayCells.length, 1)
|
|
return (
|
|
<div key={event.id} className="h-[34px] group flex items-center border-b border-white/5 bg-brand-bg hover:bg-white/5 transition-colors">
|
|
<div className="w-[35px] flex items-center justify-center gap-1 px-2 border-r border-white/5 h-full opacity-40 group-hover:opacity-100">
|
|
<BarChart2 className="size-3 cursor-pointer hover:text-primary" />
|
|
</div>
|
|
<div className="w-[45px] text-[10px] font-black text-brand-primary italic tabular-nums text-center border-r border-white/5 h-full flex items-center justify-center">
|
|
{event.id}
|
|
</div>
|
|
<div className="w-[50px] flex flex-col items-center justify-center border-r border-white/5 h-full leading-none italic font-black text-[9px]">
|
|
<span>{event.time}</span>
|
|
<span className="text-[7px] text-white/30 uppercase mt-0.5">PM</span>
|
|
</div>
|
|
<Link
|
|
href={`/event/${event.id}`}
|
|
className="flex-1 px-4 text-[10.5px] font-black text-white truncate max-w-[200px] hover:text-brand-primary transition-colors"
|
|
>
|
|
{event.homeTeam} - {event.awayTeam}
|
|
</Link>
|
|
<div
|
|
className="flex-1 grid h-full"
|
|
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
|
|
>
|
|
{displayCells.map((cell) => {
|
|
const betId = `${event.id}-${cell.id}`
|
|
const isSelected = bets.some((b) => b.id === betId)
|
|
const hasOdds = cell.odds > 0
|
|
return (
|
|
<button
|
|
key={cell.id}
|
|
type="button"
|
|
disabled={!hasOdds}
|
|
onClick={() =>
|
|
hasOdds &&
|
|
addBet({
|
|
id: betId,
|
|
event: `${event.homeTeam} - ${event.awayTeam}`,
|
|
league: `${event.sport} - ${event.country} - ${event.league}`,
|
|
market: cell.label,
|
|
selection: cell.label,
|
|
odds: cell.odds,
|
|
})
|
|
}
|
|
className={cn(
|
|
"flex items-center justify-center text-[10.5px] font-black tabular-nums transition-all border-r border-white/5",
|
|
isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5",
|
|
!hasOdds && "text-white/30 cursor-default hover:bg-transparent"
|
|
)}
|
|
>
|
|
{hasOdds ? cell.odds.toFixed(2) : "—"}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<Link
|
|
href={`/event/${event.id}`}
|
|
className="w-10 flex items-center justify-center h-full hover:bg-white/5 transition-colors border-l border-white/5 text-white/40 hover:text-white"
|
|
aria-label="View all markets"
|
|
>
|
|
<Plus className="size-3" />
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (loading && events.length === 0) {
|
|
return (
|
|
<div className="flex flex-col bg-brand-bg rounded overflow-hidden py-16 items-center justify-center gap-3">
|
|
<Loader2 className="size-8 animate-spin text-brand-primary" aria-hidden />
|
|
<p className="text-white/80 text-sm font-medium">Loading events and odds…</p>
|
|
{/* <p className="text-white/50 text-xs">Resolving odds for each event</p> */}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error && apiEvents.length === 0 && events.length === 0) {
|
|
return (
|
|
<div className="flex flex-col bg-brand-bg rounded overflow-hidden py-8 px-4 text-center">
|
|
<p className="text-white/60 text-sm mb-2">{error}</p>
|
|
<p className="text-white/40 text-xs">Check NEXT_PUBLIC_BETTING_API_BASE_URL and tenant.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (selectedLeague) {
|
|
// Group by date for league view
|
|
const groupedEvents = events.reduce((acc, event) => {
|
|
if (!acc[event.date]) acc[event.date] = []
|
|
acc[event.date].push(event)
|
|
return acc
|
|
}, {} as Record<string, (Event | AppEvent)[]>)
|
|
|
|
return (
|
|
<div className="flex flex-col bg-brand-bg rounded overflow-hidden shadow-2xl">
|
|
{/* League Header / Breadcrumbs */}
|
|
<div className="bg-brand-surface px-3 py-2 flex items-center justify-between border-b border-border/20">
|
|
<div className="flex items-center gap-2 text-[11px] font-bold text-white/60">
|
|
<Plus className="size-3 cursor-pointer hover:text-white" />
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-white font-black">•••</span>
|
|
<span className="uppercase">Football</span>
|
|
<span className="mx-1 opacity-20">|</span>
|
|
<span className="text-white uppercase">{selectedLeague === "LaLiga" ? "Spain - LaLiga" : selectedLeague}</span>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleClose} className="text-white hover:text-primary transition-colors">
|
|
<Plus className="size-5 rotate-45" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Market category tabs row 1: Main, Goals, Handicap, Half Time / Full Time, Correct Score */}
|
|
<div className="flex flex-wrap gap-1 p-2 pb-1 bg-brand-bg border-b border-border/10">
|
|
{ROW1_TABS.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setActiveTab(key)}
|
|
className={cn(
|
|
"px-3 py-1.5 text-[10px] font-black uppercase rounded-sm transition-all",
|
|
activeTab === key ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface hover:text-white"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Row 2: 1st Half, 2nd Half, Combo, Chance Mix, Home */}
|
|
<div className="flex flex-wrap gap-1 px-2 pb-2 bg-brand-bg border-b border-border/10">
|
|
{ROW2_TABS.map(({ key, label }) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setActiveTab(key)}
|
|
className={cn(
|
|
"px-3 py-1.5 text-[10px] font-black uppercase rounded-sm transition-all",
|
|
activeTab === key ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface hover:text-white"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Column Headers (dynamic by tab) */}
|
|
{renderColumnHeaders(activeTab, events)}
|
|
|
|
{/* Grouped Events */}
|
|
<div className="overflow-y-auto max-h-[700px]">
|
|
{Object.entries(groupedEvents).map(([date, dateEvents]) => (
|
|
<div key={date} className="flex flex-col">
|
|
<div className="bg-brand-surface px-2 py-1 text-[10px] font-black text-white border-b border-white/5">
|
|
{date}
|
|
</div>
|
|
{dateEvents.map((event) => renderEventItem(event, activeTab))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{showLoadMore && (
|
|
<div className="p-3 border-t border-white/10">
|
|
<button
|
|
type="button"
|
|
onClick={loadMore}
|
|
disabled={loading}
|
|
className="w-full py-2.5 text-[11px] font-bold uppercase text-brand-primary hover:bg-white/5 disabled:opacity-70 flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin shrink-0" aria-hidden />
|
|
Loading…
|
|
</>
|
|
) : (
|
|
"Load more"
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Home View (No League Selected)
|
|
const homeEventsByLeague = events.reduce((acc, event) => {
|
|
if (!acc[event.league]) acc[event.league] = []
|
|
acc[event.league].push(event)
|
|
return acc
|
|
}, {} as Record<string, Event[]>)
|
|
|
|
return (
|
|
<div className="flex flex-col bg-brand-bg rounded overflow-hidden">
|
|
{error && (
|
|
<div className="px-3 py-1.5 bg-amber-500/20 border-b border-amber-500/30 text-amber-200 text-[10px]">
|
|
Showing sample data. API: {error}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col">
|
|
{Object.entries(homeEventsByLeague).map(([leagueName, leagueEvents]) => (
|
|
<div key={leagueName} className="flex flex-col border-b border-white/5 last:border-none">
|
|
{/* League Box Header */}
|
|
<div className="bg-brand-surface-light px-3 py-1.5 text-[10px] font-bold text-brand-primary uppercase border-y border-border/20 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[14px]">
|
|
{popularLeagues.find(l => l.name === leagueName)?.icon ||
|
|
popularLeagues.find(l => l.id.toLowerCase() === leagueName.toLowerCase())?.icon ||
|
|
"⚽"}
|
|
</span>
|
|
<span>{leagueName === "LaLiga" ? "Spain - LaLiga" :
|
|
leagueName === "Premier League" ? "England - Premier League" :
|
|
leagueName === "Bundesliga" ? "Germany - Bundesliga" :
|
|
leagueName === "Ligue 1" ? "France - Ligue 1" :
|
|
leagueName}</span>
|
|
</div>
|
|
<ChevronDown className="size-4 text-white" />
|
|
</div>
|
|
|
|
{/* Column Headers for each league box */}
|
|
<div className="bg-brand-surface px-3 py-1 flex items-center text-[8px] font-black text-white/40 uppercase border-b border-border/20">
|
|
<div className="w-[180px] flex gap-4">
|
|
<span className="w-5 text-center">Stats</span>
|
|
<span className="w-6">ID</span>
|
|
<span className="w-10">Time</span>
|
|
<span>Event</span>
|
|
</div>
|
|
<div className="flex-1 grid grid-cols-10 text-center tracking-tighter">
|
|
{MARKET_HEADERS.map(h => <span key={h}>{h}</span>)}
|
|
</div>
|
|
<div className="w-10" />
|
|
</div>
|
|
|
|
{/* Matches in this league */}
|
|
<div className="flex flex-col">
|
|
{leagueEvents.map((event) => renderEventItem(event, "main"))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{showLoadMore && (
|
|
<div className="p-3 border-t border-white/10">
|
|
<button
|
|
type="button"
|
|
onClick={loadMore}
|
|
disabled={loading}
|
|
className="w-full py-2.5 text-[11px] font-bold uppercase text-brand-primary hover:bg-white/5 disabled:opacity-70 flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin shrink-0" aria-hidden />
|
|
Loading…
|
|
</>
|
|
) : (
|
|
"Load more"
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|