2034 lines
52 KiB
Vue
2034 lines
52 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter, RouterLink } from 'vue-router';
|
|
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
|
import { useBetSlipStore, type ParlaySlipError, type SlipItem, type SlipMode } from '../../stores/betSlip';
|
|
import { useAuthStore } from '../../stores/auth';
|
|
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
|
|
import api from '../../api';
|
|
import { usePlayerProfile } from '../../composables/usePlayerProfile';
|
|
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
|
|
import ConfirmDialog from '../ConfirmDialog.vue';
|
|
import GoldSpinner from '../GoldSpinner.vue';
|
|
import { type BetHistoryItem } from '../BetHistoryCard.vue';
|
|
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
|
|
import { useDesktopBetLocate } from '../../composables/useDesktopBetLocate';
|
|
|
|
type PanelTab = SlipMode | 'history';
|
|
|
|
const { t, locale } = useI18n();
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const slip = useBetSlipStore();
|
|
const auth = useAuthStore();
|
|
const { refreshProfile } = usePlayerProfile();
|
|
const { requestLocate } = useDesktopBetLocate();
|
|
|
|
const activeTab = ref<PanelTab>('single');
|
|
const transitionName = ref('list-slide');
|
|
const historyScope = ref<'all' | 'match'>('all');
|
|
const historyItems = ref<BetHistoryItem[]>([]);
|
|
const historyTotal = ref(0);
|
|
const historyPage = ref(1);
|
|
const historyPageSize = 15;
|
|
const historyLoading = ref(false);
|
|
const historyInitialLoading = ref(false);
|
|
const loading = ref(false);
|
|
const balanceLoading = ref(false);
|
|
const balance = ref<number | null>(null);
|
|
const error = ref('');
|
|
const success = ref('');
|
|
const showSuccess = ref(false);
|
|
const showPlaceConfirm = ref(false);
|
|
const placeConfirmMessage = ref('');
|
|
const pendingSingleItem = ref<SlipItem | null>(null);
|
|
const MIN_STAKE = 5;
|
|
const MAX_STAKE_INTEGER_LENGTH = 9;
|
|
const stakeInput = ref('');
|
|
const itemStakeInputs = ref<Record<string, string>>({});
|
|
const ODDS_POLL_MS = 5000;
|
|
|
|
type OddsDelta = {
|
|
oldOdds: number;
|
|
newOdds: number;
|
|
newVersion: string;
|
|
suspended: boolean;
|
|
};
|
|
|
|
interface SelectionOddsRow {
|
|
id: string;
|
|
odds: string;
|
|
oddsVersion: string;
|
|
status: string;
|
|
marketStatus: string;
|
|
marketShowOnPlayer: boolean;
|
|
matchStatus: string;
|
|
}
|
|
|
|
const oddsDeltas = ref<Record<string, OddsDelta>>({});
|
|
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const currentMatchId = computed(() => {
|
|
const match = route.path.match(/^\/match\/([^/]+)/);
|
|
if (match) return match[1];
|
|
const outright = route.path.match(/^\/outright\/([^/]+)/);
|
|
return outright ? outright[1] : '';
|
|
});
|
|
|
|
const showMatchHistoryFilter = computed(() => Boolean(currentMatchId.value));
|
|
|
|
const singleSlipItems = computed<SlipItem[]>(() => {
|
|
if (slip.singleCartItems.length) return slip.singleCartItems;
|
|
return slip.singleItem ? [slip.singleItem] : [];
|
|
});
|
|
|
|
const activeItems = computed<SlipItem[]>(() => {
|
|
if (activeTab.value === 'history') return [];
|
|
if (activeTab.value === 'parlay') return slip.parlayItems;
|
|
return singleSlipItems.value;
|
|
});
|
|
|
|
const historyHasMore = computed(
|
|
() => historyItems.value.length < historyTotal.value,
|
|
);
|
|
|
|
const activeCount = computed(() => activeItems.value.length);
|
|
|
|
function effectiveOdds(item: SlipItem) {
|
|
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
|
|
}
|
|
|
|
const activeTotalOdds = computed(() =>
|
|
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
|
|
);
|
|
const activeEstimatedReturn = computed(() => {
|
|
if (!activeItems.value.length) return 0;
|
|
if (activeTab.value === 'parlay') {
|
|
if (!Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
|
return slip.stake * activeTotalOdds.value;
|
|
}
|
|
return activeItems.value.reduce((acc, item) => {
|
|
const s = slip.getItemStake(item.selectionId);
|
|
if (!Number.isFinite(s) || s <= 0) return acc;
|
|
return acc + s * effectiveOdds(item);
|
|
}, 0);
|
|
});
|
|
|
|
const activeTotalStake = computed(() => {
|
|
if (activeTab.value === 'parlay') return Number(slip.stake) || 0;
|
|
if (activeTab.value !== 'single') return 0;
|
|
return singleSlipItems.value.reduce((acc, item) => acc + slip.getItemStake(item.selectionId), 0);
|
|
});
|
|
|
|
const hasSuspendedSelections = computed(() =>
|
|
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
|
|
);
|
|
|
|
const hasPendingOddsChanges = computed(() =>
|
|
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
|
|
);
|
|
|
|
const oddsWarningText = computed(() => {
|
|
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
|
|
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
|
|
return '';
|
|
});
|
|
|
|
const submitButtonLabel = computed(() => {
|
|
if (loading.value) return t('bet.placing');
|
|
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
|
|
if (activeTab.value === 'single') return t('bet.slip_place_all');
|
|
return t('bet.place_bet_short');
|
|
});
|
|
|
|
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
|
|
|
|
const canSubmitActive = computed(() => {
|
|
if (activeTab.value === 'parlay') {
|
|
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
|
|
}
|
|
if (activeTab.value !== 'single') return false;
|
|
return singleSlipItems.value.length > 0 && singleSlipItems.value.every((item) => item.allowSingle !== false);
|
|
});
|
|
|
|
const singleParlayOnlyHint = computed(
|
|
() =>
|
|
slip.mode === 'single' &&
|
|
Boolean(slip.singleItem) &&
|
|
slip.singleItem!.allowSingle === false &&
|
|
slip.singleItem!.allowParlay !== false,
|
|
);
|
|
|
|
const balanceText = computed(() => {
|
|
if (balanceLoading.value) return t('bet.loading');
|
|
if (balance.value == null) return '--';
|
|
return formatMoney(balance.value, locale.value);
|
|
});
|
|
|
|
const stakeText = computed(() => formatMoney(activeTotalStake.value, locale.value));
|
|
const estimatedReturnText = computed(() => formatMoney(activeEstimatedReturn.value, locale.value));
|
|
const totalOddsText = computed(() => activeTotalOdds.value.toFixed(4).replace(/0+$/, '').replace(/\.$/, ''));
|
|
|
|
const parlayWarning = computed(() => {
|
|
if (activeTab.value !== 'parlay') return '';
|
|
if (slip.lastParlayError) return parlayErrorMessage(slip.lastParlayError);
|
|
if (slip.parlayItems.length > 0 && slip.parlayItems.length < PARLAY_MIN_LEGS) {
|
|
return t('bet.parlay_need_more');
|
|
}
|
|
return '';
|
|
});
|
|
|
|
const singleInParlay = computed(() => {
|
|
const item = slip.singleItem;
|
|
return Boolean(item && slip.parlayItems.some((leg) => leg.selectionId === item.selectionId));
|
|
});
|
|
|
|
const showFooterParlayAction = computed(() => slip.mode === 'single' && Boolean(slip.singleItem));
|
|
|
|
function genId() {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function selectTab(tab: PanelTab) {
|
|
transitionName.value = '';
|
|
activeTab.value = tab;
|
|
error.value = '';
|
|
if (tab === 'history') {
|
|
if (!auth.token) {
|
|
auth.showLoginPrompt(route.fullPath);
|
|
activeTab.value = slip.mode;
|
|
nextTick(() => {
|
|
transitionName.value = 'list-slide';
|
|
});
|
|
return;
|
|
}
|
|
if (showMatchHistoryFilter.value) {
|
|
historyScope.value = 'match';
|
|
}
|
|
void loadHistory(true);
|
|
nextTick(() => {
|
|
transitionName.value = 'list-slide';
|
|
});
|
|
return;
|
|
}
|
|
slip.setMode(tab);
|
|
syncStakeInputFromSlip();
|
|
nextTick(() => {
|
|
transitionName.value = 'list-slide';
|
|
});
|
|
}
|
|
|
|
function selectHistoryScope(scope: 'all' | 'match') {
|
|
if (historyScope.value === scope) return;
|
|
historyScope.value = scope;
|
|
void loadHistory(true);
|
|
}
|
|
|
|
async function loadHistory(reset = false) {
|
|
if (!auth.token || activeTab.value !== 'history') return;
|
|
if (historyLoading.value) return;
|
|
if (reset) {
|
|
historyPage.value = 1;
|
|
historyInitialLoading.value = true;
|
|
}
|
|
historyLoading.value = true;
|
|
try {
|
|
const params: Record<string, unknown> = {
|
|
page: historyPage.value,
|
|
pageSize: historyPageSize,
|
|
};
|
|
if (historyScope.value === 'match' && currentMatchId.value) {
|
|
params.matchId = currentMatchId.value;
|
|
}
|
|
const { data } = await api.get('/player/bets', { params });
|
|
const result = data.data ?? { items: [], total: 0 };
|
|
const items = (result.items ?? []) as BetHistoryItem[];
|
|
historyTotal.value = result.total ?? 0;
|
|
historyItems.value = reset ? items : [...historyItems.value, ...items];
|
|
} catch {
|
|
if (reset) {
|
|
historyItems.value = [];
|
|
historyTotal.value = 0;
|
|
}
|
|
} finally {
|
|
historyLoading.value = false;
|
|
historyInitialLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function loadMoreHistory() {
|
|
if (!historyHasMore.value || historyLoading.value) return;
|
|
historyPage.value += 1;
|
|
void loadHistory(false);
|
|
}
|
|
|
|
function historyStatusLabel(status: string) {
|
|
const s = status.toUpperCase();
|
|
if (s === 'WON' || s === 'WIN') return t('history.status_won');
|
|
if (s === 'LOST' || s === 'LOSE') return t('history.status_lost');
|
|
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return t('history.status_push');
|
|
return t('history.status_pending');
|
|
}
|
|
|
|
function historyStatusClass(status: string) {
|
|
const s = status.toUpperCase();
|
|
if (s === 'WON' || s === 'WIN') return 'hist-won';
|
|
if (s === 'LOST' || s === 'LOSE') return 'hist-lost';
|
|
if (s === 'PUSH' || s === 'VOID' || s === 'CANCELLED') return 'hist-push';
|
|
return 'hist-pending';
|
|
}
|
|
|
|
function historyPickLabel(bet: BetHistoryItem) {
|
|
if (bet.betType === 'PARLAY' || bet.isParlay) {
|
|
const n = bet.legCount ?? bet.legs?.length ?? 0;
|
|
return n > 0 ? t('history.parlay_title', { n }) : t('history.parlay_league');
|
|
}
|
|
return bet.pickLabel || bet.matchTitle || '-';
|
|
}
|
|
|
|
function removeItem(id: string) {
|
|
slip.removeItem(id);
|
|
error.value = '';
|
|
}
|
|
|
|
function locateSlipItem(item: SlipItem) {
|
|
const isOutright = item.marketType === 'OUTRIGHT_WINNER';
|
|
const targetPath = isOutright ? `/outright/${item.matchId}` : `/match/${item.matchId}`;
|
|
requestLocate({
|
|
matchId: item.matchId,
|
|
selectionId: item.selectionId,
|
|
marketId: item.marketId,
|
|
marketType: item.marketType,
|
|
});
|
|
if (route.path !== targetPath) {
|
|
void router.push(targetPath);
|
|
}
|
|
}
|
|
|
|
function clearActiveSlip() {
|
|
error.value = '';
|
|
oddsDeltas.value = {};
|
|
if (activeTab.value === 'parlay') {
|
|
slip.clearParlay();
|
|
} else if (activeTab.value === 'single') {
|
|
slip.clearSingle();
|
|
}
|
|
syncStakeInputFromSlip();
|
|
}
|
|
|
|
function stakeAmountToInput(amount: number) {
|
|
if (!Number.isFinite(amount) || amount <= 0) return '';
|
|
const rounded = Math.round(amount * 100) / 100;
|
|
return Number.isInteger(rounded)
|
|
? String(rounded)
|
|
: rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
|
}
|
|
|
|
function sanitizeStakeInput(raw: string) {
|
|
const onlyAmountChars = raw.replace(/[^\d.]/g, '');
|
|
const [integerRaw, ...fractionParts] = onlyAmountChars.split('.');
|
|
const integerPart = integerRaw.replace(/^0+(?=\d)/, '').slice(0, MAX_STAKE_INTEGER_LENGTH);
|
|
if (!fractionParts.length) return integerPart;
|
|
const fractionPart = fractionParts.join('').slice(0, 2);
|
|
return `${integerPart || '0'}.${fractionPart}`;
|
|
}
|
|
|
|
function commitStakeInput(raw: string) {
|
|
const clean = sanitizeStakeInput(raw);
|
|
stakeInput.value = clean;
|
|
const amount = Number.parseFloat(clean);
|
|
slip.stake = Number.isFinite(amount) ? amount : 0;
|
|
}
|
|
|
|
function syncItemStakeInputs(items: SlipItem[]) {
|
|
const next: Record<string, string> = {};
|
|
for (const item of items) {
|
|
slip.initItemStake(item.selectionId);
|
|
next[item.selectionId] = stakeAmountToInput(slip.getItemStake(item.selectionId));
|
|
}
|
|
itemStakeInputs.value = next;
|
|
}
|
|
|
|
function itemStakeInputValue(selectionId: string) {
|
|
if (!(selectionId in itemStakeInputs.value)) {
|
|
slip.initItemStake(selectionId);
|
|
itemStakeInputs.value = {
|
|
...itemStakeInputs.value,
|
|
[selectionId]: stakeAmountToInput(slip.getItemStake(selectionId)),
|
|
};
|
|
}
|
|
return itemStakeInputs.value[selectionId];
|
|
}
|
|
|
|
function onItemStakeInput(selectionId: string, event: Event) {
|
|
const clean = sanitizeStakeInput((event.target as HTMLInputElement).value);
|
|
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: clean };
|
|
const amount = Number.parseFloat(clean);
|
|
slip.setItemStake(selectionId, Number.isFinite(amount) ? amount : 0);
|
|
}
|
|
|
|
function clearItemStakeInput(selectionId: string) {
|
|
itemStakeInputs.value = { ...itemStakeInputs.value, [selectionId]: '' };
|
|
slip.setItemStake(selectionId, 0);
|
|
}
|
|
|
|
function addItemStake(selectionId: string, amount: number) {
|
|
const next = (slip.getItemStake(selectionId) || 0) + amount;
|
|
const normalized = Math.round(Math.max(MIN_STAKE, next) * 100) / 100;
|
|
slip.setItemStake(selectionId, normalized);
|
|
itemStakeInputs.value = {
|
|
...itemStakeInputs.value,
|
|
[selectionId]: stakeAmountToInput(normalized),
|
|
};
|
|
}
|
|
|
|
function setItemMaxStake(selectionId: string) {
|
|
if (balance.value == null || balance.value <= 0) return;
|
|
const normalized = Math.round(balance.value * 100) / 100;
|
|
slip.setItemStake(selectionId, normalized);
|
|
itemStakeInputs.value = {
|
|
...itemStakeInputs.value,
|
|
[selectionId]: stakeAmountToInput(normalized),
|
|
};
|
|
}
|
|
|
|
function itemEstReturn(item: SlipItem) {
|
|
const s = slip.getItemStake(item.selectionId);
|
|
if (!Number.isFinite(s) || s <= 0) return 0;
|
|
return s * effectiveOdds(item);
|
|
}
|
|
|
|
function syncStakeInputFromSlip() {
|
|
stakeInput.value = stakeAmountToInput(Number(slip.stake) || 0);
|
|
if (activeTab.value === 'single') {
|
|
syncItemStakeInputs(singleSlipItems.value);
|
|
}
|
|
}
|
|
|
|
function onStakeInput(event: Event) {
|
|
commitStakeInput((event.target as HTMLInputElement).value);
|
|
}
|
|
|
|
function clearStakeInput() {
|
|
commitStakeInput('');
|
|
}
|
|
|
|
function setStake(amount: number, enforceMinimum = true) {
|
|
if (!Number.isFinite(amount)) return;
|
|
const normalized = Math.round((enforceMinimum ? Math.max(MIN_STAKE, amount) : amount) * 100) / 100;
|
|
slip.stake = normalized;
|
|
stakeInput.value = stakeAmountToInput(normalized);
|
|
}
|
|
|
|
function addStake(amount: number) {
|
|
setStake((Number(slip.stake) || 0) + amount);
|
|
}
|
|
|
|
function setMaxStake() {
|
|
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
|
|
}
|
|
|
|
function stopOddsPolling() {
|
|
if (oddsPollTimer) {
|
|
clearInterval(oddsPollTimer);
|
|
oddsPollTimer = null;
|
|
}
|
|
oddsDeltas.value = {};
|
|
}
|
|
|
|
function acceptPendingOdds() {
|
|
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
|
|
if (!delta.suspended) {
|
|
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
|
|
}
|
|
}
|
|
oddsDeltas.value = {};
|
|
}
|
|
|
|
function acceptPendingOddsForSelection(selectionId: string) {
|
|
const delta = oddsDeltas.value[selectionId];
|
|
if (!delta || delta.suspended) return;
|
|
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
|
|
const next = { ...oddsDeltas.value };
|
|
delete next[selectionId];
|
|
oddsDeltas.value = next;
|
|
}
|
|
|
|
function isItemSuspended(item: SlipItem) {
|
|
return Boolean(oddsDeltaFor(item.selectionId)?.suspended);
|
|
}
|
|
|
|
function canPlaceSingleItem(item: SlipItem) {
|
|
if (item.allowSingle === false) return false;
|
|
if (isItemSuspended(item)) return false;
|
|
return slip.getItemStake(item.selectionId) >= MIN_STAKE;
|
|
}
|
|
|
|
function validateSingleItemPlace(item: SlipItem): boolean {
|
|
if (!auth.token) {
|
|
auth.showLoginPrompt();
|
|
return false;
|
|
}
|
|
if (item.allowSingle === false) {
|
|
error.value = t('bet.slip_parlay_only_hint');
|
|
return false;
|
|
}
|
|
const itemStake = slip.getItemStake(item.selectionId);
|
|
if (itemStake < MIN_STAKE) {
|
|
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
|
return false;
|
|
}
|
|
if (balance.value != null && itemStake > balance.value) {
|
|
error.value = t('bet.outright_insufficient');
|
|
return false;
|
|
}
|
|
if (isItemSuspended(item)) {
|
|
error.value = t('bet.odds_suspended');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function onPlaceSingleItemClick(item: SlipItem) {
|
|
error.value = '';
|
|
if (!validateSingleItemPlace(item)) return;
|
|
if (oddsDeltaFor(item.selectionId) && !isItemSuspended(item)) {
|
|
acceptPendingOddsForSelection(item.selectionId);
|
|
}
|
|
pendingSingleItem.value = item;
|
|
const stake = slip.getItemStake(item.selectionId);
|
|
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
|
mode: 'single',
|
|
items: [item],
|
|
totalStake: stake,
|
|
totalReturn: stake * effectiveOdds(item),
|
|
formatMoney: (amount) => formatMoney(amount, locale.value),
|
|
getStake: () => stake,
|
|
getOdds: () => effectiveOdds(item),
|
|
});
|
|
showPlaceConfirm.value = true;
|
|
}
|
|
|
|
async function pollSelectionsOdds() {
|
|
const items = activeItems.value;
|
|
if (!items.length) return;
|
|
|
|
try {
|
|
const ids = items.map((item) => item.selectionId).join(',');
|
|
const { data } = await api.get('/player/selections/odds', { params: { ids } });
|
|
const rows: SelectionOddsRow[] = data.data?.items ?? [];
|
|
const rowMap = new Map(rows.map((row) => [row.id, row]));
|
|
const next: Record<string, OddsDelta> = {};
|
|
|
|
for (const item of items) {
|
|
const row = rowMap.get(item.selectionId);
|
|
if (!row) continue;
|
|
|
|
const suspended =
|
|
row.status !== 'OPEN' ||
|
|
row.marketStatus !== 'OPEN' ||
|
|
row.marketShowOnPlayer === false ||
|
|
row.matchStatus !== 'PUBLISHED';
|
|
const newOdds = parseFloat(row.odds);
|
|
const versionChanged = row.oddsVersion !== item.oddsVersion;
|
|
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
|
|
|
|
if (suspended || versionChanged || oddsChanged) {
|
|
const existing = oddsDeltas.value[item.selectionId];
|
|
next[item.selectionId] = {
|
|
oldOdds: existing?.oldOdds ?? item.odds,
|
|
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
|
|
newVersion: row.oddsVersion,
|
|
suspended,
|
|
};
|
|
}
|
|
}
|
|
|
|
oddsDeltas.value = next;
|
|
} catch {
|
|
/* silent retry */
|
|
}
|
|
}
|
|
|
|
function startOddsPolling() {
|
|
stopOddsPolling();
|
|
void pollSelectionsOdds();
|
|
oddsPollTimer = setInterval(() => {
|
|
void pollSelectionsOdds();
|
|
}, ODDS_POLL_MS);
|
|
}
|
|
|
|
function oddsDeltaFor(selectionId: string) {
|
|
return oddsDeltas.value[selectionId];
|
|
}
|
|
|
|
function oddsTrendClass(delta: OddsDelta) {
|
|
if (delta.suspended) return 'odds-suspended';
|
|
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
|
|
}
|
|
|
|
function singlesForSubmit() {
|
|
return [...singleSlipItems.value];
|
|
}
|
|
|
|
function validatePlaceBet(): boolean {
|
|
if (!activeItems.value.length) return false;
|
|
if (!auth.token) {
|
|
auth.showLoginPrompt();
|
|
return false;
|
|
}
|
|
if (activeTab.value === 'parlay') {
|
|
if (slip.stake < MIN_STAKE) {
|
|
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
|
return false;
|
|
}
|
|
if (balance.value != null && slip.stake > balance.value) {
|
|
error.value = t('bet.outright_insufficient');
|
|
return false;
|
|
}
|
|
} else {
|
|
const singles = singlesForSubmit();
|
|
let totalStake = 0;
|
|
for (const item of singles) {
|
|
const itemStake = slip.getItemStake(item.selectionId);
|
|
if (itemStake < MIN_STAKE) {
|
|
error.value = t('bet.slip_min_error', { amount: MIN_STAKE });
|
|
return false;
|
|
}
|
|
totalStake += itemStake;
|
|
}
|
|
if (balance.value != null && totalStake > balance.value) {
|
|
error.value = t('bet.outright_insufficient');
|
|
return false;
|
|
}
|
|
}
|
|
if (activeTab.value === 'parlay' && !canSubmitActive.value) {
|
|
error.value = slip.parlayItems.length > PARLAY_MAX_LEGS
|
|
? t('bet.parlay_max_legs')
|
|
: t('bet.parlay_need_more');
|
|
return false;
|
|
}
|
|
if (hasSuspendedSelections.value) {
|
|
error.value = t('bet.odds_suspended');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function onPlaceBetClick() {
|
|
error.value = '';
|
|
if (!validatePlaceBet()) return;
|
|
if (hasPendingOddsChanges.value) {
|
|
acceptPendingOdds();
|
|
}
|
|
pendingSingleItem.value = null;
|
|
const singles = singlesForSubmit();
|
|
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
|
|
mode: activeTab.value === 'parlay' ? 'parlay' : 'single',
|
|
items: activeTab.value === 'parlay' ? [...slip.parlayItems] : singles,
|
|
totalStake: activeTotalStake.value,
|
|
totalReturn: activeEstimatedReturn.value,
|
|
totalOdds: activeTab.value === 'parlay' ? activeTotalOdds.value : undefined,
|
|
formatMoney: (amount) => formatMoney(amount, locale.value),
|
|
getStake: (item) => slip.getItemStake(item.selectionId),
|
|
getOdds: (item) => effectiveOdds(item),
|
|
});
|
|
showPlaceConfirm.value = true;
|
|
}
|
|
|
|
async function executePlaceSingleItem(item: SlipItem) {
|
|
loading.value = true;
|
|
error.value = '';
|
|
success.value = '';
|
|
|
|
try {
|
|
await api.post('/player/bets/single', {
|
|
selectionId: item.selectionId,
|
|
oddsVersion: item.oddsVersion,
|
|
stake: slip.getItemStake(item.selectionId),
|
|
requestId: genId(),
|
|
});
|
|
slip.removeItem(item.selectionId);
|
|
success.value = t('bet.place_success');
|
|
showSuccess.value = true;
|
|
await Promise.all([loadBalance(), refreshProfile()]);
|
|
setTimeout(() => {
|
|
if (showSuccess.value) onSuccessDone();
|
|
}, 2200);
|
|
} catch (e: unknown) {
|
|
error.value =
|
|
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
|
t('bet.place_failed');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function executePlaceBet() {
|
|
loading.value = true;
|
|
error.value = '';
|
|
success.value = '';
|
|
|
|
try {
|
|
if (activeTab.value === 'parlay') {
|
|
await api.post('/player/bets/parlay', {
|
|
legs: slip.parlayItems.map((item) => ({
|
|
selectionId: item.selectionId,
|
|
oddsVersion: item.oddsVersion,
|
|
})),
|
|
stake: slip.stake,
|
|
requestId: genId(),
|
|
});
|
|
slip.clearParlay();
|
|
} else {
|
|
for (const item of singlesForSubmit()) {
|
|
await api.post('/player/bets/single', {
|
|
selectionId: item.selectionId,
|
|
oddsVersion: item.oddsVersion,
|
|
stake: slip.getItemStake(item.selectionId),
|
|
requestId: genId(),
|
|
});
|
|
}
|
|
slip.clearSingle();
|
|
}
|
|
success.value = t('bet.place_success');
|
|
showSuccess.value = true;
|
|
if (activeTab.value === 'history') {
|
|
void loadHistory(true);
|
|
}
|
|
await Promise.all([loadBalance(), refreshProfile()]);
|
|
setTimeout(() => {
|
|
if (showSuccess.value) onSuccessDone();
|
|
}, 2200);
|
|
} catch (e: unknown) {
|
|
error.value =
|
|
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
|
t('bet.place_failed');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function confirmPlaceBet() {
|
|
showPlaceConfirm.value = false;
|
|
const singleItem = pendingSingleItem.value;
|
|
pendingSingleItem.value = null;
|
|
if (singleItem) {
|
|
await executePlaceSingleItem(singleItem);
|
|
return;
|
|
}
|
|
await executePlaceBet();
|
|
}
|
|
|
|
function onSuccessDone() {
|
|
showSuccess.value = false;
|
|
error.value = '';
|
|
success.value = '';
|
|
syncStakeInputFromSlip();
|
|
}
|
|
|
|
async function loadBalance() {
|
|
if (!auth.token) {
|
|
balance.value = null;
|
|
return;
|
|
}
|
|
balanceLoading.value = true;
|
|
try {
|
|
const { data } = await api.get('/player/profile');
|
|
balance.value = parseAmount(data.data?.wallet?.availableBalance);
|
|
} catch {
|
|
balance.value = null;
|
|
} finally {
|
|
balanceLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function parlayErrorMessage(reason: ParlaySlipError) {
|
|
if (reason === 'MAX_LEGS') return t('bet.parlay_max_legs');
|
|
if (reason === 'QUARTER_LINE') return t('bet.parlay_block_quarter');
|
|
if (reason === 'OUTRIGHT') return t('bet.parlay_block_outright');
|
|
if (reason === 'NOT_ALLOWED') return t('bet.parlay_block_not_allowed');
|
|
if (reason === 'SAME_MATCH') return t('bet.slip_parlay_same_match');
|
|
return t('bet.parlay_block_not_allowed');
|
|
}
|
|
|
|
function addItemToParlay(item: SlipItem) {
|
|
if (!auth.token) {
|
|
auth.showLoginPrompt(route.fullPath);
|
|
return;
|
|
}
|
|
if (slip.parlayItems.some((leg) => leg.selectionId === item.selectionId)) {
|
|
activeTab.value = 'parlay';
|
|
slip.setMode('parlay');
|
|
error.value = '';
|
|
return;
|
|
}
|
|
const err = slip.addParlayLeg(item);
|
|
activeTab.value = 'parlay';
|
|
slip.setMode('parlay');
|
|
if (err) {
|
|
error.value = parlayErrorMessage(err);
|
|
return;
|
|
}
|
|
error.value = '';
|
|
}
|
|
|
|
function isItemInParlay(item: SlipItem) {
|
|
return slip.parlayItems.some((leg) => leg.selectionId === item.selectionId);
|
|
}
|
|
|
|
function parlayActionLabel(item: SlipItem) {
|
|
return isItemInParlay(item) ? t('bet.slip_parlay_added_short') : t('bet.add_to_parlay');
|
|
}
|
|
|
|
function addCurrentToParlay() {
|
|
if (singleInParlay.value) {
|
|
activeTab.value = 'parlay';
|
|
slip.setMode('parlay');
|
|
error.value = '';
|
|
return;
|
|
}
|
|
const err = slip.addSingleToParlay();
|
|
activeTab.value = 'parlay';
|
|
slip.setMode('parlay');
|
|
if (err) {
|
|
error.value = parlayErrorMessage(err);
|
|
return;
|
|
}
|
|
error.value = '';
|
|
}
|
|
|
|
watch(
|
|
() => activeItems.value.map((item) => item.selectionId).join(','),
|
|
(ids) => {
|
|
if (ids) {
|
|
if (activeTab.value === 'single') syncItemStakeInputs(singleSlipItems.value);
|
|
startOddsPolling();
|
|
} else {
|
|
itemStakeInputs.value = {};
|
|
stopOddsPolling();
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => slip.mode,
|
|
(mode) => {
|
|
if (activeTab.value !== 'history') {
|
|
transitionName.value = '';
|
|
activeTab.value = mode;
|
|
nextTick(() => {
|
|
transitionName.value = 'list-slide';
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(showMatchHistoryFilter, (show) => {
|
|
if (!show && historyScope.value === 'match') {
|
|
historyScope.value = 'all';
|
|
if (activeTab.value === 'history') void loadHistory(true);
|
|
}
|
|
});
|
|
|
|
watch(currentMatchId, (id, prev) => {
|
|
if (activeTab.value !== 'history') return;
|
|
if (id && id !== prev && historyScope.value === 'match') {
|
|
void loadHistory(true);
|
|
}
|
|
if (!id && historyScope.value === 'match') {
|
|
historyScope.value = 'all';
|
|
void loadHistory(true);
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => auth.token,
|
|
(token) => {
|
|
if (!token && activeTab.value === 'history') {
|
|
activeTab.value = slip.mode;
|
|
historyItems.value = [];
|
|
historyTotal.value = 0;
|
|
}
|
|
},
|
|
);
|
|
|
|
onMounted(() => {
|
|
activeTab.value = slip.mode;
|
|
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
|
activeTab.value = 'parlay';
|
|
slip.setMode('parlay');
|
|
}
|
|
syncStakeInputFromSlip();
|
|
void loadBalance();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopOddsPolling();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="betslip-panel">
|
|
<div class="panel-head">
|
|
<h3>{{ t('bet.bet_slip') || '投注单' }}</h3>
|
|
<button
|
|
type="button"
|
|
class="panel-close-btn"
|
|
@click="slip.collapsePanel()"
|
|
:aria-label="t('common.collapse') || '折叠'"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="panel-tabs">
|
|
<button
|
|
type="button"
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'single' }"
|
|
@click="selectTab('single')"
|
|
>
|
|
{{ t('bet.slip_tab_single') }}
|
|
<span v-if="slip.singleCount" class="badge">{{ slip.singleCount }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'parlay' }"
|
|
@click="selectTab('parlay')"
|
|
>
|
|
{{ t('bet.slip_tab_parlay') }}
|
|
<span v-if="slip.parlayCount" class="badge">{{ slip.parlayCount }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="tab-btn"
|
|
:class="{ active: activeTab === 'history' }"
|
|
@click="selectTab('history')"
|
|
>
|
|
{{ t('bet.slip_tab_history') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'history' && showMatchHistoryFilter" class="history-scope-bar">
|
|
<button
|
|
type="button"
|
|
class="scope-chip"
|
|
:class="{ active: historyScope === 'all' }"
|
|
@click="selectHistoryScope('all')"
|
|
>
|
|
{{ t('bet.slip_history_all') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="scope-chip"
|
|
:class="{ active: historyScope === 'match' }"
|
|
@click="selectHistoryScope('match')"
|
|
>
|
|
{{ t('bet.slip_history_match') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Warnings -->
|
|
<p v-if="activeTab !== 'history' && oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
|
|
|
<!-- Body -->
|
|
<div class="panel-body">
|
|
<template v-if="activeTab === 'history'">
|
|
<div v-if="historyInitialLoading" class="history-loading">
|
|
<GoldSpinner :size="28" />
|
|
</div>
|
|
<div v-else-if="!historyItems.length" class="empty-state">
|
|
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
|
|
</svg>
|
|
<p>{{ t('history.empty') }}</p>
|
|
</div>
|
|
<div v-else class="history-list">
|
|
<RouterLink
|
|
v-for="bet in historyItems"
|
|
:key="bet.betNo"
|
|
:to="`/bets/${bet.betNo}`"
|
|
class="history-card"
|
|
>
|
|
<div class="history-card-top">
|
|
<span class="history-pick">{{ historyPickLabel(bet) }}</span>
|
|
<span class="history-status" :class="historyStatusClass(bet.status)">
|
|
{{ historyStatusLabel(bet.status) }}
|
|
</span>
|
|
</div>
|
|
<div v-if="historyScope === 'all' && bet.matchTitle" class="history-match">
|
|
{{ bet.matchTitle }}
|
|
</div>
|
|
<div class="history-card-foot">
|
|
<span class="history-stake">{{ t('history.stake') }} {{ formatMoney(bet.stake, locale) }}</span>
|
|
<span class="history-return" :class="historyStatusClass(bet.status)">
|
|
{{ formatMoney(bet.actualReturn || bet.potentialReturn, locale) }}
|
|
</span>
|
|
</div>
|
|
<div class="history-meta">
|
|
<span>{{ bet.betNo }}</span>
|
|
<span>{{ bet.placedAt ? new Date(bet.placedAt).toLocaleString(locale) : '' }}</span>
|
|
</div>
|
|
</RouterLink>
|
|
</div>
|
|
<button
|
|
v-if="historyHasMore && !historyInitialLoading"
|
|
type="button"
|
|
class="history-load-more"
|
|
:disabled="historyLoading"
|
|
@click="loadMoreHistory"
|
|
>
|
|
{{ historyLoading ? t('bet.loading') : t('bet.slip_history_load_more') }}
|
|
</button>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div v-if="!activeItems.length" class="empty-state">
|
|
<svg class="empty-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2zm0 8H7v-2h10v2z" fill="currentColor"/>
|
|
</svg>
|
|
<p>{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}</p>
|
|
</div>
|
|
|
|
<TransitionGroup v-else :name="transitionName" tag="div" class="items-list">
|
|
<p v-if="activeTab === 'parlay' && parlayWarning" class="panel-warning" key="warning">{{ parlayWarning }}</p>
|
|
<div
|
|
v-for="item in activeItems"
|
|
:key="item.selectionId"
|
|
class="slip-card"
|
|
>
|
|
<div class="card-top">
|
|
<div class="card-meta">
|
|
<span class="match-name">{{ item.matchName }}</span>
|
|
<span class="card-market">{{ item.marketName }}</span>
|
|
</div>
|
|
<div class="card-actions">
|
|
<button type="button" class="del-btn" @click="removeItem(item.selectionId)">✕</button>
|
|
<button
|
|
type="button"
|
|
class="locate-btn"
|
|
:title="t('bet.slip_locate')"
|
|
:aria-label="t('bet.slip_locate_aria')"
|
|
@click="locateSlipItem(item)"
|
|
>
|
|
<svg class="locate-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6a2.5 2.5 0 0 1 0 5.5z"
|
|
fill="currentColor"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-selection-row">
|
|
<span class="selection-name">{{ item.selectionName }}</span>
|
|
<span class="odds-value">
|
|
<template v-if="oddsDeltaFor(item.selectionId)">
|
|
<span class="odds-change" :class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)">
|
|
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }} →
|
|
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
|
|
</span>
|
|
</template>
|
|
<template v-else>{{ item.odds.toFixed(2) }}</template>
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'single'" class="card-stake-block">
|
|
<div class="card-stake-inline">
|
|
<div class="card-stake-input-box">
|
|
<input
|
|
:value="itemStakeInputValue(item.selectionId)"
|
|
type="number"
|
|
class="card-stake-input"
|
|
:placeholder="t('bet.stake_placeholder') || '金额'"
|
|
@input="onItemStakeInput(item.selectionId, $event)"
|
|
/>
|
|
<button
|
|
v-if="itemStakeInputValue(item.selectionId)"
|
|
type="button"
|
|
class="clear-input-btn"
|
|
@click="clearItemStakeInput(item.selectionId)"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 50)">+50</button>
|
|
<button type="button" class="chip-btn mini" @click="addItemStake(item.selectionId, 100)">+100</button>
|
|
<button type="button" class="chip-btn mini" @click="setItemMaxStake(item.selectionId)">
|
|
{{ t('bet.stake_max') || '全' }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="card-action-row"
|
|
:class="{
|
|
'card-action-row--single': item.allowParlay === false || item.allowSingle === false,
|
|
}"
|
|
>
|
|
<button
|
|
v-if="item.allowSingle !== false"
|
|
type="button"
|
|
class="card-action-btn card-single-btn"
|
|
:disabled="loading || !canPlaceSingleItem(item)"
|
|
@click="onPlaceSingleItemClick(item)"
|
|
>
|
|
{{ t('bet.slip_place_single') }}
|
|
</button>
|
|
<button
|
|
v-if="item.allowParlay !== false"
|
|
type="button"
|
|
class="card-action-btn card-parlay-btn"
|
|
:class="{ active: isItemInParlay(item) }"
|
|
@click="addItemToParlay(item)"
|
|
>
|
|
<span class="card-parlay-btn-icon" aria-hidden="true">{{ isItemInParlay(item) ? '✓' : '+' }}</span>
|
|
<span>{{ isItemInParlay(item) ? t('bet.slip_parlay_added_btn') : t('bet.add_to_parlay') }}</span>
|
|
</button>
|
|
</div>
|
|
<div class="card-est-row">
|
|
<span class="est-lbl">{{ t('bet.slip_est_return') }}</span>
|
|
<span class="est-val">{{ formatMoney(itemEstReturn(item), locale) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="activeTab === 'parlay' && slip.parlayItems.length" class="parlay-summary" key="summary">
|
|
<span>{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}</span>
|
|
<span class="parlay-odds">{{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}</span>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<!-- Stake input (parlay only) -->
|
|
<div v-if="activeTab === 'parlay' && activeItems.length" class="stake-control">
|
|
<div class="stake-input-box">
|
|
<span class="currency">{{ t('bet.slip_currency') || 'CNY' }}</span>
|
|
<input
|
|
v-model="stakeInput"
|
|
type="number"
|
|
class="stake-input"
|
|
:placeholder="t('bet.stake') || '输入金额'"
|
|
@input="onStakeInput"
|
|
/>
|
|
<button v-if="stakeInput" type="button" class="clear-input-btn" @click="clearStakeInput">✕</button>
|
|
</div>
|
|
|
|
<div class="quick-chips">
|
|
<button type="button" class="chip-btn" @click="addStake(50)">+50</button>
|
|
<button type="button" class="chip-btn" @click="addStake(100)">+100</button>
|
|
<button type="button" class="chip-btn" @click="addStake(500)">+500</button>
|
|
<button type="button" class="chip-btn" @click="setMaxStake">{{ t('bet.stake_max') || '最大' }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="error" class="panel-error-msg">{{ error }}</p>
|
|
<p v-if="success" class="panel-success-msg">{{ success }}</p>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="activeTab === 'history'" class="panel-bottom-fixed panel-bottom-history">
|
|
<RouterLink to="/bets" class="history-view-all-btn">
|
|
{{ t('bet.slip_history_view_all') }}
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<div v-else-if="activeItems.length" class="panel-bottom-fixed">
|
|
<div class="panel-summary">
|
|
<div class="summary-row">
|
|
<span class="lbl">{{ t('bet.slip_total_stake') }}</span>
|
|
<span class="val">{{ stakeText }}</span>
|
|
</div>
|
|
<div class="summary-row est-return">
|
|
<span class="lbl">{{ t('bet.slip_est_return') }}</span>
|
|
<span class="val">{{ estimatedReturnText }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="panel-foot">
|
|
<button type="button" class="clear-slip-btn foot-clear" @click="clearActiveSlip">
|
|
{{ t('bet.slip_clear') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="submit-btn"
|
|
:disabled="loading || !canSubmitWithOdds"
|
|
@click="onPlaceBetClick"
|
|
>
|
|
{{ submitButtonLabel }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
v-model:visible="showPlaceConfirm"
|
|
:title="t('bet.place_confirm_title')"
|
|
:message="placeConfirmMessage"
|
|
:confirm-text="t('bet.place_bet')"
|
|
:cancel-text="t('common.cancel')"
|
|
:loading="loading"
|
|
@confirm="confirmPlaceBet"
|
|
/>
|
|
|
|
<!-- Success Overlay -->
|
|
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.betslip-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: var(--desktop-sidebar-panel-bg);
|
|
}
|
|
|
|
.panel-head {
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.panel-head h3 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
font-weight: 800;
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.panel-close-btn {
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 4px;
|
|
transition: background 0.2s, color 0.2s;
|
|
}
|
|
|
|
.panel-close-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.clear-slip-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 3px 8px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.clear-slip-btn.foot-clear {
|
|
flex: 0 0 20%;
|
|
min-width: 0;
|
|
padding: 10px 4px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.clear-slip-btn:hover {
|
|
color: var(--primary-light);
|
|
border-color: var(--border-gold-soft);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.balance-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.balance-info .lbl {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.balance-info .val {
|
|
color: var(--text);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.refresh-btn {
|
|
background: transparent;
|
|
color: var(--primary-light);
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
padding: 0 2px;
|
|
}
|
|
|
|
.panel-tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.tab-btn {
|
|
flex: 1;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
padding: 10px 4px;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
color: var(--text);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--primary-light);
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
|
|
.badge {
|
|
background: var(--primary);
|
|
color: #001A33;
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
padding: 1px 4px;
|
|
border-radius: 6px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.odds-warning {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
border-bottom: 1px solid var(--border-gold-soft);
|
|
color: var(--primary-light);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 8px 16px;
|
|
margin: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.panel-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px 12px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 60%;
|
|
color: var(--text-muted);
|
|
gap: 12px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.items-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.slip-card {
|
|
background: #0A2540;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 7px 8px;
|
|
position: relative;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.slip-card:hover {
|
|
border-color: var(--border-gold-soft);
|
|
}
|
|
|
|
.card-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.card-meta {
|
|
min-width: 0;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
|
|
.match-name {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.card-market {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.del-btn {
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
font-size: 10px;
|
|
padding: 0 2px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.card-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.locate-btn {
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
padding: 0 2px;
|
|
line-height: 1;
|
|
display: flex;
|
|
}
|
|
|
|
.locate-btn:hover {
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.locate-icon {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
|
|
.del-btn:hover {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.card-selection-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.selection-name {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.odds-value {
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
color: var(--primary-light);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.odds-change {
|
|
font-size: 11px;
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.odds-up {
|
|
background: rgba(46, 204, 113, 0.15);
|
|
color: #2ecc71;
|
|
}
|
|
|
|
.odds-down {
|
|
background: rgba(231, 76, 60, 0.15);
|
|
color: #e74c3c;
|
|
}
|
|
|
|
.odds-suspended {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: var(--text-muted);
|
|
text-decoration: line-through;
|
|
}
|
|
|
|
.card-stake-block {
|
|
margin-top: 5px;
|
|
padding-top: 5px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.card-stake-inline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.card-stake-input-box {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
background: #002244;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
padding: 0 6px;
|
|
height: 26px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.card-stake-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none !important;
|
|
color: #fff;
|
|
padding: 0;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
min-width: 0;
|
|
}
|
|
|
|
.card-stake-input::-webkit-outer-spin-button,
|
|
.card-stake-input::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
.chip-btn.mini {
|
|
flex: 0 0 auto;
|
|
padding: 0 6px;
|
|
height: 26px;
|
|
font-size: 9px;
|
|
min-width: 32px;
|
|
}
|
|
|
|
.card-action-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.card-action-row--single .card-action-btn {
|
|
flex: 1 1 100%;
|
|
}
|
|
|
|
.card-action-btn {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
padding: 6px 6px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
cursor: pointer;
|
|
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
|
}
|
|
|
|
.card-action-btn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.card-single-btn {
|
|
border: 1px solid rgba(255, 255, 255, 0.55);
|
|
background: rgba(255, 255, 255, 0.14);
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.card-single-btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.22);
|
|
border-color: var(--border-gold);
|
|
color: #fff;
|
|
}
|
|
|
|
.card-parlay-btn {
|
|
border: 1px dashed rgba(255, 255, 255, 0.45);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.card-parlay-btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: var(--border-gold-soft);
|
|
color: #fff;
|
|
}
|
|
|
|
.card-parlay-btn.active {
|
|
border-style: solid;
|
|
border-color: rgba(255, 255, 255, 0.35);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-parlay-btn.active:hover {
|
|
color: var(--primary-light);
|
|
border-color: var(--border-gold-soft);
|
|
}
|
|
|
|
.card-parlay-btn-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.16);
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.card-parlay-btn.active .card-parlay-btn-icon {
|
|
background: rgba(46, 204, 113, 0.18);
|
|
color: #2ecc71;
|
|
}
|
|
|
|
.card-est-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 9px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.card-est-row .est-lbl {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card-est-row .est-val {
|
|
color: var(--primary-light);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.panel-warning {
|
|
font-size: 11px;
|
|
color: var(--primary-light);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border: 1px solid var(--border-gold-soft);
|
|
border-radius: 4px;
|
|
padding: 8px;
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.parlay-summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
padding: 4px 6px;
|
|
background: #0A2540;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.parlay-odds {
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.stake-control {
|
|
background: #0A2540;
|
|
border-radius: 4px;
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stake-input-box {
|
|
display: flex;
|
|
align-items: center;
|
|
background: #002244;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 0 10px;
|
|
height: 36px;
|
|
}
|
|
|
|
.currency {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--primary-light);
|
|
margin-right: 6px;
|
|
}
|
|
|
|
.stake-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none !important;
|
|
color: #fff;
|
|
padding: 0;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.stake-input::-webkit-outer-spin-button,
|
|
.stake-input::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
.clear-input-btn {
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.quick-chips {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.chip-btn {
|
|
flex: 1;
|
|
background: #0A2540;
|
|
border: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
padding: 5px 0;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.chip-btn:hover {
|
|
border-color: var(--border-gold-soft);
|
|
color: #fff;
|
|
}
|
|
|
|
.panel-error-msg {
|
|
color: var(--danger);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.panel-success-msg {
|
|
color: #2ecc71;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.panel-bottom-fixed {
|
|
flex-shrink: 0;
|
|
border-top: 1px solid var(--border);
|
|
background: rgba(10, 37, 64, 0.98);
|
|
}
|
|
|
|
.panel-summary {
|
|
padding: 10px 16px 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.summary-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.summary-row .lbl {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.summary-row .val {
|
|
color: var(--text);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.summary-row.est-return .val {
|
|
color: var(--primary-light);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.panel-foot {
|
|
padding: 10px 16px 12px;
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 8px;
|
|
}
|
|
|
|
.add-parlay-btn {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border-gold-soft);
|
|
background: transparent;
|
|
color: var(--primary-light);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.add-parlay-btn:hover {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.submit-btn {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
color: #3D2800;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
transition: opacity 0.2s;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.submit-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
filter: saturate(0.5);
|
|
}
|
|
|
|
.history-view-all-btn {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border-gold-soft);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: var(--primary-light);
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
text-align: center;
|
|
text-decoration: none;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
}
|
|
|
|
.history-view-all-btn:hover {
|
|
background: rgba(255, 255, 255, 0.14);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.panel-bottom-history {
|
|
padding: 10px 16px 12px;
|
|
}
|
|
|
|
.history-scope-bar {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.scope-chip {
|
|
flex: 1;
|
|
padding: 5px 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.scope-chip.active {
|
|
color: var(--primary-light);
|
|
border-color: var(--border-gold-soft);
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.history-loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 32px 0;
|
|
}
|
|
|
|
.history-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.history-card {
|
|
display: block;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border);
|
|
background: #0A2540;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.history-card:hover {
|
|
border-color: var(--border-gold-soft);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
}
|
|
|
|
.history-card-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.history-pick {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
line-height: 1.35;
|
|
min-width: 0;
|
|
}
|
|
|
|
.history-status {
|
|
flex-shrink: 0;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.history-status.hist-won {
|
|
color: #2ecc71;
|
|
background: rgba(46, 204, 113, 0.12);
|
|
}
|
|
|
|
.history-status.hist-lost {
|
|
color: #e74c3c;
|
|
background: rgba(231, 76, 60, 0.12);
|
|
}
|
|
|
|
.history-status.hist-push {
|
|
color: #95a5a6;
|
|
background: rgba(149, 165, 166, 0.12);
|
|
}
|
|
|
|
.history-status.hist-pending {
|
|
color: var(--primary-light);
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.history-match {
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.history-card-foot {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.history-stake {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.history-return {
|
|
font-weight: 800;
|
|
}
|
|
|
|
.history-return.hist-won {
|
|
color: #2ecc71;
|
|
}
|
|
|
|
.history-return.hist-lost {
|
|
color: #e74c3c;
|
|
}
|
|
|
|
.history-return.hist-push,
|
|
.history-return.hist-pending {
|
|
color: var(--primary-light);
|
|
}
|
|
|
|
.history-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
font-size: 9px;
|
|
color: var(--text-muted);
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.history-load-more {
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
color: var(--primary-light);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.history-load-more:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Transition group list animation */
|
|
.list-slide-enter-active,
|
|
.list-slide-leave-active {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.list-slide-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(-20px) scale(0.95);
|
|
}
|
|
|
|
.list-slide-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(20px) scale(0.95);
|
|
}
|
|
|
|
/* Ensure smooth moving when list changes */
|
|
.list-slide-move {
|
|
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
</style>
|