store setup

This commit is contained in:
brooktewabe 2026-03-02 19:09:22 +03:00
parent 8941c45555
commit 4e9edbfe77
7 changed files with 729 additions and 10 deletions

View File

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

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

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

22
lib/countries.ts Normal file
View File

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

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

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

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

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

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

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

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

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