Compare commits

..

No commits in common. "1a1361ee7fb7f4273991050b716a41d7de1c4531" and "86ffd88e46a1eee30be0fa26b4e3290829080f0a" have entirely different histories.

27 changed files with 556 additions and 2111 deletions

View File

@ -1,51 +1,10 @@
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
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
}
}
const event = getEventById(id)
if (!event) {
return (
@ -58,5 +17,5 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
)
}
return <MatchDetailView event={event} apiSections={apiSections} />
return <MatchDetailView event={event} />
}

View File

@ -128,7 +128,7 @@
}
}
/* HarifSport odds button animation */
/* Fortune 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: "Harifsport - Sports Betting",
description: "Harifsport sportsbook - Live betting, in-play events, and more",
title: "FortuneBets - Ethiopian Online Casino and Sports Betting",
description: "FortuneBets - Ethiopian Online Casino and Sports Betting and more",
}
export default function RootLayout({

View File

@ -41,15 +41,15 @@ export default function LoginPage() {
/>
))}
</div>
{/* HARIF box */}
{/* FORTUNE 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">
HARIF
FORTUNE
</span>
</div>
{/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
SPORT
BETS
</span>
</div>
</div>

View File

@ -43,15 +43,15 @@ export default function RegisterPage() {
/>
))}
</div>
{/* HARIF box */}
{/* FORTUNE 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">
HARIF
FORTUNE
</span>
</div>
{/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
SPORT
BETS
</span>
</div>
</div>

View File

@ -1,7 +1,7 @@
const rules = [
{
title: "General Betting Rules",
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.",
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.",
},
{
title: "Live Betting",
@ -13,7 +13,7 @@ const rules = [
},
{
title: "Responsible Gambling",
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.",
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.",
},
{
title: "Account Rules",

View File

@ -1,154 +1,132 @@
"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,
});
}
});
} else {
displayedGames = games
.filter((g) => {
const gameProvId = String(g.providerId || g.provider_id || "")
.trim()
.toLowerCase();
const matches =
gameProvId === String(activeCategory).trim().toLowerCase();
if (
g.providerId?.toLowerCase().includes("pop") ||
g.provider_id?.toLowerCase().includes("pop")
) {
}
return matches;
games: providerGames
})
.map(mapApiGameToCard);
}
})
} else {
displayedGames = games
.filter(g => {
const gameProvId = String(g.providerId || g.provider_id || "").trim().toLowerCase()
const matches = gameProvId === String(activeCategory).trim().toLowerCase()
if (g.providerId?.toLowerCase().includes('pop') || g.provider_id?.toLowerCase().includes('pop')) {
}
return matches
})
.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">
@ -202,7 +180,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")}
@ -222,5 +200,5 @@ export default function VirtualPage() {
</div>
</main>
</div>
);
)
}

View File

@ -5,11 +5,8 @@ import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { useBetslipStore } from "@/lib/store/betslip-store"
import { mockEvents, popularLeagues, type Event } from "@/lib/mock-data"
import { useBettingStore } from "@/lib/store/betting-store"
import type { AppEvent } from "@/lib/store/betting-types"
import { SPORT_SLUG_TO_ID, getMarketsForTab, type ApiOdds, type MarketTabKey } from "@/lib/store/betting-api"
import { cn } from "@/lib/utils"
import { ChevronDown, BarChart2, TrendingUp, Plus, Loader2 } from "lucide-react"
import { ChevronDown, BarChart2, TrendingUp, Plus } from "lucide-react"
function OddsButton({ odds, onClick, isSelected }: {
odds: number
@ -29,7 +26,7 @@ function OddsButton({ odds, onClick, isSelected }: {
)
}
function EventRow({ event }: { event: Event | AppEvent }) {
function EventRow({ event }: { event: Event }) {
const { bets, addBet } = useBetslipStore()
return (
@ -85,47 +82,22 @@ function EventRow({ event }: { event: Event | AppEvent }) {
)
}
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"];
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 = "" }: {
export function EventsList({ filter = "All", sport = "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")
@ -133,17 +105,13 @@ export function EventsList({ filter = "All", sport: sportProp = "all", search =
setSelectedLeague(null)
}
const useApi = !(error && apiEvents.length === 0)
const events = useApi
? (filter === "Live" ? apiEvents.filter((e) => e.isLive) : apiEvents)
: selectedLeague
? mockEvents.filter((e) => e.league.toLowerCase() === selectedLeague.toLowerCase())
const events = selectedLeague
? mockEvents.filter(e => e.league.toLowerCase() === selectedLeague.toLowerCase())
: mockEvents.filter((e) => {
if (filter === "Live" && !e.isLive) return false
if (sportProp !== "all" && e.sport.toLowerCase() !== sportProp.toLowerCase()) return false
if (sport !== "all" && e.sport.toLowerCase() !== sport.toLowerCase()) return false
return true
})
const showLoadMore = useApi && hasMore && events.length > 0
// Common Header Rendering
const renderTableHeaders = () => (
@ -167,101 +135,66 @@ export function EventsList({ filter = "All", sport: sportProp = "all", search =
</>
)
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" />
const renderColumnHeaders = () => (
<div className="bg-brand-surface border-b border-white/5 h-8 flex items-center text-[9px] font-black text-white/40 uppercase">
<div className="w-[180px] px-3 flex items-center gap-1.5 border-r border-border/10 h-full">Main</div>
<div className="w-[180px] flex items-center justify-center border-r border-border/10 h-full">Over/Under</div>
<div className="flex-1 grid grid-cols-10 text-center h-full items-center tracking-tighter">
{MARKET_HEADERS.map(h => <span key={h}>{h}</span>)}
</div>
<div className="w-10 border-l border-border/10 h-full" />
</div>
)
}
const 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 (
const renderEventItem = (event: Event) => (
<div key={event.id} className="h-[34px] group flex items-center border-b border-white/5 bg-brand-bg hover:bg-white/5 transition-colors">
{/* Stats & Icons */}
<div className="w-[35px] flex items-center justify-center gap-1 px-2 border-r border-white/5 h-full opacity-40 group-hover:opacity-100">
<BarChart2 className="size-3 cursor-pointer hover:text-primary" />
</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>
<div
className="flex-1 grid h-full"
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
>
{displayCells.map((cell) => {
const betId = `${event.id}-${cell.id}`
{/* Odds Grid */}
<div className="flex-1 grid grid-cols-10 h-full">
{event.markets.slice(0, 10).map((market) => {
const betId = `${event.id}-${market.id}`
const isSelected = bets.some((b) => b.id === betId)
const hasOdds = cell.odds > 0
return (
<button
key={cell.id}
type="button"
disabled={!hasOdds}
onClick={() =>
hasOdds &&
addBet({
key={market.id}
onClick={() => addBet({
id: betId,
event: `${event.homeTeam} - ${event.awayTeam}`,
league: `${event.sport} - ${event.country} - ${event.league}`,
market: cell.label,
selection: cell.label,
odds: cell.odds,
})
}
market: "1X2",
selection: market.label,
odds: market.odds,
})}
className={cn(
"flex items-center justify-center text-[10.5px] font-black tabular-nums transition-all border-r border-white/5",
isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5",
!hasOdds && "text-white/30 cursor-default hover:bg-transparent"
isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5"
)}
>
{hasOdds ? cell.odds.toFixed(2) : "—"}
{market.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"
@ -271,26 +204,6 @@ export function EventsList({ filter = "All", sport: sportProp = "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
@ -298,7 +211,7 @@ export function EventsList({ filter = "All", sport: sportProp = "all", search =
if (!acc[event.date]) acc[event.date] = []
acc[event.date].push(event)
return acc
}, {} as Record<string, (Event | AppEvent)[]>)
}, {} as Record<string, Event[]>)
return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden shadow-2xl">
@ -318,41 +231,26 @@ export function EventsList({ filter = "All", sport: sportProp = "all", search =
</button>
</div>
{/* Market category tabs row 1: Main, Goals, Handicap, Half Time / Full Time, Correct Score */}
<div className="flex flex-wrap gap-1 p-2 pb-1 bg-brand-bg border-b border-border/10">
{ROW1_TABS.map(({ key, label }) => (
{/* Large Market Tab Grid */}
<div className="grid grid-cols-5 bg-brand-bg border-b border-border/10">
{[
{ label: "Main", active: true }, { label: "Goals" }, { label: "Handicap" }, { label: "Half Time / Full Time" }, { label: "Correct Score" },
{ label: "1st Half" }, { label: "2nd Half" }, { label: "Asian Markets" }, { label: "Corners" }, { label: "Home" }
].map((m, i) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
key={i}
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"
"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"
)}
>
{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}
{m.label}
</button>
))}
</div>
{/* Column Headers (dynamic by tab) */}
{renderColumnHeaders(activeTab, events)}
{/* Column Headers */}
{renderColumnHeaders()}
{/* Grouped Events */}
<div className="overflow-y-auto max-h-[700px]">
@ -361,29 +259,10 @@ export function EventsList({ filter = "All", sport: sportProp = "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, activeTab))}
{dateEvents.map(event => renderEventItem(event))}
</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>
)
}
@ -397,11 +276,6 @@ export function EventsList({ filter = "All", sport: sportProp = "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">
@ -438,30 +312,11 @@ export function EventsList({ filter = "All", sport: sportProp = "all", search =
{/* Matches in this league */}
<div className="flex flex-col">
{leagueEvents.map((event) => renderEventItem(event, "main"))}
{leagueEvents.map(event => renderEventItem(event))}
</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,19 +1,17 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
import { useBetslipStore } from "@/lib/store/betslip-store"
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 { mockEvents, type Event } from "@/lib/mock-data"
import { cn } from "@/lib/utils"
import { BarChart2, Monitor, Loader2 } from "lucide-react"
import { BarChart2, TrendingUp, Monitor, Tv } from "lucide-react"
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` : "—"
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"
const period = "H2"
return (
@ -25,12 +23,9 @@ function LiveEventRow({ event, isNoOdds }: { event: AppEvent; isNoOdds?: boolean
<span className="text-white/40">{period}</span>
</div>
<div className="flex items-center min-w-0 flex-1 gap-2">
<Link
href={`/event/${event.id}`}
className="text-[11.5px] font-black text-white truncate italic uppercase hover:text-brand-primary transition-colors"
>
<span className="text-[11.5px] font-black text-white truncate italic uppercase">
{event.homeTeam} <span className="text-brand-primary mx-1 tabular-nums">{score}</span> {event.awayTeam}
</Link>
</span>
</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" />
@ -76,38 +71,64 @@ function LiveEventRow({ event, isNoOdds }: { event: AppEvent; isNoOdds?: boolean
)
}
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
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 },
]
export function LiveEventsList() {
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[]>)
// 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 }
]
}
]
return (
<div className="flex flex-col min-h-screen bg-brand-bg">
{/* Sport Navigation: SportEnum ids, no league — event?sport_id=1&first_start_time=RFC3339&is_live=true */}
{/* Sport Navigation Carousel */}
<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>
@ -116,72 +137,59 @@ 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_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 (
{/* Live Sports */}
{liveSports.map((sport) => (
<button
key={id}
type="button"
onClick={() => setSportId(id)}
key={sport.id}
className={cn(
"flex flex-col items-center justify-center px-3 h-full border-r border-white/5 min-w-[75px] relative transition-colors",
active ? "bg-white/5" : "hover:bg-white/5"
sport.active ? "bg-white/5" : "hover:bg-white/5"
)}
>
<span className="text-[16px]">{icon}</span>
<span className="absolute top-1 right-2 text-[8.5px] font-black text-white/40">{sport.count}</span>
<span className="text-[16px]">{sport.icon}</span>
<span className={cn(
"text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap",
active ? "text-brand-primary" : "text-white/40"
sport.active ? "text-brand-primary" : "text-white/40"
)}>
{info.name}
{sport.label}
</span>
{active && <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />}
</button>
)
})}
</div>
</div>
{/* 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>
{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">
{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">
<span className="text-[9.5px] font-black text-white/60 uppercase tracking-widest leading-none">
{leagueName}
</span>
</div>
<div className="flex flex-col">
{matches.map((match) => (
<LiveEventRow
key={match.id}
event={match}
isNoOdds={!match.markets?.length || match.markets.every((m) => m.odds <= 0)}
/>
))}
</div>
</div>
))}
</div>
{sport.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>
</div>
{/* Grouped Live Matches */}
<div className="flex flex-col mb-10">
{liveMatches.map((group, gIdx) => (
<div key={gIdx} className="flex flex-col">
{/* League Header */}
<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}
</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} />
))}
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -9,8 +9,6 @@ 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"
@ -79,12 +77,11 @@ 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, 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}`
{section.outcomes.map((outcome) => {
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}`
const isSelected = bets.some((b) => b.id === betId)
return (
<div key={`${outcome.label}-${i}-${oddsStr}`} className="flex items-center justify-between gap-2">
<div key={outcome.label} className="flex items-center justify-between gap-2">
<span className="text-[11px] text-white/90 truncate">{outcome.label}</span>
<button
type="button"
@ -110,13 +107,12 @@ function MarketSectionBlock({
})}
</div>
) : (
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}`
section.outcomes.map((outcome) => {
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}`
const isSelected = bets.some((b) => b.id === betId)
return (
<div
key={`${outcome.label}-${i}-${oddsStr}`}
key={outcome.label}
className="flex items-center justify-between gap-3 py-1"
>
<span className="text-[11px] text-white/90">{outcome.label}</span>
@ -149,32 +145,19 @@ function MarketSectionBlock({
)
}
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>>(() => ({
export function MatchDetailView({ event }: { event: Event }) {
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,
...(apiSections?.length
? Object.fromEntries(detailMarkets.slice(0, 8).map((s) => [s.id, true]))
: {}),
}))
const [activeCategory, setActiveCategory] = useState("Main")
})
const [activeCategory, setActiveCategory] = useState("Cards/Bookings")
const detailMarkets = getEventDetailMarkets(event.id)
const cardsBookings = getCardsBookingsMarkets(event.id)
const toggleSection = (id: string) => {
setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] }))
@ -183,15 +166,11 @@ export function MatchDetailView({
const breadcrumbLeague =
event.league === "Premier League"
? "England - Premier League"
: event.league
? `${event.country} - ${event.league}`
: "Event"
: `${event.country} - ${event.league}`
const isCardsBookings = activeCategory === "Cards/Bookings"
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)
const leftSections = isCardsBookings ? cardsBookings.left : detailMarkets
const rightSections = isCardsBookings ? cardsBookings.right : []
return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden">
@ -210,38 +189,38 @@ export function MatchDetailView({
</h1>
</div>
{/* Match header: team names in boxes and below */}
{/* Match header */}
<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 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}
<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)}
</span>
</div>
<span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.homeTeam}</span>
<span className="text-[13px] font-bold text-white">{event.homeTeam}</span>
</div>
<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 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>
</div>
<span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.awayTeam}</span>
<span className="text-[13px] font-bold text-white">{event.awayTeam}</span>
</div>
</div>
</div>
{/* 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">
{/* 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">
{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",
"px-3 py-1.5 text-[10px] font-bold uppercase whitespace-nowrap rounded transition-colors shrink-0",
activeCategory === label
? "bg-brand-surface-light text-white border border-white/10"
: "text-white/60 hover:text-white hover:bg-white/5"
@ -252,7 +231,7 @@ export function MatchDetailView({
))}
</div>
{/* Two-column grid of market sections (split evenly so both columns are used) */}
{/* Two-column grid of market sections */}
<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 */}
@ -268,7 +247,8 @@ export function MatchDetailView({
/>
))}
</div>
{/* Right column */}
{/* Right column (Cards/Bookings only) */}
{rightSections.length > 0 && (
<div>
{rightSections.map((section) => (
<MarketSectionBlock
@ -281,6 +261,7 @@ export function MatchDetailView({
/>
))}
</div>
)}
</div>
</div>
</div>

View File

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

View File

@ -1,9 +1,4 @@
"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"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
const sports = [
{ id: "football", name: "Football", icon: "⚽" },
@ -19,23 +14,17 @@ const sports = [
]
export function SportsNav() {
const searchParams = useSearchParams()
const currentSport = searchParams.get("sport") ?? "football"
return (
<Tabs value={currentSport} className="w-full">
<Tabs defaultValue="football" 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,91 +1,58 @@
"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"
type TopMatch = {
id: string
league: string
time: string
homeTeam: string
awayTeam: string
odds: { home: number; draw: number; away: number }
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 }
}
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">
{matches.map((match) => {
{topMatches.map((match) => {
const eventName = `${match.homeTeam} - ${match.awayTeam}`
const leagueForBet = `Football - ${match.league}`
const outcomes = [
@ -127,7 +94,7 @@ export function TopMatches() {
</div>
</div>
<div className="grid grid-cols-3 gap-px bg-white/5 mx-2 mb-2 rounded-sm overflow-hidden border border-white/5">
<div className="grid grid-cols-3 gap-[1px] 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)
@ -156,7 +123,7 @@ export function TopMatches() {
{label}
</span>
<span className={cn("text-[11px] font-black tabular-nums", isSelected ? "text-black" : "text-brand-primary")}>
{odds > 0 ? odds.toFixed(2) : "—"}
{odds.toFixed(2)}
</span>
</button>
)

View File

@ -9,7 +9,7 @@ export type GameCategory =
| "favourite"
| "recently-played"
| "most-popular"
| "harif-special"
| "fortune-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: "harif-special", name: "Harif Special", icon: Zap },
{ id: "fortune-special", name: "Fortune Special", icon: Star },
{ 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>
{/* HARIF box */}
{/* FORTUNE 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">
HARIF
FORTUNE
</span>
</div>
{/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
SPORT
BETS
</span>
</div>
)

View File

@ -4,11 +4,17 @@ import Link from "next/link"
export function SiteFooter() {
return (
<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">
<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">
{/* 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>
@ -19,7 +25,9 @@ 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>
@ -30,9 +38,11 @@ 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 text-blue-400">Live betting</Link></li>
<li><Link href="/live" className="hover:text-primary transition-colors">Live betting</Link></li>
<li><Link href="/football" className="hover:text-primary transition-colors">Football</Link></li>
<li><Link href="/basketball" className="hover:text-primary transition-colors">Basketball</Link></li>
<li><Link href="/tennis" className="hover:text-primary transition-colors">Tennis</Link></li>
@ -42,41 +52,56 @@ 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-12 bg-brand-surface-light">
<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 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">HARIF</span>
<span className="text-3xl font-black text-white italic tracking-tighter skew-x-12 inline-block">
FORTUNE
</span>
</div>
<span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1">SPORT</span>
<span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1">
BETS
</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="container mx-auto max-w-5xl">
<div className="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">HARIF</span>
<span className="text-xl font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span>
</div>
<span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
<span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</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">HARIF</span>
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">FORTUNE</span>
</div>
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</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">HARIF</span>
<span className="text-base font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span>
</div>
<span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
<span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span>
</div>
<button onClick={() => setDrawerOpen(false)} className="text-white/60 hover:text-white text-2xl leading-none">×</button>
</div>

View File

@ -1,32 +1,13 @@
"use client"
import { useState, useEffect, useMemo } from "react"
import { useState } from "react"
import Link from "next/link"
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 { popularLeagues } from "@/lib/mock-data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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: "🏐" },
]
import { ChevronsLeft } from "lucide-react"
/** 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">
@ -37,106 +18,23 @@ function SoccerBallIcon({ className }: { className?: string }) {
)
}
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" },
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 },
]
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 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))
}
const [activeSport, setActiveSport] = useState("football")
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">
@ -153,18 +51,21 @@ export function SportsSidebar() {
Top Leagues
</div>
{/* Top Leagues */}
{/* Popular Leagues */}
<div className="flex flex-col">
{TOP_LEAGUES.map((league) => (
{popularLeagues.map((league) => (
<Link
key={league.id}
href={`/?sport=${sportFromUrl}&league=${league.id}`}
scroll={false}
href={`/?league=${league.id}`}
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>
@ -182,8 +83,18 @@ export function SportsSidebar() {
</Link>
</Button>
{/* Quick Filter Section: passes first_start_time (RFC3339) to events API */}
<QuickFilterSection />
{/* 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>
{/* Search Event Section */}
<div className="bg-brand-surface p-3 border-b border-border/40">
@ -197,103 +108,30 @@ export function SportsSidebar() {
</div>
</div>
{/* Nested: Sport → Countries → Leagues (mapped to sport_id & leagues API cc) */}
{/* Sport categories */}
<div className="divide-y divide-border/10 bg-brand-surface-light">
{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) */}
{sportCategories.map((sport) => (
<button
type="button"
onClick={() => setExpandedSport(isExpanded ? null : sport.id)}
key={sport.id}
onClick={() => setActiveSport(sport.id)}
className={cn(
"w-full flex items-center gap-1 py-2 pr-2 pl-1.5 text-left transition-colors h-9",
isExpanded ? "bg-brand-surface text-brand-primary" : "text-white/80 hover:bg-brand-surface hover:text-white"
"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"
)}
>
{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 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>
{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 className="flex items-center gap-2">
<span className="text-[10px] font-bold text-white/40">{sport.count}</span>
<SoccerBallIcon className="size-3.5 text-white/30 shrink-0" />
</div>
{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">

View File

@ -1,245 +0,0 @@
[
{ "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,12 +1,10 @@
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",
"ngrok-skip-browser-warning": "true",
'Content-Type': 'application/json',
},
});
@ -14,10 +12,9 @@ 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.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjQ0NDk1NCwibmJmIjoxNzcyNDQ0MzU0LCJpYXQiOjE3NzI0NDQzNTQsIlVzZXJJZCI6NSwiUm9sZSI6ImN1c3RvbWVyIiwiQ29tcGFueUlEIjp7IlZhbHVlIjoxLCJWYWxpZCI6dHJ1ZX19.6CZQp4VL9ehBh2EfMEohkoVMezT_qFdXajCKsUmWda4";
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjI3NzQxNSwibmJmIjoxNzcyMjc2ODE1LCJpYXQiOjE3NzIyNzY4MTUsIlVzZXJJZCI6NCwiUm9sZSI6InN1cGVyX2FkbWluIiwiQ29tcGFueUlEIjp7IlZhbHVlIjowLCJWYWxpZCI6ZmFsc2V9fQ.QJJ1KAFkWWCMmxxBi8rQc9C5aChN2XmTys-RCufV_Zo";
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@ -26,7 +23,7 @@ api.interceptors.request.use(
},
(error) => {
return Promise.reject(error);
},
}
);
// Response interceptor for handling common errors (like 401 Unauthorized)
@ -37,14 +34,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;

View File

@ -1,43 +0,0 @@
/**
* 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"

View File

@ -1,22 +0,0 @@
/**
* 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()
}

View File

@ -1,22 +0,0 @@
"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 }
}

View File

@ -1,446 +0,0 @@
/**
* 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) }
}
}

View File

@ -1,108 +0,0 @@
"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),
}))

View File

@ -1,191 +0,0 @@
/**
* 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
}

View File

@ -1,75 +0,0 @@
"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: [],
})
}
},
}))