Compare commits

..

5 Commits

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

View File

@ -1,10 +1,51 @@
import Link from "next/link" import Link from "next/link"
import { getEventById } from "@/lib/mock-data" import { getEventById } from "@/lib/mock-data"
import { MatchDetailView } from "@/components/betting/match-detail-view" 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 }> }) { export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const event = getEventById(id) let event: Event | undefined = getEventById(id)
let apiSections: { id: string; title: string; outcomes: { label: string; odds: number }[] }[] | undefined
const numericId = id.trim() !== "" && !Number.isNaN(Number(id)) ? Number(id) : null
if (numericId !== null) {
try {
const [eventsRes, oddsRes] = await Promise.all([
fetchEvents({ page_size: 500, page: 1 }),
fetchOddsForEvent(numericId),
])
const apiEvent = (eventsRes.data ?? []).find((e) => e.id === numericId)
if (apiEvent) {
event = apiEventToAppEvent(apiEvent, get1X2ForEvent(oddsRes.data ?? [], apiEvent.id))
} else {
event = {
id: String(numericId),
sport: "Football",
sportIcon: "⚽",
league: "",
country: "",
homeTeam: "Home",
awayTeam: "Away",
time: "",
date: "",
isLive: false,
markets: [],
totalMarkets: 0,
}
}
apiSections = apiOddsToSections(oddsRes.data ?? [])
} catch {
if (!event) event = undefined
}
}
if (!event) { if (!event) {
return ( return (
@ -17,5 +58,5 @@ export default async function EventPage({ params }: { params: Promise<{ id: stri
) )
} }
return <MatchDetailView event={event} /> return <MatchDetailView event={event} apiSections={apiSections} />
} }

View File

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

View File

@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FortuneBets - Ethiopian Online Casino and Sports Betting", title: "Harifsport - Sports Betting",
description: "FortuneBets - Ethiopian Online Casino and Sports Betting and more", description: "Harifsport sportsbook - Live betting, in-play events, and more",
} }
export default function RootLayout({ export default function RootLayout({

View File

@ -41,15 +41,15 @@ export default function LoginPage() {
/> />
))} ))}
</div> </div>
{/* FORTUNE box */} {/* HARIF box */}
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]"> <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"> <span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
FORTUNE HARIF
</span> </span>
</div> </div>
{/* SPORT text */} {/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none"> <span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
BETS SPORT
</span> </span>
</div> </div>
</div> </div>

View File

@ -43,15 +43,15 @@ export default function RegisterPage() {
/> />
))} ))}
</div> </div>
{/* FORTUNE box */} {/* HARIF box */}
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]"> <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"> <span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
FORTUNE HARIF
</span> </span>
</div> </div>
{/* SPORT text */} {/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none"> <span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
BETS SPORT
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
const rules = [ const rules = [
{ {
title: "General Betting Rules", title: "General Betting Rules",
content: "All bets are subject to FortuneBets terms and conditions. By placing a bet, you agree to abide by these rules. The minimum bet amount is 5 ETB and the maximum payout is 500,000 ETB per bet.", content: "All bets are subject to Harifsport terms and conditions. By placing a bet, you agree to abide by these rules. The minimum bet amount is 5 ETB and the maximum payout is 500,000 ETB per bet.",
}, },
{ {
title: "Live Betting", title: "Live Betting",
@ -13,7 +13,7 @@ const rules = [
}, },
{ {
title: "Responsible Gambling", title: "Responsible Gambling",
content: "FortuneBets is committed to responsible gambling. Users may set deposit limits, loss limits, or self-exclude at any time. Gambling should be entertaining, not a source of income.", content: "Harifsport is committed to responsible gambling. Users may set deposit limits, loss limits, or self-exclude at any time. Gambling should be entertaining, not a source of income.",
}, },
{ {
title: "Account Rules", title: "Account Rules",

View File

@ -1,132 +1,154 @@
"use client" "use client";
import { useState, useEffect } from "react" import { useState, useEffect } from "react";
import Image from "next/image" import Image from "next/image";
import { GamingSidebar } from "@/components/games/gaming-sidebar" import { GamingSidebar } from "@/components/games/gaming-sidebar";
import { GameRow } from "@/components/games/game-row" import { GameRow } from "@/components/games/game-row";
import { GameCard } from "@/components/games/game-card" import { GameCard } from "@/components/games/game-card";
import { Search, Heart, Clock, Star, Zap, Gamepad2, AlertCircle, LayoutGrid } from "lucide-react" import {
import { cn } from "@/lib/utils" Search,
import api from "@/lib/api" Heart,
Clock,
Star,
Zap,
Gamepad2,
AlertCircle,
LayoutGrid,
} from "lucide-react";
import { cn } from "@/lib/utils";
import api from "@/lib/api";
interface Provider { interface Provider {
provider_id: string provider_id: string;
provider_name: string provider_name: string;
logo_dark: string logo_dark: string;
logo_light: string logo_light: string;
enabled: boolean enabled: boolean;
} }
interface ApiGame { interface ApiGame {
gameId: string gameId: string;
providerId: string providerId: string;
provider: string provider: string;
name: string name: string;
category: string category: string;
deviceType: string deviceType: string;
hasDemo: boolean hasDemo: boolean;
hasFreeBets: boolean hasFreeBets: boolean;
demoUrl?: string demoUrl?: string;
image?: string // In case it gets added image?: string; // In case it gets added
thumbnail?: string thumbnail?: string;
provider_id?: string // Fallback 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() { export default function VirtualPage() {
const [activeCategory, setActiveCategory] = useState("all") const [activeCategory, setActiveCategory] = useState("all");
const [providers, setProviders] = useState<Provider[]>([]) const [providers, setProviders] = useState<Provider[]>([]);
const [games, setGames] = useState<ApiGame[]>([]) const [games, setGames] = useState<ApiGame[]>([]);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchVirtualData = async () => { const fetchVirtualData = async () => {
try { try {
setIsLoading(true) setIsLoading(true);
const [providersRes, gamesRes] = await Promise.all([ const [providersRes, gamesRes] = await Promise.all([
api.get("/virtual-game/orchestrator/providers"), 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 pData = providersRes.data;
const gData = gamesRes.data const gData = gamesRes.data;
const providersList = pData.providers || pData.data || pData || [] const providersList = pData.providers || pData.data || pData || [];
const gamesList = gData.data || gData.games || gData || [] const gamesList = gData.data || gData.games || gData || [];
setProviders(Array.isArray(providersList) ? providersList : []) setProviders(Array.isArray(providersList) ? providersList : []);
setGames(Array.isArray(gamesList) ? gamesList : []) setGames(Array.isArray(gamesList) ? gamesList : []);
} catch (err: any) { } catch (err: any) {
console.error("Failed to fetch virtual games data:", err) console.error("Failed to fetch virtual games data:", err);
setError("Failed to load games data.") setError("Failed to load games data.");
} finally { } finally {
setIsLoading(false) setIsLoading(false);
}
} }
};
fetchVirtualData() fetchVirtualData();
}, []) }, []);
// Create Sidebar Categories dynamically from providers // Create Sidebar Categories dynamically from providers
const sidebarCategories = [ const sidebarCategories = [
{ id: "all", name: "All Games", icon: LayoutGrid }, { id: "all", name: "All Games", icon: LayoutGrid },
...providers.map(p => ({ ...providers.map((p) => ({
id: p.provider_id, id: p.provider_id,
name: p.provider_name, 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 // Filter games based on active category
// If "all", group by provider // If "all", group by provider
let displayedGames: any[] = [] let displayedGames: any[] = [];
let groupedGames: { title: string, games: any[] }[] = [] let groupedGames: { title: string; games: any[] }[] = [];
const mapApiGameToCard = (game: ApiGame) => ({ const mapApiGameToCard = (game: ApiGame) => ({
id: game.gameId, id: game.gameId,
title: game.name, title: game.name,
image: game.thumbnail || game.image || DEFAULT_IMAGE, image: game.thumbnail || game.image || DEFAULT_IMAGE,
provider: game.provider provider: game.provider,
}) });
if (activeCategory === "all") { if (activeCategory === "all") {
// Group up to 12 games per provider for the rows // Group up to 12 games per provider for the rows
providers.forEach(p => { providers.forEach((p) => {
const providerIdStr = String(p.provider_id || "").trim().toLowerCase() const providerIdStr = String(p.provider_id || "")
.trim()
.toLowerCase();
const providerGames = games const providerGames = games
.filter(g => { .filter((g) => {
const gameProvId = String(g.providerId || g.provider_id || "").trim().toLowerCase() const gameProvId = String(g.providerId || g.provider_id || "")
return gameProvId === providerIdStr .trim()
.toLowerCase();
return gameProvId === providerIdStr;
}) })
.slice(0, 12) .slice(0, 12)
.map(mapApiGameToCard) .map(mapApiGameToCard);
if (providerGames.length > 0) { if (providerGames.length > 0) {
groupedGames.push({ groupedGames.push({
title: p.provider_name, title: p.provider_name,
games: providerGames games: providerGames,
}) });
} }
}) });
} else { } else {
displayedGames = games displayedGames = games
.filter(g => { .filter((g) => {
const gameProvId = String(g.providerId || g.provider_id || "").trim().toLowerCase() const gameProvId = String(g.providerId || g.provider_id || "")
const matches = gameProvId === String(activeCategory).trim().toLowerCase() .trim()
if (g.providerId?.toLowerCase().includes('pop') || g.provider_id?.toLowerCase().includes('pop')) { .toLowerCase();
const matches =
gameProvId === String(activeCategory).trim().toLowerCase();
if (
g.providerId?.toLowerCase().includes("pop") ||
g.provider_id?.toLowerCase().includes("pop")
) {
} }
return matches return matches;
}) })
.map(mapApiGameToCard) .map(mapApiGameToCard);
} }
const activeCategoryData = providers.find( 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 ( return (
<div className="flex h-[calc(100vh-140px)] overflow-hidden bg-brand-bg"> <div className="flex h-[calc(100vh-140px)] overflow-hidden bg-brand-bg">
@ -180,7 +202,7 @@ export default function VirtualPage() {
<div className="p-4"> <div className="p-4">
<div className="flex items-center justify-between mb-6"> <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"> <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> </h2>
<button <button
onClick={() => setActiveCategory("all")} onClick={() => setActiveCategory("all")}
@ -200,5 +222,5 @@ export default function VirtualPage() {
</div> </div>
</main> </main>
</div> </div>
) );
} }

View File

@ -5,8 +5,11 @@ import Link from "next/link"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { useBetslipStore } from "@/lib/store/betslip-store" import { useBetslipStore } from "@/lib/store/betslip-store"
import { mockEvents, popularLeagues, type Event } from "@/lib/mock-data" 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 { cn } from "@/lib/utils"
import { ChevronDown, BarChart2, TrendingUp, Plus } from "lucide-react" import { ChevronDown, BarChart2, TrendingUp, Plus, Loader2 } from "lucide-react"
function OddsButton({ odds, onClick, isSelected }: { function OddsButton({ odds, onClick, isSelected }: {
odds: number odds: number
@ -26,7 +29,7 @@ function OddsButton({ odds, onClick, isSelected }: {
) )
} }
function EventRow({ event }: { event: Event }) { function EventRow({ event }: { event: Event | AppEvent }) {
const { bets, addBet } = useBetslipStore() const { bets, addBet } = useBetslipStore()
return ( return (
@ -82,22 +85,47 @@ function EventRow({ event }: { event: Event }) {
) )
} }
const MARKET_HEADERS = ["1", "x", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"]; const MARKET_HEADERS = ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"]
export function EventsList({ filter = "All", sport = "all", search = "" }: { const ROW1_TABS: { key: MarketTabKey; label: string }[] = [
{ key: "main", label: "Main" },
{ key: "goals", label: "Goals" },
{ key: "handicap", label: "Handicap" },
{ key: "half_time", label: "Half Time / Full Time" },
{ key: "correct_score", label: "Correct Score" },
]
const ROW2_TABS: { key: MarketTabKey; label: string }[] = [
{ key: "1st_half", label: "1st Half" },
{ key: "2nd_half", label: "2nd Half" },
{ key: "combo", label: "Combo" },
{ key: "chance_mix", label: "Chance Mix" },
{ key: "home", label: "Home" },
]
export function EventsList({ filter = "All", sport: sportProp = "all", search = "" }: {
filter?: string filter?: string
sport?: string sport?: string
search?: string search?: string
}) { }) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const leagueQuery = searchParams.get("league") const leagueQuery = searchParams.get("league")
const sportQuery = searchParams.get("sport") ?? sportProp
const [selectedLeague, setSelectedLeague] = useState<string | null>(leagueQuery) const [selectedLeague, setSelectedLeague] = useState<string | null>(leagueQuery)
const [activeTab, setActiveTab] = useState<MarketTabKey>("main")
const { bets, addBet } = useBetslipStore() 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(() => { useEffect(() => {
setSelectedLeague(leagueQuery) setSelectedLeague(leagueQuery)
}, [leagueQuery]) }, [leagueQuery])
useEffect(() => {
setFilters(sportId, leagueId)
}, [sportId, leagueId, setFilters])
const handleClose = () => { const handleClose = () => {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.delete("league") url.searchParams.delete("league")
@ -105,13 +133,17 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
setSelectedLeague(null) setSelectedLeague(null)
} }
const events = selectedLeague const useApi = !(error && apiEvents.length === 0)
? mockEvents.filter(e => e.league.toLowerCase() === selectedLeague.toLowerCase()) const events = useApi
? (filter === "Live" ? apiEvents.filter((e) => e.isLive) : apiEvents)
: selectedLeague
? mockEvents.filter((e) => e.league.toLowerCase() === selectedLeague.toLowerCase())
: mockEvents.filter((e) => { : mockEvents.filter((e) => {
if (filter === "Live" && !e.isLive) return false if (filter === "Live" && !e.isLive) return false
if (sport !== "all" && e.sport.toLowerCase() !== sport.toLowerCase()) return false if (sportProp !== "all" && e.sport.toLowerCase() !== sportProp.toLowerCase()) return false
return true return true
}) })
const showLoadMore = useApi && hasMore && events.length > 0
// Common Header Rendering // Common Header Rendering
const renderTableHeaders = () => ( const renderTableHeaders = () => (
@ -135,66 +167,101 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
</> </>
) )
const renderColumnHeaders = () => ( const getHeadersForTab = (tab: MarketTabKey) => {
<div className="bg-brand-surface border-b border-white/5 h-8 flex items-center text-[9px] font-black text-white/40 uppercase"> const first = events[0]
<div className="w-[180px] px-3 flex items-center gap-1.5 border-r border-border/10 h-full">Main</div> const rawOdds: ApiOdds[] = first && "rawOdds" in first && Array.isArray((first as AppEvent).rawOdds) ? (first as AppEvent).rawOdds! : []
<div className="w-[180px] flex items-center justify-center border-r border-border/10 h-full">Over/Under</div> return getMarketsForTab(rawOdds, tab).headers
<div className="flex-1 grid grid-cols-10 text-center h-full items-center tracking-tighter"> }
{MARKET_HEADERS.map(h => <span key={h}>{h}</span>)}
const renderColumnHeaders = (tab: MarketTabKey, eventList: (Event | AppEvent)[]) => {
let headers = eventList.length ? getHeadersForTab(tab) : getMarketsForTab([], tab).headers
if (!headers.length) headers = getMarketsForTab([], "main").headers
const n = Math.max(headers.length, 1)
return (
<div className="bg-brand-surface border-b border-white/5 flex flex-col">
<div className="h-8 flex items-center text-[9px] font-black text-white/40 uppercase">
<div className="w-[35px] shrink-0 px-1 border-r border-border/10 h-full flex items-center justify-center" />
<div className="w-[45px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">ID</div>
<div className="w-[50px] shrink-0 border-r border-border/10 h-full flex items-center justify-center">Time</div>
<div className="flex-1 min-w-0 px-3 border-r border-border/10 h-full flex items-center">Event</div>
<div
className="flex-1 grid text-center h-full items-center tracking-tighter border-r border-border/10"
style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
>
{headers.map((h, i) => (
<span key={i} className="border-r border-white/5 last:border-r-0 px-0.5 truncate">
{h}
</span>
))}
</div>
<div className="w-10 shrink-0 border-l border-border/10 h-full" />
</div> </div>
<div className="w-10 border-l border-border/10 h-full" />
</div> </div>
) )
}
const renderEventItem = (event: Event) => ( const renderEventItem = (event: Event | AppEvent, tab: MarketTabKey) => {
const hasRawOdds = event && "rawOdds" in event && Array.isArray((event as AppEvent).rawOdds)
const rawOdds = hasRawOdds ? (event as AppEvent).rawOdds! : []
const { cells } = getMarketsForTab(rawOdds, tab)
const useMainMarkets = !hasRawOdds && event.markets?.length && (tab === "main" || tab === "combo" || tab === "chance_mix" || tab === "home")
const displayCells = useMainMarkets
? event.markets.slice(0, 10).map((m, i) => ({ id: m.id, label: MARKET_HEADERS[i] ?? m.label, odds: m.odds }))
: cells
const n = Math.max(displayCells.length, 1)
return (
<div key={event.id} className="h-[34px] group flex items-center border-b border-white/5 bg-brand-bg hover:bg-white/5 transition-colors"> <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"> <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" /> <BarChart2 className="size-3 cursor-pointer hover:text-primary" />
</div> </div>
{/* ID */}
<div className="w-[45px] text-[10px] font-black text-brand-primary italic tabular-nums text-center border-r border-white/5 h-full flex items-center justify-center"> <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} {event.id}
</div> </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]"> <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>{event.time}</span>
<span className="text-[7px] text-white/30 uppercase mt-0.5">PM</span> <span className="text-[7px] text-white/30 uppercase mt-0.5">PM</span>
</div> </div>
{/* Event Name -> same route as + icon (match detail) */}
<Link <Link
href={`/event/${event.id}`} 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" 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} {event.homeTeam} - {event.awayTeam}
</Link> </Link>
{/* Odds Grid */} <div
<div className="flex-1 grid grid-cols-10 h-full"> className="flex-1 grid h-full"
{event.markets.slice(0, 10).map((market) => { style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
const betId = `${event.id}-${market.id}` >
{displayCells.map((cell) => {
const betId = `${event.id}-${cell.id}`
const isSelected = bets.some((b) => b.id === betId) const isSelected = bets.some((b) => b.id === betId)
const hasOdds = cell.odds > 0
return ( return (
<button <button
key={market.id} key={cell.id}
onClick={() => addBet({ type="button"
disabled={!hasOdds}
onClick={() =>
hasOdds &&
addBet({
id: betId, id: betId,
event: `${event.homeTeam} - ${event.awayTeam}`, event: `${event.homeTeam} - ${event.awayTeam}`,
league: `${event.sport} - ${event.country} - ${event.league}`, league: `${event.sport} - ${event.country} - ${event.league}`,
market: "1X2", market: cell.label,
selection: market.label, selection: cell.label,
odds: market.odds, odds: cell.odds,
})} })
}
className={cn( className={cn(
"flex items-center justify-center text-[10.5px] font-black tabular-nums transition-all border-r border-white/5", "flex items-center justify-center text-[10.5px] font-black tabular-nums transition-all border-r border-white/5",
isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5" isSelected ? "bg-brand-primary text-black" : "text-brand-primary hover:bg-white/5",
!hasOdds && "text-white/30 cursor-default hover:bg-transparent"
)} )}
> >
{market.odds.toFixed(2)} {hasOdds ? cell.odds.toFixed(2) : "—"}
</button> </button>
) )
})} })}
</div> </div>
{/* More Button -> match detail page */}
<Link <Link
href={`/event/${event.id}`} 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" className="w-10 flex items-center justify-center h-full hover:bg-white/5 transition-colors border-l border-white/5 text-white/40 hover:text-white"
@ -204,6 +271,26 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
</Link> </Link>
</div> </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) { if (selectedLeague) {
// Group by date for league view // Group by date for league view
@ -211,7 +298,7 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
if (!acc[event.date]) acc[event.date] = [] if (!acc[event.date]) acc[event.date] = []
acc[event.date].push(event) acc[event.date].push(event)
return acc return acc
}, {} as Record<string, Event[]>) }, {} as Record<string, (Event | AppEvent)[]>)
return ( return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden shadow-2xl"> <div className="flex flex-col bg-brand-bg rounded overflow-hidden shadow-2xl">
@ -231,26 +318,41 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
</button> </button>
</div> </div>
{/* Large Market Tab Grid */} {/* Market category tabs row 1: Main, Goals, Handicap, Half Time / Full Time, Correct Score */}
<div className="grid grid-cols-5 bg-brand-bg border-b border-border/10"> <div className="flex flex-wrap gap-1 p-2 pb-1 bg-brand-bg border-b border-border/10">
{[ {ROW1_TABS.map(({ key, label }) => (
{ 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 <button
key={i} key={key}
type="button"
onClick={() => setActiveTab(key)}
className={cn( className={cn(
"h-8 border-r border-b border-border/10 flex items-center justify-center text-[10px] font-black uppercase transition-all", "px-3 py-1.5 text-[10px] font-black uppercase rounded-sm transition-all",
m.active ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface" activeTab === key ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface hover:text-white"
)} )}
> >
{m.label} {label}
</button>
))}
</div>
{/* Row 2: 1st Half, 2nd Half, Combo, Chance Mix, Home */}
<div className="flex flex-wrap gap-1 px-2 pb-2 bg-brand-bg border-b border-border/10">
{ROW2_TABS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setActiveTab(key)}
className={cn(
"px-3 py-1.5 text-[10px] font-black uppercase rounded-sm transition-all",
activeTab === key ? "bg-brand-primary text-black" : "text-white/60 hover:bg-brand-surface hover:text-white"
)}
>
{label}
</button> </button>
))} ))}
</div> </div>
{/* Column Headers */} {/* Column Headers (dynamic by tab) */}
{renderColumnHeaders()} {renderColumnHeaders(activeTab, events)}
{/* Grouped Events */} {/* Grouped Events */}
<div className="overflow-y-auto max-h-[700px]"> <div className="overflow-y-auto max-h-[700px]">
@ -259,10 +361,29 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
<div className="bg-brand-surface px-2 py-1 text-[10px] font-black text-white border-b border-white/5"> <div className="bg-brand-surface px-2 py-1 text-[10px] font-black text-white border-b border-white/5">
{date} {date}
</div> </div>
{dateEvents.map(event => renderEventItem(event))} {dateEvents.map((event) => renderEventItem(event, activeTab))}
</div> </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> </div>
) )
} }
@ -276,6 +397,11 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
return ( return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden"> <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"> <div className="flex flex-col">
{Object.entries(homeEventsByLeague).map(([leagueName, leagueEvents]) => ( {Object.entries(homeEventsByLeague).map(([leagueName, leagueEvents]) => (
<div key={leagueName} className="flex flex-col border-b border-white/5 last:border-none"> <div key={leagueName} className="flex flex-col border-b border-white/5 last:border-none">
@ -312,11 +438,30 @@ export function EventsList({ filter = "All", sport = "all", search = "" }: {
{/* Matches in this league */} {/* Matches in this league */}
<div className="flex flex-col"> <div className="flex flex-col">
{leagueEvents.map(event => renderEventItem(event))} {leagueEvents.map((event) => renderEventItem(event, "main"))}
</div> </div>
</div> </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> </div>
) )
} }

View File

@ -1,17 +1,19 @@
"use client" "use client"
import { useEffect } from "react"
import Link from "next/link"
import { useBetslipStore } from "@/lib/store/betslip-store" import { useBetslipStore } from "@/lib/store/betslip-store"
import { mockEvents, type Event } from "@/lib/mock-data" import { useLiveStore } from "@/lib/store/live-store"
import { SportEnum } from "@/lib/store/betting-types"
import { SPORT_ID_MAP } from "@/lib/store/betting-api"
import type { AppEvent } from "@/lib/store/betting-types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { BarChart2, TrendingUp, Monitor, Tv } from "lucide-react" import { BarChart2, Monitor, Loader2 } from "lucide-react"
function LiveEventRow({ event, isNoOdds }: { event: AppEvent; isNoOdds?: boolean }) {
function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean }) { const { addBet } = useBetslipStore()
const { bets, addBet } = useBetslipStore() const score = event.score ?? "0 - 0"
const time = event.matchMinute != null ? `${String(event.matchMinute).padStart(2, "0")}:00` : "—"
// 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" const period = "H2"
return ( return (
@ -23,9 +25,12 @@ function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean })
<span className="text-white/40">{period}</span> <span className="text-white/40">{period}</span>
</div> </div>
<div className="flex items-center min-w-0 flex-1 gap-2"> <div className="flex items-center min-w-0 flex-1 gap-2">
<span className="text-[11.5px] font-black text-white truncate italic uppercase"> <Link
href={`/event/${event.id}`}
className="text-[11.5px] font-black text-white truncate italic uppercase hover:text-brand-primary transition-colors"
>
{event.homeTeam} <span className="text-brand-primary mx-1 tabular-nums">{score}</span> {event.awayTeam} {event.homeTeam} <span className="text-brand-primary mx-1 tabular-nums">{score}</span> {event.awayTeam}
</span> </Link>
</div> </div>
<div className="flex items-center gap-2 shrink-0 px-1 opacity-20 group-hover:opacity-100 transition-opacity"> <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" /> <BarChart2 className="size-3.5 text-white" />
@ -71,64 +76,38 @@ function LiveEventRow({ event, isNoOdds }: { event: Event, isNoOdds?: boolean })
) )
} }
const liveSports = [ const LIVE_SPORT_IDS = [
{ id: "soccer", label: "Soccer", icon: "⚽", count: 25, active: true }, SportEnum.SOCCER,
{ id: "basketball", label: "Basketball", icon: "🏀", count: 39 }, SportEnum.BASKETBALL,
{ id: "ice-hockey", label: "Ice Hockey", icon: "🏒", count: 3 }, SportEnum.ICE_HOCKEY,
{ id: "tennis", label: "Tennis", icon: "🎾", count: 4 }, SportEnum.TENNIS,
{ id: "handball", label: "Handball", icon: "🤾", count: 10 }, SportEnum.HANDBALL,
{ id: "rugby", label: "Rugby", icon: "🏉", count: 2 }, SportEnum.RUGBY_UNION,
{ id: "table-tennis", label: "Table Tennis", icon: "🏓", count: 8 }, SportEnum.TABLE_TENNIS,
{ id: "volleyball", label: "Volleyball", icon: "🏐", count: 7 }, SportEnum.VOLLEYBALL,
{ id: "futsal", label: "Futsal", icon: "⚽", count: 2 }, SportEnum.FUTSAL,
{ id: "esport-counter-strike", label: "ESport Cou...", icon: "🎮", count: 2 }, SportEnum.E_SPORTS,
{ id: "esport-league-of-legends", label: "ESport Lea...", icon: "🎮", count: 1 }, ] as const
{ 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() { export function LiveEventsList() {
// Enhanced mock data local to live view to match screenshot exactly const { events, loading, error, sportId, setSportId, loadLiveEvents } = useLiveStore()
const liveMatches = [
{ useEffect(() => {
league: "Algeria - Ligue 1", loadLiveEvents()
flag: "https://flagcdn.com/w20/dz.png", }, [loadLiveEvents])
matches: [
{ ...mockEvents[0], id: "l1", homeTeam: "Paradou AC", awayTeam: "Ben Aknoun", homeScore: 3, awayScore: 5, liveMinute: 91, noOdds: true } const groupedByLeague = events.reduce((acc, ev) => {
] const key = ev.league || "Other"
}, if (!acc[key]) acc[key] = []
{ acc[key].push(ev)
league: "Australia - U23 Victoria NPL", return acc
flag: "https://flagcdn.com/w20/au.png", }, {} as Record<string, AppEvent[]>)
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 ( return (
<div className="flex flex-col min-h-screen bg-brand-bg"> <div className="flex flex-col min-h-screen bg-brand-bg">
{/* Sport Navigation Carousel */} {/* Sport Navigation: SportEnum ids, no league — event?sport_id=1&first_start_time=RFC3339&is_live=true */}
<div className="bg-brand-surface border-b border-border/20 px-2 flex items-center h-[54px] overflow-x-auto scrollbar-hide"> <div className="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"> <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]"> <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-[14px]"></span>
<span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Favourites</span> <span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Favourites</span>
@ -137,59 +116,72 @@ export function LiveEventsList() {
<span className="text-[14px]"></span> <span className="text-[14px]"></span>
<span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Prematch</span> <span className="text-[9px] font-bold text-white/40 uppercase mt-0.5">Prematch</span>
</button> </button>
{LIVE_SPORT_IDS.map((id) => {
{/* Live Sports */} const info = SPORT_ID_MAP[id]
{liveSports.map((sport) => ( if (!info) return null
const icon = id === SportEnum.SOCCER ? "⚽" : id === SportEnum.TENNIS ? "🎾" : id === SportEnum.BASKETBALL ? "🏀" : id === SportEnum.ICE_HOCKEY ? "🏒" : id === SportEnum.VOLLEYBALL ? "🏐" : id === SportEnum.HANDBALL ? "🤾" : id === SportEnum.E_SPORTS ? "🎮" : "⚽"
const active = sportId === id
return (
<button <button
key={sport.id} key={id}
type="button"
onClick={() => setSportId(id)}
className={cn( className={cn(
"flex flex-col items-center justify-center px-3 h-full border-r border-white/5 min-w-[75px] relative transition-colors", "flex flex-col items-center justify-center px-3 h-full border-r border-white/5 min-w-[75px] relative transition-colors",
sport.active ? "bg-white/5" : "hover:bg-white/5" active ? "bg-white/5" : "hover:bg-white/5"
)} )}
> >
<span className="absolute top-1 right-2 text-[8.5px] font-black text-white/40">{sport.count}</span> <span className="text-[16px]">{icon}</span>
<span className="text-[16px]">{sport.icon}</span>
<span className={cn( <span className={cn(
"text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap", "text-[9px] font-bold uppercase mt-1 tracking-tighter whitespace-nowrap",
sport.active ? "text-brand-primary" : "text-white/40" active ? "text-brand-primary" : "text-white/40"
)}> )}>
{sport.label} {info.name}
</span> </span>
{sport.active && ( {active && <div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />}
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-brand-primary" />
)}
</button> </button>
))} )
})}
</div> </div>
</div> </div>
{/* Category Header (Soccer) */} {/* Category Header */}
<div className="bg-brand-primary px-3 py-1.5 flex items-center gap-2 border-l-[4px] border-brand-primary"> <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]"></span> <span className="text-[16px]">{SPORT_ID_MAP[sportId]?.name === "Soccer" ? "⚽" : "•"}</span>
<h2 className="text-[14px] font-black text-white uppercase tracking-tight">Soccer</h2> <h2 className="text-[14px] font-black text-white uppercase tracking-tight">
{SPORT_ID_MAP[sportId]?.name ?? "Live"}
</h2>
</div> </div>
{/* Grouped Live Matches */} {loading && events.length === 0 ? (
<div className="flex items-center justify-center py-16 gap-2 text-white/60">
<Loader2 className="size-5 animate-spin" />
<span className="text-sm">Loading live events</span>
</div>
) : error && events.length === 0 ? (
<div className="py-8 px-4 text-center text-white/60 text-sm">{error}</div>
) : (
<div className="flex flex-col mb-10"> <div className="flex flex-col mb-10">
{liveMatches.map((group, gIdx) => ( {Object.entries(groupedByLeague).map(([leagueName, matches]) => (
<div key={gIdx} className="flex flex-col"> <div key={leagueName} 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"> <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"> <span className="text-[9.5px] font-black text-white/60 uppercase tracking-widest leading-none">
{group.league} {leagueName}
</span> </span>
</div> </div>
{/* Matches in this league */}
<div className="flex flex-col"> <div className="flex flex-col">
{group.matches.map((match, mIdx) => ( {matches.map((match) => (
<LiveEventRow key={match.id} event={match as any} isNoOdds={match.noOdds} /> <LiveEventRow
key={match.id}
event={match}
isNoOdds={!match.markets?.length || match.markets.every((m) => m.odds <= 0)}
/>
))} ))}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
) )
} }

View File

@ -9,6 +9,8 @@ import {
type Event, type Event,
type DetailMarketSection, type DetailMarketSection,
} from "@/lib/mock-data" } from "@/lib/mock-data"
type ApiSection = { id: string; title: string; outcomes: { label: string; odds: number }[] }
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDown, ChevronUp } from "lucide-react" import { ChevronDown, ChevronUp } from "lucide-react"
@ -77,11 +79,12 @@ function MarketSectionBlock({
<div className="px-3 pb-3 space-y-1.5"> <div className="px-3 pb-3 space-y-1.5">
{section.outcomes.length > 2 && section.outcomes.length % 2 === 0 ? ( {section.outcomes.length > 2 && section.outcomes.length % 2 === 0 ? (
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5"> <div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
{section.outcomes.map((outcome) => { {section.outcomes.map((outcome, i) => {
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}` const oddsStr = typeof outcome.odds === "number" ? outcome.odds.toFixed(2) : String(outcome.odds)
const betId = `${event.id}-${section.id}-${i}-${outcome.label.replace(/\s/g, "-").toLowerCase()}-${oddsStr}`
const isSelected = bets.some((b) => b.id === betId) const isSelected = bets.some((b) => b.id === betId)
return ( return (
<div key={outcome.label} className="flex items-center justify-between gap-2"> <div key={`${outcome.label}-${i}-${oddsStr}`} className="flex items-center justify-between gap-2">
<span className="text-[11px] text-white/90 truncate">{outcome.label}</span> <span className="text-[11px] text-white/90 truncate">{outcome.label}</span>
<button <button
type="button" type="button"
@ -107,12 +110,13 @@ function MarketSectionBlock({
})} })}
</div> </div>
) : ( ) : (
section.outcomes.map((outcome) => { section.outcomes.map((outcome, i) => {
const betId = `${event.id}-${section.id}-${outcome.label.replace(/\s/g, "-").toLowerCase()}` const oddsStr = typeof outcome.odds === "number" ? outcome.odds.toFixed(2) : String(outcome.odds)
const betId = `${event.id}-${section.id}-${i}-${outcome.label.replace(/\s/g, "-").toLowerCase()}-${oddsStr}`
const isSelected = bets.some((b) => b.id === betId) const isSelected = bets.some((b) => b.id === betId)
return ( return (
<div <div
key={outcome.label} key={`${outcome.label}-${i}-${oddsStr}`}
className="flex items-center justify-between gap-3 py-1" className="flex items-center justify-between gap-3 py-1"
> >
<span className="text-[11px] text-white/90">{outcome.label}</span> <span className="text-[11px] text-white/90">{outcome.label}</span>
@ -145,19 +149,32 @@ function MarketSectionBlock({
) )
} }
export function MatchDetailView({ event }: { event: Event }) { export function MatchDetailView({
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({ event,
apiSections,
}: {
event: Event
apiSections?: ApiSection[] | null
}) {
useBetslipStore((s) => s.bets)
const mockDetailMarkets = getEventDetailMarkets(event.id)
const cardsBookings = getCardsBookingsMarkets(event.id)
const detailMarkets: DetailMarketSection[] = (apiSections?.length
? apiSections.map((s) => ({ id: s.id, title: s.title, outcomes: s.outcomes }))
: mockDetailMarkets) as DetailMarketSection[]
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => ({
"bookings-1x2": true, "bookings-1x2": true,
"sending-off": true, "sending-off": true,
"1st-booking": true, "1st-booking": true,
"1st-half-bookings-1x2": true, "1st-half-bookings-1x2": true,
"booking-points-ou": true, "booking-points-ou": true,
"1st-half-1st-booking": true, "1st-half-1st-booking": true,
}) ...(apiSections?.length
const [activeCategory, setActiveCategory] = useState("Cards/Bookings") ? Object.fromEntries(detailMarkets.slice(0, 8).map((s) => [s.id, true]))
: {}),
const detailMarkets = getEventDetailMarkets(event.id) }))
const cardsBookings = getCardsBookingsMarkets(event.id) const [activeCategory, setActiveCategory] = useState("Main")
const toggleSection = (id: string) => { const toggleSection = (id: string) => {
setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] })) setExpandedSections((prev) => ({ ...prev, [id]: !prev[id] }))
@ -166,11 +183,15 @@ export function MatchDetailView({ event }: { event: Event }) {
const breadcrumbLeague = const breadcrumbLeague =
event.league === "Premier League" event.league === "Premier League"
? "England - Premier League" ? "England - Premier League"
: `${event.country} - ${event.league}` : event.league
? `${event.country} - ${event.league}`
: "Event"
const isCardsBookings = activeCategory === "Cards/Bookings" const isCardsBookings = activeCategory === "Cards/Bookings"
const leftSections = isCardsBookings ? cardsBookings.left : detailMarkets const allSections = isCardsBookings ? [...cardsBookings.left, ...cardsBookings.right] : detailMarkets
const rightSections = isCardsBookings ? cardsBookings.right : [] const mid = Math.ceil(allSections.length / 2)
const leftSections = allSections.slice(0, mid)
const rightSections = allSections.slice(mid)
return ( return (
<div className="flex flex-col bg-brand-bg rounded overflow-hidden"> <div className="flex flex-col bg-brand-bg rounded overflow-hidden">
@ -189,38 +210,38 @@ export function MatchDetailView({ event }: { event: Event }) {
</h1> </h1>
</div> </div>
{/* Match header */} {/* Match header: team names in boxes and below */}
<div className="bg-brand-surface px-4 py-5 border-b border-border/20"> <div className="bg-brand-surface px-4 py-5 border-b border-border/20">
<div className="flex items-center justify-center gap-10"> <div className="flex items-center justify-center gap-10">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2 min-w-0">
<div className="w-16 h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center"> <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-[10px] font-black text-white/60 uppercase"> <span className="text-[11px] font-black text-white leading-tight line-clamp-3">
{event.homeTeam.slice(0, 2)} {event.homeTeam}
</span> </span>
</div> </div>
<span className="text-[13px] font-bold text-white">{event.homeTeam}</span> <span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.homeTeam}</span>
</div> </div>
<span className="text-[12px] font-black text-white/50 uppercase">VS</span> <span className="text-[12px] font-black text-white/50 uppercase shrink-0">VS</span>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2 min-w-0">
<div className="w-16 h-20 rounded-md bg-brand-bg border border-white/10 flex items-center justify-center"> <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-[10px] font-black text-white/60 uppercase"> <span className="text-[11px] font-black text-white leading-tight line-clamp-3">
{event.awayTeam.slice(0, 2)} {event.awayTeam}
</span> </span>
</div> </div>
<span className="text-[13px] font-bold text-white">{event.awayTeam}</span> <span className="text-[13px] font-bold text-white text-center truncate max-w-[120px]">{event.awayTeam}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Category tabs: horizontal scroll, selected = darker grey */} {/* Category tabs: wrap into 23 rows, not scrollable */}
<div className="flex overflow-x-auto gap-1 p-2 bg-brand-bg border-b border-border/20 scrollbar-hide"> <div className="flex flex-wrap gap-1.5 p-2 bg-brand-bg border-b border-border/20">
{MARKET_CATEGORIES.map((label) => ( {MARKET_CATEGORIES.map((label) => (
<button <button
key={label} key={label}
type="button" type="button"
onClick={() => setActiveCategory(label)} onClick={() => setActiveCategory(label)}
className={cn( className={cn(
"px-3 py-1.5 text-[10px] font-bold uppercase whitespace-nowrap rounded transition-colors shrink-0", "px-3 py-1.5 text-[10px] font-bold uppercase whitespace-nowrap rounded transition-colors",
activeCategory === label activeCategory === label
? "bg-brand-surface-light text-white border border-white/10" ? "bg-brand-surface-light text-white border border-white/10"
: "text-white/60 hover:text-white hover:bg-white/5" : "text-white/60 hover:text-white hover:bg-white/5"
@ -231,7 +252,7 @@ export function MatchDetailView({ event }: { event: Event }) {
))} ))}
</div> </div>
{/* Two-column grid of market sections */} {/* Two-column grid of market sections (split evenly so both columns are used) */}
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-0 bg-brand-surface-light"> <div className="grid grid-cols-1 md:grid-cols-2 gap-0 bg-brand-surface-light">
{/* Left column */} {/* Left column */}
@ -247,8 +268,7 @@ export function MatchDetailView({ event }: { event: Event }) {
/> />
))} ))}
</div> </div>
{/* Right column (Cards/Bookings only) */} {/* Right column */}
{rightSections.length > 0 && (
<div> <div>
{rightSections.map((section) => ( {rightSections.map((section) => (
<MarketSectionBlock <MarketSectionBlock
@ -261,7 +281,6 @@ export function MatchDetailView({ event }: { event: Event }) {
/> />
))} ))}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -1,4 +1,9 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" "use client"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tabs } from "@/components/ui/tabs"
const sports = [ const sports = [
{ id: "football", name: "Football", icon: "⚽" }, { id: "football", name: "Football", icon: "⚽" },
@ -14,17 +19,23 @@ const sports = [
] ]
export function SportsNav() { export function SportsNav() {
const searchParams = useSearchParams()
const currentSport = searchParams.get("sport") ?? "football"
return ( return (
<Tabs defaultValue="football" className="w-full"> <Tabs value={currentSport} className="w-full">
<TabsList variant="hs-nav" className="min-h-14! h-auto! py-2"> <TabsList variant="hs-nav" className="min-h-14! h-auto! py-2">
{sports.map((sport) => ( {sports.map((sport) => (
<TabsTrigger <TabsTrigger
key={sport.id} key={sport.id}
value={sport.id} value={sport.id}
asChild
className="flex-col min-w-[70px] py-2 gap-1" 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-xl">{sport.icon}</span>
<span className="text-[10px] font-bold uppercase">{sport.name}</span> <span className="text-[10px] font-bold uppercase">{sport.name}</span>
</Link>
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>

View File

@ -1,58 +1,91 @@
"use client" "use client"
import { useState, useEffect } from "react"
import { ChevronRight } from "lucide-react" import { ChevronRight } from "lucide-react"
import { useBetslipStore } from "@/lib/store/betslip-store" import { useBetslipStore } from "@/lib/store/betslip-store"
import {
fetchEvents,
fetchOddsForEvent,
get1X2FromOddsResponse,
TOP_LEAGUES,
type ApiEvent,
} from "@/lib/betting-api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const topMatches = [ type TopMatch = {
{ id: string
id: "tm1", league: string
league: "England - Premier League", time: string
time: "05:00 PM", homeTeam: string
homeTeam: "Nottingham Forest", awayTeam: string
awayTeam: "Liverpool", odds: { home: number; draw: number; away: number }
odds: { home: 4.09, draw: 3.93, away: 1.82 } }
},
{ const FALLBACK_MATCHES: TopMatch[] = [
id: "tm2", { 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 } },
league: "England - Premier League", { 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 } },
time: "11:00 PM", { id: "tm3", league: "England - Premier League", time: "06:00 PM", homeTeam: "Chelsea", awayTeam: "Burnley", odds: { home: 1.21, draw: 6.91, away: 11.50 } },
homeTeam: "Man City", { id: "tm4", league: "Spain - LaLiga", time: "07:30 PM", homeTeam: "Arsenal", awayTeam: "Wolves", odds: { home: 1.56, draw: 4.16, away: 5.80 } },
awayTeam: "Newcastle", { 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 } },
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() { export function TopMatches() {
const { bets, addBet } = useBetslipStore() 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 ( return (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide -mx-1 px-1"> <div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide -mx-1 px-1">
{topMatches.map((match) => { {matches.map((match) => {
const eventName = `${match.homeTeam} - ${match.awayTeam}` const eventName = `${match.homeTeam} - ${match.awayTeam}`
const leagueForBet = `Football - ${match.league}` const leagueForBet = `Football - ${match.league}`
const outcomes = [ const outcomes = [
@ -94,7 +127,7 @@ export function TopMatches() {
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-[1px] bg-white/5 mx-2 mb-2 rounded-sm overflow-hidden border border-white/5"> <div className="grid grid-cols-3 gap-px bg-white/5 mx-2 mb-2 rounded-sm overflow-hidden border border-white/5">
{outcomes.map(({ key, label, odds }) => { {outcomes.map(({ key, label, odds }) => {
const betId = `${match.id}-${key}` const betId = `${match.id}-${key}`
const isSelected = bets.some((b) => b.id === betId) const isSelected = bets.some((b) => b.id === betId)
@ -123,7 +156,7 @@ export function TopMatches() {
{label} {label}
</span> </span>
<span className={cn("text-[11px] font-black tabular-nums", isSelected ? "text-black" : "text-brand-primary")}> <span className={cn("text-[11px] font-black tabular-nums", isSelected ? "text-black" : "text-brand-primary")}>
{odds.toFixed(2)} {odds > 0 ? odds.toFixed(2) : "—"}
</span> </span>
</button> </button>
) )

View File

@ -9,7 +9,7 @@ export type GameCategory =
| "favourite" | "favourite"
| "recently-played" | "recently-played"
| "most-popular" | "most-popular"
| "fortune-special" | "harif-special"
| "for-you" | "for-you"
| "slots" | "slots"
| "crash-games" | "crash-games"
@ -30,7 +30,7 @@ const categories = [
{ id: "favourite", name: "Favourite", icon: Heart }, { id: "favourite", name: "Favourite", icon: Heart },
{ id: "recently-played", name: "Recently Played", icon: Clock }, { id: "recently-played", name: "Recently Played", icon: Clock },
{ id: "most-popular", name: "Most Popular", icon: Star }, { id: "most-popular", name: "Most Popular", icon: Star },
{ id: "fortune-special", name: "Fortune Special", icon: Star }, { id: "harif-special", name: "Harif Special", icon: Zap },
{ id: "for-you", name: "For You", icon: Star }, { id: "for-you", name: "For You", icon: Star },
{ id: "slots", name: "Slots", icon: Star }, { id: "slots", name: "Slots", icon: Star },
{ id: "crash-games", name: "Crash Games", 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 key={i} className="w-[5px] h-[38px] bg-[#cc2222] -skew-x-12" />
))} ))}
</div> </div>
{/* FORTUNE box */} {/* HARIF box */}
<div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[38px]"> <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"> <span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">
FORTUNE HARIF
</span> </span>
</div> </div>
{/* SPORT text */} {/* SPORT text */}
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none"> <span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">
BETS SPORT
</span> </span>
</div> </div>
) )

View File

@ -4,17 +4,11 @@ import Link from "next/link"
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="bg-brand-surface text-white pt-16"> <footer className="bg-brand-surface text-white pt-12">
<div className="container mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-12 text-center md:text-left">
{/* 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 */} {/* ABOUT */}
<div> <div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest"> <h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">ABOUT</h3>
ABOUT
</h3>
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight"> <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="/about" className="hover:text-primary transition-colors">About us</Link></li>
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy Policy</Link></li> <li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy Policy</Link></li>
@ -25,9 +19,7 @@ export function SiteFooter() {
{/* INFORMATION */} {/* INFORMATION */}
<div> <div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest"> <h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">INFORMATION</h3>
INFORMATION
</h3>
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight"> <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="/terms" className="hover:text-primary transition-colors">Terms & Conditions</Link></li>
<li><Link href="/faq" className="hover:text-primary transition-colors">FAQ</Link></li> <li><Link href="/faq" className="hover:text-primary transition-colors">FAQ</Link></li>
@ -38,11 +30,9 @@ export function SiteFooter() {
{/* SPORTS */} {/* SPORTS */}
<div> <div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest"> <h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">SPORTS</h3>
SPORTS
</h3>
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight"> <ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight">
<li><Link href="/live" className="hover:text-primary transition-colors">Live betting</Link></li> <li><Link href="/live" className="hover:text-primary transition-colors text-blue-400">Live betting</Link></li>
<li><Link href="/football" className="hover:text-primary transition-colors">Football</Link></li> <li><Link href="/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="/basketball" className="hover:text-primary transition-colors">Basketball</Link></li>
<li><Link href="/tennis" className="hover:text-primary transition-colors">Tennis</Link></li> <li><Link href="/tennis" className="hover:text-primary transition-colors">Tennis</Link></li>
@ -52,56 +42,41 @@ export function SiteFooter() {
{/* PLAY NOW */} {/* PLAY NOW */}
<div> <div>
<h3 className="text-[12px] font-black uppercase mb-6 tracking-widest"> <h3 className="text-[12px] font-black uppercase mb-6 tracking-widest">PLAY NOW</h3>
PLAY NOW
</h3>
<ul className="space-y-2 text-[11px] text-white/60 font-medium tracking-tight"> <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="/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> <li><Link href="/special-games" className="hover:text-primary transition-colors">Special Games</Link></li>
</ul> </ul>
</div> </div>
</div>
</div> </div>
{/* Logo Section */} {/* Logo Section */}
<div className="flex flex-col items-center justify-center py-16 border-t border-white/5 mt-16 bg-brand-surface-light"> <div className="flex flex-col items-center justify-center py-16 border-t border-white/5 mt-12 bg-brand-surface-light">
<div className="flex items-center bg-brand-surface px-5 py-2"> <div className="flex items-center bg-brand-surface px-5 py-2">
<div className="bg-brand-accent px-3 py-1 -skew-x-12"> <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"> <span className="text-3xl font-black text-white italic tracking-tighter skew-x-12 inline-block">HARIF</span>
FORTUNE
</span>
</div> </div>
<span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1"> <span className="text-3xl font-black text-brand-primary italic tracking-tighter ml-1">SPORT</span>
BETS
</span>
</div> </div>
{/* Footer Links */} {/* Footer Links */}
<div className="flex flex-wrap items-center justify-center gap-6 mt-12 text-[11px] font-bold tracking-tight text-white/80"> <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"> <Link href="/affiliates" className="hover:text-primary uppercase transition-colors">Affiliates</Link>
Affiliates
</Link>
<span className="size-1 bg-white/10 rounded-full" /> <span className="size-1 bg-white/10 rounded-full" />
<Link href="/complaints" className="hover:text-primary uppercase transition-colors"> <Link href="/complaints" className="hover:text-primary uppercase transition-colors">Complaints</Link>
Complaints
</Link>
<span className="size-1 bg-white/10 rounded-full" /> <span className="size-1 bg-white/10 rounded-full" />
<Link href="/deposits" className="hover:text-primary uppercase transition-colors"> <Link href="/deposits" className="hover:text-primary uppercase transition-colors">Deposits and Withdrawals</Link>
Deposits and Withdrawals
</Link>
</div> </div>
</div> </div>
{/* Cookie Text */} {/* Cookie Text */}
<div className="bg-brand-bg py-10 px-6 text-center"> <div className="bg-brand-bg py-10 px-6 text-center">
<div className="mx-auto max-w-5xl"> <div className="container mx-auto max-w-5xl">
<p className="text-[10px] text-white/40 leading-relaxed font-medium uppercase tracking-tight"> <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. 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> </p>
</div> </div>
</div> </div>
</footer> </footer>
) )
} }

View File

@ -17,7 +17,7 @@ const allNavItems = [
{ href: "/poker", label: "POKER", isNew: true }, { href: "/poker", label: "POKER", isNew: true },
{ href: "/race", label: "RACE", isNew: true }, { href: "/race", label: "RACE", isNew: true },
{ href: "/promo", label: "PROMO" }, { href: "/promo", label: "PROMO" },
// { href: "/aviator", label: "AVIATOR" }, { href: "/aviator", label: "AVIATOR" },
] ]
const drawerLinks = [ const drawerLinks = [
@ -118,9 +118,9 @@ export function SiteHeader({ onLoginClick, onRegisterClick }: SiteHeaderProps) {
<Link href="/" className="flex-1 flex items-center justify-center"> <Link href="/" className="flex-1 flex items-center justify-center">
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[28px]"> <div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[28px]">
<span className="text-xl font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span> <span className="text-xl font-black text-white italic tracking-tighter skew-x-12 leading-none">HARIF</span>
</div> </div>
<span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span> <span className="text-xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
</div> </div>
</Link> </Link>
@ -146,9 +146,9 @@ export function SiteHeader({ onLoginClick, onRegisterClick }: SiteHeaderProps) {
<Link href="/" className="flex items-center shrink-0"> <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="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]"> <div className="bg-brand-accent px-3 py-1 -skew-x-12 flex items-center h-[34px]">
<span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">FORTUNE</span> <span className="text-2xl font-black text-white italic tracking-tighter skew-x-12 inline-block leading-none">HARIF</span>
</div> </div>
<span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span> <span className="text-2xl font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
</div> </div>
</Link> </Link>
<div className="flex items-center flex-1 justify-end px-4 h-full gap-0 bg-brand-surface"> <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 justify-between px-4 py-3 bg-brand-surface border-b border-white/10">
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[24px]"> <div className="bg-brand-accent px-2 py-0.5 -skew-x-12 flex items-center h-[24px]">
<span className="text-base font-black text-white italic tracking-tighter skew-x-12 leading-none">FORTUNE</span> <span className="text-base font-black text-white italic tracking-tighter skew-x-12 leading-none">HARIF</span>
</div> </div>
<span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">BETS</span> <span className="text-base font-black text-brand-primary italic tracking-tighter ml-1 leading-none">SPORT</span>
</div> </div>
<button onClick={() => setDrawerOpen(false)} className="text-white/60 hover:text-white text-2xl leading-none">×</button> <button onClick={() => setDrawerOpen(false)} className="text-white/60 hover:text-white text-2xl leading-none">×</button>
</div> </div>

View File

@ -1,13 +1,32 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useMemo } from "react"
import Link from "next/link" import Link from "next/link"
import { popularLeagues } from "@/lib/mock-data" import { useSearchParams } from "next/navigation"
import { TOP_LEAGUES, fetchLeagues } from "@/lib/store/betting-api"
import { useBettingStore } from "@/lib/store/betting-store"
import type { ApiLeague } from "@/lib/store/betting-types"
import { SportEnum, type QuickFilterKey } from "@/lib/store/betting-types"
import { getCountryName } from "@/lib/countries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ChevronsLeft } from "lucide-react" import { ChevronsLeft, ChevronDown, ChevronUp, Plus } from "lucide-react"
/** Sidebar sports: slug for URL, sport_id for /leagues?sport_id= (order and list from design) */
const SIDEBAR_SPORTS = [
{ id: "football", sport_id: SportEnum.SOCCER, name: "Football", icon: "⚽" },
{ id: "basketball", sport_id: SportEnum.BASKETBALL, name: "Basketball", icon: "🏀" },
{ id: "american-football", sport_id: SportEnum.AMERICAN_FOOTBALL, name: "American Football", icon: "🏈" },
{ id: "baseball", sport_id: SportEnum.BASEBALL, name: "Baseball", icon: "⚾" },
{ id: "cricket", sport_id: SportEnum.CRICKET, name: "Cricket", icon: "🏏" },
{ id: "futsal", sport_id: SportEnum.FUTSAL, name: "Futsal", icon: "⚽" },
{ id: "darts", sport_id: SportEnum.DARTS, name: "Darts", icon: "🎯" },
{ id: "ice-hockey", sport_id: SportEnum.ICE_HOCKEY, name: "Ice Hockey", icon: "🏒" },
{ id: "rugby-union", sport_id: SportEnum.RUGBY_UNION, name: "Rugby", icon: "🏉" },
{ id: "rugby-league", sport_id: SportEnum.RUGBY_LEAGUE, name: "Rugby League", icon: "🏉" },
{ id: "volleyball", sport_id: SportEnum.VOLLEYBALL, name: "Volleyball", icon: "🏐" },
]
/** Soccer ball icon - outline style for white/green theme */
function SoccerBallIcon({ className }: { className?: string }) { function SoccerBallIcon({ className }: { className?: string }) {
return ( return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@ -18,23 +37,106 @@ function SoccerBallIcon({ className }: { className?: string }) {
) )
} }
const sportCategories = [ const QUICK_FILTER_OPTIONS: { label: string; key: QuickFilterKey }[] = [
{ id: "football", name: "Football", icon: "⚽", count: 1412 }, { label: "All", key: "all" },
{ id: "tennis", name: "Tennis", icon: "🎾", count: 67 }, { label: "Today", key: "today" },
{ id: "basketball", name: "Basketball", icon: "🏀", count: 255 }, { label: "3h", key: "3h" },
{ id: "ice-hockey", name: "Ice Hockey", icon: "🏒", count: 238 }, { label: "6h", key: "6h" },
{ id: "mma", name: "MMA", icon: "🥊", count: 51 }, { label: "9h", key: "9h" },
{ id: "handball", name: "Handball", icon: "🤾", count: 92 }, { label: "12h", key: "12h" },
{ 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() { export function SportsSidebar() {
const [activeSport, setActiveSport] = useState("football") const searchParams = useSearchParams()
const sportFromUrl = searchParams.get("sport") ?? "football"
const leagueFromUrl = searchParams.get("league")
const [expandedSport, setExpandedSport] = useState<string | null>(sportFromUrl)
const [expandedCountries, setExpandedCountries] = useState<Set<string>>(new Set())
const [leaguesBySportId, setLeaguesBySportId] = useState<Record<number, ApiLeague[]>>({})
const [loadingSportId, setLoadingSportId] = useState<number | null>(null)
useEffect(() => {
setExpandedSport((prev) => (prev ?? sportFromUrl) || sportFromUrl)
}, [sportFromUrl])
const currentSport = SIDEBAR_SPORTS.find((s) => s.id === sportFromUrl)
const sportId = currentSport?.sport_id ?? SportEnum.SOCCER
useEffect(() => {
if (!expandedSport) return
const sport = SIDEBAR_SPORTS.find((s) => s.id === expandedSport)
if (!sport || sport.sport_id in leaguesBySportId) return
let cancelled = false
setLoadingSportId(sport.sport_id)
fetchLeagues(sport.sport_id)
.then((res) => {
if (!cancelled) setLeaguesBySportId((prev) => ({ ...prev, [sport.sport_id]: res.data ?? [] }))
})
.catch(() => {
if (!cancelled) setLeaguesBySportId((prev) => ({ ...prev, [sport.sport_id]: [] }))
})
.finally(() => {
if (!cancelled) setLoadingSportId(null)
})
return () => {
cancelled = true
}
}, [expandedSport, leaguesBySportId])
const toggleCountry = (cc: string) => {
setExpandedCountries((prev) => {
const next = new Set(prev)
if (next.has(cc)) next.delete(cc)
else next.add(cc)
return next
})
}
const getCountriesForSport = (sportIdNum: number) => {
const leagues = leaguesBySportId[sportIdNum] ?? []
const ccSet = new Set<string>()
leagues.forEach((l) => ccSet.add((l.cc || "").trim().toLowerCase() || "__intl__"))
return Array.from(ccSet)
.map((cc) => ({
cc: cc === "__intl__" ? "" : cc,
name: cc === "__intl__" ? "International" : getCountryName(cc),
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
const getLeaguesForCountry = (sportIdNum: number, countryCc: string) => {
const leagues = leaguesBySportId[sportIdNum] ?? []
const cc = countryCc.toLowerCase()
return leagues
.filter((l) => ((l.cc || "").trim().toLowerCase() || "") === cc)
.sort((a, b) => a.name.localeCompare(b.name))
}
return ( 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"> <aside className="hidden h-full w-[280px] shrink-0 bg-brand-surface-light lg:block overflow-y-auto border-r border-border/40 scrollbar-hide">
@ -51,21 +153,18 @@ export function SportsSidebar() {
Top Leagues Top Leagues
</div> </div>
{/* Popular Leagues */} {/* Top Leagues */}
<div className="flex flex-col"> <div className="flex flex-col">
{popularLeagues.map((league) => ( {TOP_LEAGUES.map((league) => (
<Link <Link
key={league.id} key={league.id}
href={`/?league=${league.id}`} href={`/?sport=${sportFromUrl}&league=${league.id}`}
scroll={false}
className="w-full flex items-center justify-between px-3 py-2 text-left text-white/90 hover:bg-brand-surface transition-colors border-b border-border/10 group h-9" 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="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"> <div className="size-5 shrink-0 overflow-hidden rounded-sm flex items-center justify-center bg-white/5 border border-white/10 group-hover:border-white/20 transition-colors">
{league.logo ? (
<img src={league.logo} alt="" className="size-full object-contain" />
) : (
<span className="text-[11px]"></span> <span className="text-[11px]"></span>
)}
</div> </div>
<span className="text-white/50 text-[8px] font-bold select-none"></span> <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> <span className="text-[10.5px] font-bold leading-tight truncate max-w-[140px]">{league.name}</span>
@ -83,18 +182,8 @@ export function SportsSidebar() {
</Link> </Link>
</Button> </Button>
{/* Quick Filter Section */} {/* Quick Filter Section: passes first_start_time (RFC3339) to events API */}
<div className="bg-brand-surface p-3 border-b border-border/30"> <QuickFilterSection />
<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 */} {/* Search Event Section */}
<div className="bg-brand-surface p-3 border-b border-border/40"> <div className="bg-brand-surface p-3 border-b border-border/40">
@ -108,30 +197,103 @@ export function SportsSidebar() {
</div> </div>
</div> </div>
{/* Sport categories */} {/* Nested: Sport → Countries → Leagues (mapped to sport_id & leagues API cc) */}
<div className="divide-y divide-border/10 bg-brand-surface-light"> <div className="divide-y divide-border/10 bg-brand-surface-light">
{sportCategories.map((sport) => ( {SIDEBAR_SPORTS.map((sport) => {
const isExpanded = expandedSport === sport.id
const leagues = leaguesBySportId[sport.sport_id] ?? []
const loading = loadingSportId === sport.sport_id
const countries = getCountriesForSport(sport.sport_id)
return (
<div key={sport.id} className="border-b border-border/10">
{/* Sport row: click only expands/collapses to show countries (no navigation) */}
<button <button
key={sport.id} type="button"
onClick={() => setActiveSport(sport.id)} onClick={() => setExpandedSport(isExpanded ? null : sport.id)}
className={cn( className={cn(
"w-full flex items-center justify-between px-3 py-2 text-left transition-colors border-b border-border/10 h-9", "w-full flex items-center gap-1 py-2 pr-2 pl-1.5 text-left transition-colors h-9",
activeSport === sport.id isExpanded ? "bg-brand-surface text-brand-primary" : "text-white/80 hover:bg-brand-surface hover:text-white"
? "bg-brand-surface text-white"
: "text-white/70 hover:bg-brand-surface hover:text-white"
)} )}
> >
<div className="flex items-center gap-3"> {isExpanded ? <ChevronUp className="size-3.5 shrink-0 text-current" /> : <ChevronDown className="size-3.5 shrink-0 text-current" />}
<span className="text-[12px] opacity-80 shrink-0">{sport.icon}</span> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-[10.5px] font-bold tracking-tight">{sport.name}</span> <span className="text-[12px] shrink-0">{sport.icon}</span>
</div> <span className="text-[10.5px] font-bold truncate">{sport.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> </div>
{leagues.length > 0 && (
<span className="text-[9px] font-bold text-white/40 shrink-0">{leagues.length}</span>
)}
</button> </button>
{/* Countries (nested under sport) */}
{isExpanded && (
<div className="bg-brand-surface-light/80 pl-4">
{loading ? (
<div className="py-2 text-[10px] text-white/50">Loading</div>
) : (
countries.map(({ cc, name }) => {
const countryExpanded = expandedCountries.has(cc || "__intl__")
const countryKey = cc || "__intl__"
const leaguesInCountry = getLeaguesForCountry(sport.sport_id, cc)
return (
<div key={countryKey} className="border-b border-border/5">
<button
type="button"
onClick={() => toggleCountry(countryKey)}
className={cn(
"w-full flex items-center justify-between gap-2 py-1.5 pr-2 text-left text-[10.5px] font-bold transition-colors",
countryExpanded ? "text-brand-primary" : "text-white/80 hover:text-white"
)}
>
<div className="flex items-center gap-2 min-w-0">
{cc ? (
<img
src={`https://flagcdn.com/w20/${cc}.png`}
alt=""
width={20}
height={14}
className="shrink-0 rounded-sm object-cover w-5 h-[14px]"
/>
) : (
<span className="size-5 shrink-0 flex items-center justify-center text-[10px] text-white/50"></span>
)}
<span className="truncate">{name}</span>
</div>
{countryExpanded ? <ChevronUp className="size-3 shrink-0" /> : <ChevronDown className="size-3 shrink-0" />}
</button>
{/* Leagues (nested under country) */}
{countryExpanded && (
<div className="pl-2 pb-1 max-h-48 overflow-y-auto">
{leaguesInCountry.map((league) => (
<Link
key={league.id}
href={`/?sport=${sport.id}&league=${league.id}`}
scroll={false}
className={cn(
"flex items-center justify-between gap-1 py-1.5 pr-1 text-[10px] font-bold border-b border-border/5 hover:bg-brand-surface/50 transition-colors group",
leagueFromUrl === String(league.id) ? "text-brand-primary" : "text-white/90"
)}
>
<span className="text-white/50 text-[8px] group-hover:text-brand-primary"></span>
<span className="flex-1 truncate">{league.name}</span>
<Plus className="size-3 shrink-0 text-white/40 group-hover:text-brand-primary" />
</Link>
))} ))}
</div> </div>
)}
</div>
)
})
)}
</div>
)}
</div>
)
})}
</div>
{/* Bet Services */} {/* 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"> <div className="mt-2 text-[11px] font-bold text-brand-primary px-3 py-2 uppercase border-y border-border/20 bg-brand-surface">

245
countries.json Normal file
View File

@ -0,0 +1,245 @@
[
{ "name": "Afghanistan", "code": "AF" },
{ "name": "Åland Islands", "code": "AX" },
{ "name": "Albania", "code": "AL" },
{ "name": "Algeria", "code": "DZ" },
{ "name": "American Samoa", "code": "AS" },
{ "name": "AndorrA", "code": "AD" },
{ "name": "Angola", "code": "AO" },
{ "name": "Anguilla", "code": "AI" },
{ "name": "Antarctica", "code": "AQ" },
{ "name": "Antigua and Barbuda", "code": "AG" },
{ "name": "Argentina", "code": "AR" },
{ "name": "Armenia", "code": "AM" },
{ "name": "Aruba", "code": "AW" },
{ "name": "Australia", "code": "AU" },
{ "name": "Austria", "code": "AT" },
{ "name": "Azerbaijan", "code": "AZ" },
{ "name": "Bahamas", "code": "BS" },
{ "name": "Bahrain", "code": "BH" },
{ "name": "Bangladesh", "code": "BD" },
{ "name": "Barbados", "code": "BB" },
{ "name": "Belarus", "code": "BY" },
{ "name": "Belgium", "code": "BE" },
{ "name": "Belize", "code": "BZ" },
{ "name": "Benin", "code": "BJ" },
{ "name": "Bermuda", "code": "BM" },
{ "name": "Bhutan", "code": "BT" },
{ "name": "Bolivia", "code": "BO" },
{ "name": "Bosnia and Herzegovina", "code": "BA" },
{ "name": "Botswana", "code": "BW" },
{ "name": "Bouvet Island", "code": "BV" },
{ "name": "Brazil", "code": "BR" },
{ "name": "British Indian Ocean Territory", "code": "IO" },
{ "name": "Brunei Darussalam", "code": "BN" },
{ "name": "Bulgaria", "code": "BG" },
{ "name": "Burkina Faso", "code": "BF" },
{ "name": "Burundi", "code": "BI" },
{ "name": "Cambodia", "code": "KH" },
{ "name": "Cameroon", "code": "CM" },
{ "name": "Canada", "code": "CA" },
{ "name": "Cape Verde", "code": "CV" },
{ "name": "Cayman Islands", "code": "KY" },
{ "name": "Central African Republic", "code": "CF" },
{ "name": "Chad", "code": "TD" },
{ "name": "Chile", "code": "CL" },
{ "name": "China", "code": "CN" },
{ "name": "Christmas Island", "code": "CX" },
{ "name": "Cocos (Keeling) Islands", "code": "CC" },
{ "name": "Colombia", "code": "CO" },
{ "name": "Comoros", "code": "KM" },
{ "name": "Congo", "code": "CG" },
{ "name": "Congo, The Democratic Republic of the", "code": "CD" },
{ "name": "Cook Islands", "code": "CK" },
{ "name": "Costa Rica", "code": "CR" },
{ "name": "Cote D'Ivoire", "code": "CI" },
{ "name": "Croatia", "code": "HR" },
{ "name": "Cuba", "code": "CU" },
{ "name": "Cyprus", "code": "CY" },
{ "name": "Czech Republic", "code": "CZ" },
{ "name": "Denmark", "code": "DK" },
{ "name": "Djibouti", "code": "DJ" },
{ "name": "Dominica", "code": "DM" },
{ "name": "Dominican Republic", "code": "DO" },
{ "name": "Ecuador", "code": "EC" },
{ "name": "Egypt", "code": "EG" },
{ "name": "El Salvador", "code": "SV" },
{ "name": "Equatorial Guinea", "code": "GQ" },
{ "name": "Eritrea", "code": "ER" },
{ "name": "Estonia", "code": "EE" },
{ "name": "Ethiopia", "code": "ET" },
{ "name": "Falkland Islands (Malvinas)", "code": "FK" },
{ "name": "Faroe Islands", "code": "FO" },
{ "name": "Fiji", "code": "FJ" },
{ "name": "Finland", "code": "FI" },
{ "name": "France", "code": "FR" },
{ "name": "French Guiana", "code": "GF" },
{ "name": "French Polynesia", "code": "PF" },
{ "name": "French Southern Territories", "code": "TF" },
{ "name": "Gabon", "code": "GA" },
{ "name": "Gambia", "code": "GM" },
{ "name": "Georgia", "code": "GE" },
{ "name": "Germany", "code": "DE" },
{ "name": "Ghana", "code": "GH" },
{ "name": "Gibraltar", "code": "GI" },
{ "name": "Greece", "code": "GR" },
{ "name": "Greenland", "code": "GL" },
{ "name": "Grenada", "code": "GD" },
{ "name": "Guadeloupe", "code": "GP" },
{ "name": "Guam", "code": "GU" },
{ "name": "Guatemala", "code": "GT" },
{ "name": "Guernsey", "code": "GG" },
{ "name": "Guinea", "code": "GN" },
{ "name": "Guinea-Bissau", "code": "GW" },
{ "name": "Guyana", "code": "GY" },
{ "name": "Haiti", "code": "HT" },
{ "name": "Heard Island and Mcdonald Islands", "code": "HM" },
{ "name": "Holy See (Vatican City State)", "code": "VA" },
{ "name": "Honduras", "code": "HN" },
{ "name": "Hong Kong", "code": "HK" },
{ "name": "Hungary", "code": "HU" },
{ "name": "Iceland", "code": "IS" },
{ "name": "India", "code": "IN" },
{ "name": "Indonesia", "code": "ID" },
{ "name": "Iran, Islamic Republic Of", "code": "IR" },
{ "name": "Iraq", "code": "IQ" },
{ "name": "Ireland", "code": "IE" },
{ "name": "Isle of Man", "code": "IM" },
{ "name": "Israel", "code": "IL" },
{ "name": "Italy", "code": "IT" },
{ "name": "Jamaica", "code": "JM" },
{ "name": "Japan", "code": "JP" },
{ "name": "Jersey", "code": "JE" },
{ "name": "Jordan", "code": "JO" },
{ "name": "Kazakhstan", "code": "KZ" },
{ "name": "Kenya", "code": "KE" },
{ "name": "Kiribati", "code": "KI" },
{ "name": "Korea, Democratic People'S Republic of", "code": "KP" },
{ "name": "Korea, Republic of", "code": "KR" },
{ "name": "Kuwait", "code": "KW" },
{ "name": "Kyrgyzstan", "code": "KG" },
{ "name": "Lao People'S Democratic Republic", "code": "LA" },
{ "name": "Latvia", "code": "LV" },
{ "name": "Lebanon", "code": "LB" },
{ "name": "Lesotho", "code": "LS" },
{ "name": "Liberia", "code": "LR" },
{ "name": "Libyan Arab Jamahiriya", "code": "LY" },
{ "name": "Liechtenstein", "code": "LI" },
{ "name": "Lithuania", "code": "LT" },
{ "name": "Luxembourg", "code": "LU" },
{ "name": "Macao", "code": "MO" },
{ "name": "Macedonia, The Former Yugoslav Republic of", "code": "MK" },
{ "name": "Madagascar", "code": "MG" },
{ "name": "Malawi", "code": "MW" },
{ "name": "Malaysia", "code": "MY" },
{ "name": "Maldives", "code": "MV" },
{ "name": "Mali", "code": "ML" },
{ "name": "Malta", "code": "MT" },
{ "name": "Marshall Islands", "code": "MH" },
{ "name": "Martinique", "code": "MQ" },
{ "name": "Mauritania", "code": "MR" },
{ "name": "Mauritius", "code": "MU" },
{ "name": "Mayotte", "code": "YT" },
{ "name": "Mexico", "code": "MX" },
{ "name": "Micronesia, Federated States of", "code": "FM" },
{ "name": "Moldova, Republic of", "code": "MD" },
{ "name": "Monaco", "code": "MC" },
{ "name": "Mongolia", "code": "MN" },
{ "name": "Montserrat", "code": "MS" },
{ "name": "Morocco", "code": "MA" },
{ "name": "Mozambique", "code": "MZ" },
{ "name": "Myanmar", "code": "MM" },
{ "name": "Namibia", "code": "NA" },
{ "name": "Nauru", "code": "NR" },
{ "name": "Nepal", "code": "NP" },
{ "name": "Netherlands", "code": "NL" },
{ "name": "Netherlands Antilles", "code": "AN" },
{ "name": "New Caledonia", "code": "NC" },
{ "name": "New Zealand", "code": "NZ" },
{ "name": "Nicaragua", "code": "NI" },
{ "name": "Niger", "code": "NE" },
{ "name": "Nigeria", "code": "NG" },
{ "name": "Niue", "code": "NU" },
{ "name": "Norfolk Island", "code": "NF" },
{ "name": "Northern Mariana Islands", "code": "MP" },
{ "name": "Norway", "code": "NO" },
{ "name": "Oman", "code": "OM" },
{ "name": "Pakistan", "code": "PK" },
{ "name": "Palau", "code": "PW" },
{ "name": "Palestinian Territory, Occupied", "code": "PS" },
{ "name": "Panama", "code": "PA" },
{ "name": "Papua New Guinea", "code": "PG" },
{ "name": "Paraguay", "code": "PY" },
{ "name": "Peru", "code": "PE" },
{ "name": "Philippines", "code": "PH" },
{ "name": "Pitcairn", "code": "PN" },
{ "name": "Poland", "code": "PL" },
{ "name": "Portugal", "code": "PT" },
{ "name": "Puerto Rico", "code": "PR" },
{ "name": "Qatar", "code": "QA" },
{ "name": "Reunion", "code": "RE" },
{ "name": "Romania", "code": "RO" },
{ "name": "Russian Federation", "code": "RU" },
{ "name": "RWANDA", "code": "RW" },
{ "name": "Saint Helena", "code": "SH" },
{ "name": "Saint Kitts and Nevis", "code": "KN" },
{ "name": "Saint Lucia", "code": "LC" },
{ "name": "Saint Pierre and Miquelon", "code": "PM" },
{ "name": "Saint Vincent and the Grenadines", "code": "VC" },
{ "name": "Samoa", "code": "WS" },
{ "name": "San Marino", "code": "SM" },
{ "name": "Sao Tome and Principe", "code": "ST" },
{ "name": "Saudi Arabia", "code": "SA" },
{ "name": "Senegal", "code": "SN" },
{ "name": "Serbia and Montenegro", "code": "CS" },
{ "name": "Seychelles", "code": "SC" },
{ "name": "Sierra Leone", "code": "SL" },
{ "name": "Singapore", "code": "SG" },
{ "name": "Slovakia", "code": "SK" },
{ "name": "Slovenia", "code": "SI" },
{ "name": "Solomon Islands", "code": "SB" },
{ "name": "Somalia", "code": "SO" },
{ "name": "South Africa", "code": "ZA" },
{ "name": "South Georgia and the South Sandwich Islands", "code": "GS" },
{ "name": "Spain", "code": "ES" },
{ "name": "Sri Lanka", "code": "LK" },
{ "name": "Sudan", "code": "SD" },
{ "name": "Suriname", "code": "SR" },
{ "name": "Svalbard and Jan Mayen", "code": "SJ" },
{ "name": "Swaziland", "code": "SZ" },
{ "name": "Sweden", "code": "SE" },
{ "name": "Switzerland", "code": "CH" },
{ "name": "Syrian Arab Republic", "code": "SY" },
{ "name": "Taiwan, Province of China", "code": "TW" },
{ "name": "Tajikistan", "code": "TJ" },
{ "name": "Tanzania, United Republic of", "code": "TZ" },
{ "name": "Thailand", "code": "TH" },
{ "name": "Timor-Leste", "code": "TL" },
{ "name": "Togo", "code": "TG" },
{ "name": "Tokelau", "code": "TK" },
{ "name": "Tonga", "code": "TO" },
{ "name": "Trinidad and Tobago", "code": "TT" },
{ "name": "Tunisia", "code": "TN" },
{ "name": "Turkey", "code": "TR" },
{ "name": "Turkmenistan", "code": "TM" },
{ "name": "Turks and Caicos Islands", "code": "TC" },
{ "name": "Tuvalu", "code": "TV" },
{ "name": "Uganda", "code": "UG" },
{ "name": "Ukraine", "code": "UA" },
{ "name": "United Arab Emirates", "code": "AE" },
{ "name": "United Kingdom", "code": "GB" },
{ "name": "United States", "code": "US" },
{ "name": "United States Minor Outlying Islands", "code": "UM" },
{ "name": "Uruguay", "code": "UY" },
{ "name": "Uzbekistan", "code": "UZ" },
{ "name": "Vanuatu", "code": "VU" },
{ "name": "Venezuela", "code": "VE" },
{ "name": "Viet Nam", "code": "VN" },
{ "name": "Virgin Islands, British", "code": "VG" },
{ "name": "Virgin Islands, U.S.", "code": "VI" },
{ "name": "Wallis and Futuna", "code": "WF" },
{ "name": "Western Sahara", "code": "EH" },
{ "name": "Yemen", "code": "YE" },
{ "name": "Zambia", "code": "ZM" },
{ "name": "Zimbabwe", "code": "ZW" }
]

View File

@ -1,10 +1,12 @@
import axios from 'axios'; import axios from "axios";
// Create a configured Axios instance // Create a configured Axios instance
const api = axios.create({ 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: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
"ngrok-skip-browser-warning": "true",
}, },
}); });
@ -12,9 +14,10 @@ const api = axios.create({
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
// Only access localStorage if we are running in the browser // 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 = localStorage.getItem('token');
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjI3NzQxNSwibmJmIjoxNzcyMjc2ODE1LCJpYXQiOjE3NzIyNzY4MTUsIlVzZXJJZCI6NCwiUm9sZSI6InN1cGVyX2FkbWluIiwiQ29tcGFueUlEIjp7IlZhbHVlIjowLCJWYWxpZCI6ZmFsc2V9fQ.QJJ1KAFkWWCMmxxBi8rQc9C5aChN2XmTys-RCufV_Zo"; const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjQ0NDk1NCwibmJmIjoxNzcyNDQ0MzU0LCJpYXQiOjE3NzI0NDQzNTQsIlVzZXJJZCI6NSwiUm9sZSI6ImN1c3RvbWVyIiwiQ29tcGFueUlEIjp7IlZhbHVlIjoxLCJWYWxpZCI6dHJ1ZX19.6CZQp4VL9ehBh2EfMEohkoVMezT_qFdXajCKsUmWda4";
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@ -23,7 +26,7 @@ api.interceptors.request.use(
}, },
(error) => { (error) => {
return Promise.reject(error); return Promise.reject(error);
} },
); );
// Response interceptor for handling common errors (like 401 Unauthorized) // Response interceptor for handling common errors (like 401 Unauthorized)
@ -34,14 +37,14 @@ api.interceptors.response.use(
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Handle unauthorized errors, e.g., redirecting to login or clearing the token // Handle unauthorized errors, e.g., redirecting to login or clearing the token
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
localStorage.removeItem('token'); localStorage.removeItem("token");
// Uncomment the line below to redirect automatically // Uncomment the line below to redirect automatically
// window.location.href = '/login'; // window.location.href = '/login';
} }
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default api; export default api;

43
lib/betting-api.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* Re-export betting API, types, and helpers from store folder.
* Server components (e.g. event/[id]/page) and components use these imports.
*/
export {
TOP_LEAGUES,
SPORT_ID_MAP,
SPORT_SLUG_TO_ID,
SPORT_ALL,
fetchEvents,
fetchLeagues,
fetchTopLeagues,
fetchOdds,
fetchOddsForEvent,
fetchJson,
apiEventToAppEvent,
get1X2ForEvent,
get1X2FromOddsResponse,
getListMarketsFromOddsResponse,
apiOddsToSections,
getMarketsForTab,
getTimeRangeForQuickFilter,
} from "@/lib/store/betting-api"
export { SportEnum } from "@/lib/store/betting-types"
export type {
ApiEvent,
ApiLeague,
ApiOdds,
AppEvent,
DetailMarketSectionFromApi,
EventsParams,
EventsResponse,
LeaguesResponse,
MarketTabKey,
OddsResponse,
QuickFilterKey,
TabColumnCell,
} from "@/lib/store/betting-api"
export type { SportId } from "@/lib/store/betting-types"

22
lib/countries.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* Country code (cc) to name for leagues sidebar.
* Leagues API returns cc in lowercase; countries.json uses uppercase codes.
*/
import countriesJson from "@/countries.json"
type CountryEntry = { name: string; code: string }
const CODE_TO_NAME: Record<string, string> = (countriesJson as CountryEntry[]).reduce(
(acc, c) => {
acc[c.code.toLowerCase()] = c.name
return acc
},
{} as Record<string, string>
)
/** Get country name from league cc (e.g. "al" -> "Albania"). Returns "International" for empty cc. */
export function getCountryName(cc: string): string {
if (!cc || !cc.trim()) return "International"
return CODE_TO_NAME[cc.toLowerCase()] ?? cc.toUpperCase()
}

22
lib/hooks/use-events.ts Normal file
View File

@ -0,0 +1,22 @@
"use client"
import { useEffect } from "react"
import { useBettingStore } from "@/lib/store/betting-store"
import type { AppEvent } from "@/lib/store/betting-types"
export type { AppEvent } from "@/lib/store/betting-types"
/**
* Hook that syncs URL filters with the betting store and returns events list state.
* Prefer using useBettingStore() directly when you need full control.
*/
export function useEvents(sportId: number | null, leagueId: string | null, _filterLive: boolean) {
const { events, loading, error, hasMore, loadMore, setFilters } = useBettingStore()
const total = useBettingStore((s) => s.total)
useEffect(() => {
setFilters(sportId, leagueId)
}, [sportId, leagueId, setFilters])
return { events, loading, error, hasMore, loadMore, total }
}

446
lib/store/betting-api.ts Normal file
View File

@ -0,0 +1,446 @@
/**
* Betting API client and transform logic. Types in betting-types.ts.
* Base URL and tenant from env: NEXT_PUBLIC_BETTING_API_BASE_URL, NEXT_PUBLIC_TENANT_SLUG
*/
import { SportEnum } from "./betting-types"
import type {
ApiEvent,
ApiLeague,
ApiOdds,
ApiOddsOutcome,
ApiTopLeaguesResponse,
AppEvent,
DetailMarketSectionFromApi,
EventsParams,
EventsResponse,
LeaguesResponse,
MarketTabKey,
OddsResponse,
QuickFilterKey,
TabColumnCell,
} from "./betting-types"
export type {
ApiEvent,
ApiLeague,
ApiOdds,
AppEvent,
DetailMarketSectionFromApi,
EventsParams,
EventsResponse,
LeaguesResponse,
MarketTabKey,
OddsResponse,
TabColumnCell,
} from "./betting-types"
const BASE_URL = (process.env.NEXT_PUBLIC_BETTING_API_BASE_URL || "http://localhost:8080/api/v1").replace(/\/$/, "")
const TENANT_SLUG = process.env.NEXT_PUBLIC_TENANT_SLUG || "fortunebets"
const DEFAULT_PAGE_SIZE = 20
export const TOP_LEAGUES: { id: number; name: string }[] = [
{ id: 10041282, name: "Premier League" },
{ id: 10041809, name: "UEFA Champions League" },
{ id: 10041957, name: "UEFA Europa League" },
{ id: 10083364, name: "Spain La Liga" },
{ id: 10037165, name: "Germany Bundesliga" },
{ id: 10041315, name: "Serie A" },
{ id: 10041100, name: "Ligue 1" },
{ id: 10041083, name: "Ligue 2" },
{ id: 10041391, name: "Eredivisie" },
]
/** Map sport_id (SportEnum) to display name and slug. Used for events and sidebar. */
export const SPORT_ID_MAP: Record<number, { name: string; slug: string }> = {
[SportEnum.SOCCER]: { name: "Soccer", slug: "soccer" },
[SportEnum.BASKETBALL]: { name: "Basketball", slug: "basketball" },
[SportEnum.TENNIS]: { name: "Tennis", slug: "tennis" },
[SportEnum.VOLLEYBALL]: { name: "Volleyball", slug: "volleyball" },
[SportEnum.HANDBALL]: { name: "Handball", slug: "handball" },
[SportEnum.BASEBALL]: { name: "Baseball", slug: "baseball" },
[SportEnum.HORSE_RACING]: { name: "Horse Racing", slug: "horse-racing" },
[SportEnum.GREYHOUNDS]: { name: "Greyhounds", slug: "greyhounds" },
[SportEnum.ICE_HOCKEY]: { name: "Ice Hockey", slug: "ice-hockey" },
[SportEnum.SNOOKER]: { name: "Snooker", slug: "snooker" },
[SportEnum.AMERICAN_FOOTBALL]: { name: "American Football", slug: "american-football" },
[SportEnum.CRICKET]: { name: "Cricket", slug: "cricket" },
[SportEnum.FUTSAL]: { name: "Futsal", slug: "futsal" },
[SportEnum.DARTS]: { name: "Darts", slug: "darts" },
[SportEnum.TABLE_TENNIS]: { name: "Table Tennis", slug: "table-tennis" },
[SportEnum.BADMINTON]: { name: "Badminton", slug: "badminton" },
[SportEnum.RUGBY_UNION]: { name: "Rugby Union", slug: "rugby-union" },
[SportEnum.RUGBY_LEAGUE]: { name: "Rugby League", slug: "rugby-league" },
[SportEnum.AUSTRALIAN_RULES]: { name: "Australian Rules", slug: "australian-rules" },
[SportEnum.BOWLS]: { name: "Bowls", slug: "bowls" },
[SportEnum.BOXING]: { name: "Boxing", slug: "boxing" },
[SportEnum.GAELIC_SPORTS]: { name: "Gaelic Sports", slug: "gaelic-sports" },
[SportEnum.FLOORBALL]: { name: "Floorball", slug: "floorball" },
[SportEnum.BEACH_VOLLEYBALL]: { name: "Beach Volleyball", slug: "beach-volleyball" },
[SportEnum.WATER_POLO]: { name: "Water Polo", slug: "water-polo" },
[SportEnum.SQUASH]: { name: "Squash", slug: "squash" },
[SportEnum.E_SPORTS]: { name: "E-Sports", slug: "esports" },
[SportEnum.MMA]: { name: "MMA", slug: "mma" },
[SportEnum.SURFING]: { name: "Surfing", slug: "surfing" },
}
export const SPORT_SLUG_TO_ID: Record<string, number> = {
...Object.fromEntries(Object.entries(SPORT_ID_MAP).map(([id, v]) => [v.slug, Number(id)])),
football: SportEnum.SOCCER,
}
export const SPORT_ALL = "all"
export type { QuickFilterKey } from "./betting-types"
/** Compute first_start_time and last_start_time (RFC3339) for the given quick filter */
export function getTimeRangeForQuickFilter(key: QuickFilterKey): { first_start_time?: string; last_start_time?: string } {
const now = new Date()
const toRFC3339 = (d: Date) => d.toISOString()
if (key === "all") return {}
if (key === "today") {
const start = new Date(now)
start.setUTCHours(0, 0, 0, 0)
return { first_start_time: toRFC3339(start) }
}
const first = new Date(now)
const last = new Date(now)
if (key === "3h") last.setUTCHours(last.getUTCHours() + 3)
else if (key === "6h") last.setUTCHours(last.getUTCHours() + 6)
else if (key === "9h") last.setUTCHours(last.getUTCHours() + 9)
else if (key === "12h") last.setUTCHours(last.getUTCHours() + 12)
else return {}
return { first_start_time: toRFC3339(first), last_start_time: toRFC3339(last) }
}
function getTenantUrl(path: string, search?: Record<string, string | number | undefined>) {
const url = new URL(`${BASE_URL}/api/v1/tenant/${TENANT_SLUG}${path}`)
if (search) {
Object.entries(search).forEach(([k, v]) => {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v))
})
}
return url.toString()
}
const API_HEADERS: HeadersInit = {
"Content-Type": "application/json",
"ngrok-skip-browser-warning": "true",
}
export async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(url, {
...options,
cache: "no-store",
headers: { ...API_HEADERS, ...options.headers },
})
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`)
const contentType = res.headers.get("content-type") ?? ""
if (!contentType.includes("application/json")) {
const text = await res.text()
if (text.trimStart().startsWith("<!") || text.trimStart().startsWith("<html")) {
throw new Error(
"Server returned HTML instead of JSON. Check NEXT_PUBLIC_BETTING_API_BASE_URL and that the API is reachable."
)
}
throw new Error(`Unexpected content-type: ${contentType}`)
}
return res.json() as Promise<T>
}
function parseTime(iso: string): { time: string; date: string } {
try {
const d = new Date(iso)
const time = d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false })
const date = d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" })
return { time, date }
} catch {
return { time: "--:--", date: "" }
}
}
export function apiEventToAppEvent(
e: ApiEvent,
mainOddsOrListMarkets?: { "1": number; X: number; "2": number } | null | { id: string; label: string; odds: number }[]
): Omit<AppEvent, "rawOdds"> {
const { time, date } = parseTime(e.start_time)
const sportInfo = SPORT_ID_MAP[e.sport_id] || { name: "Other", slug: "other" }
const sportIcon = e.sport_id === SportEnum.SOCCER ? "⚽" : e.sport_id === SportEnum.TENNIS ? "🎾" : e.sport_id === SportEnum.BASKETBALL ? "🏀" : e.sport_id === SportEnum.ICE_HOCKEY ? "🏒" : "⚽"
const isListMarkets = Array.isArray(mainOddsOrListMarkets) && mainOddsOrListMarkets.length > 0
const markets = isListMarkets
? mainOddsOrListMarkets
: mainOddsOrListMarkets && "1" in mainOddsOrListMarkets
? [
{ id: "1", label: "1", odds: mainOddsOrListMarkets["1"] },
{ id: "x", label: "x", odds: mainOddsOrListMarkets.X },
{ id: "2", label: "2", odds: mainOddsOrListMarkets["2"] },
]
: [
{ id: "1", label: "1", odds: 0 },
{ id: "x", label: "x", odds: 0 },
{ id: "2", label: "2", odds: 0 },
]
return {
id: String(e.id),
sport: sportInfo.name,
sportIcon,
league: e.league_name,
country: e.league_cc.toUpperCase() || "",
homeTeam: e.home_team,
awayTeam: e.away_team,
time,
date,
isLive: !!e.is_live,
markets,
totalMarkets: e.total_odd_outcomes ?? 0,
...(e.score != null && { score: e.score }),
...(e.match_minute != null && { matchMinute: e.match_minute }),
}
}
export async function fetchEvents(params: EventsParams = {}): Promise<EventsResponse> {
const page = params.page ?? 1
const page_size = params.page_size ?? DEFAULT_PAGE_SIZE
const search: Record<string, string | number> = {
page_size,
page,
...(params.sport_id != null && { sport_id: params.sport_id }),
...(params.league_id != null && { league_id: params.league_id }),
...(params.first_start_time != null && params.first_start_time !== "" && { first_start_time: params.first_start_time }),
...(params.last_start_time != null && params.last_start_time !== "" && { last_start_time: params.last_start_time }),
...(params.is_live === true && { is_live: true }),
}
const json = await fetchJson<{ data?: ApiEvent[]; total?: number; page?: number; total_pages?: number; message?: string; status?: string }>(
getTenantUrl("/events", search),
{ next: { revalidate: 60 } }
)
return {
data: json.data ?? [],
total: json.total,
page: json.page ?? page,
total_pages: json.total_pages,
message: json.message,
status: json.status,
}
}
export async function fetchLeagues(sportId?: number): Promise<LeaguesResponse> {
const search = sportId != null ? { sport_id: sportId } : undefined
const json = await fetchJson<{ data?: ApiLeague[]; message?: string; status?: string }>(
getTenantUrl("/leagues", search),
{ next: { revalidate: 300 } }
)
return { data: json.data ?? [], message: json.message, status: json.status }
}
export async function fetchTopLeagues(): Promise<ApiTopLeaguesResponse> {
const json = await fetchJson<{ data?: ApiTopLeaguesResponse }>(getTenantUrl("/top-leagues"), {
next: { revalidate: 120 },
})
return json.data ?? { leagues: [] }
}
export async function fetchOdds(): Promise<OddsResponse> {
const json = await fetchJson<{ data?: ApiOdds[]; message?: string; status?: string }>(getTenantUrl("/odds"), {
next: { revalidate: 30 },
})
return { data: json.data ?? [], message: json.message, status: json.status }
}
export async function fetchOddsForEvent(upcomingId: number): Promise<OddsResponse> {
const url = `${BASE_URL}/api/v1/tenant/${TENANT_SLUG}/odds/upcoming/${upcomingId}`
const json = await fetchJson<{ data?: ApiOdds[]; message?: string; status?: string }>(url)
return { data: json.data ?? [], message: json.message, status: json.status }
}
export function get1X2ForEvent(oddsList: ApiOdds[], eventId: number): { "1": number; X: number; "2": number } | null {
const ft = oddsList.find((o) => o.event_id === eventId && o.market_type === "full_time_result")
if (!ft?.raw_odds?.length) return null
const one = ft.raw_odds.find((o) => o.name === "1")
const draw = ft.raw_odds.find((o) => o.name === "Draw")
const two = ft.raw_odds.find((o) => o.name === "2")
if (!one || !draw || !two) return null
return {
"1": parseFloat(one.odds) || 0,
X: parseFloat(draw.odds) || 0,
"2": parseFloat(two.odds) || 0,
}
}
export function get1X2FromOddsResponse(oddsList: ApiOdds[]): { "1": number; X: number; "2": number } | null {
const ft = (oddsList ?? []).find((o) => o.market_type === "full_time_result")
if (!ft?.raw_odds?.length) return null
const one = ft.raw_odds.find((o) => o.name === "1")
const draw = ft.raw_odds.find((o) => o.name === "Draw")
const two = ft.raw_odds.find((o) => o.name === "2")
if (!one || !draw || !two) return null
return {
"1": parseFloat(one.odds) || 0,
X: parseFloat(draw.odds) || 0,
"2": parseFloat(two.odds) || 0,
}
}
export function getListMarketsFromOddsResponse(oddsList: ApiOdds[]): { id: string; label: string; odds: number }[] {
const list: { id: string; label: string; odds: number }[] = []
const raw = oddsList ?? []
const ft = raw.find((o) => o.market_type === "full_time_result")
if (ft?.raw_odds?.length) {
const one = ft.raw_odds.find((o) => o.name === "1")
const draw = ft.raw_odds.find((o) => o.name === "Draw")
const two = ft.raw_odds.find((o) => o.name === "2")
list.push(
{ id: "1", label: "1", odds: one ? parseFloat(one.odds) || 0 : 0 },
{ id: "x", label: "x", odds: draw ? parseFloat(draw.odds) || 0 : 0 },
{ id: "2", label: "2", odds: two ? parseFloat(two.odds) || 0 : 0 }
)
} else {
list.push({ id: "1", label: "1", odds: 0 }, { id: "x", label: "x", odds: 0 }, { id: "2", label: "2", odds: 0 })
}
const ou = raw.find((o) => o.market_type === "goals_over_under" || o.market_type === "goal_line")
const line25 = ou?.raw_odds?.filter((o) => o.name === "2.5" || o.name === "2,5") ?? []
const over = line25.find((o) => o.header === "Over")
const under = line25.find((o) => o.header === "Under")
list.push(
{ id: "over25", label: "Over (2.5)", odds: over ? parseFloat(over.odds) || 0 : 0 },
{ id: "under25", label: "Under (2.5)", odds: under ? parseFloat(under.odds) || 0 : 0 }
)
const dc = raw.find((o) => o.market_type === "double_chance")
const dcOutcomes = dc?.raw_odds ?? []
const dc1X = dcOutcomes[0]
const dcX2 = dcOutcomes[1]
const dc12 = dcOutcomes[2]
list.push(
{ id: "1x", label: "1X", odds: dc1X ? parseFloat(dc1X.odds) || 0 : 0 },
{ id: "12", label: "12", odds: dc12 ? parseFloat(dc12.odds) || 0 : 0 },
{ id: "x2", label: "X2", odds: dcX2 ? parseFloat(dcX2.odds) || 0 : 0 }
)
const btts = raw.find((o) => o.market_type === "both_teams_to_score")
const yes = btts?.raw_odds?.find((o) => o.name === "Yes")
const no = btts?.raw_odds?.find((o) => o.name === "No")
list.push(
{ id: "yes", label: "Yes", odds: yes ? parseFloat(yes.odds) || 0 : 0 },
{ id: "no", label: "No", odds: no ? parseFloat(no.odds) || 0 : 0 }
)
return list
}
export function apiOddsToSections(apiOdds: ApiOdds[]): DetailMarketSectionFromApi[] {
return (apiOdds ?? []).map((market) => ({
id: `${market.market_type}-${market.id}`,
title: market.market_name,
outcomes: (market.raw_odds ?? []).map((o: ApiOddsOutcome) => {
const label = o.name ?? o.header ?? (o.handicap ? `Handicap ${o.handicap}` : null) ?? String(o.id)
return { label, odds: parseFloat(o.odds) || 0 }
}),
}))
}
export function getMarketsForTab(
rawOdds: ApiOdds[],
tabKey: MarketTabKey
): { headers: string[]; cells: TabColumnCell[] } {
const raw = rawOdds ?? []
const out: TabColumnCell[] = []
const push = (id: string, label: string, odds: number) => {
out.push({ id, label, odds })
}
switch (tabKey) {
case "main":
case "combo":
case "chance_mix":
case "home":
return { headers: ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"], cells: getListMarketsFromOddsResponse(raw) }
case "goals": {
const ou = raw.find((o) => o.market_type === "goals_over_under" || o.market_type === "goal_line")
const line25 = ou?.raw_odds?.filter((o) => o.name === "2.5" || o.name === "2,5") ?? []
const over = line25.find((o) => o.header === "Over")
const under = line25.find((o) => o.header === "Under")
push("over25", "Over (2.5)", over ? parseFloat(over.odds) || 0 : 0)
push("under25", "Under (2.5)", under ? parseFloat(under.odds) || 0 : 0)
const btts = raw.find((o) => o.market_type === "both_teams_to_score")
const yes = btts?.raw_odds?.find((o) => o.name === "Yes")
const no = btts?.raw_odds?.find((o) => o.name === "No")
push("yes", "Yes", yes ? parseFloat(yes.odds) || 0 : 0)
push("no", "No", no ? parseFloat(no.odds) || 0 : 0)
return { headers: ["Over (2.5)", "Under (2.5)", "Yes", "No"], cells: out }
}
case "handicap": {
const ah = raw.find((o) => o.market_type === "asian_handicap")
const ahOutcomes = ah?.raw_odds ?? []
if (ahOutcomes[0]) push("ah1", ahOutcomes[0].header ?? "1", parseFloat(ahOutcomes[0].odds) || 0)
if (ahOutcomes[1]) push("ah2", ahOutcomes[1].header ?? "2", parseFloat(ahOutcomes[1].odds) || 0)
const gl = raw.find((o) => o.market_type === "goal_line")
const glOver = gl?.raw_odds?.find((o) => o.header === "Over")
const glUnder = gl?.raw_odds?.find((o) => o.header === "Under")
push("gl_over", "Over", glOver ? parseFloat(glOver.odds) || 0 : 0)
push("gl_under", "Under", glUnder ? parseFloat(glUnder.odds) || 0 : 0)
if (!out.length) return { headers: ["1", "2", "Over", "Under"], cells: [{ id: "ah1", label: "1", odds: 0 }, { id: "ah2", label: "2", odds: 0 }, { id: "gl_over", label: "Over", odds: 0 }, { id: "gl_under", label: "Under", odds: 0 }] }
return { headers: out.map((c) => c.label), cells: out }
}
case "half_time":
case "1st_half": {
const ht = raw.find((o) => o.market_type === "half_time_result")
if (ht?.raw_odds?.length) {
const one = ht.raw_odds.find((o) => o.name === "1")
const draw = ht.raw_odds.find((o) => o.name === "Draw")
const two = ht.raw_odds.find((o) => o.name === "2")
push("ht1", "1", one ? parseFloat(one.odds) || 0 : 0)
push("htx", "X", draw ? parseFloat(draw.odds) || 0 : 0)
push("ht2", "2", two ? parseFloat(two.odds) || 0 : 0)
}
const htdc = raw.find((o) => o.market_type === "half_time_double_chance")
if (htdc?.raw_odds?.length) {
htdc.raw_odds.slice(0, 3).forEach((o, i) => push(`htdc${i}`, o.name ?? String(i), parseFloat(o.odds) || 0))
}
const btts1 = raw.find((o) => o.market_type === "both_teams_to_score_in_1st_half")
if (btts1?.raw_odds?.length) {
const y = btts1.raw_odds.find((o) => o.name === "Yes")
const n = btts1.raw_odds.find((o) => o.name === "No")
push("btts1_yes", "Yes", y ? parseFloat(y.odds) || 0 : 0)
push("btts1_no", "No", n ? parseFloat(n.odds) || 0 : 0)
}
return { headers: out.map((c) => c.label), cells: out }
}
case "2nd_half": {
const ht2 = raw.find((o) => o.market_type === "2nd_half_result")
if (ht2?.raw_odds?.length) {
const one = ht2.raw_odds.find((o) => o.name === "1")
const draw = ht2.raw_odds.find((o) => o.name === "Draw")
const two = ht2.raw_odds.find((o) => o.name === "2")
push("2h1", "1", one ? parseFloat(one.odds) || 0 : 0)
push("2hx", "X", draw ? parseFloat(draw.odds) || 0 : 0)
push("2h2", "2", two ? parseFloat(two.odds) || 0 : 0)
}
const btts2 = raw.find((o) => o.market_type === "both_teams_to_score_in_2nd_half")
if (btts2?.raw_odds?.length) {
const y = btts2.raw_odds.find((o) => o.name === "Yes")
const n = btts2.raw_odds.find((o) => o.name === "No")
push("btts2_yes", "Yes", y ? parseFloat(y.odds) || 0 : 0)
push("btts2_no", "No", n ? parseFloat(n.odds) || 0 : 0)
}
return { headers: out.map((c) => c.label), cells: out }
}
case "correct_score": {
const halfTimeCs = raw.find((o) => o.market_type === "half_time_correct_score")
const fullTimeCs = raw.find((o) => o.market_type === "correct_score")
const cs = halfTimeCs ?? fullTimeCs
const outcomes = (cs?.raw_odds ?? []).slice(0, 10)
outcomes.forEach((o, i) => push(`cs${i}`, o.name ?? String(i), parseFloat(o.odds) || 0))
if (!out.length) {
const fallbackHeaders = ["1-0", "0-0", "1-1", "0-1", "2-0", "2-1", "1-2", "0-2"]
return { headers: fallbackHeaders, cells: fallbackHeaders.map((h, i) => ({ id: `cs${i}`, label: h, odds: 0 })) }
}
return { headers: out.map((c) => c.label), cells: out }
}
default:
return { headers: ["1", "X", "2", "Over (2.5)", "Under (2.5)", "1X", "12", "X2", "Yes", "No"], cells: getListMarketsFromOddsResponse(raw) }
}
}

108
lib/store/betting-store.ts Normal file
View File

@ -0,0 +1,108 @@
"use client"
import { create } from "zustand"
import type { AppEvent, QuickFilterKey } from "./betting-types"
import {
fetchEvents,
fetchOddsForEvent,
apiEventToAppEvent,
getListMarketsFromOddsResponse,
getTimeRangeForQuickFilter,
type EventsParams,
} from "./betting-api"
const PAGE_SIZE = 12
type BettingState = {
events: AppEvent[]
page: number
total: number | null
loading: boolean
error: string | null
hasMore: boolean
sportId: number | null
leagueId: string | null
quickFilter: QuickFilterKey
setFilters: (sportId: number | null, leagueId: string | null) => void
setQuickFilter: (key: QuickFilterKey) => void
loadPage: (pageNum: number, append: boolean) => Promise<void>
loadMore: () => void
reset: () => void
}
const initialState = {
events: [],
page: 1,
total: null,
loading: true,
error: null as string | null,
hasMore: true,
sportId: null as number | null,
leagueId: null as string | null,
quickFilter: "all" as QuickFilterKey,
}
export const useBettingStore = create<BettingState>((set, get) => ({
...initialState,
setFilters: (sportId, leagueId) => {
const prev = get()
if (prev.sportId === sportId && prev.leagueId === leagueId) return
set({ sportId, leagueId, page: 1 })
get().loadPage(1, false)
},
setQuickFilter: (quickFilter) => {
set({ quickFilter, page: 1 })
get().loadPage(1, false)
},
loadPage: async (pageNum: number, append: boolean) => {
const { sportId, leagueId, quickFilter } = get()
const timeRange = getTimeRangeForQuickFilter(quickFilter)
set({ loading: true, error: null })
try {
const params: EventsParams = {
page: pageNum,
page_size: PAGE_SIZE,
sport_id: sportId ?? undefined,
league_id: leagueId ? Number(leagueId) : undefined,
...timeRange,
}
const res = await fetchEvents(params)
const apiEvents = res.data ?? []
const oddsResponses = await Promise.all(
apiEvents.map((e) => fetchOddsForEvent(e.id).catch(() => ({ data: [] as typeof res.data })))
)
const newEvents: AppEvent[] = apiEvents.map((e, i) => {
const oddsList = oddsResponses[i]?.data ?? []
const listMarkets = getListMarketsFromOddsResponse(oddsList)
const appEvent = apiEventToAppEvent(e, listMarkets) as AppEvent
appEvent.rawOdds = oddsList
return appEvent
})
set((s) => ({
events: append ? [...s.events, ...newEvents] : newEvents,
loading: false,
total: res.total ?? s.total,
hasMore: newEvents.length === PAGE_SIZE,
page: pageNum,
}))
} catch (err) {
set({
loading: false,
error: err instanceof Error ? err.message : "Failed to load events",
events: append ? get().events : [],
})
}
},
loadMore: () => {
const { page, loadPage } = get()
const next = page + 1
set({ page: next })
loadPage(next, true)
},
reset: () => set(initialState),
}))

191
lib/store/betting-types.ts Normal file
View File

@ -0,0 +1,191 @@
/**
* Betting API and app types. Used by betting-store and betting-api.
*/
/** Sport IDs for API (sport_id). Use for live and prematch. */
export const SportEnum = {
SOCCER: 1,
BASKETBALL: 18,
TENNIS: 13,
VOLLEYBALL: 91,
HANDBALL: 78,
BASEBALL: 16,
HORSE_RACING: 2,
GREYHOUNDS: 4,
ICE_HOCKEY: 17,
SNOOKER: 14,
AMERICAN_FOOTBALL: 12,
CRICKET: 3,
FUTSAL: 83,
DARTS: 15,
TABLE_TENNIS: 92,
BADMINTON: 94,
RUGBY_UNION: 8,
RUGBY_LEAGUE: 19,
AUSTRALIAN_RULES: 36,
BOWLS: 66,
BOXING: 9,
GAELIC_SPORTS: 75,
FLOORBALL: 90,
BEACH_VOLLEYBALL: 95,
WATER_POLO: 110,
SQUASH: 107,
E_SPORTS: 151,
MMA: 162,
SURFING: 148,
} as const
export type SportId = (typeof SportEnum)[keyof typeof SportEnum]
export type ApiEvent = {
id: number
source_event_id: string
sport_id: number
match_name: string
home_team: string
away_team: string
home_team_id: number
away_team_id: number
home_team_image: string
away_team_image: string
league_id: number
league_name: string
league_cc: string
start_time: string
source: string
status: string
is_live: boolean
is_featured?: boolean
is_active?: boolean
total_odd_outcomes?: number
number_of_bets?: number
total_amount?: number
average_bet_amount?: number
total_potential_winnings?: number
score?: string
match_minute?: number
timer_status?: string
added_time?: number
match_period?: number
fetched_at?: string
updated_at?: string
}
export type ApiLeague = {
id: number
name: string
cc: string
bet365_id?: number
sport_id: number
default_is_active: boolean
default_is_featured: boolean
}
export type ApiOddsOutcome = {
id: string
name?: string
odds: string
header?: string
handicap?: string
}
export type ApiOdds = {
id: number
event_id: number
market_type: string
market_name: string
market_category: string
market_id: number
number_of_outcomes: number
raw_odds: ApiOddsOutcome[]
fetched_at: string
expires_at: string
is_active: boolean
}
export type ApiTopLeaguesResponse = {
leagues: Array<{
league_id: number
league_name: string
league_cc: string
league_sport_id: number
events: ApiEvent[]
}>
}
export type EventsParams = {
page?: number
page_size?: number
sport_id?: number
league_id?: number
/** RFC3339 datetime; filter events with start_time >= this */
first_start_time?: string
/** RFC3339 datetime; filter events with start_time <= this (e.g. for 3h/6h/12h windows) */
last_start_time?: string
/** When true, return only in-play/live events */
is_live?: boolean
}
/** Quick filter key for time-based event filtering */
export type QuickFilterKey = "all" | "today" | "3h" | "6h" | "9h" | "12h"
export type EventsResponse = {
data: ApiEvent[]
total?: number
page?: number
total_pages?: number
message?: string
status?: string
}
export type LeaguesResponse = {
data: ApiLeague[]
message?: string
status?: string
}
export type OddsResponse = {
data: ApiOdds[]
message?: string
status?: string
}
export type DetailMarketSectionFromApi = {
id: string
title: string
outcomes: { label: string; odds: number }[]
}
export type MarketTabKey =
| "main"
| "goals"
| "handicap"
| "half_time"
| "correct_score"
| "1st_half"
| "2nd_half"
| "combo"
| "chance_mix"
| "home"
export type TabColumnCell = { id: string; label: string; odds: number }
export type AppEvent = {
id: string
sport: string
sportIcon: string
league: string
country: string
homeTeam: string
awayTeam: string
time: string
date: string
isLive: boolean
markets: { id: string; label: string; odds: number }[]
totalMarkets: number
rawOdds?: ApiOdds[]
/** Live: e.g. "2 - 1" */
score?: string
/** Live: match minute */
matchMinute?: number
}

75
lib/store/live-store.ts Normal file
View File

@ -0,0 +1,75 @@
"use client"
import { create } from "zustand"
import { SportEnum } from "./betting-types"
import type { AppEvent } from "./betting-types"
import {
fetchEvents,
fetchOddsForEvent,
apiEventToAppEvent,
getListMarketsFromOddsResponse,
} from "./betting-api"
const LIVE_PAGE_SIZE = 24
/** Start of today in UTC, RFC3339 — for live events first_start_time */
function getFirstStartTimeToday(): string {
const d = new Date()
d.setUTCHours(0, 0, 0, 0)
return d.toISOString()
}
type LiveState = {
events: AppEvent[]
loading: boolean
error: string | null
sportId: number
setSportId: (sportId: number) => void
loadLiveEvents: () => Promise<void>
}
export const useLiveStore = create<LiveState>((set, get) => ({
events: [],
loading: false,
error: null,
sportId: SportEnum.SOCCER,
setSportId: (sportId) => {
set({ sportId })
get().loadLiveEvents()
},
loadLiveEvents: async () => {
const { sportId } = get()
set({ loading: true, error: null })
try {
const first_start_time = getFirstStartTimeToday()
const res = await fetchEvents({
sport_id: sportId,
page: 1,
page_size: LIVE_PAGE_SIZE,
first_start_time,
is_live: true,
// no league_id - get all leagues
})
const apiEvents = (res.data ?? []).filter((e) => e.is_live === true)
const oddsResponses = await Promise.all(
apiEvents.map((e) => fetchOddsForEvent(e.id).catch(() => ({ data: [] as typeof res.data })))
)
const newEvents: AppEvent[] = apiEvents.map((e, i) => {
const oddsList = oddsResponses[i]?.data ?? []
const listMarkets = getListMarketsFromOddsResponse(oddsList)
const appEvent = apiEventToAppEvent(e, listMarkets) as AppEvent
appEvent.rawOdds = oddsList
return appEvent
})
set({ events: newEvents, loading: false })
} catch (err) {
set({
loading: false,
error: err instanceof Error ? err.message : "Failed to load live events",
events: [],
})
}
},
}))