Compare commits

...

5 Commits

Author SHA1 Message Date
1a1361ee7f layout components 2026-03-02 19:10:36 +03:00
0354a182f3 event and virtual pages 2026-03-02 19:10:22 +03:00
7a6f8f4279 layout changes 2026-03-02 19:09:43 +03:00
4e9edbfe77 store setup 2026-03-02 19:09:22 +03:00
8941c45555 Refactor live events list and match detail view components
- Updated LiveEventsList to use live data from the live store and improved event rendering with links to event details.
- Enhanced MatchDetailView to accept API sections for dynamic market rendering and improved state management for expanded sections.
- Modified SportsNav to utilize search parameters for active sport highlighting.
- Refactored TopMatches to fetch live match data and odds from the API, replacing static fallback data.
- Improved UI elements for better responsiveness and user experience across components.
2026-03-02 19:08:52 +03:00
27 changed files with 2111 additions and 556 deletions

View File

@ -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} />
}

View File

@ -128,7 +128,7 @@
}
}
/* Fortune odds button animation */
/* HarifSport odds button animation */
@keyframes odds-flash {
0% {
background-color: oklch(0.55 0.18 145);

View File

@ -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({

View File

@ -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>

View File

@ -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>

View File

@ -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",

View File

@ -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')) {
.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
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">
@ -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>
)
);
}

View File

@ -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())
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 (sport !== "all" && e.sport.toLowerCase() !== sport.toLowerCase()) 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,66 +167,101 @@ 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>)}
const getHeadersForTab = (tab: MarketTabKey) => {
const first = events[0]
const rawOdds: ApiOdds[] = first && "rawOdds" in first && Array.isArray((first as AppEvent).rawOdds) ? (first as AppEvent).rawOdds! : []
return getMarketsForTab(rawOdds, tab).headers
}
const renderColumnHeaders = (tab: MarketTabKey, eventList: (Event | AppEvent)[]) => {
let headers = eventList.length ? getHeadersForTab(tab) : getMarketsForTab([], tab).headers
if (!headers.length) headers = getMarketsForTab([], "main").headers
const n = Math.max(headers.length, 1)
return (
<div className="bg-brand-surface border-b border-white/5 flex flex-col">
<div className="h-8 flex items-center text-[9px] font-black text-white/40 uppercase">
<div className="w-[35px] shrink-0 px-1 border-r border-border/10 h-full flex items-center justify-center" />
<div className="w-[45px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">ID</div>
<div className="w-[50px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">Time</div>
<div className="flex-1 min-w-0 px-3 border-r border-border/10 h-full flex items-center">Event</div>
<div
className="flex-1 grid text-center h-full items-center tracking-tighter border-r border-border/10"
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
>
{headers.map((h, i) => (
<span key={i} className="border-r border-white/5 last:border-r-0 px-0.5 truncate">
{h}
</span>
))}
</div>
<div className="w-10 shrink-0 border-l border-border/10 h-full" />
</div>
<div className="w-10 border-l border-border/10 h-full" />
</div>
)
}
const renderEventItem = (event: Event) => (
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">
{/* 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" />
</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}
</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>
</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}`
<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={market.id}
onClick={() => addBet({
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: "1X2",
selection: market.label,
odds: market.odds,
})}
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"
isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5",
!hasOdds && "text-white/30 cursor-default hover:bg-transparent"
)}
>
{market.odds.toFixed(2)}
{hasOdds ? cell.odds.toFixed(2) : "—"}
</button>
)
})}
</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"
@ -204,6 +271,26 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
</Link>
</div>
)
}
if (loading && events.length === 0) {
return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden py-16 items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-brand-primary" aria-hidden />
<p className="text-white/80 text-sm font-medium">Loading events and odds</p>
{/* <p className="text-white/50 text-xs">Resolving odds for each event</p> */}
</div>
)
}
if (error && apiEvents.length === 0 && events.length === 0) {
return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden py-8 px-4 text-center">
<p className="text-white/60 text-sm mb-2">{error}</p>
<p className="text-white/40 text-xs">Check NEXT_PUBLIC_BETTING_API_BASE_URL and tenant.</p>
</div>
)
}
if (selectedLeague) {
// Group by date for league view
@ -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) => (
{/* 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={i}
key={key}
type="button"
onClick={() => setActiveTab(key)}
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"
"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"
)}
>
{m.label}
{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>
)
}

View File

@ -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) => (
{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={sport.id}
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",
sport.active ? "bg-white/5" : "hover:bg-white/5"
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="text-[16px]">{icon}</span>
<span className={cn(
"text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap",
sport.active ? "text-brand-primary" : "text-white/40"
active ? "text-brand-primary" : "text-white/40"
)}>
{sport.label}
{info.name}
</span>
{sport.active && (
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />
)}
{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>
)
}

View File

@ -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 23 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,8 +268,7 @@ export function MatchDetailView({ event }: { event: Event }) {
/>
))}
</div>
{/* Right column (Cards/Bookings only) */}
{rightSections.length > 0 && (
{/* Right column */}
<div>
{rightSections.map((section) => (
<MarketSectionBlock
@ -261,7 +281,6 @@ export function MatchDetailView({ event }: { event: Event }) {
/>
))}
</div>
)}
</div>
</div>
</div>

View File

@ -21,7 +21,7 @@ export function SportHome() {
<HomeTabs />
</>
)}
<EventsList />
<EventsList key={`${searchParams.get("sport") ?? "all"}-${searchParams.get("league") ?? ""}`} />
</div>
)
}

View File

@ -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"
>
<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>

View File

@ -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>
)

View File

@ -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 },

View File

@ -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>
)

View File

@ -4,17 +4,11 @@ import Link from "next/link"
export function SiteFooter() {
return (
<footer className="bg-brand-surface text-white pt-16">
{/* 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">
<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>
<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>
@ -25,9 +19,7 @@ export function SiteFooter() {
{/* INFORMATION */}
<div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
INFORMATION
</h3>
<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>
@ -38,11 +30,9 @@ export function SiteFooter() {
{/* SPORTS */}
<div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
SPORTS
</h3>
<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="/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>
@ -52,56 +42,41 @@ export function SiteFooter() {
{/* PLAY NOW */}
<div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">
PLAY NOW
</h3>
<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>
</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>
<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>
<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="/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>
)
}

View File

@ -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>

View File

@ -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>
)}
</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,30 +197,103 @@ 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) => (
{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
key={sport.id}
onClick={() => setActiveSport(sport.id)}
type="button"
onClick={() => setExpandedSport(isExpanded ? null : 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"
"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"
)}
>
<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>
</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" />
{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>
{/* Bet Services */}
<div className="mt-2 text-[11px] font-bold text-brand-primary px-3 py-2 uppercase border-y border-border/20 bg-brand-surface">

245
countries.json Normal file
View 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" }
]

View File

@ -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') {
if (typeof window !== "undefined") {
// const token = localStorage.getItem('token');
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjI3NzQxNSwibmJmIjoxNzcyMjc2ODE1LCJpYXQiOjE3NzIyNzY4MTUsIlVzZXJJZCI6NCwiUm9sZSI6InN1cGVyX2FkbWluIiwiQ29tcGFueUlEIjp7IlZhbHVlIjowLCJWYWxpZCI6ZmFsc2V9fQ.QJJ1KAFkWWCMmxxBi8rQc9C5aChN2XmTys-RCufV_Zo";
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: [],
})
}
},
}))