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