store setup
This commit is contained in:
parent
8941c45555
commit
4e9edbfe77
21
lib/api.ts
21
lib/api.ts
|
|
@ -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
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