import { defineStore } from 'pinia'; import { ref, computed, watch } from 'vue'; import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS, canSelectForParlay, type ParlayRejectReason, } from '@thebet365/shared'; import { useAuthStore } from './auth'; export interface SlipItem { selectionId: string; oddsVersion: string; matchId: string; matchName: string; marketId?: string; marketName?: string; selectionName: string; odds: number; marketType: string; lineValue?: number | null; allowSingle?: boolean; allowParlay?: boolean; } export type SlipMode = 'single' | 'parlay'; export type ParlaySlipError = ParlayRejectReason | 'MAX_LEGS' | 'SAME_MATCH'; const BET_SLIP_STORAGE_PREFIX = 'player:betSlip:v1'; const DESKTOP_PANEL_EXPANDED_KEY = 'player:desktopBetSlipExpanded:v1'; function readDesktopPanelExpanded() { try { return localStorage.getItem(DESKTOP_PANEL_EXPANDED_KEY) !== '0'; } catch { return true; } } function writeDesktopPanelExpanded(expanded: boolean) { try { localStorage.setItem(DESKTOP_PANEL_EXPANDED_KEY, expanded ? '1' : '0'); } catch { // ignore quota / private mode errors } } interface BetSlipPersisted { singleItem: SlipItem | null; singleCartItems: SlipItem[]; parlayItems: SlipItem[]; stake: number; singleStakes: Record; mode: SlipMode; } function betSlipStorageKey(userId?: string | null) { return userId ? `${BET_SLIP_STORAGE_PREFIX}:${userId}` : `${BET_SLIP_STORAGE_PREFIX}:guest`; } function isValidSlipItem(item: unknown): item is SlipItem { if (!item || typeof item !== 'object') return false; const row = item as SlipItem; return Boolean( row.selectionId && row.oddsVersion && row.matchId && row.matchName && row.selectionName && typeof row.odds === 'number' && Number.isFinite(row.odds), ); } function readPersisted(userId?: string | null): BetSlipPersisted | null { try { const raw = localStorage.getItem(betSlipStorageKey(userId)); if (!raw) return null; return JSON.parse(raw) as BetSlipPersisted; } catch { return null; } } function writePersisted(payload: BetSlipPersisted, userId?: string | null) { try { localStorage.setItem(betSlipStorageKey(userId), JSON.stringify(payload)); } catch { // ignore quota / private mode errors } } function removePersisted(userId?: string | null) { localStorage.removeItem(betSlipStorageKey(userId)); } export const useBetSlipStore = defineStore('betSlip', () => { const singleItem = ref(null); const singleCartItems = ref([]); const parlayItems = ref([]); const stake = ref(5); const singleStakes = ref>({}); const mode = ref('single'); const lastParlayError = ref(null); const auth = useAuthStore(); let persistPaused = false; function slipSnapshot(): BetSlipPersisted { return { singleItem: singleItem.value, singleCartItems: singleCartItems.value, parlayItems: parlayItems.value, stake: stake.value, singleStakes: singleStakes.value, mode: mode.value, }; } function hydrateFromStorage(userId?: string | null) { persistPaused = true; try { const saved = readPersisted(userId); if (!saved) { singleItem.value = null; singleCartItems.value = []; parlayItems.value = []; singleStakes.value = {}; stake.value = 5; mode.value = 'single'; lastParlayError.value = null; return; } singleItem.value = saved.singleItem && isValidSlipItem(saved.singleItem) ? saved.singleItem : null; singleCartItems.value = (saved.singleCartItems ?? []).filter(isValidSlipItem); parlayItems.value = (saved.parlayItems ?? []).filter(isValidSlipItem); stake.value = typeof saved.stake === 'number' && Number.isFinite(saved.stake) && saved.stake > 0 ? saved.stake : 5; singleStakes.value = saved.singleStakes && typeof saved.singleStakes === 'object' ? { ...saved.singleStakes } : {}; mode.value = saved.mode === 'parlay' ? 'parlay' : 'single'; lastParlayError.value = null; } finally { persistPaused = false; } } function persistToStorage() { if (persistPaused) return; const userId = auth.user?.id ?? null; const snapshot = slipSnapshot(); const hasItems = Boolean(snapshot.singleItem) || snapshot.singleCartItems.length > 0 || snapshot.parlayItems.length > 0; if (!hasItems) { removePersisted(userId); return; } writePersisted(snapshot, userId); } hydrateFromStorage(auth.user?.id ?? null); watch( () => auth.user?.id ?? null, (userId) => { hydrateFromStorage(userId); }, ); watch( [singleItem, singleCartItems, parlayItems, stake, singleStakes, mode], () => { persistToStorage(); }, { deep: true }, ); const items = computed(() => mode.value === 'parlay' ? parlayItems.value : singleCartItems.value.length ? singleCartItems.value : singleItem.value ? [singleItem.value] : [], ); const count = computed(() => items.value.length); const parlayCount = computed(() => parlayItems.value.length); const singleCount = computed(() => singleCartItems.value.length || (singleItem.value ? 1 : 0), ); const totalCount = computed(() => singleCount.value + parlayCount.value); const isParlay = computed(() => mode.value === 'parlay' && parlayItems.value.length >= PARLAY_MIN_LEGS); function defaultStakeAmount() { const base = Number(stake.value); return Number.isFinite(base) && base > 0 ? base : 5; } function initItemStake(selectionId: string) { if (singleStakes.value[selectionId] == null) { singleStakes.value = { ...singleStakes.value, [selectionId]: defaultStakeAmount() }; } } function getItemStake(selectionId: string) { const v = singleStakes.value[selectionId]; if (v != null && Number.isFinite(v)) return v; return defaultStakeAmount(); } function setItemStake(selectionId: string, amount: number) { singleStakes.value = { ...singleStakes.value, [selectionId]: amount }; } function clearItemStake(selectionId: string) { if (!(selectionId in singleStakes.value)) return; const next = { ...singleStakes.value }; delete next[selectionId]; singleStakes.value = next; } function clearAllItemStakes() { singleStakes.value = {}; } function parseLineValue(v: number | string | null | undefined): number | null { if (v == null || v === '') return null; const n = typeof v === 'number' ? v : parseFloat(String(v)); return Number.isFinite(n) ? n : null; } function setMode(nextMode: SlipMode) { mode.value = nextMode; lastParlayError.value = null; } /** 球赛/详情页:点击任意选项后作为当前单注,打开投注抽屉 */ function setSingleItem(item: SlipItem) { mode.value = 'single'; singleItem.value = item; initItemStake(item.selectionId); lastParlayError.value = null; } function addItem(item: SlipItem) { setSingleItem(item); } /** PC 单关购买列表 */ function addToSingleCart(item: SlipItem) { mode.value = 'single'; if (singleCartItems.value.some((i) => i.selectionId === item.selectionId)) return; singleCartItems.value.push(item); initItemStake(item.selectionId); lastParlayError.value = null; } function isInSlip(selectionId: string) { return ( singleItem.value?.selectionId === selectionId || singleCartItems.value.some((i) => i.selectionId === selectionId) || parlayItems.value.some((i) => i.selectionId === selectionId) ); } /** 串关:必须来自不同赛事,仍按盘口规则过滤不可串关项 */ function addParlayLeg(item: SlipItem): ParlaySlipError | null { mode.value = 'parlay'; const samePick = parlayItems.value.findIndex((i) => i.selectionId === item.selectionId); if (samePick >= 0) { lastParlayError.value = null; return null; } if (parlayItems.value.some((i) => i.matchId === item.matchId)) { lastParlayError.value = 'SAME_MATCH'; return 'SAME_MATCH'; } const check = canSelectForParlay({ marketType: item.marketType, lineValue: parseLineValue(item.lineValue), allowParlay: item.allowParlay, }); if (!check.ok) { lastParlayError.value = check.reason; return check.reason; } if (parlayItems.value.length >= PARLAY_MAX_LEGS) { lastParlayError.value = 'MAX_LEGS'; return 'MAX_LEGS'; } parlayItems.value.push(item); lastParlayError.value = null; return null; } function addSingleToParlay(): ParlaySlipError | null { if (!singleItem.value) return null; return addParlayLeg(singleItem.value); } function removeItem(selectionId: string) { if (singleItem.value?.selectionId === selectionId) { singleItem.value = null; } singleCartItems.value = singleCartItems.value.filter((i) => i.selectionId !== selectionId); parlayItems.value = parlayItems.value.filter((i) => i.selectionId !== selectionId); clearItemStake(selectionId); if (mode.value === 'parlay' && !parlayItems.value.length) mode.value = 'single'; lastParlayError.value = null; } function clearSingle() { const legacy = singleItem.value; for (const item of singleCartItems.value) clearItemStake(item.selectionId); if (legacy) clearItemStake(legacy.selectionId); singleItem.value = null; singleCartItems.value = []; if (mode.value === 'single') lastParlayError.value = null; } function clearParlay() { parlayItems.value = []; mode.value = 'single'; lastParlayError.value = null; } function clear() { if (mode.value === 'parlay') { clearParlay(); return; } clearSingle(); } function clearAll() { singleItem.value = null; singleCartItems.value = []; parlayItems.value = []; clearAllItemStakes(); mode.value = 'single'; lastParlayError.value = null; removePersisted(auth.user?.id ?? null); } const totalOdds = computed(() => items.value.reduce((acc, i) => acc * i.odds, 1), ); const potentialReturn = computed(() => { if (!items.value.length) return 0; if (mode.value === 'parlay' && canPlaceParlay.value) { return stake.value * totalOdds.value; } return items.value.reduce( (acc, i) => acc + getItemStake(i.selectionId) * i.odds, 0, ); }); const totalSingleStake = computed(() => items.value.reduce((acc, i) => acc + getItemStake(i.selectionId), 0), ); const canPlaceParlay = computed( () => mode.value === 'parlay' && parlayItems.value.length >= PARLAY_MIN_LEGS && parlayItems.value.length <= PARLAY_MAX_LEGS, ); const canPlaceBatchSingles = computed( () => mode.value === 'single' && (singleCartItems.value.length > 0 || singleItem.value != null), ); const canSubmit = computed( () => canPlaceParlay.value || canPlaceBatchSingles.value, ); const drawerOpen = ref(false); const panelExpanded = ref(readDesktopPanelExpanded()); watch(panelExpanded, (expanded) => { writeDesktopPanelExpanded(expanded); }); function openDrawer() { drawerOpen.value = true; } function closeDrawer() { drawerOpen.value = false; } function expandPanel() { panelExpanded.value = true; } function collapsePanel() { panelExpanded.value = false; } function togglePanelExpanded() { panelExpanded.value = !panelExpanded.value; } function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) { if (singleItem.value?.selectionId === selectionId) { singleItem.value = { ...singleItem.value, odds, oddsVersion }; } const cartIdx = singleCartItems.value.findIndex((i) => i.selectionId === selectionId); if (cartIdx >= 0) { singleCartItems.value[cartIdx] = { ...singleCartItems.value[cartIdx], odds, oddsVersion }; } const idx = parlayItems.value.findIndex((i) => i.selectionId === selectionId); if (idx >= 0) { parlayItems.value[idx] = { ...parlayItems.value[idx], odds, oddsVersion }; } } return { singleItem, singleCartItems, parlayItems, singleStakes, items, stake, mode, singleCount, parlayCount, totalCount, count, isParlay, totalOdds, potentialReturn, totalSingleStake, canPlaceParlay, canPlaceBatchSingles, canSubmit, lastParlayError, drawerOpen, panelExpanded, setMode, setSingleItem, addItem, addToSingleCart, isInSlip, addParlayLeg, addSingleToParlay, removeItem, clearSingle, clearParlay, clear, clearAll, openDrawer, closeDrawer, expandPanel, collapsePanel, togglePanelExpanded, updateSelectionOdds, getItemStake, setItemStake, initItemStake, }; });