/** * 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 = { [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 = { ...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) { 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(url: string, options: RequestInit = {}): Promise { 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(" } 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 { 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 { const page = params.page ?? 1 const page_size = params.page_size ?? DEFAULT_PAGE_SIZE const search: Record = { 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 { 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 { const json = await fetchJson<{ data?: ApiTopLeaguesResponse }>(getTenantUrl("/top-leagues"), { next: { revalidate: 120 }, }) return json.data ?? { leagues: [] } } export async function fetchOdds(): Promise { 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 { 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) } } }