feat(theme-4): sync inbox/announcements/presence/deposit from main

This commit is contained in:
2026-06-18 10:08:03 +08:00
parent d3ca8498fb
commit 5c5aa7e55a
87 changed files with 6911 additions and 1026 deletions

View File

@@ -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 {

View File

@@ -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 {

View 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>

View File

@@ -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>

View 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>

View 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>