diff --git a/apps/admin/src/views/match-form.ts b/apps/admin/src/views/match-form.ts index 8a5d055..b406955 100644 --- a/apps/admin/src/views/match-form.ts +++ b/apps/admin/src/views/match-form.ts @@ -81,6 +81,8 @@ export interface AdminMarket { lineValue: number | null; paramsJson?: Record | null; status: string; + allowSingle?: boolean; + allowParlay?: boolean; showOnPlayer: boolean; promoLabel: string; promoLabelI18n?: Record; diff --git a/apps/admin/src/views/matches/MatchMarketsPanel.vue b/apps/admin/src/views/matches/MatchMarketsPanel.vue index afa8c0d..1c20c00 100644 --- a/apps/admin/src/views/matches/MatchMarketsPanel.vue +++ b/apps/admin/src/views/matches/MatchMarketsPanel.vue @@ -198,8 +198,8 @@ function mapMarkets(detail: AdminMatchDetail) { lineValue: m.lineValue, paramsJson: m.paramsJson ?? null, status: m.status, - allowSingle: true, - allowParlay: true, + allowSingle: m.allowSingle ?? true, + allowParlay: m.allowParlay ?? true, showOnPlayer: m.showOnPlayer ?? true, sortOrder: m.sortOrder ?? index, promoLabel: m.promoLabel ?? '', diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index 9b24b97..179a8c1 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -871,6 +871,8 @@ export class MatchesService { lineValue: m.lineValue != null ? Number(m.lineValue) : null, paramsJson: m.paramsJson ?? null, status: m.status, + allowSingle: m.allowSingle, + allowParlay: m.allowParlay, showOnPlayer: m.showOnPlayer, promoLabel: m.promoLabel ?? '', promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n), diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index 90b1e4b..74ea1e1 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -57,9 +57,17 @@ const canSubmitActive = computed(() => { if (activeTab.value === 'parlay') { return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS; } - return Boolean(slip.singleItem); + return Boolean(slip.singleItem) && slip.singleItem.allowSingle !== false; }); +const singleParlayOnlyHint = computed( + () => + activeTab.value === '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 '--'; @@ -356,6 +364,7 @@ watch(
{{ slip.singleItem.odds.toFixed(2) }}
+

{{ t('bet.slip_parlay_only_hint') }}

@@ -86,6 +87,17 @@ const { t } = useI18n(); border: 1px solid rgba(255, 179, 0, 0.3); } +.row-status { + flex-shrink: 0; + font-size: 9px; + font-weight: 700; + color: #f0b429; + padding: 2px 6px; + border-radius: 3px; + background: rgba(240, 180, 41, 0.12); + border: 1px solid rgba(240, 180, 41, 0.35); +} + .row.expanded .row-label { color: var(--primary); } @@ -95,16 +107,4 @@ const { t } = useI18n(); color: var(--text-muted); font-weight: 600; } - -.row-chevron { - font-size: 11px; - color: var(--text-muted); - transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s; - flex-shrink: 0; -} - -.row-chevron.open { - transform: rotate(90deg); - color: var(--primary); -} diff --git a/apps/player/src/i18n/en-US.ts b/apps/player/src/i18n/en-US.ts index 85c21f6..c44cae6 100644 --- a/apps/player/src/i18n/en-US.ts +++ b/apps/player/src/i18n/en-US.ts @@ -310,6 +310,8 @@ export default { parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed', parlay_block_not_allowed: 'This market cannot be parlayed', parlay_need_more: 'Select at least 2 legs for parlay', + market_status_suspended: 'Suspended', + market_status_closed: 'Closed', back: 'Back', refresh: 'Refresh', download: 'Download', @@ -393,6 +395,7 @@ export default { slip_tab_parlay: 'Parlay', slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay', slip_add_parlay: 'Add to parlay', + slip_parlay_only_hint: 'This market is parlay-only. Tap Add to parlay to bet.', slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.', slip_parlay_count: '{n} parlay leg(s)', slip_total_stake: 'Total stake', diff --git a/apps/player/src/i18n/ms-MY.ts b/apps/player/src/i18n/ms-MY.ts index 2218ec5..24eeab9 100644 --- a/apps/player/src/i18n/ms-MY.ts +++ b/apps/player/src/i18n/ms-MY.ts @@ -316,6 +316,8 @@ export default { parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay', parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay', parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan', + market_status_suspended: 'Digantung', + market_status_closed: 'Ditutup', back: 'Kembali', refresh: 'Muat semula', download: 'Muat turun', @@ -399,6 +401,7 @@ export default { slip_tab_parlay: 'Parlay', slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay', slip_add_parlay: 'Tambah ke parlay', + slip_parlay_only_hint: 'Pasaran ini hanya parlay. Ketik Tambah ke parlay untuk bertaruh.', slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.', slip_parlay_count: '{n} pilihan parlay', slip_total_stake: 'Jumlah pertaruhan', diff --git a/apps/player/src/i18n/zh-CN.ts b/apps/player/src/i18n/zh-CN.ts index 3185b67..cab89e8 100644 --- a/apps/player/src/i18n/zh-CN.ts +++ b/apps/player/src/i18n/zh-CN.ts @@ -310,6 +310,8 @@ export default { parlay_block_quarter: '四分盘让球/大小不可串关', parlay_block_not_allowed: '该玩法不可串关', parlay_need_more: '请至少选择 2 项进行串关', + market_status_suspended: '暂停', + market_status_closed: '已关闭', back: '返回', refresh: '刷新', download: '下载', @@ -393,6 +395,7 @@ export default { slip_tab_parlay: '串关', slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」', slip_add_parlay: '加入串关', + slip_parlay_only_hint: '此盘口不支持单关,请点「加入串关」后再投注', slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。', slip_parlay_count: '{n} 项串关', slip_total_stake: '总投注金额', diff --git a/apps/player/src/stores/betSlip.ts b/apps/player/src/stores/betSlip.ts index 8809074..8926a6a 100644 --- a/apps/player/src/stores/betSlip.ts +++ b/apps/player/src/stores/betSlip.ts @@ -18,6 +18,7 @@ export interface SlipItem { odds: number; marketType: string; lineValue?: number | null; + allowSingle?: boolean; allowParlay?: boolean; } diff --git a/apps/player/src/views/MatchDetailView.vue b/apps/player/src/views/MatchDetailView.vue index 92c959b..5800c3a 100644 --- a/apps/player/src/views/MatchDetailView.vue +++ b/apps/player/src/views/MatchDetailView.vue @@ -36,6 +36,7 @@ interface Market { lineKey?: string | null; period: string; lineValue?: string | number | null; + status?: string; allowSingle?: boolean; allowParlay?: boolean; promoLabel?: string | null; @@ -191,6 +192,28 @@ function marketLabel(market: Market | null | undefined) { return market?.marketDisplayName?.trim() || market?.marketType || ''; } +function normalizeMarketStatus(status?: string) { + return (status ?? 'OPEN').toUpperCase(); +} + +function isMarketOpen(market: Market) { + return normalizeMarketStatus(market.status) === 'OPEN'; +} + +function marketStatusLabel(status?: string) { + const normalized = normalizeMarketStatus(status); + if (normalized === 'SUSPENDED') return t('bet.market_status_suspended'); + if (normalized === 'CLOSED') return t('bet.market_status_closed'); + return ''; +} + +function isMarketLocked(market: Market) { + if (!bettingOpen.value || !isMarketOpen(market)) return true; + const singleOk = market.allowSingle !== false; + const parlayOk = market.allowParlay !== false; + return !singleOk && !parlayOk; +} + function selectionLabel(sel: Selection) { const parsedScore = parseScoreCode(sel.selectionCode, t); if (parsedScore) return parsedScore.display; @@ -224,8 +247,7 @@ function isSelected(id: string) { } function toggleSelection(sel: Selection, market: Market) { - if (!match.value || !bettingOpen.value) return; - if (market.allowSingle === false) return; + if (!match.value || isMarketLocked(market)) return; if (!auth.token) { goLogin(); return; @@ -244,6 +266,7 @@ function toggleSelection(sel: Selection, market: Market) { market.lineValue != null && market.lineValue !== '' ? parseFloat(String(market.lineValue)) : null, + allowSingle: market.allowSingle, allowParlay: market.allowParlay, }); slip.openDrawer(); @@ -388,24 +411,32 @@ function onPickSelection(selId: string, marketId: string) { -
+
{{ phaseLabel }} {{ phaseLabel }} + + {{ marketStatusLabel(market.status) }} +