Fortune-PlayLogic/lib/store/betting-api.ts

447 lines
19 KiB
TypeScript

/**
* 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").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) }
}
}