feat(theme-4): sync inbox/announcements/presence/deposit from main
This commit is contained in:
BIN
apps/player/src/assets/images/vs.png
Normal file
BIN
apps/player/src/assets/images/vs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -1,23 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: string[]; embedded?: boolean }>(),
|
||||
defineProps<{ items: string[]; targetId?: string; embedded?: boolean }>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
|
||||
const detailTo = computed(() =>
|
||||
props.targetId ? `/announcements/${props.targetId}` : '/announcements',
|
||||
);
|
||||
|
||||
const text = computed(() => {
|
||||
const list = props.items.filter(Boolean);
|
||||
if (!list.length) return '';
|
||||
return list.join(' ◆ ');
|
||||
});
|
||||
|
||||
function goDetail() {
|
||||
void router.push(detailTo.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="text" class="marquee-bar" :class="{ embedded }">
|
||||
<button
|
||||
v-if="text"
|
||||
type="button"
|
||||
class="marquee-bar"
|
||||
:class="{ embedded }"
|
||||
@click="goDetail"
|
||||
>
|
||||
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
|
||||
<div class="marquee-viewport">
|
||||
<div class="marquee-track">
|
||||
@@ -25,11 +41,12 @@ const text = computed(() => {
|
||||
<span class="marquee-text" aria-hidden="true">{{ text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marquee-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -39,6 +56,11 @@ const text = computed(() => {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.marquee-bar.embedded {
|
||||
@@ -66,9 +88,9 @@ const text = computed(() => {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: var(--surface-active);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.marquee-viewport {
|
||||
@@ -82,6 +104,7 @@ const text = computed(() => {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: marquee-scroll 18s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marquee-text {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import {
|
||||
@@ -37,6 +37,27 @@ const MIN_STAKE = 5;
|
||||
const MAX_STAKE_INTEGER_LENGTH = 9;
|
||||
const stakeInput = ref('');
|
||||
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
|
||||
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 activeItems = computed<SlipItem[]>(() => {
|
||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||
@@ -44,20 +65,47 @@ const activeItems = computed<SlipItem[]>(() => {
|
||||
});
|
||||
|
||||
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 * item.odds, 1),
|
||||
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
|
||||
);
|
||||
const activeEstimatedReturn = computed(() => {
|
||||
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
|
||||
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
|
||||
return slip.stake * activeItems.value[0].odds;
|
||||
return slip.stake * effectiveOdds(activeItems.value[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');
|
||||
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;
|
||||
}
|
||||
return Boolean(slip.singleItem) && slip.singleItem.allowSingle !== false;
|
||||
return Boolean(slip.singleItem) && slip.singleItem!.allowSingle !== false;
|
||||
});
|
||||
|
||||
const singleParlayOnlyHint = computed(
|
||||
@@ -222,6 +270,81 @@ 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 = {};
|
||||
}
|
||||
|
||||
async function pollSelectionsOdds() {
|
||||
const items = activeItems.value;
|
||||
if (!items.length || !show.value) 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 on next tick */
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
if (!activeItems.value.length) return;
|
||||
if (!auth.token) {
|
||||
@@ -242,6 +365,13 @@ async function placeBet() {
|
||||
: t('bet.parlay_need_more');
|
||||
return;
|
||||
}
|
||||
if (hasSuspendedSelections.value) {
|
||||
error.value = t('bet.odds_suspended');
|
||||
return;
|
||||
}
|
||||
if (hasPendingOddsChanges.value) {
|
||||
acceptPendingOdds();
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -287,7 +417,10 @@ async function placeBet() {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
stopOddsPolling();
|
||||
return;
|
||||
}
|
||||
activeTab.value = slip.mode;
|
||||
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
|
||||
activeTab.value = 'parlay';
|
||||
@@ -297,9 +430,21 @@ watch(
|
||||
success.value = '';
|
||||
syncStakeInputFromSlip();
|
||||
loadBalance();
|
||||
startOddsPolling();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => activeItems.value.map((item) => item.selectionId).join(','),
|
||||
() => {
|
||||
if (show.value) void pollSelectionsOdds();
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopOddsPolling();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => slip.mode,
|
||||
(mode) => {
|
||||
@@ -329,6 +474,8 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
||||
|
||||
<div class="slip-tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -362,7 +509,19 @@ watch(
|
||||
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
|
||||
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
|
||||
<div class="item-odds">
|
||||
<template v-if="oddsDeltaFor(slip.singleItem.selectionId)">
|
||||
<span
|
||||
class="odds-change"
|
||||
:class="oddsTrendClass(oddsDeltaFor(slip.singleItem.selectionId)!)"
|
||||
>
|
||||
{{ oddsDeltaFor(slip.singleItem.selectionId)!.oldOdds.toFixed(2) }}
|
||||
→
|
||||
{{ oddsDeltaFor(slip.singleItem.selectionId)!.newOdds.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ slip.singleItem.odds.toFixed(2) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="singleParlayOnlyHint" class="warning">{{ t('bet.slip_parlay_only_hint') }}</p>
|
||||
|
||||
@@ -379,7 +538,19 @@ watch(
|
||||
<div class="item-pick">{{ item.selectionName }}</div>
|
||||
</div>
|
||||
<div class="item-side">
|
||||
<strong>{{ item.odds.toFixed(2) }}</strong>
|
||||
<strong>
|
||||
<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>
|
||||
</strong>
|
||||
<button type="button" class="remove" @click="removeItem(item.selectionId)">
|
||||
{{ t('bet.slip_remove') }}
|
||||
</button>
|
||||
@@ -450,10 +621,10 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="loading || !canSubmitActive"
|
||||
:disabled="loading || !canSubmitWithOdds"
|
||||
@click="placeBet"
|
||||
>
|
||||
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
|
||||
{{ submitButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,11 +648,11 @@ watch(
|
||||
max-height: 88vh;
|
||||
background: var(--bg-body);
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-top: 1px solid var(--border-gold-soft);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
@@ -500,16 +671,15 @@ watch(
|
||||
.drawer-kicker {
|
||||
margin: 0 0 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drawer-head h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@@ -519,7 +689,7 @@ watch(
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
@@ -542,16 +712,15 @@ watch(
|
||||
.balance-bar strong {
|
||||
justify-self: end;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.balance-refresh {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -560,25 +729,24 @@ watch(
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 8px 12px 0;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.slip-tab {
|
||||
min-height: 38px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.slip-tab.active {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
@@ -589,11 +757,10 @@ watch(
|
||||
height: 18px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
@@ -618,13 +785,13 @@ watch(
|
||||
padding: 11px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slip-item--single {
|
||||
border-color: var(--border-gold-soft);
|
||||
background: linear-gradient(180deg, var(--surface-accent) 0%, var(--bg-card) 100%);
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.item-main {
|
||||
@@ -633,7 +800,7 @@ watch(
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -643,15 +810,22 @@ watch(
|
||||
.item-market {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.item-pick {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.item-odds,
|
||||
.item-side strong {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-side {
|
||||
@@ -665,7 +839,7 @@ watch(
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -677,12 +851,12 @@ watch(
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-weight: 900;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -693,21 +867,47 @@ watch(
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.odds-warning {
|
||||
margin: 0 16px 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(244, 162, 97, 0.12);
|
||||
border: 1px solid rgba(244, 162, 97, 0.28);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.odds-change {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.odds-change.odds-up {
|
||||
color: #6ee7a0;
|
||||
}
|
||||
|
||||
.odds-change.odds-down {
|
||||
color: #ff8b8b;
|
||||
}
|
||||
|
||||
.odds-change.odds-suspended {
|
||||
color: #ffb84d;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.error {
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
border: 1px solid rgba(255, 77, 79, 0.22);
|
||||
color: #ff8a80;
|
||||
border-radius: 6px;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -716,7 +916,7 @@ watch(
|
||||
margin: 8px 2px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stake-area {
|
||||
@@ -724,7 +924,7 @@ watch(
|
||||
padding: 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stake-head,
|
||||
@@ -741,8 +941,7 @@ watch(
|
||||
.return strong {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stake-input-row {
|
||||
@@ -751,7 +950,7 @@ watch(
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
@@ -770,7 +969,7 @@ watch(
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
@@ -826,15 +1025,9 @@ watch(
|
||||
.quick-stakes button {
|
||||
min-height: 50px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-stakes button:active {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.return {
|
||||
@@ -853,9 +1046,9 @@ watch(
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--bg-card);
|
||||
background: var(--bg-body);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.drawer-foot--split {
|
||||
@@ -869,8 +1062,7 @@ watch(
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: none;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
186
apps/player/src/components/ConfirmDialog.vue
Normal file
186
apps/player/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
danger: false,
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
confirm: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const resolvedTitle = computed(() => props.title ?? t('common.confirm'));
|
||||
const resolvedConfirmText = computed(() => props.confirmText ?? t('common.confirm'));
|
||||
const resolvedCancelText = computed(() => props.cancelText ?? t('common.cancel'));
|
||||
|
||||
function close() {
|
||||
if (props.loading) return;
|
||||
emit('update:visible', false);
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (props.loading) return;
|
||||
emit('confirm');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="confirm-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="confirm-overlay"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="confirm-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="title ? 'confirm-dialog-title' : undefined"
|
||||
:aria-describedby="'confirm-dialog-message'"
|
||||
>
|
||||
<h2 v-if="title" id="confirm-dialog-title" class="confirm-title">{{ resolvedTitle }}</h2>
|
||||
<p id="confirm-dialog-message" class="confirm-message">{{ message }}</p>
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="confirm-btn cancel"
|
||||
:disabled="loading"
|
||||
@click="close"
|
||||
>
|
||||
{{ resolvedCancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="confirm-btn confirm"
|
||||
:class="{ danger }"
|
||||
:disabled="loading"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ resolvedConfirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
background: var(--dropdown-bg, #0A2540);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
padding: 22px 18px 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.confirm-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.confirm-btn.cancel {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.confirm-btn.confirm {
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.confirm-btn.confirm.danger {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirm-fade-enter-active,
|
||||
.confirm-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-fade-enter-active .confirm-modal,
|
||||
.confirm-fade-leave-active .confirm-modal {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-fade-enter-from,
|
||||
.confirm-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.confirm-fade-enter-from .confirm-modal,
|
||||
.confirm-fade-leave-to .confirm-modal {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
@@ -1,165 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { buildCustomerServiceUrl } from '../config/customerService';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { profileRaw, avatarUrl } = usePlayerProfile();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const iframeSrc = computed(() => {
|
||||
const visitor = auth.user
|
||||
? {
|
||||
name:
|
||||
profileRaw.value?.username ||
|
||||
profileRaw.value?.preferences?.phone ||
|
||||
auth.user.username ||
|
||||
'',
|
||||
avatar: avatarUrl.value
|
||||
? new URL(avatarUrl.value, window.location.origin).href
|
||||
: '',
|
||||
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
|
||||
}
|
||||
: null;
|
||||
|
||||
return buildCustomerServiceUrl(t('support.connecting'), visitor);
|
||||
});
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="cs-overlay" @click.self="close">
|
||||
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
|
||||
<header class="cs-header">
|
||||
<h2 class="cs-title">{{ t('support.title') }}</h2>
|
||||
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="cs-body">
|
||||
<iframe
|
||||
v-if="visible"
|
||||
:key="iframeSrc"
|
||||
class="cs-frame"
|
||||
:src="iframeSrc"
|
||||
:title="t('support.title')"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cs-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.cs-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 420px);
|
||||
height: min(82vh, 680px);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.cs-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cs-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.cs-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cs-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
60
apps/player/src/components/CustomerServicePanel.vue
Normal file
60
apps/player/src/components/CustomerServicePanel.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { buildCustomerServiceUrl } from '../config/customerService';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { profileRaw, avatarUrl } = usePlayerProfile();
|
||||
|
||||
const iframeSrc = computed(() => {
|
||||
const visitor = auth.user
|
||||
? {
|
||||
name:
|
||||
profileRaw.value?.username ||
|
||||
profileRaw.value?.preferences?.phone ||
|
||||
auth.user.username ||
|
||||
'',
|
||||
avatar: avatarUrl.value
|
||||
? new URL(avatarUrl.value, window.location.origin).href
|
||||
: '',
|
||||
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
|
||||
}
|
||||
: null;
|
||||
|
||||
return buildCustomerServiceUrl(t('support.connecting'), visitor);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs-panel">
|
||||
<iframe
|
||||
:key="iframeSrc"
|
||||
class="cs-frame"
|
||||
:src="iframeSrc"
|
||||
:title="t('support.title')"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cs-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 -16px;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.cs-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: calc(100dvh - 140px);
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
345
apps/player/src/components/MessageListPanel.vue
Normal file
345
apps/player/src/components/MessageListPanel.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from './GoldSpinner.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
listLoaded,
|
||||
loadMessages,
|
||||
refreshUnreadCount,
|
||||
deleteMessage,
|
||||
} = usePlayerMessages();
|
||||
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const deletingId = ref<string | null>(null);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const pendingDeleteId = ref<string | null>(null);
|
||||
|
||||
const hasMore = computed(() => messages.value.length < total.value);
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function messageTitle(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function messagePreview(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED' || item.type === 'DEPOSIT_REJECTED') {
|
||||
const deposit = item.payload as DepositMessagePayload | null;
|
||||
if (deposit?.orderNo) return deposit.orderNo;
|
||||
}
|
||||
return item.body.length > 80 ? `${item.body.slice(0, 80)}…` : item.body;
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/messages/${id}`);
|
||||
}
|
||||
|
||||
async function fetchPage(nextPage: number, append = false) {
|
||||
const result = await loadMessages(nextPage, append);
|
||||
if (result) {
|
||||
page.value = result.page;
|
||||
total.value = result.total;
|
||||
}
|
||||
}
|
||||
|
||||
function tryLoad() {
|
||||
if (!auth.token) return;
|
||||
void fetchPage(1);
|
||||
void refreshUnreadCount();
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
auth.showLoginPrompt('/messages');
|
||||
}
|
||||
|
||||
function onDelete(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (deletingId.value) return;
|
||||
pendingDeleteId.value = id;
|
||||
deleteConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
function onDeleteCancel() {
|
||||
pendingDeleteId.value = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = pendingDeleteId.value;
|
||||
if (!id || deletingId.value) return;
|
||||
deletingId.value = id;
|
||||
try {
|
||||
await deleteMessage(id);
|
||||
total.value = Math.max(0, total.value - 1);
|
||||
deleteConfirmVisible.value = false;
|
||||
pendingDeleteId.value = null;
|
||||
} finally {
|
||||
deletingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(tryLoad);
|
||||
onActivated(tryLoad);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-list-panel">
|
||||
<div v-if="!auth.token" class="guest-hint">
|
||||
<p>{{ t('auth.login_required') }}</p>
|
||||
<button type="button" class="login-link" @click="goLogin">{{ t('auth.go_login') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="loading && !listLoaded" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!messages.length" class="empty">
|
||||
<p>{{ t('messages.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<div
|
||||
v-for="item in messages"
|
||||
:key="item.id"
|
||||
class="list-row"
|
||||
:class="{ unread: !item.isRead }"
|
||||
>
|
||||
<button type="button" class="row-body" @click="openDetail(item.id)">
|
||||
<span class="row-dot" aria-hidden="true" />
|
||||
<span class="row-main">
|
||||
<span class="title-row">
|
||||
<span class="title">{{ messageTitle(item) }}</span>
|
||||
<span class="status-badge" :class="{ unread: !item.isRead }">
|
||||
{{ item.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="preview">{{ messagePreview(item) }}</span>
|
||||
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="delete-btn"
|
||||
:aria-label="t('messages.delete')"
|
||||
:disabled="deletingId === item.id"
|
||||
@click="onDelete(item.id, $event)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M6 7h12M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7h12Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-if="hasMore" type="button" class="load-more" :disabled="loading" @click="fetchPage(page + 1, true)">
|
||||
{{ loading ? t('common.loading_more') : t('messages.load_more') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
:title="t('messages.delete')"
|
||||
:message="t('messages.delete_confirm')"
|
||||
:confirm-text="t('messages.delete')"
|
||||
danger
|
||||
:loading="!!deletingId"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="onDeleteCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guest-hint,
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-link {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.row-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 14px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-row.unread .title {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-row.unread .row-dot {
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
color: #d8d8d8;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-badge.unread {
|
||||
color: var(--text);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn:active:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
}
|
||||
|
||||
.delete-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
174
apps/player/src/composables/useDepositNotifications.ts
Normal file
174
apps/player/src/composables/useDepositNotifications.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import api from '../api';
|
||||
import { usePlayerProfile } from './usePlayerProfile';
|
||||
import { usePlayerMessages } from './usePlayerMessages';
|
||||
|
||||
interface DepositOrderRow {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
amount: string;
|
||||
status: string;
|
||||
rejectReason?: string | null;
|
||||
}
|
||||
|
||||
const POLL_FAST_MS = 8_000;
|
||||
const POLL_SLOW_MS = 30_000;
|
||||
const TRACKED_STORAGE_KEY = 'player_deposit_tracked_pending';
|
||||
|
||||
const lastStatus = new Map<string, string>();
|
||||
const trackedPending = new Set<string>();
|
||||
const notifiedKeys = new Set<string>();
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollingActive = false;
|
||||
|
||||
function notifyKey(orderId: string, type: 'approved' | 'rejected') {
|
||||
return `${orderId}:${type}`;
|
||||
}
|
||||
|
||||
function loadTrackedFromStorage() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(TRACKED_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const ids: string[] = JSON.parse(raw);
|
||||
for (const id of ids) {
|
||||
if (id) trackedPending.add(String(id));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function persistTrackedToStorage() {
|
||||
try {
|
||||
sessionStorage.setItem(TRACKED_STORAGE_KEY, JSON.stringify([...trackedPending]));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function addTracked(orderId: string) {
|
||||
trackedPending.add(orderId);
|
||||
persistTrackedToStorage();
|
||||
}
|
||||
|
||||
function removeTracked(orderId: string) {
|
||||
if (!trackedPending.delete(orderId)) return;
|
||||
persistTrackedToStorage();
|
||||
}
|
||||
|
||||
function hasPendingInterest() {
|
||||
return trackedPending.size > 0;
|
||||
}
|
||||
|
||||
function schedulePoll(intervalMs: number) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(() => {
|
||||
void pollOnce();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function adjustPollInterval() {
|
||||
if (!pollingActive) return;
|
||||
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
|
||||
}
|
||||
|
||||
function trackPendingOrder(orderId: string) {
|
||||
const id = String(orderId);
|
||||
if (!id) return;
|
||||
addTracked(id);
|
||||
lastStatus.set(id, 'PENDING');
|
||||
adjustPollInterval();
|
||||
void pollOnce();
|
||||
}
|
||||
|
||||
function shouldNotify(orderId: string, prev: string | undefined, next: string) {
|
||||
if (next !== 'APPROVED' && next !== 'REJECTED') return false;
|
||||
if (prev === 'PENDING') return true;
|
||||
if (trackedPending.has(orderId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleStatusChange(order: DepositOrderRow): boolean {
|
||||
const prev = lastStatus.get(order.id);
|
||||
const next = order.status;
|
||||
lastStatus.set(order.id, next);
|
||||
|
||||
if (next === 'PENDING') {
|
||||
addTracked(order.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
removeTracked(order.id);
|
||||
|
||||
if (!shouldNotify(order.id, prev, next)) return false;
|
||||
|
||||
const type = next === 'APPROVED' ? 'approved' : 'rejected';
|
||||
const key = notifyKey(order.id, type);
|
||||
if (notifiedKeys.has(key)) return false;
|
||||
notifiedKeys.add(key);
|
||||
|
||||
void usePlayerMessages().refreshUnreadCount();
|
||||
return next === 'APPROVED';
|
||||
}
|
||||
|
||||
async function pollOnce() {
|
||||
try {
|
||||
const { data } = await api.get('/player/deposit-orders', { params: { page: 1 } });
|
||||
const items: DepositOrderRow[] = data.data?.items ?? [];
|
||||
const { refreshProfile } = usePlayerProfile();
|
||||
|
||||
let needsProfileRefresh = false;
|
||||
for (const order of items) {
|
||||
if (handleStatusChange(order)) needsProfileRefresh = true;
|
||||
}
|
||||
|
||||
if (needsProfileRefresh) await refreshProfile();
|
||||
adjustPollInterval();
|
||||
} catch {
|
||||
/* silent retry on next tick */
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!document.hidden && pollingActive) void pollOnce();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollingActive) {
|
||||
void pollOnce();
|
||||
return;
|
||||
}
|
||||
pollingActive = true;
|
||||
loadTrackedFromStorage();
|
||||
for (const id of trackedPending) {
|
||||
if (!lastStatus.has(id)) lastStatus.set(id, 'PENDING');
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
void pollOnce();
|
||||
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
pollingActive = false;
|
||||
trackedPending.clear();
|
||||
lastStatus.clear();
|
||||
notifiedKeys.clear();
|
||||
try {
|
||||
sessionStorage.removeItem(TRACKED_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
|
||||
export function useDepositNotifications() {
|
||||
return {
|
||||
trackPendingOrder,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
pollOnce,
|
||||
};
|
||||
}
|
||||
23
apps/player/src/composables/useInboxFeature.ts
Normal file
23
apps/player/src/composables/useInboxFeature.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue';
|
||||
import { usePlayerHome } from './usePlayerHome';
|
||||
|
||||
/** 玩家端站内邮箱功能开关(来自 /player/home) */
|
||||
export function useInboxFeature() {
|
||||
const { homeRaw } = usePlayerHome();
|
||||
|
||||
const inboxEnabled = computed(() => homeRaw.value?.inboxEnabled !== false);
|
||||
|
||||
const hubRoute = computed(() =>
|
||||
inboxEnabled.value ? '/messages' : '/messages?tab=support',
|
||||
);
|
||||
|
||||
const hubOpenLabelKey = computed(() =>
|
||||
inboxEnabled.value ? 'inbox_hub.open' : 'inbox_hub.open_support',
|
||||
);
|
||||
|
||||
const hubTitleKey = computed(() =>
|
||||
inboxEnabled.value ? 'inbox_hub.title' : 'inbox_hub.tab_support',
|
||||
);
|
||||
|
||||
return { inboxEnabled, hubRoute, hubOpenLabelKey, hubTitleKey };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import api from '../api';
|
||||
import type { BannerItem } from '../components/BannerCarousel.vue';
|
||||
import { resolveBanners } from '../constants/defaultBanner';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
import { stripHtml } from '../utils/html';
|
||||
|
||||
export interface PlayerHomeMatch {
|
||||
id: string;
|
||||
@@ -20,12 +21,52 @@ export interface PlayerHomeMatch {
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export interface PlayerContentItem {
|
||||
id: string;
|
||||
contentType?: string;
|
||||
sortOrder?: number;
|
||||
createdAt?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
translation?: {
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type PlayerAnnouncementItem = PlayerContentItem;
|
||||
|
||||
interface HomePayload {
|
||||
banners?: BannerItem[];
|
||||
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
banners?: PlayerContentItem[];
|
||||
announcements?: PlayerAnnouncementItem[];
|
||||
ticker?: PlayerAnnouncementItem[];
|
||||
notices?: PlayerAnnouncementItem[];
|
||||
hotMatches?: PlayerHomeMatch[];
|
||||
upcomingMatches?: PlayerHomeMatch[];
|
||||
inboxEnabled?: boolean;
|
||||
}
|
||||
|
||||
function mergeMatchList(
|
||||
existing: PlayerHomeMatch[] | undefined,
|
||||
fresh: PlayerHomeMatch[] | undefined,
|
||||
): PlayerHomeMatch[] | undefined {
|
||||
if (!fresh) return existing;
|
||||
if (!existing) return fresh;
|
||||
|
||||
const freshMap = new Map(fresh.map((m) => [m.id, m]));
|
||||
for (const m of existing) {
|
||||
const f = freshMap.get(m.id);
|
||||
if (f) Object.assign(m, f);
|
||||
}
|
||||
const existingIds = new Set(existing.map((m) => m.id));
|
||||
for (const fm of fresh) {
|
||||
if (!existingIds.has(fm.id)) existing.push(fm);
|
||||
}
|
||||
for (let i = existing.length - 1; i >= 0; i--) {
|
||||
if (!freshMap.has(existing[i].id)) existing.splice(i, 1);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const homeRaw = ref<HomePayload | null>(null);
|
||||
@@ -40,12 +81,22 @@ function collectAnnouncementLines(data: HomePayload | null): string[] {
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const item of source) {
|
||||
const text = item.translation?.title || item.translation?.body;
|
||||
const title = item.translation?.title?.trim();
|
||||
const text = title || stripHtml(item.translation?.body ?? '');
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function collectAnnouncementItems(data: HomePayload | null): PlayerAnnouncementItem[] {
|
||||
if (!data) return [];
|
||||
const source =
|
||||
data.announcements && data.announcements.length > 0
|
||||
? data.announcements
|
||||
: [...(data.ticker ?? []), ...(data.notices ?? [])];
|
||||
return source.filter((item) => item.translation?.title || item.translation?.body);
|
||||
}
|
||||
|
||||
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
|
||||
export function usePlayerHome() {
|
||||
const { t } = useI18n();
|
||||
@@ -64,26 +115,10 @@ export function usePlayerHome() {
|
||||
existing.announcements = fresh.announcements;
|
||||
existing.ticker = fresh.ticker;
|
||||
existing.notices = fresh.notices;
|
||||
existing.inboxEnabled = fresh.inboxEnabled;
|
||||
|
||||
if (fresh.hotMatches && existing.hotMatches) {
|
||||
const freshMap = new Map(fresh.hotMatches.map((m) => [m.id, m]));
|
||||
for (const m of existing.hotMatches) {
|
||||
const f = freshMap.get(m.id);
|
||||
if (f) Object.assign(m, f);
|
||||
}
|
||||
// 处理新增或删除的比赛
|
||||
const existingIds = new Set(existing.hotMatches.map((m) => m.id));
|
||||
for (const fm of fresh.hotMatches) {
|
||||
if (!existingIds.has(fm.id)) existing.hotMatches.push(fm);
|
||||
}
|
||||
for (let i = existing.hotMatches.length - 1; i >= 0; i--) {
|
||||
if (!freshMap.has(existing.hotMatches[i].id)) {
|
||||
existing.hotMatches.splice(i, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing.hotMatches = fresh.hotMatches;
|
||||
}
|
||||
existing.hotMatches = mergeMatchList(existing.hotMatches, fresh.hotMatches);
|
||||
existing.upcomingMatches = mergeMatchList(existing.upcomingMatches, fresh.upcomingMatches);
|
||||
} else {
|
||||
homeRaw.value = fresh;
|
||||
}
|
||||
@@ -94,18 +129,24 @@ export function usePlayerHome() {
|
||||
}
|
||||
}
|
||||
|
||||
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
|
||||
const banners = computed(() => resolveBanners(homeRaw.value?.banners as BannerItem[] | undefined));
|
||||
const bannerItems = computed(() => homeRaw.value?.banners ?? []);
|
||||
const announcements = computed(() =>
|
||||
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
|
||||
);
|
||||
const announcementItems = computed(() => collectAnnouncementItems(homeRaw.value));
|
||||
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
|
||||
const upcomingMatches = computed(() => homeRaw.value?.upcomingMatches ?? []);
|
||||
|
||||
return {
|
||||
homeRaw,
|
||||
loading,
|
||||
load,
|
||||
banners,
|
||||
bannerItems,
|
||||
announcements,
|
||||
announcementItems,
|
||||
hotMatches,
|
||||
upcomingMatches,
|
||||
};
|
||||
}
|
||||
|
||||
130
apps/player/src/composables/usePlayerMessages.ts
Normal file
130
apps/player/src/composables/usePlayerMessages.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
|
||||
export type PlayerMessageType =
|
||||
| 'DEPOSIT_APPROVED'
|
||||
| 'DEPOSIT_REJECTED'
|
||||
| 'BANNER_PROMO'
|
||||
| 'ANNOUNCEMENT_PROMO';
|
||||
|
||||
export type DepositMessagePayload = {
|
||||
depositOrderId?: string;
|
||||
orderNo?: string;
|
||||
amount?: string;
|
||||
approvedAmount?: string | null;
|
||||
rejectReason?: string | null;
|
||||
};
|
||||
|
||||
export type BannerPromoPayload = {
|
||||
contentId?: string;
|
||||
};
|
||||
|
||||
export interface PlayerMessage {
|
||||
id: string;
|
||||
type: PlayerMessageType | string;
|
||||
title: string;
|
||||
body: string;
|
||||
payload: DepositMessagePayload | BannerPromoPayload | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
isRead: boolean;
|
||||
}
|
||||
|
||||
interface MessageListResponse {
|
||||
items: PlayerMessage[];
|
||||
total: number;
|
||||
unreadCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const unreadCount = ref(0);
|
||||
const messages = ref<PlayerMessage[]>([]);
|
||||
const loading = ref(false);
|
||||
const listLoaded = ref(false);
|
||||
|
||||
async function refreshUnreadCount() {
|
||||
try {
|
||||
const { data } = await api.get('/player/messages/unread-count');
|
||||
unreadCount.value = Number(data.data?.unreadCount ?? 0);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(page = 1, append = false) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/messages', { params: { page, pageSize: 20 } });
|
||||
const payload = data.data as MessageListResponse;
|
||||
const items = payload?.items ?? [];
|
||||
messages.value = append ? [...messages.value, ...items] : items;
|
||||
unreadCount.value = Number(payload?.unreadCount ?? unreadCount.value);
|
||||
listLoaded.value = true;
|
||||
return payload;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessageDetail(id: string) {
|
||||
const { data } = await api.get(`/player/messages/${id}`);
|
||||
return data.data as PlayerMessage;
|
||||
}
|
||||
|
||||
async function markMessageRead(id: string) {
|
||||
const { data } = await api.patch(`/player/messages/${id}/read`);
|
||||
const updated = data.data as PlayerMessage;
|
||||
messages.value = messages.value.map((item) =>
|
||||
item.id === id ? { ...item, ...updated, isRead: true } : item,
|
||||
);
|
||||
if (unreadCount.value > 0) unreadCount.value -= 1;
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await api.patch('/player/messages/read-all');
|
||||
messages.value = messages.value.map((item) => ({
|
||||
...item,
|
||||
isRead: true,
|
||||
readAt: item.readAt ?? new Date().toISOString(),
|
||||
}));
|
||||
unreadCount.value = 0;
|
||||
}
|
||||
|
||||
async function deleteMessage(id: string) {
|
||||
const { data } = await api.delete(`/player/messages/${id}`);
|
||||
const wasUnread = Boolean(data.data?.wasUnread);
|
||||
messages.value = messages.value.filter((item) => item.id !== id);
|
||||
if (wasUnread && unreadCount.value > 0) unreadCount.value -= 1;
|
||||
}
|
||||
|
||||
async function deleteAllMessages() {
|
||||
await api.delete('/player/messages');
|
||||
messages.value = [];
|
||||
unreadCount.value = 0;
|
||||
listLoaded.value = true;
|
||||
}
|
||||
|
||||
function resetMessagesState() {
|
||||
unreadCount.value = 0;
|
||||
messages.value = [];
|
||||
listLoaded.value = false;
|
||||
}
|
||||
|
||||
export function usePlayerMessages() {
|
||||
return {
|
||||
unreadCount,
|
||||
messages,
|
||||
loading,
|
||||
listLoaded,
|
||||
refreshUnreadCount,
|
||||
loadMessages,
|
||||
loadMessageDetail,
|
||||
markMessageRead,
|
||||
markAllRead,
|
||||
deleteMessage,
|
||||
deleteAllMessages,
|
||||
resetMessagesState,
|
||||
};
|
||||
}
|
||||
37
apps/player/src/composables/usePresencePing.ts
Normal file
37
apps/player/src/composables/usePresencePing.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import api from '../api';
|
||||
|
||||
const PING_INTERVAL_MS = 60_000;
|
||||
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let active = false;
|
||||
|
||||
async function sendPing() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
try {
|
||||
await api.post('/player/presence/ping');
|
||||
} catch {
|
||||
/* ignore transient network errors */
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!active) return;
|
||||
if (document.visibilityState === 'visible') void sendPing();
|
||||
}
|
||||
|
||||
export function startPresencePing() {
|
||||
if (active) return;
|
||||
active = true;
|
||||
void sendPing();
|
||||
pingTimer = setInterval(() => void sendPing(), PING_INTERVAL_MS);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
|
||||
export function stopPresencePing() {
|
||||
active = false;
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
@@ -8,11 +8,16 @@ export default {
|
||||
load_failed: 'Failed to load',
|
||||
retry: 'Retry',
|
||||
back_to_top: 'Back to top',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
},
|
||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||
home: {
|
||||
hot_matches: 'Hot matches',
|
||||
hot_tab: 'Hot',
|
||||
upcoming_tab: 'Upcoming',
|
||||
no_matches: 'No matches',
|
||||
upcoming_empty: 'No matches kicking off in the next 3 days',
|
||||
announcement_badge: 'Notice',
|
||||
announcement_default:
|
||||
'Welcome to TheBet365 · Football events are live · Bet responsibly',
|
||||
@@ -21,6 +26,67 @@ export default {
|
||||
banner_slide: 'Slide {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
detail_title: 'Announcement',
|
||||
empty: 'No announcements yet',
|
||||
not_found: 'This announcement is unavailable',
|
||||
view_all: 'View all announcements',
|
||||
type_notice: 'Notice',
|
||||
type_ticker: 'Ticker',
|
||||
type_banner: 'Banner',
|
||||
related_link: 'Related link',
|
||||
go_link: 'Go to page',
|
||||
open_link: 'Open link',
|
||||
back: 'Back',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Search teams or leagues',
|
||||
no_results: 'No matches found',
|
||||
results_count: '{count} matches found',
|
||||
hint: 'Enter a team or league to filter on the Bet page',
|
||||
},
|
||||
deposit_notify: {
|
||||
approved_title: 'Deposit approved',
|
||||
rejected_title: 'Deposit rejected',
|
||||
view_history: 'View deposit history',
|
||||
view_messages: 'Open inbox',
|
||||
dismiss: 'Dismiss',
|
||||
},
|
||||
messages: {
|
||||
title: 'Inbox',
|
||||
detail_title: 'Message',
|
||||
empty: 'No messages yet',
|
||||
not_found: 'Message not found',
|
||||
view_all: 'Back to inbox',
|
||||
back: 'Back',
|
||||
mark_all_read: 'Mark all read',
|
||||
load_more: 'Load more',
|
||||
delete: 'Delete',
|
||||
delete_all: 'Delete all',
|
||||
delete_confirm: 'Delete this message?',
|
||||
delete_all_confirm: 'Delete all messages? This cannot be undone.',
|
||||
banner_promo_view: 'View promotion',
|
||||
content_promo_view: 'View details',
|
||||
status_unread: 'Unread',
|
||||
status_read: 'Read',
|
||||
deposit_approved_title: 'Deposit approved',
|
||||
deposit_rejected_title: 'Deposit rejected',
|
||||
deposit_approved_body: 'Order {orderNo} approved. Requested {amount}, credited {approvedAmount}.',
|
||||
deposit_rejected_body: 'Order {orderNo} ({amount}) was rejected. {reason}',
|
||||
reject_reason: 'Rejection reason',
|
||||
no_reason: 'No reason provided',
|
||||
view_recharge_history: 'View recharge history',
|
||||
unread_badge: '{count} unread',
|
||||
open_inbox: 'Open inbox',
|
||||
},
|
||||
inbox_hub: {
|
||||
title: 'Messages & Support',
|
||||
tab_messages: 'Inbox',
|
||||
tab_support: 'Support',
|
||||
open: 'Open messages and support',
|
||||
open_support: 'Open support',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Football',
|
||||
stake: 'Stake',
|
||||
@@ -304,13 +370,13 @@ 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',
|
||||
reward_active: 'Reward active!',
|
||||
market_closed: 'Not open',
|
||||
market_status_suspended: 'Suspended',
|
||||
market_status_closed: 'Closed',
|
||||
match_phase_closed_pending: 'Closed pending',
|
||||
match_phase_settled: 'Settled',
|
||||
view_match: 'View match',
|
||||
@@ -396,6 +462,11 @@ export default {
|
||||
slip_currency: 'Amount',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Minimum stake is {amount}',
|
||||
odds_changed: 'Odds have changed on some selections. Accept to continue.',
|
||||
odds_suspended: 'Some selections are suspended or closed. Remove them to continue.',
|
||||
accept_changes_place: 'Accept changes & place bet',
|
||||
odds_was: 'Was',
|
||||
odds_now: 'Now',
|
||||
place_success: 'Bet placed',
|
||||
place_failed: 'Bet failed',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ export default {
|
||||
load_failed: 'Gagal dimuat',
|
||||
retry: 'Cuba lagi',
|
||||
back_to_top: 'Kembali ke atas',
|
||||
cancel: 'Batal',
|
||||
confirm: 'Sahkan',
|
||||
},
|
||||
nav: {
|
||||
home: 'Laman Utama',
|
||||
@@ -18,7 +20,10 @@ export default {
|
||||
},
|
||||
home: {
|
||||
hot_matches: 'Perlawanan popular',
|
||||
hot_tab: 'Popular',
|
||||
upcoming_tab: 'Terdekat',
|
||||
no_matches: 'Tiada perlawanan',
|
||||
upcoming_empty: 'Tiada perlawanan dalam 3 hari akan datang',
|
||||
announcement_badge: 'Notis',
|
||||
announcement_default:
|
||||
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
|
||||
@@ -27,6 +32,67 @@ export default {
|
||||
banner_slide: 'Slaid {n}',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
announcements: {
|
||||
title: 'Pusat Pengumuman',
|
||||
detail_title: 'Butiran Pengumuman',
|
||||
empty: 'Tiada pengumuman',
|
||||
not_found: 'Pengumuman tidak tersedia',
|
||||
view_all: 'Lihat semua pengumuman',
|
||||
type_notice: 'Notis',
|
||||
type_ticker: 'Ticker',
|
||||
type_banner: 'Banner',
|
||||
related_link: 'Pautan berkaitan',
|
||||
go_link: 'Pergi ke halaman',
|
||||
open_link: 'Buka pautan',
|
||||
back: 'Kembali',
|
||||
},
|
||||
search: {
|
||||
placeholder: 'Cari pasukan atau liga',
|
||||
no_results: 'Tiada perlawanan dijumpai',
|
||||
results_count: '{count} perlawanan dijumpai',
|
||||
hint: 'Masukkan pasukan atau liga untuk tapis di halaman Pertaruhan',
|
||||
},
|
||||
deposit_notify: {
|
||||
approved_title: 'Deposit diluluskan',
|
||||
rejected_title: 'Deposit ditolak',
|
||||
view_history: 'Lihat sejarah deposit',
|
||||
view_messages: 'Buka peti mesej',
|
||||
dismiss: 'Tutup',
|
||||
},
|
||||
messages: {
|
||||
title: 'Peti Mesej',
|
||||
detail_title: 'Butiran Mesej',
|
||||
empty: 'Tiada mesej',
|
||||
not_found: 'Mesej tidak dijumpai',
|
||||
view_all: 'Kembali ke peti mesej',
|
||||
back: 'Kembali',
|
||||
mark_all_read: 'Tanda semua dibaca',
|
||||
load_more: 'Muat lagi',
|
||||
delete: 'Padam',
|
||||
delete_all: 'Padam semua',
|
||||
delete_confirm: 'Padam mesej ini?',
|
||||
delete_all_confirm: 'Padam semua mesej? Tindakan ini tidak boleh dibatalkan.',
|
||||
banner_promo_view: 'Lihat promosi',
|
||||
content_promo_view: 'Lihat butiran',
|
||||
status_unread: 'Belum dibaca',
|
||||
status_read: 'Dibaca',
|
||||
deposit_approved_title: 'Deposit diluluskan',
|
||||
deposit_rejected_title: 'Deposit ditolak',
|
||||
deposit_approved_body: 'Pesanan {orderNo} diluluskan. Diminta {amount}, dikreditkan {approvedAmount}.',
|
||||
deposit_rejected_body: 'Pesanan {orderNo} ({amount}) ditolak. {reason}',
|
||||
reject_reason: 'Sebab penolakan',
|
||||
no_reason: 'Tiada sebab diberikan',
|
||||
view_recharge_history: 'Lihat sejarah deposit',
|
||||
unread_badge: '{count} belum dibaca',
|
||||
open_inbox: 'Buka peti mesej',
|
||||
},
|
||||
inbox_hub: {
|
||||
title: 'Mesej & Sokongan',
|
||||
tab_messages: 'Peti Mesej',
|
||||
tab_support: 'Sokongan',
|
||||
open: 'Buka mesej dan sokongan',
|
||||
open_support: 'Buka sokongan',
|
||||
},
|
||||
history: {
|
||||
league_default: 'Bola Sepak',
|
||||
stake: 'Jumlah',
|
||||
@@ -310,13 +376,13 @@ 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',
|
||||
reward_active: 'Ganjaran aktif!',
|
||||
market_closed: 'Belum dibuka',
|
||||
market_status_suspended: 'Digantung',
|
||||
market_status_closed: 'Ditutup',
|
||||
match_phase_closed_pending: 'Ditutup menunggu',
|
||||
match_phase_settled: 'Selesai',
|
||||
view_match: 'Lihat perlawanan',
|
||||
@@ -402,6 +468,11 @@ export default {
|
||||
slip_currency: 'Amaun',
|
||||
slip_min: 'Min',
|
||||
slip_min_error: 'Jumlah minimum ialah {amount}',
|
||||
odds_changed: 'Odds beberapa pilihan telah berubah. Terima untuk teruskan.',
|
||||
odds_suspended: 'Beberapa pilihan digantung atau ditutup. Buang untuk teruskan.',
|
||||
accept_changes_place: 'Terima perubahan & pertaruh',
|
||||
odds_was: 'Asal',
|
||||
odds_now: 'Baharu',
|
||||
place_success: 'Pertaruhan berjaya',
|
||||
place_failed: 'Pertaruhan gagal',
|
||||
},
|
||||
|
||||
@@ -8,11 +8,16 @@ export default {
|
||||
load_failed: '加载失败',
|
||||
retry: '重试',
|
||||
back_to_top: '回到顶部',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
},
|
||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||
home: {
|
||||
hot_matches: '热门赛事',
|
||||
hot_tab: '热门',
|
||||
upcoming_tab: '近期',
|
||||
no_matches: '暂无赛事',
|
||||
upcoming_empty: '未来 3 天内暂无赛事',
|
||||
announcement_badge: '公告',
|
||||
announcement_default:
|
||||
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
|
||||
@@ -21,6 +26,67 @@ export default {
|
||||
banner_slide: '第 {n} 张',
|
||||
banner_fallback: 'Banner',
|
||||
},
|
||||
announcements: {
|
||||
title: '公告中心',
|
||||
detail_title: '公告详情',
|
||||
empty: '暂无公告',
|
||||
not_found: '公告不存在或已下线',
|
||||
view_all: '查看全部公告',
|
||||
type_notice: '公告',
|
||||
type_ticker: '跑马灯',
|
||||
type_banner: 'Banner',
|
||||
related_link: '相关链接',
|
||||
go_link: '前往页面',
|
||||
open_link: '打开链接',
|
||||
back: '返回',
|
||||
},
|
||||
search: {
|
||||
placeholder: '搜索球队或联赛',
|
||||
no_results: '未找到相关赛事',
|
||||
results_count: '找到 {count} 场赛事',
|
||||
hint: '输入球队或联赛名称,跳转至投注页筛选',
|
||||
},
|
||||
deposit_notify: {
|
||||
approved_title: '充值已到账',
|
||||
rejected_title: '充值未通过',
|
||||
view_history: '查看充值记录',
|
||||
view_messages: '查看消息中心',
|
||||
dismiss: '关闭',
|
||||
},
|
||||
messages: {
|
||||
title: '消息中心',
|
||||
detail_title: '消息详情',
|
||||
empty: '暂无消息',
|
||||
not_found: '消息不存在',
|
||||
view_all: '返回消息列表',
|
||||
back: '返回',
|
||||
mark_all_read: '全部已读',
|
||||
load_more: '加载更多',
|
||||
delete: '删除',
|
||||
delete_all: '全部删除',
|
||||
delete_confirm: '确定删除这条消息吗?',
|
||||
delete_all_confirm: '确定删除全部消息吗?此操作不可恢复。',
|
||||
banner_promo_view: '查看推广',
|
||||
content_promo_view: '查看详情',
|
||||
status_unread: '未读',
|
||||
status_read: '已读',
|
||||
deposit_approved_title: '充值已到账',
|
||||
deposit_rejected_title: '充值未通过',
|
||||
deposit_approved_body: '订单 {orderNo} 已审核通过,申请 {amount},到账 {approvedAmount}。',
|
||||
deposit_rejected_body: '订单 {orderNo}({amount})未通过审核。{reason}',
|
||||
reject_reason: '拒绝原因',
|
||||
no_reason: '未提供原因',
|
||||
view_recharge_history: '查看充值记录',
|
||||
unread_badge: '{count} 条未读',
|
||||
open_inbox: '打开消息中心',
|
||||
},
|
||||
inbox_hub: {
|
||||
title: '消息与客服',
|
||||
tab_messages: '邮箱',
|
||||
tab_support: '客服',
|
||||
open: '打开消息与客服',
|
||||
open_support: '打开客服',
|
||||
},
|
||||
history: {
|
||||
league_default: '足球',
|
||||
stake: '投注',
|
||||
@@ -235,7 +301,7 @@ export default {
|
||||
audit_amount: '金额',
|
||||
audit_credited: '入账金额',
|
||||
audit_remark_label: '备注',
|
||||
audit_summary: '审核记录 · {count} 步',
|
||||
audit_summary: '审核记录 · {count} 条',
|
||||
audit_toggle_show: '查看审核记录',
|
||||
audit_toggle_hide: '收起审核记录',
|
||||
view_detail: '查看详情',
|
||||
@@ -304,13 +370,13 @@ export default {
|
||||
parlay_block_quarter: '四分盘让球/大小不可串关',
|
||||
parlay_block_not_allowed: '该玩法不可串关',
|
||||
parlay_need_more: '请至少选择 2 项进行串关',
|
||||
market_status_suspended: '暂停',
|
||||
market_status_closed: '已关闭',
|
||||
back: '返回',
|
||||
refresh: '刷新',
|
||||
download: '下载',
|
||||
reward_active: '奖励生效中!',
|
||||
market_closed: '暂未开盘',
|
||||
market_status_suspended: '暂停',
|
||||
market_status_closed: '已关闭',
|
||||
match_phase_closed_pending: '封盘待结算',
|
||||
match_phase_settled: '已结算',
|
||||
view_match: '查看赛况',
|
||||
@@ -396,6 +462,11 @@ export default {
|
||||
slip_currency: '金额',
|
||||
slip_min: '最低',
|
||||
slip_min_error: '最低投注金额为 {amount}',
|
||||
odds_changed: '部分选项赔率已变更,请确认后下注',
|
||||
odds_suspended: '部分选项已暂停或关闭,请移除后重试',
|
||||
accept_changes_place: '接受变更并下注',
|
||||
odds_was: '原赔率',
|
||||
odds_now: '新赔率',
|
||||
place_success: '下注成功',
|
||||
place_failed: '下注失败',
|
||||
},
|
||||
|
||||
@@ -13,11 +13,13 @@ import BackToTopButton from '../components/BackToTopButton.vue';
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
||||
const CustomerServiceModal = defineAsyncComponent(
|
||||
() => import('../components/CustomerServiceModal.vue'),
|
||||
);
|
||||
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
|
||||
import { useDepositNotifications } from '../composables/useDepositNotifications';
|
||||
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
@@ -52,11 +54,15 @@ const showBottomNav = computed(() => {
|
||||
) return true;
|
||||
return false;
|
||||
});
|
||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
|
||||
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
|
||||
const { startPolling, stopPolling } = useDepositNotifications();
|
||||
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
|
||||
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
|
||||
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
|
||||
|
||||
const mainRef = ref<HTMLElement | null>(null);
|
||||
const tabScrollTops = new Map<string, number>();
|
||||
const customerServiceOpen = ref(false);
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) void loadPlayerHome(true);
|
||||
@@ -87,6 +93,13 @@ watch(
|
||||
// 个人资料仅登录用户需要
|
||||
if (token) {
|
||||
void loadProfile(true);
|
||||
startPolling();
|
||||
startPresencePing();
|
||||
if (inboxEnabled.value) void refreshUnreadCount();
|
||||
} else {
|
||||
stopPolling();
|
||||
stopPresencePing();
|
||||
resetMessagesState();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -96,6 +109,9 @@ watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!auth.token) return;
|
||||
if (inboxEnabled.value && path.startsWith('/messages')) {
|
||||
void refreshUnreadCount();
|
||||
}
|
||||
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
|
||||
void refreshProfile();
|
||||
}
|
||||
@@ -108,13 +124,12 @@ watch(
|
||||
<header v-if="showHeader" class="header">
|
||||
<img src="/logo.png" alt="TheBet365" class="logo" />
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="support-btn"
|
||||
:aria-label="t('support.open')"
|
||||
@click="customerServiceOpen = true"
|
||||
<RouterLink
|
||||
:to="hubRoute"
|
||||
class="hub-btn"
|
||||
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
|
||||
>
|
||||
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
||||
fill="none"
|
||||
@@ -126,8 +141,8 @@ watch(
|
||||
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="support-label">{{ t('support.short') }}</span>
|
||||
</button>
|
||||
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="hub-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</RouterLink>
|
||||
<LocaleSwitcher />
|
||||
<template v-if="auth.user">
|
||||
<CashBalanceChip />
|
||||
@@ -140,7 +155,11 @@ watch(
|
||||
</header>
|
||||
|
||||
<div v-if="showAnnouncement" class="announce-strip">
|
||||
<AnnouncementMarquee :items="announcements" embedded />
|
||||
<AnnouncementMarquee
|
||||
:items="announcements"
|
||||
:target-id="primaryAnnouncementId"
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav, 'main--sub-page': isDetailPage }]">
|
||||
@@ -182,7 +201,6 @@ watch(
|
||||
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
|
||||
|
||||
<BetSlipDrawer v-model="slip.drawerOpen" />
|
||||
<CustomerServiceModal v-model="customerServiceOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -227,40 +245,45 @@ watch(
|
||||
width: var(--header-chip-h);
|
||||
}
|
||||
|
||||
.support-btn {
|
||||
.hub-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
width: var(--header-chip-h, 36px);
|
||||
height: var(--header-chip-h, 36px);
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-btn:active {
|
||||
background: var(--surface-active);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.support-icon {
|
||||
.hub-btn:active {
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.hub-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-label {
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.hub-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-body);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
|
||||
@@ -28,6 +28,10 @@ const router = createRouter({
|
||||
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'messages', component: () => import('../views/InboxHubView.vue'), meta: { keepAlive: true, requiresAuth: true } },
|
||||
{ path: 'messages/:id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: 'announcements', component: () => import('../views/AnnouncementListView.vue'), meta: { keepAlive: true } },
|
||||
{ path: 'announcements/:id', component: () => import('../views/AnnouncementDetailView.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -179,6 +179,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
|
||||
if (singleItem.value?.selectionId === selectionId) {
|
||||
singleItem.value = { ...singleItem.value, odds, oddsVersion };
|
||||
}
|
||||
const idx = parlayItems.value.findIndex((i) => i.selectionId === selectionId);
|
||||
if (idx >= 0) {
|
||||
parlayItems.value[idx] = { ...parlayItems.value[idx], odds, oddsVersion };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
singleItem,
|
||||
parlayItems,
|
||||
@@ -209,5 +219,6 @@ export const useBetSlipStore = defineStore('betSlip', () => {
|
||||
clearAll,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
updateSelectionOdds,
|
||||
};
|
||||
});
|
||||
|
||||
65
apps/player/src/utils/html.ts
Normal file
65
apps/player/src/utils/html.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
|
||||
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
|
||||
]);
|
||||
|
||||
function sanitizeNode(node: Node): Node | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.cloneNode(false);
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
const safe = sanitizeNode(child);
|
||||
if (safe) frag.appendChild(safe);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
const out = document.createElement(tag);
|
||||
if (tag === 'img') {
|
||||
const src = el.getAttribute('src')?.trim();
|
||||
if (!src || /^javascript:/i.test(src)) return null;
|
||||
out.setAttribute('src', src);
|
||||
const alt = el.getAttribute('alt');
|
||||
if (alt) out.setAttribute('alt', alt);
|
||||
return out;
|
||||
}
|
||||
if (tag === 'a') {
|
||||
const href = el.getAttribute('href')?.trim();
|
||||
if (!href || /^javascript:/i.test(href)) return null;
|
||||
out.setAttribute('href', href);
|
||||
out.setAttribute('target', '_blank');
|
||||
out.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
const safe = sanitizeNode(child);
|
||||
if (safe) out.appendChild(safe);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 玩家端公告正文 HTML 白名单净化 */
|
||||
export function sanitizeAnnouncementHtml(html: string): string {
|
||||
if (!html?.trim()) return '';
|
||||
if (!/[<>]/.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const container = document.createElement('div');
|
||||
for (const child of Array.from(doc.body.childNodes)) {
|
||||
const safe = sanitizeNode(child);
|
||||
if (safe) container.appendChild(safe);
|
||||
}
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
if (!/[<>]/.test(html)) return html.trim();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import defaultBannerImg from '../assets/images/banner.webp';
|
||||
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
|
||||
import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
|
||||
|
||||
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
|
||||
|
||||
const announcementId = computed(() => String(route.params.id ?? ''));
|
||||
|
||||
const item = computed<PlayerContentItem | null>(() => {
|
||||
const id = announcementId.value;
|
||||
return (
|
||||
bannerItems.value.find((entry) => entry.id === id) ??
|
||||
announcementItems.value.find((entry) => entry.id === id) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const isBanner = computed(() => item.value?.contentType === 'BANNER');
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push('/announcements');
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function itemTitle(entry: PlayerContentItem) {
|
||||
const title = entry.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
const bodyText = stripHtml(entry.translation?.body ?? '');
|
||||
return bodyText || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function itemBodyHtml(entry: PlayerContentItem) {
|
||||
const title = entry.translation?.title?.trim();
|
||||
const body = entry.translation?.body?.trim();
|
||||
if (!body) return '';
|
||||
if (title && stripHtml(body) === title) return '';
|
||||
return sanitizeAnnouncementHtml(body);
|
||||
}
|
||||
|
||||
const heroImageUrl = computed(() => {
|
||||
const url = item.value?.translation?.imageUrl?.trim();
|
||||
if (url) return url;
|
||||
if (isBanner.value) return defaultBannerImg || FALLBACK_IMG;
|
||||
return '';
|
||||
});
|
||||
|
||||
const linkTarget = computed(() => item.value?.linkTarget?.trim() ?? '');
|
||||
|
||||
const externalLinkUrl = computed(() => {
|
||||
if (item.value?.linkType !== 'URL' || !linkTarget.value) return '';
|
||||
return /^https?:\/\//i.test(linkTarget.value)
|
||||
? linkTarget.value
|
||||
: `https://${linkTarget.value}`;
|
||||
});
|
||||
|
||||
function onHeroError(e: Event) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (img.dataset.fallbackApplied) return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
img.src = defaultBannerImg || FALLBACK_IMG;
|
||||
}
|
||||
|
||||
function followRouteLink() {
|
||||
if (item.value?.linkType === 'ROUTE' && linkTarget.value) {
|
||||
void router.push(linkTarget.value);
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink() {
|
||||
if (externalLinkUrl.value) {
|
||||
window.open(externalLinkUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await load(true);
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
watch(announcementId, () => {
|
||||
if (!item.value && !loading.value) void refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announce-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('announcements.detail_title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !item" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!item" class="empty">
|
||||
<p>{{ t('announcements.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<figure v-if="heroImageUrl" class="detail-hero">
|
||||
<img :src="heroImageUrl" :alt="itemTitle(item)" loading="lazy" @error="onHeroError" />
|
||||
</figure>
|
||||
|
||||
<div class="detail-body-wrap">
|
||||
<p v-if="item.createdAt" class="detail-date">{{ formatDate(item.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ itemTitle(item) }}</h2>
|
||||
<div v-if="itemBodyHtml(item)" class="detail-body" v-html="itemBodyHtml(item)" />
|
||||
|
||||
<div v-if="item.linkType && linkTarget" class="detail-link">
|
||||
<p class="link-label">{{ t('announcements.related_link') }}</p>
|
||||
<p class="link-address">{{ linkTarget }}</p>
|
||||
<button
|
||||
v-if="item.linkType === 'ROUTE'"
|
||||
type="button"
|
||||
class="link-action"
|
||||
@click="followRouteLink"
|
||||
>
|
||||
{{ t('announcements.go_link') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="item.linkType === 'URL'"
|
||||
type="button"
|
||||
class="link-action link-action--outline"
|
||||
@click="openExternalLink"
|
||||
>
|
||||
{{ t('announcements.open_link') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announce-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-article {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
margin: 16px -16px 0;
|
||||
padding: 0;
|
||||
background: var(--bg-body);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.detail-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.detail-body-wrap {
|
||||
padding: 18px 0 0;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.detail-body :deep(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 14px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-body :deep(ul),
|
||||
.detail-body :deep(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detail-body :deep(a) {
|
||||
color: var(--primary-light);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-address {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--primary-light);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-action {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-action--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
178
apps/player/src/views/AnnouncementListView.vue
Normal file
178
apps/player/src/views/AnnouncementListView.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { stripHtml } from '../utils/html';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems, loading, load } = usePlayerHome();
|
||||
|
||||
const items = computed(() => announcementItems.value);
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/announcements/${id}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function itemTitle(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function itemPreview(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
const bodyText = stripHtml(item.translation?.body ?? '');
|
||||
if (bodyText && bodyText !== title) return bodyText;
|
||||
return '';
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void load(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announce-page">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('announcements.title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !items.length" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
<p>{{ t('announcements.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="list-row"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<span class="row-main">
|
||||
<span class="title">{{ itemTitle(item) }}</span>
|
||||
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
|
||||
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announce-page {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onActivated } from 'vue';
|
||||
import { onActivated, computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/card-bg.webp';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import VsBadge from '../components/VsBadge.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import type { PlayerHomeMatch } from '../composables/usePlayerHome';
|
||||
|
||||
type HotTab = 'hot' | 'upcoming';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, loading, load } = usePlayerHome();
|
||||
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||
const activeTab = ref<HotTab>('hot');
|
||||
|
||||
const bannerFallbackTo = computed(() => {
|
||||
const id = announcementItems.value[0]?.id;
|
||||
return id ? `/announcements/${id}` : '/announcements';
|
||||
});
|
||||
|
||||
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
|
||||
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
|
||||
);
|
||||
|
||||
const emptyMessage = computed(() =>
|
||||
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
|
||||
);
|
||||
|
||||
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: async () => { await load(true); },
|
||||
@@ -47,14 +64,35 @@ function formatKickoff(startTime: string) {
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<BannerCarousel :banners="banners" />
|
||||
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
|
||||
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div class="match-card-list">
|
||||
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hot-tab"
|
||||
:class="{ active: activeTab === 'hot' }"
|
||||
:aria-selected="activeTab === 'hot'"
|
||||
@click="activeTab = 'hot'"
|
||||
>
|
||||
{{ t('home.hot_tab') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hot-tab"
|
||||
:class="{ active: activeTab === 'upcoming' }"
|
||||
:aria-selected="activeTab === 'upcoming'"
|
||||
@click="activeTab = 'upcoming'"
|
||||
>
|
||||
{{ t('home.upcoming_tab') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(match, index) in hotMatches"
|
||||
v-for="(match, index) in displayedMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
@@ -68,7 +106,31 @@ function formatKickoff(startTime: string) {
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<VsBadge size="md" />
|
||||
<div class="vs-arena">
|
||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`hzBoltGrad-${match.id}`" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
||||
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
||||
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="hz-path hz-path-main"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
||||
/>
|
||||
<path
|
||||
class="hz-path hz-path-sub"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hz-beam" aria-hidden="true" />
|
||||
<img :src="vsImg" alt="" class="vs-img" />
|
||||
</div>
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
:team-code="match.awayTeamCode"
|
||||
@@ -77,11 +139,10 @@ function formatKickoff(startTime: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !hotMatches.length" class="empty">
|
||||
<div v-if="!loading && !displayedMatches.length" class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,15 +156,34 @@ function formatKickoff(startTime: string) {
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: var(--space-section);
|
||||
margin-bottom: var(--space-section);
|
||||
.hot-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.match-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-section);
|
||||
.hot-tab {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.hot-tab.active {
|
||||
font-weight: 700;
|
||||
background: var(--surface-active);
|
||||
border-color: var(--primary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hot-tab:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
@@ -113,7 +193,7 @@ function formatKickoff(startTime: string) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -152,10 +232,10 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.match-teams {
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.match-time {
|
||||
@@ -172,6 +252,189 @@ function formatKickoff(startTime: string) {
|
||||
max-width: 46%;
|
||||
}
|
||||
|
||||
.vs-arena {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hz-lightning {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hz-path {
|
||||
fill: none;
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-path-main {
|
||||
animation: hz-strike-main 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-path-sub {
|
||||
stroke-width: 1.6;
|
||||
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-beam {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(94, 184, 255, 0) 0%,
|
||||
rgba(184, 236, 255, 0.95) 28%,
|
||||
#fff 50%,
|
||||
rgba(255, 208, 128, 0.95) 72%,
|
||||
rgba(255, 144, 64, 0) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
filter: blur(0.4px);
|
||||
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hz-beam {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vs-img {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 48px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.match-card--live-anim .vs-img {
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-strike-main {
|
||||
0%,
|
||||
72%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
74% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-strike-sub {
|
||||
0%,
|
||||
74%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
77% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-beam-flash {
|
||||
0%,
|
||||
71%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(0.6);
|
||||
}
|
||||
|
||||
73% {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.15;
|
||||
transform: translateY(-50%) scaleX(0.95);
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.75;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vs-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.82;
|
||||
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter:
|
||||
drop-shadow(0 0 3px rgba(255, 230, 140, 0.7))
|
||||
drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vs-img {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.hz-path,
|
||||
.hz-beam {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
|
||||
315
apps/player/src/views/InboxHubView.vue
Normal file
315
apps/player/src/views/InboxHubView.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MessageListPanel from '../components/MessageListPanel.vue';
|
||||
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
type HubTab = 'messages' | 'support';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||
const {
|
||||
unreadCount,
|
||||
messages,
|
||||
listLoaded,
|
||||
refreshUnreadCount,
|
||||
markAllRead,
|
||||
deleteAllMessages,
|
||||
} = usePlayerMessages();
|
||||
|
||||
const markingAll = ref(false);
|
||||
const deletingAll = ref(false);
|
||||
const deleteAllConfirmVisible = ref(false);
|
||||
|
||||
const activeTab = computed<HubTab>(() => {
|
||||
if (!inboxEnabled.value) return 'support';
|
||||
return route.query.tab === 'support' ? 'support' : 'messages';
|
||||
});
|
||||
|
||||
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
|
||||
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
|
||||
const showMessageActions = computed(
|
||||
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
|
||||
);
|
||||
|
||||
function switchTab(tab: HubTab) {
|
||||
if (!inboxEnabled.value || tab === activeTab.value) return;
|
||||
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
|
||||
}
|
||||
|
||||
async function onMarkAllRead() {
|
||||
if (!unreadInList.value || markingAll.value) return;
|
||||
markingAll.value = true;
|
||||
try {
|
||||
await markAllRead();
|
||||
await refreshUnreadCount();
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deleteAllConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deletingAll.value = true;
|
||||
try {
|
||||
await deleteAllMessages();
|
||||
await refreshUnreadCount();
|
||||
deleteAllConfirmVisible.value = false;
|
||||
} finally {
|
||||
deletingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSupportTabWhenDisabled() {
|
||||
if (inboxEnabled.value || route.path !== '/messages') return;
|
||||
if (route.query.tab === 'support') return;
|
||||
void router.replace({ path: '/messages', query: { tab: 'support' } });
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
watch(inboxEnabled, (enabled, prev) => {
|
||||
if (prev && !enabled) ensureSupportTabWhenDisabled();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(ensureSupportTabWhenDisabled);
|
||||
|
||||
onActivated(() => {
|
||||
if (inboxEnabled.value) {
|
||||
void refreshUnreadCount();
|
||||
return;
|
||||
}
|
||||
ensureSupportTabWhenDisabled();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
|
||||
<header v-if="!inboxEnabled" class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t(hubTitleKey) }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="inboxEnabled" class="hub-top-bar">
|
||||
<button type="button" class="hub-back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<nav class="hub-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'messages' }"
|
||||
:aria-selected="activeTab === 'messages'"
|
||||
@click="switchTab('messages')"
|
||||
>
|
||||
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
|
||||
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'support' }"
|
||||
:aria-selected="activeTab === 'support'"
|
||||
@click="switchTab('support')"
|
||||
>
|
||||
{{ t('inbox_hub.tab_support') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div v-if="showMessageActions" class="message-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:disabled="!unreadInList || markingAll"
|
||||
@click="onMarkAllRead"
|
||||
>
|
||||
{{ t('messages.mark_all_read') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn danger"
|
||||
:disabled="deletingAll"
|
||||
@click="onDeleteAll"
|
||||
>
|
||||
{{ t('messages.delete_all') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
|
||||
<CustomerServicePanel v-if="activeTab === 'support'" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteAllConfirmVisible"
|
||||
:title="t('messages.delete_all')"
|
||||
:message="t('messages.delete_all_confirm')"
|
||||
:confirm-text="t('messages.delete_all')"
|
||||
danger
|
||||
:loading="deletingAll"
|
||||
@confirm="confirmDeleteAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbox-hub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.inbox-hub--tabs {
|
||||
margin: -12px -16px 0;
|
||||
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hub-top-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hub-back-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hub-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hub-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hub-tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-tab-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hub-tab-badge {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 0 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
}
|
||||
</style>
|
||||
360
apps/player/src/views/MessageDetailView.vue
Normal file
360
apps/player/src/views/MessageDetailView.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
usePlayerMessages,
|
||||
type DepositMessagePayload,
|
||||
type BannerPromoPayload,
|
||||
type PlayerMessage,
|
||||
} from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const messageId = computed(() => String(route.params.id ?? ''));
|
||||
const message = ref<PlayerMessage | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
|
||||
const depositPayload = computed(() => {
|
||||
if (
|
||||
!message.value ||
|
||||
message.value.type === 'BANNER_PROMO' ||
|
||||
message.value.type === 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (message.value.payload ?? null) as DepositMessagePayload | null;
|
||||
});
|
||||
const contentPromoId = computed(() => {
|
||||
if (
|
||||
message.value?.type !== 'BANNER_PROMO' &&
|
||||
message.value?.type !== 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (message.value.payload as BannerPromoPayload | null)?.contentId;
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push(hubRoute.value);
|
||||
}
|
||||
|
||||
function viewContentPromo() {
|
||||
if (!contentPromoId.value) return;
|
||||
router.push(`/announcements/${contentPromoId.value}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function messageTitle(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function messageBody(item: PlayerMessage) {
|
||||
const deposit = depositPayload.value;
|
||||
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_approved_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
approvedAmount: formatMoney(
|
||||
deposit.approvedAmount ?? deposit.amount ?? '0',
|
||||
locale.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_rejected_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
|
||||
});
|
||||
}
|
||||
return item.body;
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!messageId.value) return;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
message.value = await loadMessageDetail(messageId.value);
|
||||
if (message.value && !message.value.isRead) {
|
||||
await markMessageRead(messageId.value);
|
||||
message.value = { ...message.value, isRead: true };
|
||||
}
|
||||
} catch {
|
||||
error.value = true;
|
||||
message.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleteConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteMessage(messageId.value);
|
||||
deleteConfirmVisible.value = false;
|
||||
router.replace(hubRoute.value);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
watch(messageId, () => {
|
||||
void fetchDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('messages.detail_title') }}</h1>
|
||||
<button
|
||||
v-if="message"
|
||||
type="button"
|
||||
class="delete-header-btn"
|
||||
:aria-label="t('messages.delete')"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('messages.delete') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !message" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !message" class="empty">
|
||||
<p>{{ t('messages.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<span class="status-badge" :class="{ unread: !message.isRead }">
|
||||
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||
</span>
|
||||
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
||||
<p class="detail-body">{{ messageBody(message) }}</p>
|
||||
|
||||
<div
|
||||
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||
class="reason-box"
|
||||
>
|
||||
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
|
||||
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="contentPromoId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="viewContentPromo"
|
||||
>
|
||||
{{ t('messages.content_promo_view') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="depositPayload?.depositOrderId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="router.push('/wallet/recharge/history')"
|
||||
>
|
||||
{{ t('messages.view_recharge_history') }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
:title="t('messages.delete')"
|
||||
:message="t('messages.delete_confirm')"
|
||||
:confirm-text="t('messages.delete')"
|
||||
danger
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.delete-header-btn {
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-header-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-badge.unread {
|
||||
color: var(--text);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-article {
|
||||
padding: 18px 0 0;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.reason-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reason-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -12,12 +12,14 @@ import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
||||
import { sumCashbackFromStats } from '../utils/walletStats';
|
||||
import WalletBalanceCard from '../components/WalletBalanceCard.vue';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
@@ -141,6 +143,17 @@ function logout() {
|
||||
</span>
|
||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="hubRoute" class="settings-cell settings-cell--accent-entry">
|
||||
<span class="cell-main">
|
||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
|
||||
<path d="m4 7 8 5.5L20 7" />
|
||||
</svg>
|
||||
<span class="cell-label">{{ t('messages.title') }}</span>
|
||||
</span>
|
||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="settings-group settings-group--profile">
|
||||
|
||||
Reference in New Issue
Block a user