feat(theme-2): sync inbox/announcements/presence/deposit from main
This commit is contained in:
@@ -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,21 +41,26 @@ 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;
|
||||
margin: -4px -2px 14px;
|
||||
padding: 10px 12px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #EFF6FF;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.marquee-bar.embedded {
|
||||
@@ -47,8 +68,8 @@ const text = computed(() => {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F5F7FA;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #EFF6FF;
|
||||
}
|
||||
|
||||
.marquee-bar.embedded .marquee-badge {
|
||||
@@ -66,9 +87,9 @@ const text = computed(() => {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
color: #FFFFFF;
|
||||
background: #003D6B;
|
||||
border: 1px solid #003D6B;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -83,6 +104,7 @@ const text = computed(() => {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: marquee-scroll 18s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marquee-text {
|
||||
@@ -90,7 +112,7 @@ const text = computed(() => {
|
||||
padding-right: 80px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #003D6B;
|
||||
color: var(--primary-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { formatMoney, parseAmount } from '../utils/localeDisplay';
|
||||
import BetSuccessOverlay from './BetSuccessOverlay.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import api from '../api';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
@@ -37,6 +38,28 @@ 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 clearConfirmVisible = ref(false);
|
||||
|
||||
const activeItems = computed<SlipItem[]>(() => {
|
||||
if (activeTab.value === 'parlay') return slip.parlayItems;
|
||||
@@ -44,20 +67,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 +272,93 @@ 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';
|
||||
}
|
||||
|
||||
function onClearSlip() {
|
||||
if (!activeItems.value.length) return;
|
||||
clearConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmClearSlip() {
|
||||
if (activeTab.value === 'parlay') slip.clearParlay();
|
||||
else slip.clearSingle();
|
||||
clearConfirmVisible.value = false;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function placeBet() {
|
||||
if (!activeItems.value.length) return;
|
||||
if (!auth.token) {
|
||||
@@ -242,6 +379,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 +431,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 +444,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 +488,8 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
|
||||
|
||||
<div class="slip-tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -362,7 +523,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 +552,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,16 +635,25 @@ 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>
|
||||
</div>
|
||||
|
||||
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="clearConfirmVisible"
|
||||
:title="t('bet.slip_clear_title', '清空投注单')"
|
||||
:message="t('bet.slip_clear_confirm', '确认清空投注单?')"
|
||||
:confirm-text="t('bet.slip_clear_confirm_btn', '清空')"
|
||||
danger
|
||||
@confirm="confirmClearSlip"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -871,4 +1065,34 @@ watch(
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.odds-warning {
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
color: #92400e;
|
||||
border-bottom: 1px solid rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
|
||||
.odds-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.odds-up {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.odds-down {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.odds-suspended {
|
||||
color: #6b7280;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
|
||||
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.48);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 22px 18px 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 61, 107, 0.12);
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
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: #fff;
|
||||
}
|
||||
|
||||
.confirm-btn.confirm.danger {
|
||||
background: #dc2626;
|
||||
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.35);
|
||||
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: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cs-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #003D6B;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6B7280;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #1A1A2E;
|
||||
}
|
||||
|
||||
.cs-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #F5F7FA;
|
||||
}
|
||||
|
||||
.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: #6B7280;
|
||||
}
|
||||
|
||||
.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-active);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(0, 61, 107, 0.06);
|
||||
color: var(--primary);
|
||||
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: var(--primary-dark, #002847);
|
||||
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 6px rgba(0, 61, 107, 0.35);
|
||||
}
|
||||
|
||||
.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: #374151;
|
||||
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: var(--text-muted);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.status-badge.unread {
|
||||
color: var(--primary);
|
||||
background: rgba(0, 61, 107, 0.08);
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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: #9ca3af;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-btn:active:not(:disabled) {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.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(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user