Compare commits
5 Commits
86ffd88e46
...
1a1361ee7f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1361ee7f | |||
| 0354a182f3 | |||
| 7a6f8f4279 | |||
| 4e9edbfe77 | |||
| 8941c45555 |
|
|
@ -1,10 +1,51 @@
|
|||
import Link from "next/link"
|
||||
import { getEventById } from "@/lib/mock-data"
|
||||
import { MatchDetailView } from "@/components/betting/match-detail-view"
|
||||
import {
|
||||
fetchEvents,
|
||||
fetchOddsForEvent,
|
||||
apiEventToAppEvent,
|
||||
get1X2ForEvent,
|
||||
apiOddsToSections,
|
||||
} from "@/lib/betting-api"
|
||||
import type { Event } from "@/lib/mock-data"
|
||||
|
||||
export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const event = getEventById(id)
|
||||
let event: Event | undefined = getEventById(id)
|
||||
let apiSections: { id: string; title: string; outcomes: { label: string; odds: number }[] }[] | undefined
|
||||
|
||||
const numericId = id.trim() !== "" && !Number.isNaN(Number(id)) ? Number(id) : null
|
||||
if (numericId !== null) {
|
||||
try {
|
||||
const [eventsRes, oddsRes] = await Promise.all([
|
||||
fetchEvents({ page_size: 500, page: 1 }),
|
||||
fetchOddsForEvent(numericId),
|
||||
])
|
||||
const apiEvent = (eventsRes.data ?? []).find((e) => e.id === numericId)
|
||||
if (apiEvent) {
|
||||
event = apiEventToAppEvent(apiEvent, get1X2ForEvent(oddsRes.data ?? [], apiEvent.id))
|
||||
} else {
|
||||
event = {
|
||||
id: String(numericId),
|
||||
sport: "Football",
|
||||
sportIcon: "⚽",
|
||||
league: "",
|
||||
country: "",
|
||||
homeTeam: "Home",
|
||||
awayTeam: "Away",
|
||||
time: "",
|
||||
date: "",
|
||||
isLive: false,
|
||||
markets: [],
|
||||
totalMarkets: 0,
|
||||
}
|
||||
}
|
||||
apiSections = apiOddsToSections(oddsRes.data ?? [])
|
||||
} catch {
|
||||
if (!event) event = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
|
|
@ -17,5 +58,5 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
|
|||
)
|
||||
}
|
||||
|
||||
return <MatchDetailView event={event} />
|
||||
return <MatchDetailView event={event} apiSections={apiSections} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Fortune odds button animation */
|
||||
/* HarifSport odds button animation */
|
||||
@keyframes odds-flash {
|
||||
0% {
|
||||
background-color: oklch(0.55 0.18 145);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FortuneBets - Ethiopian Online Casino and Sports Betting",
|
||||
description: "FortuneBets - Ethiopian Online Casino and Sports Betting and more",
|
||||
title: "Harifsport - Sports Betting",
|
||||
description: "Harifsport sportsbook - Live betting, in-play events, and more",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -41,15 +41,15 @@ export default function LoginPage() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* FORTUNE box */}
|
||||
{/* HARIF box */}
|
||||
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]">
|
||||
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
|
||||
FORTUNE
|
||||
HARIF
|
||||
</span>
|
||||
</div>
|
||||
{/* SPORT text */}
|
||||
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
|
||||
BETS
|
||||
SPORT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,15 +43,15 @@ export default function RegisterPage() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* FORTUNE box */}
|
||||
{/* HARIF box */}
|
||||
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]">
|
||||
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
|
||||
FORTUNE
|
||||
HARIF
|
||||
</span>
|
||||
</div>
|
||||
{/* SPORT text */}
|
||||
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
|
||||
BETS
|
||||
SPORT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const rules = [
|
||||
{
|
||||
title: "General Betting Rules",
|
||||
content: "All bets are subject to FortuneBets terms and conditions. By placing a bet, you agree to abide by these rules. The minimum bet amount is 5 ETB and the maximum payout is 500,000 ETB per bet.",
|
||||
content: "All bets are subject to Harifsport terms and conditions. By placing a bet, you agree to abide by these rules. The minimum bet amount is 5 ETB and the maximum payout is 500,000 ETB per bet.",
|
||||
},
|
||||
{
|
||||
title: "Live Betting",
|
||||
|
|
@ -13,7 +13,7 @@ const rules = [
|
|||
},
|
||||
{
|
||||
title: "Responsible Gambling",
|
||||
content: "FortuneBets is committed to responsible gambling. Users may set deposit limits, loss limits, or self-exclude at any time. Gambling should be entertaining, not a source of income.",
|
||||
content: "Harifsport is committed to responsible gambling. Users may set deposit limits, loss limits, or self-exclude at any time. Gambling should be entertaining, not a source of income.",
|
||||
},
|
||||
{
|
||||
title: "Account Rules",
|
||||
|
|
|
|||
|
|
@ -1,132 +1,154 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { GamingSidebar } from "@/components/games/gaming-sidebar"
|
||||
import { GameRow } from "@/components/games/game-row"
|
||||
import { GameCard } from "@/components/games/game-card"
|
||||
import { Search, Heart, Clock, Star, Zap, Gamepad2, AlertCircle, LayoutGrid } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import api from "@/lib/api"
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { GamingSidebar } from "@/components/games/gaming-sidebar";
|
||||
import { GameRow } from "@/components/games/game-row";
|
||||
import { GameCard } from "@/components/games/game-card";
|
||||
import {
|
||||
Search,
|
||||
Heart,
|
||||
Clock,
|
||||
Star,
|
||||
Zap,
|
||||
Gamepad2,
|
||||
AlertCircle,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface Provider {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
logo_dark: string
|
||||
logo_light: string
|
||||
enabled: boolean
|
||||
provider_id: string;
|
||||
provider_name: string;
|
||||
logo_dark: string;
|
||||
logo_light: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ApiGame {
|
||||
gameId: string
|
||||
providerId: string
|
||||
provider: string
|
||||
name: string
|
||||
category: string
|
||||
deviceType: string
|
||||
hasDemo: boolean
|
||||
hasFreeBets: boolean
|
||||
demoUrl?: string
|
||||
image?: string // In case it gets added
|
||||
thumbnail?: string
|
||||
provider_id?: string // Fallback
|
||||
gameId: string;
|
||||
providerId: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
category: string;
|
||||
deviceType: string;
|
||||
hasDemo: boolean;
|
||||
hasFreeBets: boolean;
|
||||
demoUrl?: string;
|
||||
image?: string; // In case it gets added
|
||||
thumbnail?: string;
|
||||
provider_id?: string; // Fallback
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE = "https://st.pokgaming.com/gameThumbnails/246.jpg"
|
||||
const DEFAULT_IMAGE = "https://st.pokgaming.com/gameThumbnails/246.jpg";
|
||||
|
||||
export default function VirtualPage() {
|
||||
const [activeCategory, setActiveCategory] = useState("all")
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [games, setGames] = useState<ApiGame[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [games, setGames] = useState<ApiGame[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVirtualData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setIsLoading(true);
|
||||
const [providersRes, gamesRes] = await Promise.all([
|
||||
api.get("/virtual-game/orchestrator/providers"),
|
||||
api.get("/virtual-game/orchestrator/games", { params: { limit: 2000 } })
|
||||
])
|
||||
api.get("/virtual-game/orchestrator/games", {
|
||||
params: { limit: 2000 },
|
||||
}),
|
||||
]);
|
||||
|
||||
const pData = providersRes.data
|
||||
const gData = gamesRes.data
|
||||
const pData = providersRes.data;
|
||||
const gData = gamesRes.data;
|
||||
|
||||
const providersList = pData.providers || pData.data || pData || []
|
||||
const gamesList = gData.data || gData.games || gData || []
|
||||
const providersList = pData.providers || pData.data || pData || [];
|
||||
const gamesList = gData.data || gData.games || gData || [];
|
||||
|
||||
setProviders(Array.isArray(providersList) ? providersList : [])
|
||||
setGames(Array.isArray(gamesList) ? gamesList : [])
|
||||
setProviders(Array.isArray(providersList) ? providersList : []);
|
||||
setGames(Array.isArray(gamesList) ? gamesList : []);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch virtual games data:", err)
|
||||
setError("Failed to load games data.")
|
||||
console.error("Failed to fetch virtual games data:", err);
|
||||
setError("Failed to load games data.");
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchVirtualData()
|
||||
}, [])
|
||||
fetchVirtualData();
|
||||
}, []);
|
||||
|
||||
// Create Sidebar Categories dynamically from providers
|
||||
const sidebarCategories = [
|
||||
{ id: "all", name: "All Games", icon: LayoutGrid },
|
||||
...providers.map(p => ({
|
||||
...providers.map((p) => ({
|
||||
id: p.provider_id,
|
||||
name: p.provider_name,
|
||||
icon: p.logo_dark || p.logo_light || Gamepad2
|
||||
}))
|
||||
]
|
||||
icon: p.logo_dark || p.logo_light || Gamepad2,
|
||||
})),
|
||||
];
|
||||
|
||||
// Filter games based on active category
|
||||
// If "all", group by provider
|
||||
let displayedGames: any[] = []
|
||||
let groupedGames: { title: string, games: any[] }[] = []
|
||||
let displayedGames: any[] = [];
|
||||
let groupedGames: { title: string; games: any[] }[] = [];
|
||||
|
||||
const mapApiGameToCard = (game: ApiGame) => ({
|
||||
id: game.gameId,
|
||||
title: game.name,
|
||||
image: game.thumbnail || game.image || DEFAULT_IMAGE,
|
||||
provider: game.provider
|
||||
})
|
||||
provider: game.provider,
|
||||
});
|
||||
|
||||
if (activeCategory === "all") {
|
||||
// Group up to 12 games per provider for the rows
|
||||
providers.forEach(p => {
|
||||
const providerIdStr = String(p.provider_id || "").trim().toLowerCase()
|
||||
providers.forEach((p) => {
|
||||
const providerIdStr = String(p.provider_id || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const providerGames = games
|
||||
.filter(g => {
|
||||
const gameProvId = String(g.providerId || g.provider_id || "").trim().toLowerCase()
|
||||
return gameProvId === providerIdStr
|
||||
.filter((g) => {
|
||||
const gameProvId = String(g.providerId || g.provider_id || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return gameProvId === providerIdStr;
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map(mapApiGameToCard)
|
||||
.map(mapApiGameToCard);
|
||||
|
||||
if (providerGames.length > 0) {
|
||||
groupedGames.push({
|
||||
title: p.provider_name,
|
||||
games: providerGames
|
||||
})
|
||||
games: providerGames,
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
} else {
|
||||
|
||||
displayedGames = games
|
||||
.filter(g => {
|
||||
const gameProvId = String(g.providerId || g.provider_id || "").trim().toLowerCase()
|
||||
const matches = gameProvId === String(activeCategory).trim().toLowerCase()
|
||||
if (g.providerId?.toLowerCase().includes('pop') || g.provider_id?.toLowerCase().includes('pop')) {
|
||||
}
|
||||
return matches
|
||||
.filter((g) => {
|
||||
const gameProvId = String(g.providerId || g.provider_id || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const matches =
|
||||
gameProvId === String(activeCategory).trim().toLowerCase();
|
||||
if (
|
||||
g.providerId?.toLowerCase().includes("pop") ||
|
||||
g.provider_id?.toLowerCase().includes("pop")
|
||||
) {
|
||||
}
|
||||
return matches;
|
||||
})
|
||||
.map(mapApiGameToCard)
|
||||
|
||||
.map(mapApiGameToCard);
|
||||
}
|
||||
|
||||
const activeCategoryData = providers.find(
|
||||
p => String(p.provider_id || "").trim().toLowerCase() === String(activeCategory).trim().toLowerCase()
|
||||
)
|
||||
(p) =>
|
||||
String(p.provider_id || "")
|
||||
.trim()
|
||||
.toLowerCase() === String(activeCategory).trim().toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-140px)] overflow-hidden bg-brand-bg">
|
||||
|
|
@ -155,13 +177,13 @@ export default function VirtualPage() {
|
|||
<div className="mt-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-white/60 text-sm font-bold flex items-center justify-center gap-2">
|
||||
<div className="size-4 rounded-full border-2 border-brand-primary border-t-transparent animate-spin" />
|
||||
Loading games...
|
||||
<div className="size-4 rounded-full border-2 border-brand-primary border-t-transparent animate-spin" />
|
||||
Loading games...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-400 text-sm font-bold flex items-center justify-center gap-2">
|
||||
<AlertCircle className="size-4" />
|
||||
{error}
|
||||
<AlertCircle className="size-4" />
|
||||
{error}
|
||||
</div>
|
||||
) : activeCategory === "all" ? (
|
||||
// Show all categories
|
||||
|
|
@ -180,7 +202,7 @@ export default function VirtualPage() {
|
|||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-white uppercase border-l-4 border-brand-primary pl-4">
|
||||
{activeCategoryData?.provider_name || 'Games'}
|
||||
{activeCategoryData?.provider_name || "Games"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setActiveCategory("all")}
|
||||
|
|
@ -200,5 +222,5 @@ export default function VirtualPage() {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ 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 } from "lucide-react"
|
||||
import { ChevronDown, BarChart2, TrendingUp, Plus, Loader2 } from "lucide-react"
|
||||
|
||||
function OddsButton({ odds, onClick, isSelected }: {
|
||||
odds: number
|
||||
|
|
@ -26,7 +29,7 @@ function OddsButton({ odds, onClick, isSelected }: {
|
|||
)
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: Event }) {
|
||||
function EventRow({ event }: { event: Event | AppEvent }) {
|
||||
const { bets, addBet } = useBetslipStore()
|
||||
|
||||
return (
|
||||
|
|
@ -82,22 +85,47 @@ function EventRow({ event }: { event: Event }) {
|
|||
)
|
||||
}
|
||||
|
||||
const MARKET_HEADERS = ["1", "x", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"];
|
||||
const MARKET_HEADERS = ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"]
|
||||
|
||||
export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
||||
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")
|
||||
|
|
@ -105,13 +133,17 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
setSelectedLeague(null)
|
||||
}
|
||||
|
||||
const events = selectedLeague
|
||||
? mockEvents.filter(e => e.league.toLowerCase() === selectedLeague.toLowerCase())
|
||||
: mockEvents.filter((e) => {
|
||||
if (filter === "Live" && !e.isLive) return false
|
||||
if (sport !== "all" && e.sport.toLowerCase() !== sport.toLowerCase()) return false
|
||||
return true
|
||||
})
|
||||
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 = () => (
|
||||
|
|
@ -135,75 +167,130 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
</>
|
||||
)
|
||||
|
||||
const renderColumnHeaders = () => (
|
||||
<div className="bg-brand-surface border-b border-white/5 h-8 flex items-center text-[9px] font-black text-white/40 uppercase">
|
||||
<div className="w-[180px] px-3 flex items-center gap-1.5 border-r border-border/10 h-full">Main</div>
|
||||
<div className="w-[180px] flex items-center justify-center border-r border-border/10 h-full">Over/Under</div>
|
||||
<div className="flex-1 grid grid-cols-10 text-center h-full items-center tracking-tighter">
|
||||
{MARKET_HEADERS.map(h => <span key={h}>{h}</span>)}
|
||||
</div>
|
||||
<div className="w-10 border-l border-border/10 h-full" />
|
||||
</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 renderEventItem = (event: Event) => (
|
||||
<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">
|
||||
{/* Stats & Icons */}
|
||||
<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" />
|
||||
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>
|
||||
{/* ID */}
|
||||
<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}
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{/* Time */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{/* Event Name -> same route as + icon (match detail) */}
|
||||
<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>
|
||||
{/* Odds Grid */}
|
||||
<div className="flex-1 grid grid-cols-10 h-full">
|
||||
{event.markets.slice(0, 10).map((market) => {
|
||||
const betId = `${event.id}-${market.id}`
|
||||
const isSelected = bets.some((b) => b.id === betId)
|
||||
return (
|
||||
<button
|
||||
key={market.id}
|
||||
onClick={() => addBet({
|
||||
id: betId,
|
||||
event: `${event.homeTeam} - ${event.awayTeam}`,
|
||||
league: `${event.sport} - ${event.country} - ${event.league}`,
|
||||
market: "1X2",
|
||||
selection: market.label,
|
||||
odds: market.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"
|
||||
)}
|
||||
>
|
||||
{market.odds.toFixed(2)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{/* More 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-white/5 text-white/40 hover:text-white"
|
||||
aria-label="View all markets"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedLeague) {
|
||||
// Group by date for league view
|
||||
|
|
@ -211,7 +298,7 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
if (!acc[event.date]) acc[event.date] = []
|
||||
acc[event.date].push(event)
|
||||
return acc
|
||||
}, {} as Record<string, Event[]>)
|
||||
}, {} as Record<string, (Event | AppEvent)[]>)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-brand-bg rounded overflow-hidden shadow-2xl">
|
||||
|
|
@ -231,26 +318,41 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Large Market Tab Grid */}
|
||||
<div className="grid grid-cols-5 bg-brand-bg border-b border-border/10">
|
||||
{[
|
||||
{ label: "Main", active: true }, { label: "Goals" }, { label: "Handicap" }, { label: "Half Time / Full Time" }, { label: "Correct Score" },
|
||||
{ label: "1st Half" }, { label: "2nd Half" }, { label: "Asian Markets" }, { label: "Corners" }, { label: "Home" }
|
||||
].map((m, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-8 border-r border-b border-border/10 flex items-center justify-center text-[10px] font-black uppercase transition-all",
|
||||
m.active ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface"
|
||||
)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
{/* 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 */}
|
||||
{renderColumnHeaders()}
|
||||
{/* Column Headers (dynamic by tab) */}
|
||||
{renderColumnHeaders(activeTab, events)}
|
||||
|
||||
{/* Grouped Events */}
|
||||
<div className="overflow-y-auto max-h-[700px]">
|
||||
|
|
@ -259,10 +361,29 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
<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))}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
@ -276,6 +397,11 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
|
||||
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">
|
||||
|
|
@ -312,11 +438,30 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
|
|||
|
||||
{/* Matches in this league */}
|
||||
<div className="flex flex-col">
|
||||
{leagueEvents.map(event => renderEventItem(event))}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useBetslipStore } from "@/lib/store/betslip-store"
|
||||
import { mockEvents, type Event } from "@/lib/mock-data"
|
||||
import { useLiveStore } from "@/lib/store/live-store"
|
||||
import { SportEnum } from "@/lib/store/betting-types"
|
||||
import { SPORT_ID_MAP } from "@/lib/store/betting-api"
|
||||
import type { AppEvent } from "@/lib/store/betting-types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BarChart2, TrendingUp, Monitor, Tv } from "lucide-react"
|
||||
import { BarChart2, Monitor, Loader2 } from "lucide-react"
|
||||
|
||||
|
||||
function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean }) {
|
||||
const { bets, addBet } = useBetslipStore()
|
||||
|
||||
// Dummy data for demonstration
|
||||
const score = event.homeScore !== undefined ? `${event.homeScore} - ${event.awayScore}` : "0 - 0"
|
||||
const time = event.liveMinute ? `${event.liveMinute}:00` : "83:10"
|
||||
function LiveEventRow({ event, isNoOdds }: { event: AppEvent; isNoOdds?: boolean }) {
|
||||
const { addBet } = useBetslipStore()
|
||||
const score = event.score ?? "0 - 0"
|
||||
const time = event.matchMinute != null ? `${String(event.matchMinute).padStart(2, "0")}:00` : "—"
|
||||
const period = "H2"
|
||||
|
||||
return (
|
||||
|
|
@ -23,9 +25,12 @@ function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean })
|
|||
<span className="text-white/40">{period}</span>
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 flex-1 gap-2">
|
||||
<span className="text-[11.5px] font-black text-white truncate italic uppercase">
|
||||
<Link
|
||||
href={`/event/${event.id}`}
|
||||
className="text-[11.5px] font-black text-white truncate italic uppercase hover:text-brand-primary transition-colors"
|
||||
>
|
||||
{event.homeTeam} <span className="text-brand-primary mx-1 tabular-nums">{score}</span> {event.awayTeam}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 px-1 opacity-20 group-hover:opacity-100 transition-opacity">
|
||||
<BarChart2 className="size-3.5 text-white" />
|
||||
|
|
@ -71,64 +76,38 @@ function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean })
|
|||
)
|
||||
}
|
||||
|
||||
const liveSports = [
|
||||
{ id: "soccer", label: "Soccer", icon: "⚽", count: 25, active: true },
|
||||
{ id: "basketball", label: "Basketball", icon: "🏀", count: 39 },
|
||||
{ id: "ice-hockey", label: "Ice Hockey", icon: "🏒", count: 3 },
|
||||
{ id: "tennis", label: "Tennis", icon: "🎾", count: 4 },
|
||||
{ id: "handball", label: "Handball", icon: "🤾", count: 10 },
|
||||
{ id: "rugby", label: "Rugby", icon: "🏉", count: 2 },
|
||||
{ id: "table-tennis", label: "Table Tennis", icon: "🏓", count: 8 },
|
||||
{ id: "volleyball", label: "Volleyball", icon: "🏐", count: 7 },
|
||||
{ id: "futsal", label: "Futsal", icon: "⚽", count: 2 },
|
||||
{ id: "esport-counter-strike", label: "ESport Cou...", icon: "🎮", count: 2 },
|
||||
{ id: "esport-league-of-legends", label: "ESport Lea...", icon: "🎮", count: 1 },
|
||||
{ id: "esport-dota-2", label: "ESport Dota", icon: "🎮", count: 1 },
|
||||
{ id: "efootball", label: "eFootball", icon: "⚽", count: 4 },
|
||||
{ id: "ebasketball", label: "eBasketball", icon: "🏀", count: 1 },
|
||||
]
|
||||
const LIVE_SPORT_IDS = [
|
||||
SportEnum.SOCCER,
|
||||
SportEnum.BASKETBALL,
|
||||
SportEnum.ICE_HOCKEY,
|
||||
SportEnum.TENNIS,
|
||||
SportEnum.HANDBALL,
|
||||
SportEnum.RUGBY_UNION,
|
||||
SportEnum.TABLE_TENNIS,
|
||||
SportEnum.VOLLEYBALL,
|
||||
SportEnum.FUTSAL,
|
||||
SportEnum.E_SPORTS,
|
||||
] as const
|
||||
|
||||
export function LiveEventsList() {
|
||||
// Enhanced mock data local to live view to match screenshot exactly
|
||||
const liveMatches = [
|
||||
{
|
||||
league: "Algeria - Ligue 1",
|
||||
flag: "https://flagcdn.com/w20/dz.png",
|
||||
matches: [
|
||||
{ ...mockEvents[0], id: "l1", homeTeam: "Paradou AC", awayTeam: "Ben Aknoun", homeScore: 3, awayScore: 5, liveMinute: 91, noOdds: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
league: "Australia - U23 Victoria NPL",
|
||||
flag: "https://flagcdn.com/w20/au.png",
|
||||
matches: [
|
||||
{ ...mockEvents[1], id: "l2", homeTeam: "Oakleigh Cannons FC", awayTeam: "Altona Magic SC", homeScore: 5, awayScore: 1, liveMinute: 87, noOdds: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
league: "Australia - U23 Victoria Premier League 1",
|
||||
flag: "https://flagcdn.com/w20/au.png",
|
||||
matches: [
|
||||
{ ...mockEvents[2], id: "l3", homeTeam: "Northcote City FC", awayTeam: "Western United FC", homeScore: 4, awayScore: 0, liveMinute: 83, noOdds: false },
|
||||
{ ...mockEvents[3], id: "l4", homeTeam: "Melbourne Knights FC", awayTeam: "Melbourne Victory FC", homeScore: 0, awayScore: 3, liveMinute: 81, noOdds: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
league: "Australia - Victoria NPL, Women",
|
||||
flag: "https://flagcdn.com/w20/au.png",
|
||||
matches: [
|
||||
{ ...mockEvents[4], id: "l5", homeTeam: "Preston Lions FC", awayTeam: "South Melbourne FC", homeScore: 1, awayScore: 1, liveMinute: 52, noOdds: true },
|
||||
{ ...mockEvents[0], id: "l6", homeTeam: "Bentleigh Greens SC", awayTeam: "Box Hill United", homeScore: 0, awayScore: 6, liveMinute: 83, noOdds: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
const { events, loading, error, sportId, setSportId, loadLiveEvents } = useLiveStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadLiveEvents()
|
||||
}, [loadLiveEvents])
|
||||
|
||||
const groupedByLeague = events.reduce((acc, ev) => {
|
||||
const key = ev.league || "Other"
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(ev)
|
||||
return acc
|
||||
}, {} as Record<string, AppEvent[]>)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-brand-bg">
|
||||
{/* Sport Navigation Carousel */}
|
||||
{/* Sport Navigation: SportEnum ids, no league — event?sport_id=1&first_start_time=RFC3339&is_live=true */}
|
||||
<div className="bg-brand-surface border-b border-border/20 px-2 flex items-center h-[54px] overflow-x-auto scrollbar-hide">
|
||||
<div className="flex items-center gap-0 h-full">
|
||||
{/* Favourites & Prematch */}
|
||||
<button className="flex flex-col items-center justify-center px-4 h-full border-r border-white/5 min-w-[70px]">
|
||||
<span className="text-[14px]">⭐</span>
|
||||
<span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Favourites</span>
|
||||
|
|
@ -137,59 +116,72 @@ export function LiveEventsList() {
|
|||
<span className="text-[14px]">⏱️</span>
|
||||
<span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Prematch</span>
|
||||
</button>
|
||||
|
||||
{/* Live Sports */}
|
||||
{liveSports.map((sport) => (
|
||||
<button
|
||||
key={sport.id}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center px-3 h-full border-r border-white/5 min-w-[75px] relative transition-colors",
|
||||
sport.active ? "bg-white/5" : "hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<span className="absolute top-1 right-2 text-[8.5px] font-black text-white/40">{sport.count}</span>
|
||||
<span className="text-[16px]">{sport.icon}</span>
|
||||
<span className={cn(
|
||||
"text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap",
|
||||
sport.active ? "text-brand-primary" : "text-white/40"
|
||||
)}>
|
||||
{sport.label}
|
||||
</span>
|
||||
{sport.active && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{LIVE_SPORT_IDS.map((id) => {
|
||||
const info = SPORT_ID_MAP[id]
|
||||
if (!info) return null
|
||||
const icon = id === SportEnum.SOCCER ? "⚽" : id === SportEnum.TENNIS ? "🎾" : id === SportEnum.BASKETBALL ? "🏀" : id === SportEnum.ICE_HOCKEY ? "🏒" : id === SportEnum.VOLLEYBALL ? "🏐" : id === SportEnum.HANDBALL ? "🤾" : id === SportEnum.E_SPORTS ? "🎮" : "⚽"
|
||||
const active = sportId === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSportId(id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center px-3 h-full border-r border-white/5 min-w-[75px] relative transition-colors",
|
||||
active ? "bg-white/5" : "hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<span className="text-[16px]">{icon}</span>
|
||||
<span className={cn(
|
||||
"text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap",
|
||||
active ? "text-brand-primary" : "text-white/40"
|
||||
)}>
|
||||
{info.name}
|
||||
</span>
|
||||
{active && <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Header (Soccer) */}
|
||||
<div className="bg-brand-primary px-3 py-1.5 flex items-center gap-2 border-l-[4px] border-brand-primary">
|
||||
<span className="text-[16px]">⚽</span>
|
||||
<h2 className="text-[14px] font-black text-white uppercase tracking-tight">Soccer</h2>
|
||||
{/* Category Header */}
|
||||
<div className="bg-brand-primary px-3 py-1.5 flex items-center gap-2 border-l-4 border-brand-primary">
|
||||
<span className="text-[16px]">{SPORT_ID_MAP[sportId]?.name === "Soccer" ? "⚽" : "•"}</span>
|
||||
<h2 className="text-[14px] font-black text-white uppercase tracking-tight">
|
||||
{SPORT_ID_MAP[sportId]?.name ?? "Live"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Grouped Live Matches */}
|
||||
{loading && events.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16 gap-2 text-white/60">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="text-sm">Loading live events…</span>
|
||||
</div>
|
||||
) : error && events.length === 0 ? (
|
||||
<div className="py-8 px-4 text-center text-white/60 text-sm">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col mb-10">
|
||||
{liveMatches.map((group, gIdx) => (
|
||||
<div key={gIdx} className="flex flex-col">
|
||||
{/* League Header */}
|
||||
{Object.entries(groupedByLeague).map(([leagueName, matches]) => (
|
||||
<div key={leagueName} className="flex flex-col">
|
||||
<div className="bg-brand-surface px-3 py-1 border-b border-border/10 flex items-center gap-2">
|
||||
<img src={group.flag} width="14" alt={group.league} className="rounded-sm opacity-60" />
|
||||
<span className="text-[9.5px] font-black text-white/60 uppercase tracking-widest leading-none">
|
||||
{group.league}
|
||||
{leagueName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Matches in this league */}
|
||||
<div className="flex flex-col">
|
||||
{group.matches.map((match, mIdx) => (
|
||||
<LiveEventRow key={match.id} event={match as any} isNoOdds={match.noOdds} />
|
||||
{matches.map((match) => (
|
||||
<LiveEventRow
|
||||
key={match.id}
|
||||
event={match}
|
||||
isNoOdds={!match.markets?.length || match.markets.every((m) => m.odds <= 0)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
type Event,
|
||||
type DetailMarketSection,
|
||||
} from "@/lib/mock-data"
|
||||
|
||||
type ApiSection = { id: string; title: string; outcomes: { label: string; odds: number }[] }
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
|
|
@ -77,11 +79,12 @@ function MarketSectionBlock({
|
|||
<div className="px-3 pb-3 space-y-1.5">
|
||||
{section.outcomes.length > 2 && section.outcomes.length % 2 === 0 ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{section.outcomes.map((outcome) => {
|
||||
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}`
|
||||
{section.outcomes.map((outcome, i) => {
|
||||
const oddsStr = typeof outcome.odds === "number" ? outcome.odds.toFixed(2) : String(outcome.odds)
|
||||
const betId = `${event.id}-${section.id}-${i}-${outcome.label.replace(/\s/g, "-").toLowerCase()}-${oddsStr}`
|
||||
const isSelected = bets.some((b) => b.id === betId)
|
||||
return (
|
||||
<div key={outcome.label} className="flex items-center justify-between gap-2">
|
||||
<div key={`${outcome.label}-${i}-${oddsStr}`} className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-white/90 truncate">{outcome.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -107,12 +110,13 @@ function MarketSectionBlock({
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
section.outcomes.map((outcome) => {
|
||||
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}`
|
||||
section.outcomes.map((outcome, i) => {
|
||||
const oddsStr = typeof outcome.odds === "number" ? outcome.odds.toFixed(2) : String(outcome.odds)
|
||||
const betId = `${event.id}-${section.id}-${i}-${outcome.label.replace(/\s/g, "-").toLowerCase()}-${oddsStr}`
|
||||
const isSelected = bets.some((b) => b.id === betId)
|
||||
return (
|
||||
<div
|
||||
key={outcome.label}
|
||||
key={`${outcome.label}-${i}-${oddsStr}`}
|
||||
className="flex items-center justify-between gap-3 py-1"
|
||||
>
|
||||
<span className="text-[11px] text-white/90">{outcome.label}</span>
|
||||
|
|
@ -145,19 +149,32 @@ function MarketSectionBlock({
|
|||
)
|
||||
}
|
||||
|
||||
export function MatchDetailView({ event }: { event: Event }) {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
export function MatchDetailView({
|
||||
event,
|
||||
apiSections,
|
||||
}: {
|
||||
event: Event
|
||||
apiSections?: ApiSection[] | null
|
||||
}) {
|
||||
useBetslipStore((s) => s.bets)
|
||||
const mockDetailMarkets = getEventDetailMarkets(event.id)
|
||||
const cardsBookings = getCardsBookingsMarkets(event.id)
|
||||
const detailMarkets: DetailMarketSection[] = (apiSections?.length
|
||||
? apiSections.map((s) => ({ id: s.id, title: s.title, outcomes: s.outcomes }))
|
||||
: mockDetailMarkets) as DetailMarketSection[]
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => ({
|
||||
"bookings-1x2": true,
|
||||
"sending-off": true,
|
||||
"1st-booking": true,
|
||||
"1st-half-bookings-1x2": true,
|
||||
"booking-points-ou": true,
|
||||
"1st-half-1st-booking": true,
|
||||
})
|
||||
const [activeCategory, setActiveCategory] = useState("Cards/Bookings")
|
||||
|
||||
const detailMarkets = getEventDetailMarkets(event.id)
|
||||
const cardsBookings = getCardsBookingsMarkets(event.id)
|
||||
...(apiSections?.length
|
||||
? Object.fromEntries(detailMarkets.slice(0, 8).map((s) => [s.id, true]))
|
||||
: {}),
|
||||
}))
|
||||
const [activeCategory, setActiveCategory] = useState("Main")
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
|
|
@ -166,11 +183,15 @@ export function MatchDetailView({ event }: { event: Event }) {
|
|||
const breadcrumbLeague =
|
||||
event.league === "Premier League"
|
||||
? "England - Premier League"
|
||||
: `${event.country} - ${event.league}`
|
||||
: event.league
|
||||
? `${event.country} - ${event.league}`
|
||||
: "Event"
|
||||
|
||||
const isCardsBookings = activeCategory === "Cards/Bookings"
|
||||
const leftSections = isCardsBookings ? cardsBookings.left : detailMarkets
|
||||
const rightSections = isCardsBookings ? cardsBookings.right : []
|
||||
const allSections = isCardsBookings ? [...cardsBookings.left, ...cardsBookings.right] : detailMarkets
|
||||
const mid = Math.ceil(allSections.length / 2)
|
||||
const leftSections = allSections.slice(0, mid)
|
||||
const rightSections = allSections.slice(mid)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-brand-bg rounded overflow-hidden">
|
||||
|
|
@ -189,38 +210,38 @@ export function MatchDetailView({ event }: { event: Event }) {
|
|||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Match header */}
|
||||
{/* Match header: team names in boxes and below */}
|
||||
<div className="bg-brand-surface px-4 py-5 border-b border-border/20">
|
||||
<div className="flex items-center justify-center gap-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center">
|
||||
<span className="text-[10px] font-black text-white/60 uppercase">
|
||||
{event.homeTeam.slice(0, 2)}
|
||||
<div className="flex flex-col items-center gap-2 min-w-0">
|
||||
<div className="w-20 min-h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center px-2 py-3 text-center">
|
||||
<span className="text-[11px] font-black text-white leading-tight line-clamp-3">
|
||||
{event.homeTeam}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] font-bold text-white">{event.homeTeam}</span>
|
||||
<span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.homeTeam}</span>
|
||||
</div>
|
||||
<span className="text-[12px] font-black text-white/50 uppercase">VS</span>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center">
|
||||
<span className="text-[10px] font-black text-white/60 uppercase">
|
||||
{event.awayTeam.slice(0, 2)}
|
||||
<span className="text-[12px] font-black text-white/50 uppercase shrink-0">VS</span>
|
||||
<div className="flex flex-col items-center gap-2 min-w-0">
|
||||
<div className="w-20 min-h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center px-2 py-3 text-center">
|
||||
<span className="text-[11px] font-black text-white leading-tight line-clamp-3">
|
||||
{event.awayTeam}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] font-bold text-white">{event.awayTeam}</span>
|
||||
<span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.awayTeam}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category tabs: horizontal scroll, selected = darker grey */}
|
||||
<div className="flex overflow-x-auto gap-1 p-2 bg-brand-bg border-b border-border/20 scrollbar-hide">
|
||||
{/* Category tabs: wrap into 2–3 rows, not scrollable */}
|
||||
<div className="flex flex-wrap gap-1.5 p-2 bg-brand-bg border-b border-border/20">
|
||||
{MARKET_CATEGORIES.map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(label)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-[10px] font-bold uppercase whitespace-nowrap rounded transition-colors shrink-0",
|
||||
"px-3 py-1.5 text-[10px] font-bold uppercase whitespace-nowrap rounded transition-colors",
|
||||
activeCategory === label
|
||||
? "bg-brand-surface-light text-white border border-white/10"
|
||||
: "text-white/60 hover:text-white hover:bg-white/5"
|
||||
|
|
@ -231,7 +252,7 @@ export function MatchDetailView({ event }: { event: Event }) {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column grid of market sections */}
|
||||
{/* Two-column grid of market sections (split evenly so both columns are used) */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-0 bg-brand-surface-light">
|
||||
{/* Left column */}
|
||||
|
|
@ -247,21 +268,19 @@ export function MatchDetailView({ event }: { event: Event }) {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Right column (Cards/Bookings only) */}
|
||||
{rightSections.length > 0 && (
|
||||
<div>
|
||||
{rightSections.map((section) => (
|
||||
<MarketSectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
event={event}
|
||||
marketName={section.title}
|
||||
isExpanded={expandedSections[section.id] ?? false}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Right column */}
|
||||
<div>
|
||||
{rightSections.map((section) => (
|
||||
<MarketSectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
event={event}
|
||||
marketName={section.title}
|
||||
isExpanded={expandedSections[section.id] ?? false}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function SportHome() {
|
|||
<HomeTabs />
|
||||
</>
|
||||
)}
|
||||
<EventsList />
|
||||
<EventsList key={`${searchParams.get("sport") ?? "all"}-${searchParams.get("league") ?? ""}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tabs } from "@/components/ui/tabs"
|
||||
|
||||
const sports = [
|
||||
{ id: "football", name: "Football", icon: "⚽" },
|
||||
|
|
@ -14,17 +19,23 @@ const sports = [
|
|||
]
|
||||
|
||||
export function SportsNav() {
|
||||
const searchParams = useSearchParams()
|
||||
const currentSport = searchParams.get("sport") ?? "football"
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="football" className="w-full">
|
||||
<Tabs value={currentSport} className="w-full">
|
||||
<TabsList variant="hs-nav" className="min-h-14! h-auto! py-2">
|
||||
{sports.map((sport) => (
|
||||
<TabsTrigger
|
||||
key={sport.id}
|
||||
value={sport.id}
|
||||
asChild
|
||||
className="flex-col min-w-[70px] py-2 gap-1"
|
||||
>
|
||||
<span className="text-xl">{sport.icon}</span>
|
||||
<span className="text-[10px] font-bold uppercase">{sport.name}</span>
|
||||
<Link href={`/?sport=${sport.id}`} scroll={false} className="flex flex-col items-center gap-1">
|
||||
<span className="text-xl">{sport.icon}</span>
|
||||
<span className="text-[10px] font-bold uppercase">{sport.name}</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,91 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { useBetslipStore } from "@/lib/store/betslip-store"
|
||||
import {
|
||||
fetchEvents,
|
||||
fetchOddsForEvent,
|
||||
get1X2FromOddsResponse,
|
||||
TOP_LEAGUES,
|
||||
type ApiEvent,
|
||||
} from "@/lib/betting-api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const topMatches = [
|
||||
{
|
||||
id: "tm1",
|
||||
league: "England - Premier League",
|
||||
time: "05:00 PM",
|
||||
homeTeam: "Nottingham Forest",
|
||||
awayTeam: "Liverpool",
|
||||
odds: { home: 4.09, draw: 3.93, away: 1.82 }
|
||||
},
|
||||
{
|
||||
id: "tm2",
|
||||
league: "England - Premier League",
|
||||
time: "11:00 PM",
|
||||
homeTeam: "Man City",
|
||||
awayTeam: "Newcastle",
|
||||
odds: { home: 1.50, draw: 5.17, away: 5.93 }
|
||||
},
|
||||
{
|
||||
id: "tm3",
|
||||
league: "England - Premier League",
|
||||
time: "06:00 PM",
|
||||
homeTeam: "Chelsea",
|
||||
awayTeam: "Burnley",
|
||||
odds: { home: 1.21, draw: 6.91, away: 11.50 }
|
||||
},
|
||||
{
|
||||
id: "tm4",
|
||||
league: "Spain - LaLiga",
|
||||
time: "07:30 PM",
|
||||
homeTeam: "Arsenal",
|
||||
awayTeam: "Wolves",
|
||||
odds: { home: 1.56, draw: 4.16, away: 5.80 }
|
||||
},
|
||||
{
|
||||
id: "tm5",
|
||||
league: "Italy - Serie A",
|
||||
time: "09:45 PM",
|
||||
homeTeam: "Inter Milan",
|
||||
awayTeam: "Napoli",
|
||||
odds: { home: 1.85, draw: 3.60, away: 4.20 }
|
||||
}
|
||||
type TopMatch = {
|
||||
id: string
|
||||
league: string
|
||||
time: string
|
||||
homeTeam: string
|
||||
awayTeam: string
|
||||
odds: { home: number; draw: number; away: number }
|
||||
}
|
||||
|
||||
const FALLBACK_MATCHES: TopMatch[] = [
|
||||
{ id: "tm1", league: "England - Premier League", time: "05:00 PM", homeTeam: "Nottingham Forest", awayTeam: "Liverpool", odds: { home: 4.09, draw: 3.93, away: 1.82 } },
|
||||
{ id: "tm2", league: "England - Premier League", time: "11:00 PM", homeTeam: "Man City", awayTeam: "Newcastle", odds: { home: 1.50, draw: 5.17, away: 5.93 } },
|
||||
{ id: "tm3", league: "England - Premier League", time: "06:00 PM", homeTeam: "Chelsea", awayTeam: "Burnley", odds: { home: 1.21, draw: 6.91, away: 11.50 } },
|
||||
{ id: "tm4", league: "Spain - LaLiga", time: "07:30 PM", homeTeam: "Arsenal", awayTeam: "Wolves", odds: { home: 1.56, draw: 4.16, away: 5.80 } },
|
||||
{ id: "tm5", league: "Italy - Serie A", time: "09:45 PM", homeTeam: "Inter Milan", awayTeam: "Napoli", odds: { home: 1.85, draw: 3.60, away: 4.20 } },
|
||||
]
|
||||
|
||||
function parseTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: true })
|
||||
} catch {
|
||||
return "--:--"
|
||||
}
|
||||
}
|
||||
|
||||
export function TopMatches() {
|
||||
const { bets, addBet } = useBetslipStore()
|
||||
const [matches, setMatches] = useState<TopMatch[]>(FALLBACK_MATCHES)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const TOP_MATCHES_SIZE = 5
|
||||
const leagueIds = TOP_LEAGUES.slice(0, 4).map((l) => l.id)
|
||||
const nameById = Object.fromEntries(TOP_LEAGUES.map((l) => [l.id, l.name]))
|
||||
|
||||
Promise.all(leagueIds.map((league_id) => fetchEvents({ league_id, page_size: 2, page: 1 })))
|
||||
.then(async (leagueResponses) => {
|
||||
if (cancelled) return
|
||||
const list: TopMatch[] = []
|
||||
const eventMeta: { e: ApiEvent; leagueName: string }[] = []
|
||||
leagueResponses.forEach((res, i) => {
|
||||
const leagueName = nameById[leagueIds[i]] ?? ""
|
||||
const events = res.data ?? []
|
||||
for (const e of events) {
|
||||
eventMeta.push({ e, leagueName })
|
||||
if (eventMeta.length >= TOP_MATCHES_SIZE) break
|
||||
}
|
||||
})
|
||||
const oddsResponses = await Promise.all(
|
||||
eventMeta.slice(0, TOP_MATCHES_SIZE).map(({ e }) => fetchOddsForEvent(e.id).catch(() => ({ data: [] })))
|
||||
)
|
||||
eventMeta.slice(0, TOP_MATCHES_SIZE).forEach(({ e, leagueName }, i) => {
|
||||
const mainOdds = get1X2FromOddsResponse(oddsResponses[i]?.data ?? [])
|
||||
list.push({
|
||||
id: String(e.id),
|
||||
league: leagueName,
|
||||
time: parseTime(e.start_time),
|
||||
homeTeam: e.home_team,
|
||||
awayTeam: e.away_team,
|
||||
odds: mainOdds
|
||||
? { home: mainOdds["1"], draw: mainOdds.X, away: mainOdds["2"] }
|
||||
: { home: 0, draw: 0, away: 0 },
|
||||
})
|
||||
})
|
||||
if (list.length > 0) setMatches(list)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide -mx-1 px-1">
|
||||
{topMatches.map((match) => {
|
||||
{matches.map((match) => {
|
||||
const eventName = `${match.homeTeam} - ${match.awayTeam}`
|
||||
const leagueForBet = `Football - ${match.league}`
|
||||
const outcomes = [
|
||||
|
|
@ -94,7 +127,7 @@ export function TopMatches() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-[1px] bg-white/5 mx-2 mb-2 rounded-sm overflow-hidden border border-white/5">
|
||||
<div className="grid grid-cols-3 gap-px bg-white/5 mx-2 mb-2 rounded-sm overflow-hidden border border-white/5">
|
||||
{outcomes.map(({ key, label, odds }) => {
|
||||
const betId = `${match.id}-${key}`
|
||||
const isSelected = bets.some((b) => b.id === betId)
|
||||
|
|
@ -123,7 +156,7 @@ export function TopMatches() {
|
|||
{label}
|
||||
</span>
|
||||
<span className={cn("text-[11px] font-black tabular-nums", isSelected ? "text-black" : "text-brand-primary")}>
|
||||
{odds.toFixed(2)}
|
||||
{odds > 0 ? odds.toFixed(2) : "—"}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export type GameCategory =
|
|||
| "favourite"
|
||||
| "recently-played"
|
||||
| "most-popular"
|
||||
| "fortune-special"
|
||||
| "harif-special"
|
||||
| "for-you"
|
||||
| "slots"
|
||||
| "crash-games"
|
||||
|
|
@ -30,7 +30,7 @@ const categories = [
|
|||
{ id: "favourite", name: "Favourite", icon: Heart },
|
||||
{ id: "recently-played", name: "Recently Played", icon: Clock },
|
||||
{ id: "most-popular", name: "Most Popular", icon: Star },
|
||||
{ id: "fortune-special", name: "Fortune Special", icon: Star },
|
||||
{ id: "harif-special", name: "Harif Special", icon: Zap },
|
||||
{ id: "for-you", name: "For You", icon: Star },
|
||||
{ id: "slots", name: "Slots", icon: Star },
|
||||
{ id: "crash-games", name: "Crash Games", icon: Star },
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ function Logo() {
|
|||
<div key={i} className="w-[5px] h-[38px] bg-[#cc2222] -skew-x-12" />
|
||||
))}
|
||||
</div>
|
||||
{/* FORTUNE box */}
|
||||
{/* HARIF box */}
|
||||
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]">
|
||||
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
|
||||
FORTUNE
|
||||
HARIF
|
||||
</span>
|
||||
</div>
|
||||
{/* SPORT text */}
|
||||
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
|
||||
BETS
|
||||
SPORT
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,104 +4,79 @@ import Link from "next/link"
|
|||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="bg-brand-surface text-white pt-16">
|
||||
<footer className="bg-brand-surface text-white pt-12">
|
||||
<div className="container mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-12 text-center md:text-left">
|
||||
{/* ABOUT */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">ABOUT</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/about" className="hover:text-primary transition-colors">About us</Link></li>
|
||||
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link href="/responsible-gaming" className="hover:text-primary transition-colors">Responsible Gaming</Link></li>
|
||||
<li><Link href="/contact" className="hover:text-primary transition-colors">Contact us</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Centered Columns */}
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-20 text-center md:text-left justify-items-center md:justify-items-start">
|
||||
{/* INFORMATION */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">INFORMATION</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/terms" className="hover:text-primary transition-colors">Terms & Conditions</Link></li>
|
||||
<li><Link href="/faq" className="hover:text-primary transition-colors">FAQ</Link></li>
|
||||
<li><Link href="/rules" className="hover:text-primary transition-colors">Betting Rules</Link></li>
|
||||
<li><Link href="/info" className="hover:text-primary transition-colors">Betting Information</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* ABOUT */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
|
||||
ABOUT
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/about" className="hover:text-primary transition-colors">About us</Link></li>
|
||||
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link href="/responsible-gaming" className="hover:text-primary transition-colors">Responsible Gaming</Link></li>
|
||||
<li><Link href="/contact" className="hover:text-primary transition-colors">Contact us</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* INFORMATION */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
|
||||
INFORMATION
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/terms" className="hover:text-primary transition-colors">Terms & Conditions</Link></li>
|
||||
<li><Link href="/faq" className="hover:text-primary transition-colors">FAQ</Link></li>
|
||||
<li><Link href="/rules" className="hover:text-primary transition-colors">Betting Rules</Link></li>
|
||||
<li><Link href="/info" className="hover:text-primary transition-colors">Betting Information</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* SPORTS */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
|
||||
SPORTS
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/live" className="hover:text-primary transition-colors">Live betting</Link></li>
|
||||
<li><Link href="/football" className="hover:text-primary transition-colors">Football</Link></li>
|
||||
<li><Link href="/basketball" className="hover:text-primary transition-colors">Basketball</Link></li>
|
||||
<li><Link href="/tennis" className="hover:text-primary transition-colors">Tennis</Link></li>
|
||||
<li><Link href="/volleyball" className="hover:text-primary transition-colors">Volleyball</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
|
||||
PLAY NOW
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/virtual" className="hover:text-primary transition-colors">Virtual</Link></li>
|
||||
<li><Link href="/special-games" className="hover:text-primary transition-colors">Special Games</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* SPORTS */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">SPORTS</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/live" className="hover:text-primary transition-colors text-blue-400">Live betting</Link></li>
|
||||
<li><Link href="/football" className="hover:text-primary transition-colors">Football</Link></li>
|
||||
<li><Link href="/basketball" className="hover:text-primary transition-colors">Basketball</Link></li>
|
||||
<li><Link href="/tennis" className="hover:text-primary transition-colors">Tennis</Link></li>
|
||||
<li><Link href="/volleyball" className="hover:text-primary transition-colors">Volleyball</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">PLAY NOW</h3>
|
||||
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
|
||||
<li><Link href="/virtual" className="hover:text-primary transition-colors">Virtual</Link></li>
|
||||
<li><Link href="/special-games" className="hover:text-primary transition-colors">Special Games</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo Section */}
|
||||
<div className="flex flex-col items-center justify-center py-16 border-t border-white/5 mt-16 bg-brand-surface-light">
|
||||
<div className="flex flex-col items-center justify-center py-16 border-t border-white/5 mt-12 bg-brand-surface-light">
|
||||
<div className="flex items-center bg-brand-surface px-5 py-2">
|
||||
<div className="bg-brand-accent px-3 py-1 -skew-x-12">
|
||||
<span className="text-3xl font-black text-white italic tracking-tighter skew-x-12 inline-block">
|
||||
FORTUNE
|
||||
</span>
|
||||
<span className="text-3xl font-black text-white italic tracking-tighter skew-x-12 inline-block">HARIF</span>
|
||||
</div>
|
||||
<span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1">
|
||||
BETS
|
||||
</span>
|
||||
<span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1">SPORT</span>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 mt-12 text-[11px] font-bold tracking-tight text-white/80">
|
||||
<Link href="/affiliates" className="hover:text-primary uppercase transition-colors">
|
||||
Affiliates
|
||||
</Link>
|
||||
<span className="size-1 bg-white/10 rounded-full" />
|
||||
<Link href="/complaints" className="hover:text-primary uppercase transition-colors">
|
||||
Complaints
|
||||
</Link>
|
||||
<span className="size-1 bg-white/10 rounded-full" />
|
||||
<Link href="/deposits" className="hover:text-primary uppercase transition-colors">
|
||||
Deposits and Withdrawals
|
||||
</Link>
|
||||
<Link href="/affiliates" className="hover:text-primary uppercase transition-colors">Affiliates</Link>
|
||||
<span className="size-1 bg-white/10 rounded-full" />
|
||||
<Link href="/complaints" className="hover:text-primary uppercase transition-colors">Complaints</Link>
|
||||
<span className="size-1 bg-white/10 rounded-full" />
|
||||
<Link href="/deposits" className="hover:text-primary uppercase transition-colors">Deposits and Withdrawals</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cookie Text */}
|
||||
<div className="bg-brand-bg py-10 px-6 text-center">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="container mx-auto max-w-5xl">
|
||||
<p className="text-[10px] text-white/40 leading-relaxed font-medium uppercase tracking-tight">
|
||||
By accessing, or continuing to use or browse this site, you consent to our use of certain cookies to improve your experience with us. We only use cookies that will enhance your experience and will not interfere with your privacy. Please look at our Cookie Policy for further informations on our use of the cookie and how you can disable it or manage it if you so choose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ const allNavItems = [
|
|||
{ href: "/poker", label: "POKER", isNew: true },
|
||||
{ href: "/race", label: "RACE", isNew: true },
|
||||
{ href: "/promo", label: "PROMO" },
|
||||
// { href: "/aviator", label: "AVIATOR" },
|
||||
{ href: "/aviator", label: "AVIATOR" },
|
||||
]
|
||||
|
||||
const drawerLinks = [
|
||||
|
|
@ -118,9 +118,9 @@ export function SiteHeader({ onLoginClick, onRegisterClick }: SiteHeaderProps) {
|
|||
<Link href="/" className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[28px]">
|
||||
<span className="text-xl font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span>
|
||||
<span className="text-xl font-black text-white italic tracking-tighter skew-x-12 leading-none">HARIF</span>
|
||||
</div>
|
||||
<span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span>
|
||||
<span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
|
@ -146,9 +146,9 @@ export function SiteHeader({ onLoginClick, onRegisterClick }: SiteHeaderProps) {
|
|||
<Link href="/" className="flex items-center shrink-0">
|
||||
<div className="flex items-center bg-brand-surface h-[60px] px-4 w-[280px] shrink-0 border-r border-white/5">
|
||||
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[34px]">
|
||||
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">FORTUNE</span>
|
||||
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">HARIF</span>
|
||||
</div>
|
||||
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span>
|
||||
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center flex-1 justify-end px-4 h-full gap-0 bg-brand-surface">
|
||||
|
|
@ -288,9 +288,9 @@ export function SiteHeader({ onLoginClick, onRegisterClick }: SiteHeaderProps) {
|
|||
<div className="flex items-center justify-between px-4 py-3 bg-brand-surface border-b border-white/10">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[24px]">
|
||||
<span className="text-base font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span>
|
||||
<span className="text-base font-black text-white italic tracking-tighter skew-x-12 leading-none">HARIF</span>
|
||||
</div>
|
||||
<span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span>
|
||||
<span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
|
||||
</div>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-white/60 hover:text-white text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { popularLeagues } from "@/lib/mock-data"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { TOP_LEAGUES, fetchLeagues } from "@/lib/store/betting-api"
|
||||
import { useBettingStore } from "@/lib/store/betting-store"
|
||||
import type { ApiLeague } from "@/lib/store/betting-types"
|
||||
import { SportEnum, type QuickFilterKey } from "@/lib/store/betting-types"
|
||||
import { getCountryName } from "@/lib/countries"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronsLeft } from "lucide-react"
|
||||
import { ChevronsLeft, ChevronDown, ChevronUp, Plus } from "lucide-react"
|
||||
|
||||
/** Sidebar sports: slug for URL, sport_id for /leagues?sport_id= (order and list from design) */
|
||||
const SIDEBAR_SPORTS = [
|
||||
{ id: "football", sport_id: SportEnum.SOCCER, name: "Football", icon: "⚽" },
|
||||
{ id: "basketball", sport_id: SportEnum.BASKETBALL, name: "Basketball", icon: "🏀" },
|
||||
{ id: "american-football", sport_id: SportEnum.AMERICAN_FOOTBALL, name: "American Football", icon: "🏈" },
|
||||
{ id: "baseball", sport_id: SportEnum.BASEBALL, name: "Baseball", icon: "⚾" },
|
||||
{ id: "cricket", sport_id: SportEnum.CRICKET, name: "Cricket", icon: "🏏" },
|
||||
{ id: "futsal", sport_id: SportEnum.FUTSAL, name: "Futsal", icon: "⚽" },
|
||||
{ id: "darts", sport_id: SportEnum.DARTS, name: "Darts", icon: "🎯" },
|
||||
{ id: "ice-hockey", sport_id: SportEnum.ICE_HOCKEY, name: "Ice Hockey", icon: "🏒" },
|
||||
{ id: "rugby-union", sport_id: SportEnum.RUGBY_UNION, name: "Rugby", icon: "🏉" },
|
||||
{ id: "rugby-league", sport_id: SportEnum.RUGBY_LEAGUE, name: "Rugby League", icon: "🏉" },
|
||||
{ id: "volleyball", sport_id: SportEnum.VOLLEYBALL, name: "Volleyball", icon: "🏐" },
|
||||
]
|
||||
|
||||
/** Soccer ball icon - outline style for white/green theme */
|
||||
function SoccerBallIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
@ -18,23 +37,106 @@ function SoccerBallIcon({ className }: { className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
const sportCategories = [
|
||||
{ id: "football", name: "Football", icon: "⚽", count: 1412 },
|
||||
{ id: "tennis", name: "Tennis", icon: "🎾", count: 67 },
|
||||
{ id: "basketball", name: "Basketball", icon: "🏀", count: 255 },
|
||||
{ id: "ice-hockey", name: "Ice Hockey", icon: "🏒", count: 238 },
|
||||
{ id: "mma", name: "MMA", icon: "🥊", count: 51 },
|
||||
{ id: "handball", name: "Handball", icon: "🤾", count: 92 },
|
||||
{ id: "darts", name: "Darts", icon: "🎯", count: 25 },
|
||||
{ id: "snooker", name: "Snooker", icon: "🎱", count: 3 },
|
||||
{ id: "cricket", name: "Cricket", icon: "🏏", count: 42 },
|
||||
{ id: "dota2", name: "Dota 2", icon: "🎮", count: 2 },
|
||||
{ id: "rugby", name: "Rugby", icon: "🏉", count: 41 },
|
||||
{ id: "volleyball", name: "Volleyball", icon: "🏐", count: 69 },
|
||||
const QUICK_FILTER_OPTIONS: { label: string; key: QuickFilterKey }[] = [
|
||||
{ label: "All", key: "all" },
|
||||
{ label: "Today", key: "today" },
|
||||
{ label: "3h", key: "3h" },
|
||||
{ label: "6h", key: "6h" },
|
||||
{ label: "9h", key: "9h" },
|
||||
{ label: "12h", key: "12h" },
|
||||
]
|
||||
|
||||
function QuickFilterSection() {
|
||||
const quickFilter = useBettingStore((s) => s.quickFilter)
|
||||
const setQuickFilter = useBettingStore((s) => s.setQuickFilter)
|
||||
return (
|
||||
<div className="bg-brand-surface p-3 border-b border-border/30">
|
||||
<span className="text-brand-primary text-[10.5px] uppercase font-black block mb-2 tracking-tight">Quick Filter</span>
|
||||
<div className="grid grid-cols-6 gap-px">
|
||||
{QUICK_FILTER_OPTIONS.map(({ label, key }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setQuickFilter(key)}
|
||||
className={cn(
|
||||
"text-[10px] py-1.5 font-bold transition-colors",
|
||||
quickFilter === key ? "bg-brand-surface-light text-white" : "bg-brand-surface text-white/50 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SportsSidebar() {
|
||||
const [activeSport, setActiveSport] = useState("football")
|
||||
const searchParams = useSearchParams()
|
||||
const sportFromUrl = searchParams.get("sport") ?? "football"
|
||||
const leagueFromUrl = searchParams.get("league")
|
||||
|
||||
const [expandedSport, setExpandedSport] = useState<string | null>(sportFromUrl)
|
||||
const [expandedCountries, setExpandedCountries] = useState<Set<string>>(new Set())
|
||||
const [leaguesBySportId, setLeaguesBySportId] = useState<Record<number, ApiLeague[]>>({})
|
||||
const [loadingSportId, setLoadingSportId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedSport((prev) => (prev ?? sportFromUrl) || sportFromUrl)
|
||||
}, [sportFromUrl])
|
||||
|
||||
const currentSport = SIDEBAR_SPORTS.find((s) => s.id === sportFromUrl)
|
||||
const sportId = currentSport?.sport_id ?? SportEnum.SOCCER
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedSport) return
|
||||
const sport = SIDEBAR_SPORTS.find((s) => s.id === expandedSport)
|
||||
if (!sport || sport.sport_id in leaguesBySportId) return
|
||||
let cancelled = false
|
||||
setLoadingSportId(sport.sport_id)
|
||||
fetchLeagues(sport.sport_id)
|
||||
.then((res) => {
|
||||
if (!cancelled) setLeaguesBySportId((prev) => ({ ...prev, [sport.sport_id]: res.data ?? [] }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLeaguesBySportId((prev) => ({ ...prev, [sport.sport_id]: [] }))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingSportId(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [expandedSport, leaguesBySportId])
|
||||
|
||||
const toggleCountry = (cc: string) => {
|
||||
setExpandedCountries((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(cc)) next.delete(cc)
|
||||
else next.add(cc)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getCountriesForSport = (sportIdNum: number) => {
|
||||
const leagues = leaguesBySportId[sportIdNum] ?? []
|
||||
const ccSet = new Set<string>()
|
||||
leagues.forEach((l) => ccSet.add((l.cc || "").trim().toLowerCase() || "__intl__"))
|
||||
return Array.from(ccSet)
|
||||
.map((cc) => ({
|
||||
cc: cc === "__intl__" ? "" : cc,
|
||||
name: cc === "__intl__" ? "International" : getCountryName(cc),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
const getLeaguesForCountry = (sportIdNum: number, countryCc: string) => {
|
||||
const leagues = leaguesBySportId[sportIdNum] ?? []
|
||||
const cc = countryCc.toLowerCase()
|
||||
return leagues
|
||||
.filter((l) => ((l.cc || "").trim().toLowerCase() || "") === cc)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden h-full w-[280px] shrink-0 bg-brand-surface-light lg:block overflow-y-auto border-r border-border/40 scrollbar-hide">
|
||||
|
|
@ -51,21 +153,18 @@ export function SportsSidebar() {
|
|||
Top Leagues
|
||||
</div>
|
||||
|
||||
{/* Popular Leagues */}
|
||||
{/* Top Leagues */}
|
||||
<div className="flex flex-col">
|
||||
{popularLeagues.map((league) => (
|
||||
{TOP_LEAGUES.map((league) => (
|
||||
<Link
|
||||
key={league.id}
|
||||
href={`/?league=${league.id}`}
|
||||
href={`/?sport=${sportFromUrl}&league=${league.id}`}
|
||||
scroll={false}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-left text-white/90 hover:bg-brand-surface transition-colors border-b border-border/10 group h-9"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="size-5 shrink-0 overflow-hidden rounded-sm flex items-center justify-center bg-white/5 border border-white/10 group-hover:border-white/20 transition-colors">
|
||||
{league.logo ? (
|
||||
<img src={league.logo} alt="" className="size-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[11px]">⚽</span>
|
||||
)}
|
||||
<span className="text-[11px]">⚽</span>
|
||||
</div>
|
||||
<span className="text-white/50 text-[8px] font-bold select-none">•</span>
|
||||
<span className="text-[10.5px] font-bold leading-tight truncate max-w-[140px]">{league.name}</span>
|
||||
|
|
@ -83,18 +182,8 @@ export function SportsSidebar() {
|
|||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Quick Filter Section */}
|
||||
<div className="bg-brand-surface p-3 border-b border-border/30">
|
||||
<span className="text-brand-primary text-[10.5px] uppercase font-black block mb-2 tracking-tight">Quick Filter</span>
|
||||
<div className="grid grid-cols-6 gap-[1px]">
|
||||
{["All", "Today", "3h", "6h", "9h", "12h"].map((t) => (
|
||||
<button key={t} className={cn(
|
||||
"text-[10px] py-1.5 font-bold transition-colors",
|
||||
t === "All" ? "bg-brand-surface-light text-white" : "bg-brand-surface text-white/50 hover:text-white"
|
||||
)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Filter Section: passes first_start_time (RFC3339) to events API */}
|
||||
<QuickFilterSection />
|
||||
|
||||
{/* Search Event Section */}
|
||||
<div className="bg-brand-surface p-3 border-b border-border/40">
|
||||
|
|
@ -108,29 +197,102 @@ export function SportsSidebar() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sport categories */}
|
||||
{/* Nested: Sport → Countries → Leagues (mapped to sport_id & leagues API cc) */}
|
||||
<div className="divide-y divide-border/10 bg-brand-surface-light">
|
||||
{sportCategories.map((sport) => (
|
||||
<button
|
||||
key={sport.id}
|
||||
onClick={() => setActiveSport(sport.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 text-left transition-colors border-b border-border/10 h-9",
|
||||
activeSport === sport.id
|
||||
? "bg-brand-surface text-white"
|
||||
: "text-white/70 hover:bg-brand-surface hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] opacity-80 shrink-0">{sport.icon}</span>
|
||||
<span className="text-[10.5px] font-bold tracking-tight">{sport.name}</span>
|
||||
{SIDEBAR_SPORTS.map((sport) => {
|
||||
const isExpanded = expandedSport === sport.id
|
||||
const leagues = leaguesBySportId[sport.sport_id] ?? []
|
||||
const loading = loadingSportId === sport.sport_id
|
||||
const countries = getCountriesForSport(sport.sport_id)
|
||||
|
||||
return (
|
||||
<div key={sport.id} className="border-b border-border/10">
|
||||
{/* Sport row: click only expands/collapses to show countries (no navigation) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedSport(isExpanded ? null : sport.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-1 py-2 pr-2 pl-1.5 text-left transition-colors h-9",
|
||||
isExpanded ? "bg-brand-surface text-brand-primary" : "text-white/80 hover:bg-brand-surface hover:text-white"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="size-3.5 shrink-0 text-current" /> : <ChevronDown className="size-3.5 shrink-0 text-current" />}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-[12px] shrink-0">{sport.icon}</span>
|
||||
<span className="text-[10.5px] font-bold truncate">{sport.name}</span>
|
||||
</div>
|
||||
{leagues.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-white/40 shrink-0">{leagues.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Countries (nested under sport) */}
|
||||
{isExpanded && (
|
||||
<div className="bg-brand-surface-light/80 pl-4">
|
||||
{loading ? (
|
||||
<div className="py-2 text-[10px] text-white/50">Loading…</div>
|
||||
) : (
|
||||
countries.map(({ cc, name }) => {
|
||||
const countryExpanded = expandedCountries.has(cc || "__intl__")
|
||||
const countryKey = cc || "__intl__"
|
||||
const leaguesInCountry = getLeaguesForCountry(sport.sport_id, cc)
|
||||
|
||||
return (
|
||||
<div key={countryKey} className="border-b border-border/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCountry(countryKey)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 py-1.5 pr-2 text-left text-[10.5px] font-bold transition-colors",
|
||||
countryExpanded ? "text-brand-primary" : "text-white/80 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{cc ? (
|
||||
<img
|
||||
src={`https://flagcdn.com/w20/${cc}.png`}
|
||||
alt=""
|
||||
width={20}
|
||||
height={14}
|
||||
className="shrink-0 rounded-sm object-cover w-5 h-[14px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="size-5 shrink-0 flex items-center justify-center text-[10px] text-white/50">◆</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
{countryExpanded ? <ChevronUp className="size-3 shrink-0" /> : <ChevronDown className="size-3 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{/* Leagues (nested under country) */}
|
||||
{countryExpanded && (
|
||||
<div className="pl-2 pb-1 max-h-48 overflow-y-auto">
|
||||
{leaguesInCountry.map((league) => (
|
||||
<Link
|
||||
key={league.id}
|
||||
href={`/?sport=${sport.id}&league=${league.id}`}
|
||||
scroll={false}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-1 py-1.5 pr-1 text-[10px] font-bold border-b border-border/5 hover:bg-brand-surface/50 transition-colors group",
|
||||
leagueFromUrl === String(league.id) ? "text-brand-primary" : "text-white/90"
|
||||
)}
|
||||
>
|
||||
<span className="text-white/50 text-[8px] group-hover:text-brand-primary">•</span>
|
||||
<span className="flex-1 truncate">{league.name}</span>
|
||||
<Plus className="size-3 shrink-0 text-white/40 group-hover:text-brand-primary" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-white/40">{sport.count}</span>
|
||||
<SoccerBallIcon className="size-3.5 text-white/30 shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bet Services */}
|
||||
|
|
|
|||
245
countries.json
Normal file
245
countries.json
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
[
|
||||
{ "name": "Afghanistan", "code": "AF" },
|
||||
{ "name": "Åland Islands", "code": "AX" },
|
||||
{ "name": "Albania", "code": "AL" },
|
||||
{ "name": "Algeria", "code": "DZ" },
|
||||
{ "name": "American Samoa", "code": "AS" },
|
||||
{ "name": "AndorrA", "code": "AD" },
|
||||
{ "name": "Angola", "code": "AO" },
|
||||
{ "name": "Anguilla", "code": "AI" },
|
||||
{ "name": "Antarctica", "code": "AQ" },
|
||||
{ "name": "Antigua and Barbuda", "code": "AG" },
|
||||
{ "name": "Argentina", "code": "AR" },
|
||||
{ "name": "Armenia", "code": "AM" },
|
||||
{ "name": "Aruba", "code": "AW" },
|
||||
{ "name": "Australia", "code": "AU" },
|
||||
{ "name": "Austria", "code": "AT" },
|
||||
{ "name": "Azerbaijan", "code": "AZ" },
|
||||
{ "name": "Bahamas", "code": "BS" },
|
||||
{ "name": "Bahrain", "code": "BH" },
|
||||
{ "name": "Bangladesh", "code": "BD" },
|
||||
{ "name": "Barbados", "code": "BB" },
|
||||
{ "name": "Belarus", "code": "BY" },
|
||||
{ "name": "Belgium", "code": "BE" },
|
||||
{ "name": "Belize", "code": "BZ" },
|
||||
{ "name": "Benin", "code": "BJ" },
|
||||
{ "name": "Bermuda", "code": "BM" },
|
||||
{ "name": "Bhutan", "code": "BT" },
|
||||
{ "name": "Bolivia", "code": "BO" },
|
||||
{ "name": "Bosnia and Herzegovina", "code": "BA" },
|
||||
{ "name": "Botswana", "code": "BW" },
|
||||
{ "name": "Bouvet Island", "code": "BV" },
|
||||
{ "name": "Brazil", "code": "BR" },
|
||||
{ "name": "British Indian Ocean Territory", "code": "IO" },
|
||||
{ "name": "Brunei Darussalam", "code": "BN" },
|
||||
{ "name": "Bulgaria", "code": "BG" },
|
||||
{ "name": "Burkina Faso", "code": "BF" },
|
||||
{ "name": "Burundi", "code": "BI" },
|
||||
{ "name": "Cambodia", "code": "KH" },
|
||||
{ "name": "Cameroon", "code": "CM" },
|
||||
{ "name": "Canada", "code": "CA" },
|
||||
{ "name": "Cape Verde", "code": "CV" },
|
||||
{ "name": "Cayman Islands", "code": "KY" },
|
||||
{ "name": "Central African Republic", "code": "CF" },
|
||||
{ "name": "Chad", "code": "TD" },
|
||||
{ "name": "Chile", "code": "CL" },
|
||||
{ "name": "China", "code": "CN" },
|
||||
{ "name": "Christmas Island", "code": "CX" },
|
||||
{ "name": "Cocos (Keeling) Islands", "code": "CC" },
|
||||
{ "name": "Colombia", "code": "CO" },
|
||||
{ "name": "Comoros", "code": "KM" },
|
||||
{ "name": "Congo", "code": "CG" },
|
||||
{ "name": "Congo, The Democratic Republic of the", "code": "CD" },
|
||||
{ "name": "Cook Islands", "code": "CK" },
|
||||
{ "name": "Costa Rica", "code": "CR" },
|
||||
{ "name": "Cote D'Ivoire", "code": "CI" },
|
||||
{ "name": "Croatia", "code": "HR" },
|
||||
{ "name": "Cuba", "code": "CU" },
|
||||
{ "name": "Cyprus", "code": "CY" },
|
||||
{ "name": "Czech Republic", "code": "CZ" },
|
||||
{ "name": "Denmark", "code": "DK" },
|
||||
{ "name": "Djibouti", "code": "DJ" },
|
||||
{ "name": "Dominica", "code": "DM" },
|
||||
{ "name": "Dominican Republic", "code": "DO" },
|
||||
{ "name": "Ecuador", "code": "EC" },
|
||||
{ "name": "Egypt", "code": "EG" },
|
||||
{ "name": "El Salvador", "code": "SV" },
|
||||
{ "name": "Equatorial Guinea", "code": "GQ" },
|
||||
{ "name": "Eritrea", "code": "ER" },
|
||||
{ "name": "Estonia", "code": "EE" },
|
||||
{ "name": "Ethiopia", "code": "ET" },
|
||||
{ "name": "Falkland Islands (Malvinas)", "code": "FK" },
|
||||
{ "name": "Faroe Islands", "code": "FO" },
|
||||
{ "name": "Fiji", "code": "FJ" },
|
||||
{ "name": "Finland", "code": "FI" },
|
||||
{ "name": "France", "code": "FR" },
|
||||
{ "name": "French Guiana", "code": "GF" },
|
||||
{ "name": "French Polynesia", "code": "PF" },
|
||||
{ "name": "French Southern Territories", "code": "TF" },
|
||||
{ "name": "Gabon", "code": "GA" },
|
||||
{ "name": "Gambia", "code": "GM" },
|
||||
{ "name": "Georgia", "code": "GE" },
|
||||
{ "name": "Germany", "code": "DE" },
|
||||
{ "name": "Ghana", "code": "GH" },
|
||||
{ "name": "Gibraltar", "code": "GI" },
|
||||
{ "name": "Greece", "code": "GR" },
|
||||
{ "name": "Greenland", "code": "GL" },
|
||||
{ "name": "Grenada", "code": "GD" },
|
||||
{ "name": "Guadeloupe", "code": "GP" },
|
||||
{ "name": "Guam", "code": "GU" },
|
||||
{ "name": "Guatemala", "code": "GT" },
|
||||
{ "name": "Guernsey", "code": "GG" },
|
||||
{ "name": "Guinea", "code": "GN" },
|
||||
{ "name": "Guinea-Bissau", "code": "GW" },
|
||||
{ "name": "Guyana", "code": "GY" },
|
||||
{ "name": "Haiti", "code": "HT" },
|
||||
{ "name": "Heard Island and Mcdonald Islands", "code": "HM" },
|
||||
{ "name": "Holy See (Vatican City State)", "code": "VA" },
|
||||
{ "name": "Honduras", "code": "HN" },
|
||||
{ "name": "Hong Kong", "code": "HK" },
|
||||
{ "name": "Hungary", "code": "HU" },
|
||||
{ "name": "Iceland", "code": "IS" },
|
||||
{ "name": "India", "code": "IN" },
|
||||
{ "name": "Indonesia", "code": "ID" },
|
||||
{ "name": "Iran, Islamic Republic Of", "code": "IR" },
|
||||
{ "name": "Iraq", "code": "IQ" },
|
||||
{ "name": "Ireland", "code": "IE" },
|
||||
{ "name": "Isle of Man", "code": "IM" },
|
||||
{ "name": "Israel", "code": "IL" },
|
||||
{ "name": "Italy", "code": "IT" },
|
||||
{ "name": "Jamaica", "code": "JM" },
|
||||
{ "name": "Japan", "code": "JP" },
|
||||
{ "name": "Jersey", "code": "JE" },
|
||||
{ "name": "Jordan", "code": "JO" },
|
||||
{ "name": "Kazakhstan", "code": "KZ" },
|
||||
{ "name": "Kenya", "code": "KE" },
|
||||
{ "name": "Kiribati", "code": "KI" },
|
||||
{ "name": "Korea, Democratic People'S Republic of", "code": "KP" },
|
||||
{ "name": "Korea, Republic of", "code": "KR" },
|
||||
{ "name": "Kuwait", "code": "KW" },
|
||||
{ "name": "Kyrgyzstan", "code": "KG" },
|
||||
{ "name": "Lao People'S Democratic Republic", "code": "LA" },
|
||||
{ "name": "Latvia", "code": "LV" },
|
||||
{ "name": "Lebanon", "code": "LB" },
|
||||
{ "name": "Lesotho", "code": "LS" },
|
||||
{ "name": "Liberia", "code": "LR" },
|
||||
{ "name": "Libyan Arab Jamahiriya", "code": "LY" },
|
||||
{ "name": "Liechtenstein", "code": "LI" },
|
||||
{ "name": "Lithuania", "code": "LT" },
|
||||
{ "name": "Luxembourg", "code": "LU" },
|
||||
{ "name": "Macao", "code": "MO" },
|
||||
{ "name": "Macedonia, The Former Yugoslav Republic of", "code": "MK" },
|
||||
{ "name": "Madagascar", "code": "MG" },
|
||||
{ "name": "Malawi", "code": "MW" },
|
||||
{ "name": "Malaysia", "code": "MY" },
|
||||
{ "name": "Maldives", "code": "MV" },
|
||||
{ "name": "Mali", "code": "ML" },
|
||||
{ "name": "Malta", "code": "MT" },
|
||||
{ "name": "Marshall Islands", "code": "MH" },
|
||||
{ "name": "Martinique", "code": "MQ" },
|
||||
{ "name": "Mauritania", "code": "MR" },
|
||||
{ "name": "Mauritius", "code": "MU" },
|
||||
{ "name": "Mayotte", "code": "YT" },
|
||||
{ "name": "Mexico", "code": "MX" },
|
||||
{ "name": "Micronesia, Federated States of", "code": "FM" },
|
||||
{ "name": "Moldova, Republic of", "code": "MD" },
|
||||
{ "name": "Monaco", "code": "MC" },
|
||||
{ "name": "Mongolia", "code": "MN" },
|
||||
{ "name": "Montserrat", "code": "MS" },
|
||||
{ "name": "Morocco", "code": "MA" },
|
||||
{ "name": "Mozambique", "code": "MZ" },
|
||||
{ "name": "Myanmar", "code": "MM" },
|
||||
{ "name": "Namibia", "code": "NA" },
|
||||
{ "name": "Nauru", "code": "NR" },
|
||||
{ "name": "Nepal", "code": "NP" },
|
||||
{ "name": "Netherlands", "code": "NL" },
|
||||
{ "name": "Netherlands Antilles", "code": "AN" },
|
||||
{ "name": "New Caledonia", "code": "NC" },
|
||||
{ "name": "New Zealand", "code": "NZ" },
|
||||
{ "name": "Nicaragua", "code": "NI" },
|
||||
{ "name": "Niger", "code": "NE" },
|
||||
{ "name": "Nigeria", "code": "NG" },
|
||||
{ "name": "Niue", "code": "NU" },
|
||||
{ "name": "Norfolk Island", "code": "NF" },
|
||||
{ "name": "Northern Mariana Islands", "code": "MP" },
|
||||
{ "name": "Norway", "code": "NO" },
|
||||
{ "name": "Oman", "code": "OM" },
|
||||
{ "name": "Pakistan", "code": "PK" },
|
||||
{ "name": "Palau", "code": "PW" },
|
||||
{ "name": "Palestinian Territory, Occupied", "code": "PS" },
|
||||
{ "name": "Panama", "code": "PA" },
|
||||
{ "name": "Papua New Guinea", "code": "PG" },
|
||||
{ "name": "Paraguay", "code": "PY" },
|
||||
{ "name": "Peru", "code": "PE" },
|
||||
{ "name": "Philippines", "code": "PH" },
|
||||
{ "name": "Pitcairn", "code": "PN" },
|
||||
{ "name": "Poland", "code": "PL" },
|
||||
{ "name": "Portugal", "code": "PT" },
|
||||
{ "name": "Puerto Rico", "code": "PR" },
|
||||
{ "name": "Qatar", "code": "QA" },
|
||||
{ "name": "Reunion", "code": "RE" },
|
||||
{ "name": "Romania", "code": "RO" },
|
||||
{ "name": "Russian Federation", "code": "RU" },
|
||||
{ "name": "RWANDA", "code": "RW" },
|
||||
{ "name": "Saint Helena", "code": "SH" },
|
||||
{ "name": "Saint Kitts and Nevis", "code": "KN" },
|
||||
{ "name": "Saint Lucia", "code": "LC" },
|
||||
{ "name": "Saint Pierre and Miquelon", "code": "PM" },
|
||||
{ "name": "Saint Vincent and the Grenadines", "code": "VC" },
|
||||
{ "name": "Samoa", "code": "WS" },
|
||||
{ "name": "San Marino", "code": "SM" },
|
||||
{ "name": "Sao Tome and Principe", "code": "ST" },
|
||||
{ "name": "Saudi Arabia", "code": "SA" },
|
||||
{ "name": "Senegal", "code": "SN" },
|
||||
{ "name": "Serbia and Montenegro", "code": "CS" },
|
||||
{ "name": "Seychelles", "code": "SC" },
|
||||
{ "name": "Sierra Leone", "code": "SL" },
|
||||
{ "name": "Singapore", "code": "SG" },
|
||||
{ "name": "Slovakia", "code": "SK" },
|
||||
{ "name": "Slovenia", "code": "SI" },
|
||||
{ "name": "Solomon Islands", "code": "SB" },
|
||||
{ "name": "Somalia", "code": "SO" },
|
||||
{ "name": "South Africa", "code": "ZA" },
|
||||
{ "name": "South Georgia and the South Sandwich Islands", "code": "GS" },
|
||||
{ "name": "Spain", "code": "ES" },
|
||||
{ "name": "Sri Lanka", "code": "LK" },
|
||||
{ "name": "Sudan", "code": "SD" },
|
||||
{ "name": "Suriname", "code": "SR" },
|
||||
{ "name": "Svalbard and Jan Mayen", "code": "SJ" },
|
||||
{ "name": "Swaziland", "code": "SZ" },
|
||||
{ "name": "Sweden", "code": "SE" },
|
||||
{ "name": "Switzerland", "code": "CH" },
|
||||
{ "name": "Syrian Arab Republic", "code": "SY" },
|
||||
{ "name": "Taiwan, Province of China", "code": "TW" },
|
||||
{ "name": "Tajikistan", "code": "TJ" },
|
||||
{ "name": "Tanzania, United Republic of", "code": "TZ" },
|
||||
{ "name": "Thailand", "code": "TH" },
|
||||
{ "name": "Timor-Leste", "code": "TL" },
|
||||
{ "name": "Togo", "code": "TG" },
|
||||
{ "name": "Tokelau", "code": "TK" },
|
||||
{ "name": "Tonga", "code": "TO" },
|
||||
{ "name": "Trinidad and Tobago", "code": "TT" },
|
||||
{ "name": "Tunisia", "code": "TN" },
|
||||
{ "name": "Turkey", "code": "TR" },
|
||||
{ "name": "Turkmenistan", "code": "TM" },
|
||||
{ "name": "Turks and Caicos Islands", "code": "TC" },
|
||||
{ "name": "Tuvalu", "code": "TV" },
|
||||
{ "name": "Uganda", "code": "UG" },
|
||||
{ "name": "Ukraine", "code": "UA" },
|
||||
{ "name": "United Arab Emirates", "code": "AE" },
|
||||
{ "name": "United Kingdom", "code": "GB" },
|
||||
{ "name": "United States", "code": "US" },
|
||||
{ "name": "United States Minor Outlying Islands", "code": "UM" },
|
||||
{ "name": "Uruguay", "code": "UY" },
|
||||
{ "name": "Uzbekistan", "code": "UZ" },
|
||||
{ "name": "Vanuatu", "code": "VU" },
|
||||
{ "name": "Venezuela", "code": "VE" },
|
||||
{ "name": "Viet Nam", "code": "VN" },
|
||||
{ "name": "Virgin Islands, British", "code": "VG" },
|
||||
{ "name": "Virgin Islands, U.S.", "code": "VI" },
|
||||
{ "name": "Wallis and Futuna", "code": "WF" },
|
||||
{ "name": "Western Sahara", "code": "EH" },
|
||||
{ "name": "Yemen", "code": "YE" },
|
||||
{ "name": "Zambia", "code": "ZM" },
|
||||
{ "name": "Zimbabwe", "code": "ZW" }
|
||||
]
|
||||
23
lib/api.ts
23
lib/api.ts
|
|
@ -1,10 +1,12 @@
|
|||
import axios from 'axios';
|
||||
import axios from "axios";
|
||||
|
||||
// Create a configured Axios instance
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1',
|
||||
baseURL:
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -12,9 +14,10 @@ const api = axios.create({
|
|||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Only access localStorage if we are running in the browser
|
||||
if (typeof window !== 'undefined') {
|
||||
// const token = localStorage.getItem('token');
|
||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjI3NzQxNSwibmJmIjoxNzcyMjc2ODE1LCJpYXQiOjE3NzIyNzY4MTUsIlVzZXJJZCI6NCwiUm9sZSI6InN1cGVyX2FkbWluIiwiQ29tcGFueUlEIjp7IlZhbHVlIjowLCJWYWxpZCI6ZmFsc2V9fQ.QJJ1KAFkWWCMmxxBi8rQc9C5aChN2XmTys-RCufV_Zo";
|
||||
if (typeof window !== "undefined") {
|
||||
// const token = localStorage.getItem('token');
|
||||
const token =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjQ0NDk1NCwibmJmIjoxNzcyNDQ0MzU0LCJpYXQiOjE3NzI0NDQzNTQsIlVzZXJJZCI6NSwiUm9sZSI6ImN1c3RvbWVyIiwiQ29tcGFueUlEIjp7IlZhbHVlIjoxLCJWYWxpZCI6dHJ1ZX19.6CZQp4VL9ehBh2EfMEohkoVMezT_qFdXajCKsUmWda4";
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -23,7 +26,7 @@ api.interceptors.request.use(
|
|||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor for handling common errors (like 401 Unauthorized)
|
||||
|
|
@ -34,14 +37,14 @@ api.interceptors.response.use(
|
|||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized errors, e.g., redirecting to login or clearing the token
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("token");
|
||||
// Uncomment the line below to redirect automatically
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
43
lib/betting-api.ts
Normal file
43
lib/betting-api.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Re-export betting API, types, and helpers from store folder.
|
||||
* Server components (e.g. event/[id]/page) and components use these imports.
|
||||
*/
|
||||
|
||||
export {
|
||||
TOP_LEAGUES,
|
||||
SPORT_ID_MAP,
|
||||
SPORT_SLUG_TO_ID,
|
||||
SPORT_ALL,
|
||||
fetchEvents,
|
||||
fetchLeagues,
|
||||
fetchTopLeagues,
|
||||
fetchOdds,
|
||||
fetchOddsForEvent,
|
||||
fetchJson,
|
||||
apiEventToAppEvent,
|
||||
get1X2ForEvent,
|
||||
get1X2FromOddsResponse,
|
||||
getListMarketsFromOddsResponse,
|
||||
apiOddsToSections,
|
||||
getMarketsForTab,
|
||||
getTimeRangeForQuickFilter,
|
||||
} from "@/lib/store/betting-api"
|
||||
|
||||
export { SportEnum } from "@/lib/store/betting-types"
|
||||
|
||||
export type {
|
||||
ApiEvent,
|
||||
ApiLeague,
|
||||
ApiOdds,
|
||||
AppEvent,
|
||||
DetailMarketSectionFromApi,
|
||||
EventsParams,
|
||||
EventsResponse,
|
||||
LeaguesResponse,
|
||||
MarketTabKey,
|
||||
OddsResponse,
|
||||
QuickFilterKey,
|
||||
TabColumnCell,
|
||||
} from "@/lib/store/betting-api"
|
||||
|
||||
export type { SportId } from "@/lib/store/betting-types"
|
||||
22
lib/countries.ts
Normal file
22
lib/countries.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Country code (cc) to name for leagues sidebar.
|
||||
* Leagues API returns cc in lowercase; countries.json uses uppercase codes.
|
||||
*/
|
||||
|
||||
import countriesJson from "@/countries.json"
|
||||
|
||||
type CountryEntry = { name: string; code: string }
|
||||
|
||||
const CODE_TO_NAME: Record<string, string> = (countriesJson as CountryEntry[]).reduce(
|
||||
(acc, c) => {
|
||||
acc[c.code.toLowerCase()] = c.name
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
|
||||
/** Get country name from league cc (e.g. "al" -> "Albania"). Returns "International" for empty cc. */
|
||||
export function getCountryName(cc: string): string {
|
||||
if (!cc || !cc.trim()) return "International"
|
||||
return CODE_TO_NAME[cc.toLowerCase()] ?? cc.toUpperCase()
|
||||
}
|
||||
22
lib/hooks/use-events.ts
Normal file
22
lib/hooks/use-events.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useBettingStore } from "@/lib/store/betting-store"
|
||||
import type { AppEvent } from "@/lib/store/betting-types"
|
||||
|
||||
export type { AppEvent } from "@/lib/store/betting-types"
|
||||
|
||||
/**
|
||||
* Hook that syncs URL filters with the betting store and returns events list state.
|
||||
* Prefer using useBettingStore() directly when you need full control.
|
||||
*/
|
||||
export function useEvents(sportId: number | null, leagueId: string | null, _filterLive: boolean) {
|
||||
const { events, loading, error, hasMore, loadMore, setFilters } = useBettingStore()
|
||||
const total = useBettingStore((s) => s.total)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(sportId, leagueId)
|
||||
}, [sportId, leagueId, setFilters])
|
||||
|
||||
return { events, loading, error, hasMore, loadMore, total }
|
||||
}
|
||||
446
lib/store/betting-api.ts
Normal file
446
lib/store/betting-api.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/**
|
||||
* Betting API client and transform logic. Types in betting-types.ts.
|
||||
* Base URL and tenant from env: NEXT_PUBLIC_BETTING_API_BASE_URL, NEXT_PUBLIC_TENANT_SLUG
|
||||
*/
|
||||
|
||||
import { SportEnum } from "./betting-types"
|
||||
import type {
|
||||
ApiEvent,
|
||||
ApiLeague,
|
||||
ApiOdds,
|
||||
ApiOddsOutcome,
|
||||
ApiTopLeaguesResponse,
|
||||
AppEvent,
|
||||
DetailMarketSectionFromApi,
|
||||
EventsParams,
|
||||
EventsResponse,
|
||||
LeaguesResponse,
|
||||
MarketTabKey,
|
||||
OddsResponse,
|
||||
QuickFilterKey,
|
||||
TabColumnCell,
|
||||
} from "./betting-types"
|
||||
|
||||
export type {
|
||||
ApiEvent,
|
||||
ApiLeague,
|
||||
ApiOdds,
|
||||
AppEvent,
|
||||
DetailMarketSectionFromApi,
|
||||
EventsParams,
|
||||
EventsResponse,
|
||||
LeaguesResponse,
|
||||
MarketTabKey,
|
||||
OddsResponse,
|
||||
TabColumnCell,
|
||||
} from "./betting-types"
|
||||
|
||||
const BASE_URL = (process.env.NEXT_PUBLIC_BETTING_API_BASE_URL || "http://localhost:8080/api/v1").replace(/\/$/, "")
|
||||
const TENANT_SLUG = process.env.NEXT_PUBLIC_TENANT_SLUG || "fortunebets"
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export const TOP_LEAGUES: { id: number; name: string }[] = [
|
||||
{ id: 10041282, name: "Premier League" },
|
||||
{ id: 10041809, name: "UEFA Champions League" },
|
||||
{ id: 10041957, name: "UEFA Europa League" },
|
||||
{ id: 10083364, name: "Spain La Liga" },
|
||||
{ id: 10037165, name: "Germany Bundesliga" },
|
||||
{ id: 10041315, name: "Serie A" },
|
||||
{ id: 10041100, name: "Ligue 1" },
|
||||
{ id: 10041083, name: "Ligue 2" },
|
||||
{ id: 10041391, name: "Eredivisie" },
|
||||
]
|
||||
|
||||
/** Map sport_id (SportEnum) to display name and slug. Used for events and sidebar. */
|
||||
export const SPORT_ID_MAP: Record<number, { name: string; slug: string }> = {
|
||||
[SportEnum.SOCCER]: { name: "Soccer", slug: "soccer" },
|
||||
[SportEnum.BASKETBALL]: { name: "Basketball", slug: "basketball" },
|
||||
[SportEnum.TENNIS]: { name: "Tennis", slug: "tennis" },
|
||||
[SportEnum.VOLLEYBALL]: { name: "Volleyball", slug: "volleyball" },
|
||||
[SportEnum.HANDBALL]: { name: "Handball", slug: "handball" },
|
||||
[SportEnum.BASEBALL]: { name: "Baseball", slug: "baseball" },
|
||||
[SportEnum.HORSE_RACING]: { name: "Horse Racing", slug: "horse-racing" },
|
||||
[SportEnum.GREYHOUNDS]: { name: "Greyhounds", slug: "greyhounds" },
|
||||
[SportEnum.ICE_HOCKEY]: { name: "Ice Hockey", slug: "ice-hockey" },
|
||||
[SportEnum.SNOOKER]: { name: "Snooker", slug: "snooker" },
|
||||
[SportEnum.AMERICAN_FOOTBALL]: { name: "American Football", slug: "american-football" },
|
||||
[SportEnum.CRICKET]: { name: "Cricket", slug: "cricket" },
|
||||
[SportEnum.FUTSAL]: { name: "Futsal", slug: "futsal" },
|
||||
[SportEnum.DARTS]: { name: "Darts", slug: "darts" },
|
||||
[SportEnum.TABLE_TENNIS]: { name: "Table Tennis", slug: "table-tennis" },
|
||||
[SportEnum.BADMINTON]: { name: "Badminton", slug: "badminton" },
|
||||
[SportEnum.RUGBY_UNION]: { name: "Rugby Union", slug: "rugby-union" },
|
||||
[SportEnum.RUGBY_LEAGUE]: { name: "Rugby League", slug: "rugby-league" },
|
||||
[SportEnum.AUSTRALIAN_RULES]: { name: "Australian Rules", slug: "australian-rules" },
|
||||
[SportEnum.BOWLS]: { name: "Bowls", slug: "bowls" },
|
||||
[SportEnum.BOXING]: { name: "Boxing", slug: "boxing" },
|
||||
[SportEnum.GAELIC_SPORTS]: { name: "Gaelic Sports", slug: "gaelic-sports" },
|
||||
[SportEnum.FLOORBALL]: { name: "Floorball", slug: "floorball" },
|
||||
[SportEnum.BEACH_VOLLEYBALL]: { name: "Beach Volleyball", slug: "beach-volleyball" },
|
||||
[SportEnum.WATER_POLO]: { name: "Water Polo", slug: "water-polo" },
|
||||
[SportEnum.SQUASH]: { name: "Squash", slug: "squash" },
|
||||
[SportEnum.E_SPORTS]: { name: "E-Sports", slug: "esports" },
|
||||
[SportEnum.MMA]: { name: "MMA", slug: "mma" },
|
||||
[SportEnum.SURFING]: { name: "Surfing", slug: "surfing" },
|
||||
}
|
||||
|
||||
export const SPORT_SLUG_TO_ID: Record<string, number> = {
|
||||
...Object.fromEntries(Object.entries(SPORT_ID_MAP).map(([id, v]) => [v.slug, Number(id)])),
|
||||
football: SportEnum.SOCCER,
|
||||
}
|
||||
|
||||
export const SPORT_ALL = "all"
|
||||
|
||||
export type { QuickFilterKey } from "./betting-types"
|
||||
|
||||
/** Compute first_start_time and last_start_time (RFC3339) for the given quick filter */
|
||||
export function getTimeRangeForQuickFilter(key: QuickFilterKey): { first_start_time?: string; last_start_time?: string } {
|
||||
const now = new Date()
|
||||
const toRFC3339 = (d: Date) => d.toISOString()
|
||||
if (key === "all") return {}
|
||||
if (key === "today") {
|
||||
const start = new Date(now)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
return { first_start_time: toRFC3339(start) }
|
||||
}
|
||||
const first = new Date(now)
|
||||
const last = new Date(now)
|
||||
if (key === "3h") last.setUTCHours(last.getUTCHours() + 3)
|
||||
else if (key === "6h") last.setUTCHours(last.getUTCHours() + 6)
|
||||
else if (key === "9h") last.setUTCHours(last.getUTCHours() + 9)
|
||||
else if (key === "12h") last.setUTCHours(last.getUTCHours() + 12)
|
||||
else return {}
|
||||
return { first_start_time: toRFC3339(first), last_start_time: toRFC3339(last) }
|
||||
}
|
||||
|
||||
function getTenantUrl(path: string, search?: Record<string, string | number | undefined>) {
|
||||
const url = new URL(`${BASE_URL}/api/v1/tenant/${TENANT_SLUG}${path}`)
|
||||
if (search) {
|
||||
Object.entries(search).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== "") url.searchParams.set(k, String(v))
|
||||
})
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const API_HEADERS: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true",
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
headers: { ...API_HEADERS, ...options.headers },
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||
const contentType = res.headers.get("content-type") ?? ""
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await res.text()
|
||||
if (text.trimStart().startsWith("<!") || text.trimStart().startsWith("<html")) {
|
||||
throw new Error(
|
||||
"Server returned HTML instead of JSON. Check NEXT_PUBLIC_BETTING_API_BASE_URL and that the API is reachable."
|
||||
)
|
||||
}
|
||||
throw new Error(`Unexpected content-type: ${contentType}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
function parseTime(iso: string): { time: string; date: string } {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const time = d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false })
|
||||
const date = d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" })
|
||||
return { time, date }
|
||||
} catch {
|
||||
return { time: "--:--", date: "" }
|
||||
}
|
||||
}
|
||||
|
||||
export function apiEventToAppEvent(
|
||||
e: ApiEvent,
|
||||
mainOddsOrListMarkets?: { "1": number; X: number; "2": number } | null | { id: string; label: string; odds: number }[]
|
||||
): Omit<AppEvent, "rawOdds"> {
|
||||
const { time, date } = parseTime(e.start_time)
|
||||
const sportInfo = SPORT_ID_MAP[e.sport_id] || { name: "Other", slug: "other" }
|
||||
const sportIcon = e.sport_id === SportEnum.SOCCER ? "⚽" : e.sport_id === SportEnum.TENNIS ? "🎾" : e.sport_id === SportEnum.BASKETBALL ? "🏀" : e.sport_id === SportEnum.ICE_HOCKEY ? "🏒" : "⚽"
|
||||
const isListMarkets = Array.isArray(mainOddsOrListMarkets) && mainOddsOrListMarkets.length > 0
|
||||
const markets = isListMarkets
|
||||
? mainOddsOrListMarkets
|
||||
: mainOddsOrListMarkets && "1" in mainOddsOrListMarkets
|
||||
? [
|
||||
{ id: "1", label: "1", odds: mainOddsOrListMarkets["1"] },
|
||||
{ id: "x", label: "x", odds: mainOddsOrListMarkets.X },
|
||||
{ id: "2", label: "2", odds: mainOddsOrListMarkets["2"] },
|
||||
]
|
||||
: [
|
||||
{ id: "1", label: "1", odds: 0 },
|
||||
{ id: "x", label: "x", odds: 0 },
|
||||
{ id: "2", label: "2", odds: 0 },
|
||||
]
|
||||
return {
|
||||
id: String(e.id),
|
||||
sport: sportInfo.name,
|
||||
sportIcon,
|
||||
league: e.league_name,
|
||||
country: e.league_cc.toUpperCase() || "",
|
||||
homeTeam: e.home_team,
|
||||
awayTeam: e.away_team,
|
||||
time,
|
||||
date,
|
||||
isLive: !!e.is_live,
|
||||
markets,
|
||||
totalMarkets: e.total_odd_outcomes ?? 0,
|
||||
...(e.score != null && { score: e.score }),
|
||||
...(e.match_minute != null && { matchMinute: e.match_minute }),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEvents(params: EventsParams = {}): Promise<EventsResponse> {
|
||||
const page = params.page ?? 1
|
||||
const page_size = params.page_size ?? DEFAULT_PAGE_SIZE
|
||||
const search: Record<string, string | number> = {
|
||||
page_size,
|
||||
page,
|
||||
...(params.sport_id != null && { sport_id: params.sport_id }),
|
||||
...(params.league_id != null && { league_id: params.league_id }),
|
||||
...(params.first_start_time != null && params.first_start_time !== "" && { first_start_time: params.first_start_time }),
|
||||
...(params.last_start_time != null && params.last_start_time !== "" && { last_start_time: params.last_start_time }),
|
||||
...(params.is_live === true && { is_live: true }),
|
||||
}
|
||||
const json = await fetchJson<{ data?: ApiEvent[]; total?: number; page?: number; total_pages?: number; message?: string; status?: string }>(
|
||||
getTenantUrl("/events", search),
|
||||
{ next: { revalidate: 60 } }
|
||||
)
|
||||
return {
|
||||
data: json.data ?? [],
|
||||
total: json.total,
|
||||
page: json.page ?? page,
|
||||
total_pages: json.total_pages,
|
||||
message: json.message,
|
||||
status: json.status,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLeagues(sportId?: number): Promise<LeaguesResponse> {
|
||||
const search = sportId != null ? { sport_id: sportId } : undefined
|
||||
const json = await fetchJson<{ data?: ApiLeague[]; message?: string; status?: string }>(
|
||||
getTenantUrl("/leagues", search),
|
||||
{ next: { revalidate: 300 } }
|
||||
)
|
||||
return { data: json.data ?? [], message: json.message, status: json.status }
|
||||
}
|
||||
|
||||
export async function fetchTopLeagues(): Promise<ApiTopLeaguesResponse> {
|
||||
const json = await fetchJson<{ data?: ApiTopLeaguesResponse }>(getTenantUrl("/top-leagues"), {
|
||||
next: { revalidate: 120 },
|
||||
})
|
||||
return json.data ?? { leagues: [] }
|
||||
}
|
||||
|
||||
export async function fetchOdds(): Promise<OddsResponse> {
|
||||
const json = await fetchJson<{ data?: ApiOdds[]; message?: string; status?: string }>(getTenantUrl("/odds"), {
|
||||
next: { revalidate: 30 },
|
||||
})
|
||||
return { data: json.data ?? [], message: json.message, status: json.status }
|
||||
}
|
||||
|
||||
export async function fetchOddsForEvent(upcomingId: number): Promise<OddsResponse> {
|
||||
const url = `${BASE_URL}/api/v1/tenant/${TENANT_SLUG}/odds/upcoming/${upcomingId}`
|
||||
const json = await fetchJson<{ data?: ApiOdds[]; message?: string; status?: string }>(url)
|
||||
return { data: json.data ?? [], message: json.message, status: json.status }
|
||||
}
|
||||
|
||||
export function get1X2ForEvent(oddsList: ApiOdds[], eventId: number): { "1": number; X: number; "2": number } | null {
|
||||
const ft = oddsList.find((o) => o.event_id === eventId && o.market_type === "full_time_result")
|
||||
if (!ft?.raw_odds?.length) return null
|
||||
const one = ft.raw_odds.find((o) => o.name === "1")
|
||||
const draw = ft.raw_odds.find((o) => o.name === "Draw")
|
||||
const two = ft.raw_odds.find((o) => o.name === "2")
|
||||
if (!one || !draw || !two) return null
|
||||
return {
|
||||
"1": parseFloat(one.odds) || 0,
|
||||
X: parseFloat(draw.odds) || 0,
|
||||
"2": parseFloat(two.odds) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function get1X2FromOddsResponse(oddsList: ApiOdds[]): { "1": number; X: number; "2": number } | null {
|
||||
const ft = (oddsList ?? []).find((o) => o.market_type === "full_time_result")
|
||||
if (!ft?.raw_odds?.length) return null
|
||||
const one = ft.raw_odds.find((o) => o.name === "1")
|
||||
const draw = ft.raw_odds.find((o) => o.name === "Draw")
|
||||
const two = ft.raw_odds.find((o) => o.name === "2")
|
||||
if (!one || !draw || !two) return null
|
||||
return {
|
||||
"1": parseFloat(one.odds) || 0,
|
||||
X: parseFloat(draw.odds) || 0,
|
||||
"2": parseFloat(two.odds) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function getListMarketsFromOddsResponse(oddsList: ApiOdds[]): { id: string; label: string; odds: number }[] {
|
||||
const list: { id: string; label: string; odds: number }[] = []
|
||||
const raw = oddsList ?? []
|
||||
|
||||
const ft = raw.find((o) => o.market_type === "full_time_result")
|
||||
if (ft?.raw_odds?.length) {
|
||||
const one = ft.raw_odds.find((o) => o.name === "1")
|
||||
const draw = ft.raw_odds.find((o) => o.name === "Draw")
|
||||
const two = ft.raw_odds.find((o) => o.name === "2")
|
||||
list.push(
|
||||
{ id: "1", label: "1", odds: one ? parseFloat(one.odds) || 0 : 0 },
|
||||
{ id: "x", label: "x", odds: draw ? parseFloat(draw.odds) || 0 : 0 },
|
||||
{ id: "2", label: "2", odds: two ? parseFloat(two.odds) || 0 : 0 }
|
||||
)
|
||||
} else {
|
||||
list.push({ id: "1", label: "1", odds: 0 }, { id: "x", label: "x", odds: 0 }, { id: "2", label: "2", odds: 0 })
|
||||
}
|
||||
|
||||
const ou = raw.find((o) => o.market_type === "goals_over_under" || o.market_type === "goal_line")
|
||||
const line25 = ou?.raw_odds?.filter((o) => o.name === "2.5" || o.name === "2,5") ?? []
|
||||
const over = line25.find((o) => o.header === "Over")
|
||||
const under = line25.find((o) => o.header === "Under")
|
||||
list.push(
|
||||
{ id: "over25", label: "Over (2.5)", odds: over ? parseFloat(over.odds) || 0 : 0 },
|
||||
{ id: "under25", label: "Under (2.5)", odds: under ? parseFloat(under.odds) || 0 : 0 }
|
||||
)
|
||||
|
||||
const dc = raw.find((o) => o.market_type === "double_chance")
|
||||
const dcOutcomes = dc?.raw_odds ?? []
|
||||
const dc1X = dcOutcomes[0]
|
||||
const dcX2 = dcOutcomes[1]
|
||||
const dc12 = dcOutcomes[2]
|
||||
list.push(
|
||||
{ id: "1x", label: "1X", odds: dc1X ? parseFloat(dc1X.odds) || 0 : 0 },
|
||||
{ id: "12", label: "12", odds: dc12 ? parseFloat(dc12.odds) || 0 : 0 },
|
||||
{ id: "x2", label: "X2", odds: dcX2 ? parseFloat(dcX2.odds) || 0 : 0 }
|
||||
)
|
||||
|
||||
const btts = raw.find((o) => o.market_type === "both_teams_to_score")
|
||||
const yes = btts?.raw_odds?.find((o) => o.name === "Yes")
|
||||
const no = btts?.raw_odds?.find((o) => o.name === "No")
|
||||
list.push(
|
||||
{ id: "yes", label: "Yes", odds: yes ? parseFloat(yes.odds) || 0 : 0 },
|
||||
{ id: "no", label: "No", odds: no ? parseFloat(no.odds) || 0 : 0 }
|
||||
)
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export function apiOddsToSections(apiOdds: ApiOdds[]): DetailMarketSectionFromApi[] {
|
||||
return (apiOdds ?? []).map((market) => ({
|
||||
id: `${market.market_type}-${market.id}`,
|
||||
title: market.market_name,
|
||||
outcomes: (market.raw_odds ?? []).map((o: ApiOddsOutcome) => {
|
||||
const label = o.name ?? o.header ?? (o.handicap ? `Handicap ${o.handicap}` : null) ?? String(o.id)
|
||||
return { label, odds: parseFloat(o.odds) || 0 }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function getMarketsForTab(
|
||||
rawOdds: ApiOdds[],
|
||||
tabKey: MarketTabKey
|
||||
): { headers: string[]; cells: TabColumnCell[] } {
|
||||
const raw = rawOdds ?? []
|
||||
const out: TabColumnCell[] = []
|
||||
const push = (id: string, label: string, odds: number) => {
|
||||
out.push({ id, label, odds })
|
||||
}
|
||||
|
||||
switch (tabKey) {
|
||||
case "main":
|
||||
case "combo":
|
||||
case "chance_mix":
|
||||
case "home":
|
||||
return { headers: ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"], cells: getListMarketsFromOddsResponse(raw) }
|
||||
case "goals": {
|
||||
const ou = raw.find((o) => o.market_type === "goals_over_under" || o.market_type === "goal_line")
|
||||
const line25 = ou?.raw_odds?.filter((o) => o.name === "2.5" || o.name === "2,5") ?? []
|
||||
const over = line25.find((o) => o.header === "Over")
|
||||
const under = line25.find((o) => o.header === "Under")
|
||||
push("over25", "Over (2.5)", over ? parseFloat(over.odds) || 0 : 0)
|
||||
push("under25", "Under (2.5)", under ? parseFloat(under.odds) || 0 : 0)
|
||||
const btts = raw.find((o) => o.market_type === "both_teams_to_score")
|
||||
const yes = btts?.raw_odds?.find((o) => o.name === "Yes")
|
||||
const no = btts?.raw_odds?.find((o) => o.name === "No")
|
||||
push("yes", "Yes", yes ? parseFloat(yes.odds) || 0 : 0)
|
||||
push("no", "No", no ? parseFloat(no.odds) || 0 : 0)
|
||||
return { headers: ["Over (2.5)", "Under (2.5)", "Yes", "No"], cells: out }
|
||||
}
|
||||
case "handicap": {
|
||||
const ah = raw.find((o) => o.market_type === "asian_handicap")
|
||||
const ahOutcomes = ah?.raw_odds ?? []
|
||||
if (ahOutcomes[0]) push("ah1", ahOutcomes[0].header ?? "1", parseFloat(ahOutcomes[0].odds) || 0)
|
||||
if (ahOutcomes[1]) push("ah2", ahOutcomes[1].header ?? "2", parseFloat(ahOutcomes[1].odds) || 0)
|
||||
const gl = raw.find((o) => o.market_type === "goal_line")
|
||||
const glOver = gl?.raw_odds?.find((o) => o.header === "Over")
|
||||
const glUnder = gl?.raw_odds?.find((o) => o.header === "Under")
|
||||
push("gl_over", "Over", glOver ? parseFloat(glOver.odds) || 0 : 0)
|
||||
push("gl_under", "Under", glUnder ? parseFloat(glUnder.odds) || 0 : 0)
|
||||
if (!out.length) return { headers: ["1", "2", "Over", "Under"], cells: [{ id: "ah1", label: "1", odds: 0 }, { id: "ah2", label: "2", odds: 0 }, { id: "gl_over", label: "Over", odds: 0 }, { id: "gl_under", label: "Under", odds: 0 }] }
|
||||
return { headers: out.map((c) => c.label), cells: out }
|
||||
}
|
||||
case "half_time":
|
||||
case "1st_half": {
|
||||
const ht = raw.find((o) => o.market_type === "half_time_result")
|
||||
if (ht?.raw_odds?.length) {
|
||||
const one = ht.raw_odds.find((o) => o.name === "1")
|
||||
const draw = ht.raw_odds.find((o) => o.name === "Draw")
|
||||
const two = ht.raw_odds.find((o) => o.name === "2")
|
||||
push("ht1", "1", one ? parseFloat(one.odds) || 0 : 0)
|
||||
push("htx", "X", draw ? parseFloat(draw.odds) || 0 : 0)
|
||||
push("ht2", "2", two ? parseFloat(two.odds) || 0 : 0)
|
||||
}
|
||||
const htdc = raw.find((o) => o.market_type === "half_time_double_chance")
|
||||
if (htdc?.raw_odds?.length) {
|
||||
htdc.raw_odds.slice(0, 3).forEach((o, i) => push(`htdc${i}`, o.name ?? String(i), parseFloat(o.odds) || 0))
|
||||
}
|
||||
const btts1 = raw.find((o) => o.market_type === "both_teams_to_score_in_1st_half")
|
||||
if (btts1?.raw_odds?.length) {
|
||||
const y = btts1.raw_odds.find((o) => o.name === "Yes")
|
||||
const n = btts1.raw_odds.find((o) => o.name === "No")
|
||||
push("btts1_yes", "Yes", y ? parseFloat(y.odds) || 0 : 0)
|
||||
push("btts1_no", "No", n ? parseFloat(n.odds) || 0 : 0)
|
||||
}
|
||||
return { headers: out.map((c) => c.label), cells: out }
|
||||
}
|
||||
case "2nd_half": {
|
||||
const ht2 = raw.find((o) => o.market_type === "2nd_half_result")
|
||||
if (ht2?.raw_odds?.length) {
|
||||
const one = ht2.raw_odds.find((o) => o.name === "1")
|
||||
const draw = ht2.raw_odds.find((o) => o.name === "Draw")
|
||||
const two = ht2.raw_odds.find((o) => o.name === "2")
|
||||
push("2h1", "1", one ? parseFloat(one.odds) || 0 : 0)
|
||||
push("2hx", "X", draw ? parseFloat(draw.odds) || 0 : 0)
|
||||
push("2h2", "2", two ? parseFloat(two.odds) || 0 : 0)
|
||||
}
|
||||
const btts2 = raw.find((o) => o.market_type === "both_teams_to_score_in_2nd_half")
|
||||
if (btts2?.raw_odds?.length) {
|
||||
const y = btts2.raw_odds.find((o) => o.name === "Yes")
|
||||
const n = btts2.raw_odds.find((o) => o.name === "No")
|
||||
push("btts2_yes", "Yes", y ? parseFloat(y.odds) || 0 : 0)
|
||||
push("btts2_no", "No", n ? parseFloat(n.odds) || 0 : 0)
|
||||
}
|
||||
return { headers: out.map((c) => c.label), cells: out }
|
||||
}
|
||||
case "correct_score": {
|
||||
const halfTimeCs = raw.find((o) => o.market_type === "half_time_correct_score")
|
||||
const fullTimeCs = raw.find((o) => o.market_type === "correct_score")
|
||||
const cs = halfTimeCs ?? fullTimeCs
|
||||
const outcomes = (cs?.raw_odds ?? []).slice(0, 10)
|
||||
outcomes.forEach((o, i) => push(`cs${i}`, o.name ?? String(i), parseFloat(o.odds) || 0))
|
||||
if (!out.length) {
|
||||
const fallbackHeaders = ["1-0", "0-0", "1-1", "0-1", "2-0", "2-1", "1-2", "0-2"]
|
||||
return { headers: fallbackHeaders, cells: fallbackHeaders.map((h, i) => ({ id: `cs${i}`, label: h, odds: 0 })) }
|
||||
}
|
||||
return { headers: out.map((c) => c.label), cells: out }
|
||||
}
|
||||
default:
|
||||
return { headers: ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"], cells: getListMarketsFromOddsResponse(raw) }
|
||||
}
|
||||
}
|
||||
108
lib/store/betting-store.ts
Normal file
108
lib/store/betting-store.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import type { AppEvent, QuickFilterKey } from "./betting-types"
|
||||
import {
|
||||
fetchEvents,
|
||||
fetchOddsForEvent,
|
||||
apiEventToAppEvent,
|
||||
getListMarketsFromOddsResponse,
|
||||
getTimeRangeForQuickFilter,
|
||||
type EventsParams,
|
||||
} from "./betting-api"
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
type BettingState = {
|
||||
events: AppEvent[]
|
||||
page: number
|
||||
total: number | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
hasMore: boolean
|
||||
sportId: number | null
|
||||
leagueId: string | null
|
||||
quickFilter: QuickFilterKey
|
||||
setFilters: (sportId: number | null, leagueId: string | null) => void
|
||||
setQuickFilter: (key: QuickFilterKey) => void
|
||||
loadPage: (pageNum: number, append: boolean) => Promise<void>
|
||||
loadMore: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
events: [],
|
||||
page: 1,
|
||||
total: null,
|
||||
loading: true,
|
||||
error: null as string | null,
|
||||
hasMore: true,
|
||||
sportId: null as number | null,
|
||||
leagueId: null as string | null,
|
||||
quickFilter: "all" as QuickFilterKey,
|
||||
}
|
||||
|
||||
export const useBettingStore = create<BettingState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setFilters: (sportId, leagueId) => {
|
||||
const prev = get()
|
||||
if (prev.sportId === sportId && prev.leagueId === leagueId) return
|
||||
set({ sportId, leagueId, page: 1 })
|
||||
get().loadPage(1, false)
|
||||
},
|
||||
|
||||
setQuickFilter: (quickFilter) => {
|
||||
set({ quickFilter, page: 1 })
|
||||
get().loadPage(1, false)
|
||||
},
|
||||
|
||||
loadPage: async (pageNum: number, append: boolean) => {
|
||||
const { sportId, leagueId, quickFilter } = get()
|
||||
const timeRange = getTimeRangeForQuickFilter(quickFilter)
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const params: EventsParams = {
|
||||
page: pageNum,
|
||||
page_size: PAGE_SIZE,
|
||||
sport_id: sportId ?? undefined,
|
||||
league_id: leagueId ? Number(leagueId) : undefined,
|
||||
...timeRange,
|
||||
}
|
||||
const res = await fetchEvents(params)
|
||||
const apiEvents = res.data ?? []
|
||||
const oddsResponses = await Promise.all(
|
||||
apiEvents.map((e) => fetchOddsForEvent(e.id).catch(() => ({ data: [] as typeof res.data })))
|
||||
)
|
||||
const newEvents: AppEvent[] = apiEvents.map((e, i) => {
|
||||
const oddsList = oddsResponses[i]?.data ?? []
|
||||
const listMarkets = getListMarketsFromOddsResponse(oddsList)
|
||||
const appEvent = apiEventToAppEvent(e, listMarkets) as AppEvent
|
||||
appEvent.rawOdds = oddsList
|
||||
return appEvent
|
||||
})
|
||||
set((s) => ({
|
||||
events: append ? [...s.events, ...newEvents] : newEvents,
|
||||
loading: false,
|
||||
total: res.total ?? s.total,
|
||||
hasMore: newEvents.length === PAGE_SIZE,
|
||||
page: pageNum,
|
||||
}))
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load events",
|
||||
events: append ? get().events : [],
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadMore: () => {
|
||||
const { page, loadPage } = get()
|
||||
const next = page + 1
|
||||
set({ page: next })
|
||||
loadPage(next, true)
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
191
lib/store/betting-types.ts
Normal file
191
lib/store/betting-types.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* Betting API and app types. Used by betting-store and betting-api.
|
||||
*/
|
||||
|
||||
/** Sport IDs for API (sport_id). Use for live and prematch. */
|
||||
export const SportEnum = {
|
||||
SOCCER: 1,
|
||||
BASKETBALL: 18,
|
||||
TENNIS: 13,
|
||||
VOLLEYBALL: 91,
|
||||
HANDBALL: 78,
|
||||
BASEBALL: 16,
|
||||
HORSE_RACING: 2,
|
||||
GREYHOUNDS: 4,
|
||||
ICE_HOCKEY: 17,
|
||||
SNOOKER: 14,
|
||||
AMERICAN_FOOTBALL: 12,
|
||||
CRICKET: 3,
|
||||
FUTSAL: 83,
|
||||
DARTS: 15,
|
||||
TABLE_TENNIS: 92,
|
||||
BADMINTON: 94,
|
||||
RUGBY_UNION: 8,
|
||||
RUGBY_LEAGUE: 19,
|
||||
AUSTRALIAN_RULES: 36,
|
||||
BOWLS: 66,
|
||||
BOXING: 9,
|
||||
GAELIC_SPORTS: 75,
|
||||
FLOORBALL: 90,
|
||||
BEACH_VOLLEYBALL: 95,
|
||||
WATER_POLO: 110,
|
||||
SQUASH: 107,
|
||||
E_SPORTS: 151,
|
||||
MMA: 162,
|
||||
SURFING: 148,
|
||||
} as const
|
||||
|
||||
export type SportId = (typeof SportEnum)[keyof typeof SportEnum]
|
||||
|
||||
export type ApiEvent = {
|
||||
id: number
|
||||
source_event_id: string
|
||||
sport_id: number
|
||||
match_name: string
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_id: number
|
||||
away_team_id: number
|
||||
home_team_image: string
|
||||
away_team_image: string
|
||||
league_id: number
|
||||
league_name: string
|
||||
league_cc: string
|
||||
start_time: string
|
||||
source: string
|
||||
status: string
|
||||
is_live: boolean
|
||||
is_featured?: boolean
|
||||
is_active?: boolean
|
||||
total_odd_outcomes?: number
|
||||
number_of_bets?: number
|
||||
total_amount?: number
|
||||
average_bet_amount?: number
|
||||
total_potential_winnings?: number
|
||||
score?: string
|
||||
match_minute?: number
|
||||
timer_status?: string
|
||||
added_time?: number
|
||||
match_period?: number
|
||||
fetched_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type ApiLeague = {
|
||||
id: number
|
||||
name: string
|
||||
cc: string
|
||||
bet365_id?: number
|
||||
sport_id: number
|
||||
default_is_active: boolean
|
||||
default_is_featured: boolean
|
||||
}
|
||||
|
||||
export type ApiOddsOutcome = {
|
||||
id: string
|
||||
name?: string
|
||||
odds: string
|
||||
header?: string
|
||||
handicap?: string
|
||||
}
|
||||
|
||||
export type ApiOdds = {
|
||||
id: number
|
||||
event_id: number
|
||||
market_type: string
|
||||
market_name: string
|
||||
market_category: string
|
||||
market_id: number
|
||||
number_of_outcomes: number
|
||||
raw_odds: ApiOddsOutcome[]
|
||||
fetched_at: string
|
||||
expires_at: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export type ApiTopLeaguesResponse = {
|
||||
leagues: Array<{
|
||||
league_id: number
|
||||
league_name: string
|
||||
league_cc: string
|
||||
league_sport_id: number
|
||||
events: ApiEvent[]
|
||||
}>
|
||||
}
|
||||
|
||||
export type EventsParams = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
sport_id?: number
|
||||
league_id?: number
|
||||
/** RFC3339 datetime; filter events with start_time >= this */
|
||||
first_start_time?: string
|
||||
/** RFC3339 datetime; filter events with start_time <= this (e.g. for 3h/6h/12h windows) */
|
||||
last_start_time?: string
|
||||
/** When true, return only in-play/live events */
|
||||
is_live?: boolean
|
||||
}
|
||||
|
||||
/** Quick filter key for time-based event filtering */
|
||||
export type QuickFilterKey = "all" | "today" | "3h" | "6h" | "9h" | "12h"
|
||||
|
||||
export type EventsResponse = {
|
||||
data: ApiEvent[]
|
||||
total?: number
|
||||
page?: number
|
||||
total_pages?: number
|
||||
message?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type LeaguesResponse = {
|
||||
data: ApiLeague[]
|
||||
message?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type OddsResponse = {
|
||||
data: ApiOdds[]
|
||||
message?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type DetailMarketSectionFromApi = {
|
||||
id: string
|
||||
title: string
|
||||
outcomes: { label: string; odds: number }[]
|
||||
}
|
||||
|
||||
export type MarketTabKey =
|
||||
| "main"
|
||||
| "goals"
|
||||
| "handicap"
|
||||
| "half_time"
|
||||
| "correct_score"
|
||||
| "1st_half"
|
||||
| "2nd_half"
|
||||
| "combo"
|
||||
| "chance_mix"
|
||||
| "home"
|
||||
|
||||
export type TabColumnCell = { id: string; label: string; odds: number }
|
||||
|
||||
export type AppEvent = {
|
||||
id: string
|
||||
sport: string
|
||||
sportIcon: string
|
||||
league: string
|
||||
country: string
|
||||
homeTeam: string
|
||||
awayTeam: string
|
||||
time: string
|
||||
date: string
|
||||
isLive: boolean
|
||||
markets: { id: string; label: string; odds: number }[]
|
||||
totalMarkets: number
|
||||
rawOdds?: ApiOdds[]
|
||||
/** Live: e.g. "2 - 1" */
|
||||
score?: string
|
||||
/** Live: match minute */
|
||||
matchMinute?: number
|
||||
}
|
||||
75
lib/store/live-store.ts
Normal file
75
lib/store/live-store.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { SportEnum } from "./betting-types"
|
||||
import type { AppEvent } from "./betting-types"
|
||||
import {
|
||||
fetchEvents,
|
||||
fetchOddsForEvent,
|
||||
apiEventToAppEvent,
|
||||
getListMarketsFromOddsResponse,
|
||||
} from "./betting-api"
|
||||
|
||||
const LIVE_PAGE_SIZE = 24
|
||||
|
||||
/** Start of today in UTC, RFC3339 — for live events first_start_time */
|
||||
function getFirstStartTimeToday(): string {
|
||||
const d = new Date()
|
||||
d.setUTCHours(0, 0, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
type LiveState = {
|
||||
events: AppEvent[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
sportId: number
|
||||
setSportId: (sportId: number) => void
|
||||
loadLiveEvents: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useLiveStore = create<LiveState>((set, get) => ({
|
||||
events: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
sportId: SportEnum.SOCCER,
|
||||
|
||||
setSportId: (sportId) => {
|
||||
set({ sportId })
|
||||
get().loadLiveEvents()
|
||||
},
|
||||
|
||||
loadLiveEvents: async () => {
|
||||
const { sportId } = get()
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const first_start_time = getFirstStartTimeToday()
|
||||
const res = await fetchEvents({
|
||||
sport_id: sportId,
|
||||
page: 1,
|
||||
page_size: LIVE_PAGE_SIZE,
|
||||
first_start_time,
|
||||
is_live: true,
|
||||
// no league_id - get all leagues
|
||||
})
|
||||
const apiEvents = (res.data ?? []).filter((e) => e.is_live === true)
|
||||
const oddsResponses = await Promise.all(
|
||||
apiEvents.map((e) => fetchOddsForEvent(e.id).catch(() => ({ data: [] as typeof res.data })))
|
||||
)
|
||||
const newEvents: AppEvent[] = apiEvents.map((e, i) => {
|
||||
const oddsList = oddsResponses[i]?.data ?? []
|
||||
const listMarkets = getListMarketsFromOddsResponse(oddsList)
|
||||
const appEvent = apiEventToAppEvent(e, listMarkets) as AppEvent
|
||||
appEvent.rawOdds = oddsList
|
||||
return appEvent
|
||||
})
|
||||
set({ events: newEvents, loading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load live events",
|
||||
events: [],
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user