Files
thebet365/apps/player/src/stores/betSlip.ts

475 lines
13 KiB
TypeScript

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<string, number>;
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<SlipItem | null>(null);
const singleCartItems = ref<SlipItem[]>([]);
const parlayItems = ref<SlipItem[]>([]);
const stake = ref<number>(5);
const singleStakes = ref<Record<string, number>>({});
const mode = ref<SlipMode>('single');
const lastParlayError = ref<ParlaySlipError | null>(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,
};
});