store setup
This commit is contained in:
parent
8941c45555
commit
4e9edbfe77
23
lib/api.ts
23
lib/api.ts
|
|
@ -1,10 +1,12 @@
|
|||
import axios from 'axios';
|
||||
import axios from "axios";
|
||||
|
||||
// Create a configured Axios instance
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1',
|
||||
baseURL:
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -12,9 +14,10 @@ const api = axios.create({
|
|||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Only access localStorage if we are running in the browser
|
||||
if (typeof window !== 'undefined') {
|
||||
// const token = localStorage.getItem('token');
|
||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjI3NzQxNSwibmJmIjoxNzcyMjc2ODE1LCJpYXQiOjE3NzIyNzY4MTUsIlVzZXJJZCI6NCwiUm9sZSI6InN1cGVyX2FkbWluIiwiQ29tcGFueUlEIjp7IlZhbHVlIjowLCJWYWxpZCI6ZmFsc2V9fQ.QJJ1KAFkWWCMmxxBi8rQc9C5aChN2XmTys-RCufV_Zo";
|
||||
if (typeof window !== "undefined") {
|
||||
// const token = localStorage.getItem('token');
|
||||
const token =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0dW5lLWJldCIsImF1ZCI6WyJhcGkuZm9ydHVuZWJldHMubmV0Il0sImV4cCI6MTc3MjQ0NDk1NCwibmJmIjoxNzcyNDQ0MzU0LCJpYXQiOjE3NzI0NDQzNTQsIlVzZXJJZCI6NSwiUm9sZSI6ImN1c3RvbWVyIiwiQ29tcGFueUlEIjp7IlZhbHVlIjoxLCJWYWxpZCI6dHJ1ZX19.6CZQp4VL9ehBh2EfMEohkoVMezT_qFdXajCKsUmWda4";
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
|
@ -23,7 +26,7 @@ api.interceptors.request.use(
|
|||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor for handling common errors (like 401 Unauthorized)
|
||||
|
|
@ -34,14 +37,14 @@ api.interceptors.response.use(
|
|||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized errors, e.g., redirecting to login or clearing the token
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("token");
|
||||
// Uncomment the line below to redirect automatically
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
43
lib/betting-api.ts
Normal file
43
lib/betting-api.ts
Normal 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
22
lib/countries.ts
Normal 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
22
lib/hooks/use-events.ts
Normal 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
446
lib/store/betting-api.ts
Normal 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
108
lib/store/betting-store.ts
Normal 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
75
lib/store/live-store.ts
Normal 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: [],
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user