From 4e9edbfe77ab7cc6c87f49cb3cdb60eba3c8123b Mon Sep 17 00:00:00 2001 From: brooktewabe Date: Mon, 2 Mar 2026 19:09:22 +0300 Subject: [PATCH] store setup --- lib/api.ts | 23 +- lib/betting-api.ts | 43 ++++ lib/countries.ts | 22 ++ lib/hooks/use-events.ts | 22 ++ lib/store/betting-api.ts | 446 +++++++++++++++++++++++++++++++++++++ lib/store/betting-store.ts | 108 +++++++++ lib/store/live-store.ts | 75 +++++++ 7 files changed, 729 insertions(+), 10 deletions(-) create mode 100644 lib/betting-api.ts create mode 100644 lib/countries.ts create mode 100644 lib/hooks/use-events.ts create mode 100644 lib/store/betting-api.ts create mode 100644 lib/store/betting-store.ts create mode 100644 lib/store/live-store.ts diff --git a/lib/api.ts b/lib/api.ts index d40efb2..8acfa59 100644 --- a/lib/api.ts +++ b/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; diff --git a/lib/betting-api.ts b/lib/betting-api.ts new file mode 100644 index 0000000..9e535fd --- /dev/null +++ b/lib/betting-api.ts @@ -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" diff --git a/lib/countries.ts b/lib/countries.ts new file mode 100644 index 0000000..afc6aaf --- /dev/null +++ b/lib/countries.ts @@ -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 = (countriesJson as CountryEntry[]).reduce( + (acc, c) => { + acc[c.code.toLowerCase()] = c.name + return acc + }, + {} as Record +) + +/** 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() +} diff --git a/lib/hooks/use-events.ts b/lib/hooks/use-events.ts new file mode 100644 index 0000000..f03a752 --- /dev/null +++ b/lib/hooks/use-events.ts @@ -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 } +} diff --git a/lib/store/betting-api.ts b/lib/store/betting-api.ts new file mode 100644 index 0000000..5645836 --- /dev/null +++ b/lib/store/betting-api.ts @@ -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 = { + [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) } + } +} diff --git a/lib/store/betting-store.ts b/lib/store/betting-store.ts new file mode 100644 index 0000000..273b675 --- /dev/null +++ b/lib/store/betting-store.ts @@ -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 + 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((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), +})) diff --git a/lib/store/live-store.ts b/lib/store/live-store.ts new file mode 100644 index 0000000..037de21 --- /dev/null +++ b/lib/store/live-store.ts @@ -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 +} + +export const useLiveStore = create((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: [], + }) + } + }, +}))