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

This commit is contained in:
2026-06-18 10:02:32 +08:00
parent 0d430857c7
commit 6881d325d5
87 changed files with 6944 additions and 964 deletions

View 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(--primary);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.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: var(--radius-sm);
padding: 8px 14px;
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
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: var(--text);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: var(--text-secondary);
}
.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: var(--radius-sm);
}
.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: var(--radius-sm);
background: var(--gradient-primary);
color: #FFFFFF;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.link-action--outline {
background: transparent;
border: 1px solid var(--border);
color: var(--primary);
}
</style>

View 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(--primary);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.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>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { usePlayerMatches } from '../composables/usePlayerMatches';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import MatchBetCard from '../components/MatchBetCard.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
@@ -59,8 +60,10 @@ interface LeagueGroup {
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const mainTab = ref<MainTab>('matches');
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
@@ -88,10 +91,31 @@ const pullIndicatorStyle = () => ({
opacity: Math.min(pullDistance.value / 48, 1),
});
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function syncSearchFromRoute() {
const q = route.query.search;
searchQuery.value = typeof q === 'string' ? q : '';
}
function matchesSearchKeyword(m: Match, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
const keyword = searchQuery.value.trim().toLowerCase();
return matches.value.filter((m) => {
if (!matchesSearchKeyword(m, keyword)) return false;
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
@@ -125,7 +149,7 @@ const availableLeagues = computed(() => {
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { id, name: m.leagueName });
map.set(id, { id, name: normalizeLeagueName(m.leagueName) });
}
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -164,7 +188,7 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
if (!map.has(id)) {
map.set(id, {
leagueId: id,
leagueName: m.leagueName,
leagueName: normalizeLeagueName(m.leagueName),
leagueLogoUrl: m.leagueLogoUrl ?? null,
matches: [],
});
@@ -188,6 +212,16 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
const isSearchActive = computed(() => searchQuery.value.trim().length > 0);
const sortedFilteredMatches = computed(() =>
[...filteredMatches.value].sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
),
);
const expandedLeagues = ref(new Set<string>());
watch(leagueGroups, (groups) => {
@@ -218,9 +252,15 @@ function selectMainTab(tab: MainTab) {
onActivated(() => {
filterNow.value = new Date();
filterState.value.time = 'all';
syncSearchFromRoute();
void loadSummary(true, true);
});
watch(
() => route.query.search,
() => syncSearchFromRoute(),
);
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -257,6 +297,17 @@ function goMatch(id: string) {
</div>
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="search-bar">
<span class="search-icon" aria-hidden="true"></span>
<input
v-model="searchQuery"
type="search"
:placeholder="t('search.placeholder')"
enterkeyhint="search"
autocomplete="off"
/>
</div>
<div class="filters-bar">
<div class="phase-filter">
<div class="league-dropdown">
@@ -332,7 +383,22 @@ function goMatch(id: string) {
</div>
<template v-else>
<div>
<div v-if="leagueGroups.length" class="league-list">
<div v-if="isSearchActive && sortedFilteredMatches.length" class="search-results">
<p class="search-results-meta">
{{ t('search.results_count', { count: sortedFilteredMatches.length }) }}
</p>
<div class="search-match-list">
<article
v-for="match in sortedFilteredMatches"
:key="match.id"
class="search-result-item"
>
<p class="search-result-league">{{ normalizeLeagueName(match.leagueName) }}</p>
<MatchBetCard :match="match" @bet="goMatch" />
</article>
</div>
</div>
<div v-else-if="!isSearchActive && leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in leagueGroups"
:key="group.leagueId"
@@ -347,7 +413,7 @@ function goMatch(id: string) {
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
<p>{{ isSearchActive ? t('search.no_results') : t('bet.no_matches') }}</p>
</div>
</div>
</template>
@@ -621,4 +687,72 @@ function goMatch(id: string) {
.outright-tab {
min-height: 0;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 8px;
padding: 0 10px;
height: 32px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
}
.search-icon {
color: var(--primary-light);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.search-results {
padding: 0 12px;
}
.search-results-meta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 8px;
}
.search-match-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.search-result-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-result-league {
font-size: 11px;
font-weight: 600;
color: var(--primary-light);
line-height: 1.3;
}
</style>

View File

@@ -1,5 +1,5 @@
<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';
@@ -11,11 +11,28 @@ 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,16 +64,36 @@ function formatKickoff(startTime: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<BannerCarousel :banners="banners" />
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<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>
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div
v-for="(match, index) in hotMatches"
v-for="(match, index) in displayedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
@@ -104,9 +141,9 @@ function formatKickoff(startTime: string) {
</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>
@@ -120,6 +157,31 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-tabs {
display: flex;
gap: 0;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.hot-tab {
flex: 1;
padding: 12px 6px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.hot-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.quick-entries {
display: grid;
grid-template-columns: repeat(4, 1fr);

View 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(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.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(--primary);
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(--primary);
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(--bg-body);
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: var(--radius-sm);
background: rgba(244, 162, 97, 0.08);
color: var(--primary);
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: var(--danger);
}
</style>

View 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(--primary);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
flex: 1;
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.delete-header-btn {
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: var(--radius-sm);
padding: 6px 10px;
background: rgba(239, 68, 68, 0.08);
color: var(--danger);
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: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
}
.status-badge.unread {
color: var(--primary);
background: rgba(244, 162, 97, 0.12);
}
.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: var(--radius-sm);
padding: 8px 14px;
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
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: var(--text);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: var(--text-secondary);
}
.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: var(--danger);
font-weight: 700;
}
.reason-text {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text-secondary);
}
.action-btn {
margin-top: 24px;
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>

View File

@@ -8,6 +8,7 @@ import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useInboxFeature } from '../composables/useInboxFeature';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
@@ -18,6 +19,7 @@ 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);
@@ -188,6 +190,12 @@ const balanceAmountClass = computed(() => {
</span>
<span class="qa-label">{{ t('recharge.history_title') }}</span>
</RouterLink>
<RouterLink :to="hubRoute" class="qa-item">
<span class="qa-icon qa-icon--inbox">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><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>
<span class="qa-label">{{ t('messages.title') }}</span>
</RouterLink>
</div>
<section class="settings-group">
@@ -586,6 +594,11 @@ const balanceAmountClass = computed(() => {
color: #F4A261;
}
.qa-icon--inbox {
background: rgba(14, 165, 233, 0.15);
color: #0EA5E9;
}
.qa-label {
font-size: 13px;
font-weight: 600;