feat(theme-3): sync inbox/announcements/presence/deposit/search from main
This commit is contained in:
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
320
apps/player/src/views/AnnouncementDetailView.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import defaultBannerImg from '../assets/images/banner.webp';
|
||||
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
|
||||
import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
|
||||
|
||||
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
|
||||
|
||||
const announcementId = computed(() => String(route.params.id ?? ''));
|
||||
|
||||
const item = computed<PlayerContentItem | null>(() => {
|
||||
const id = announcementId.value;
|
||||
return (
|
||||
bannerItems.value.find((entry) => entry.id === id) ??
|
||||
announcementItems.value.find((entry) => entry.id === id) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const isBanner = computed(() => item.value?.contentType === 'BANNER');
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push('/announcements');
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function itemTitle(entry: PlayerContentItem) {
|
||||
const title = entry.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
const bodyText = stripHtml(entry.translation?.body ?? '');
|
||||
return bodyText || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function itemBodyHtml(entry: PlayerContentItem) {
|
||||
const title = entry.translation?.title?.trim();
|
||||
const body = entry.translation?.body?.trim();
|
||||
if (!body) return '';
|
||||
if (title && stripHtml(body) === title) return '';
|
||||
return sanitizeAnnouncementHtml(body);
|
||||
}
|
||||
|
||||
const heroImageUrl = computed(() => {
|
||||
const url = item.value?.translation?.imageUrl?.trim();
|
||||
if (url) return url;
|
||||
if (isBanner.value) return defaultBannerImg || FALLBACK_IMG;
|
||||
return '';
|
||||
});
|
||||
|
||||
const linkTarget = computed(() => item.value?.linkTarget?.trim() ?? '');
|
||||
|
||||
const externalLinkUrl = computed(() => {
|
||||
if (item.value?.linkType !== 'URL' || !linkTarget.value) return '';
|
||||
return /^https?:\/\//i.test(linkTarget.value)
|
||||
? linkTarget.value
|
||||
: `https://${linkTarget.value}`;
|
||||
});
|
||||
|
||||
function onHeroError(e: Event) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
if (img.dataset.fallbackApplied) return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
img.src = defaultBannerImg || FALLBACK_IMG;
|
||||
}
|
||||
|
||||
function followRouteLink() {
|
||||
if (item.value?.linkType === 'ROUTE' && linkTarget.value) {
|
||||
void router.push(linkTarget.value);
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalLink() {
|
||||
if (externalLinkUrl.value) {
|
||||
window.open(externalLinkUrl.value, '_blank', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await load(true);
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
watch(announcementId, () => {
|
||||
if (!item.value && !loading.value) void refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announce-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('announcements.detail_title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !item" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!item" class="empty">
|
||||
<p>{{ t('announcements.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<figure v-if="heroImageUrl" class="detail-hero">
|
||||
<img :src="heroImageUrl" :alt="itemTitle(item)" loading="lazy" @error="onHeroError" />
|
||||
</figure>
|
||||
|
||||
<div class="detail-body-wrap">
|
||||
<p v-if="item.createdAt" class="detail-date">{{ formatDate(item.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ itemTitle(item) }}</h2>
|
||||
<div v-if="itemBodyHtml(item)" class="detail-body" v-html="itemBodyHtml(item)" />
|
||||
|
||||
<div v-if="item.linkType && linkTarget" class="detail-link">
|
||||
<p class="link-label">{{ t('announcements.related_link') }}</p>
|
||||
<p class="link-address">{{ linkTarget }}</p>
|
||||
<button
|
||||
v-if="item.linkType === 'ROUTE'"
|
||||
type="button"
|
||||
class="link-action"
|
||||
@click="followRouteLink"
|
||||
>
|
||||
{{ t('announcements.go_link') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="item.linkType === 'URL'"
|
||||
type="button"
|
||||
class="link-action link-action--outline"
|
||||
@click="openExternalLink"
|
||||
>
|
||||
{{ t('announcements.open_link') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announce-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--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>
|
||||
178
apps/player/src/views/AnnouncementListView.vue
Normal file
178
apps/player/src/views/AnnouncementListView.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { stripHtml } from '../utils/html';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems, loading, load } = usePlayerHome();
|
||||
|
||||
const items = computed(() => announcementItems.value);
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/announcements/${id}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function itemTitle(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function itemPreview(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
const bodyText = stripHtml(item.translation?.body ?? '');
|
||||
if (bodyText && bodyText !== title) return bodyText;
|
||||
return '';
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void load(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announce-page">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('announcements.title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !items.length" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
<p>{{ t('announcements.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="list-row"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<span class="row-main">
|
||||
<span class="title">{{ itemTitle(item) }}</span>
|
||||
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
|
||||
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announce-page {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
315
apps/player/src/views/InboxHubView.vue
Normal file
315
apps/player/src/views/InboxHubView.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MessageListPanel from '../components/MessageListPanel.vue';
|
||||
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
type HubTab = 'messages' | 'support';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||
const {
|
||||
unreadCount,
|
||||
messages,
|
||||
listLoaded,
|
||||
refreshUnreadCount,
|
||||
markAllRead,
|
||||
deleteAllMessages,
|
||||
} = usePlayerMessages();
|
||||
|
||||
const markingAll = ref(false);
|
||||
const deletingAll = ref(false);
|
||||
const deleteAllConfirmVisible = ref(false);
|
||||
|
||||
const activeTab = computed<HubTab>(() => {
|
||||
if (!inboxEnabled.value) return 'support';
|
||||
return route.query.tab === 'support' ? 'support' : 'messages';
|
||||
});
|
||||
|
||||
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
|
||||
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
|
||||
const showMessageActions = computed(
|
||||
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
|
||||
);
|
||||
|
||||
function switchTab(tab: HubTab) {
|
||||
if (!inboxEnabled.value || tab === activeTab.value) return;
|
||||
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
|
||||
}
|
||||
|
||||
async function onMarkAllRead() {
|
||||
if (!unreadInList.value || markingAll.value) return;
|
||||
markingAll.value = true;
|
||||
try {
|
||||
await markAllRead();
|
||||
await refreshUnreadCount();
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deleteAllConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deletingAll.value = true;
|
||||
try {
|
||||
await deleteAllMessages();
|
||||
await refreshUnreadCount();
|
||||
deleteAllConfirmVisible.value = false;
|
||||
} finally {
|
||||
deletingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSupportTabWhenDisabled() {
|
||||
if (inboxEnabled.value || route.path !== '/messages') return;
|
||||
if (route.query.tab === 'support') return;
|
||||
void router.replace({ path: '/messages', query: { tab: 'support' } });
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
watch(inboxEnabled, (enabled, prev) => {
|
||||
if (prev && !enabled) ensureSupportTabWhenDisabled();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(ensureSupportTabWhenDisabled);
|
||||
|
||||
onActivated(() => {
|
||||
if (inboxEnabled.value) {
|
||||
void refreshUnreadCount();
|
||||
return;
|
||||
}
|
||||
ensureSupportTabWhenDisabled();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
|
||||
<header v-if="!inboxEnabled" class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t(hubTitleKey) }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="inboxEnabled" class="hub-top-bar">
|
||||
<button type="button" class="hub-back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<nav class="hub-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'messages' }"
|
||||
:aria-selected="activeTab === 'messages'"
|
||||
@click="switchTab('messages')"
|
||||
>
|
||||
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
|
||||
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'support' }"
|
||||
:aria-selected="activeTab === 'support'"
|
||||
@click="switchTab('support')"
|
||||
>
|
||||
{{ t('inbox_hub.tab_support') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div v-if="showMessageActions" class="message-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:disabled="!unreadInList || markingAll"
|
||||
@click="onMarkAllRead"
|
||||
>
|
||||
{{ t('messages.mark_all_read') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn danger"
|
||||
:disabled="deletingAll"
|
||||
@click="onDeleteAll"
|
||||
>
|
||||
{{ t('messages.delete_all') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
|
||||
<CustomerServicePanel v-if="activeTab === 'support'" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteAllConfirmVisible"
|
||||
:title="t('messages.delete_all')"
|
||||
:message="t('messages.delete_all_confirm')"
|
||||
:confirm-text="t('messages.delete_all')"
|
||||
danger
|
||||
:loading="deletingAll"
|
||||
@confirm="confirmDeleteAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbox-hub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.inbox-hub--tabs {
|
||||
margin: -12px -16px 0;
|
||||
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--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>
|
||||
360
apps/player/src/views/MessageDetailView.vue
Normal file
360
apps/player/src/views/MessageDetailView.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
usePlayerMessages,
|
||||
type DepositMessagePayload,
|
||||
type BannerPromoPayload,
|
||||
type PlayerMessage,
|
||||
} from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const messageId = computed(() => String(route.params.id ?? ''));
|
||||
const message = ref<PlayerMessage | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
|
||||
const depositPayload = computed(() => {
|
||||
if (
|
||||
!message.value ||
|
||||
message.value.type === 'BANNER_PROMO' ||
|
||||
message.value.type === 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (message.value.payload ?? null) as DepositMessagePayload | null;
|
||||
});
|
||||
const contentPromoId = computed(() => {
|
||||
if (
|
||||
message.value?.type !== 'BANNER_PROMO' &&
|
||||
message.value?.type !== 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (message.value.payload as BannerPromoPayload | null)?.contentId;
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push(hubRoute.value);
|
||||
}
|
||||
|
||||
function viewContentPromo() {
|
||||
if (!contentPromoId.value) return;
|
||||
router.push(`/announcements/${contentPromoId.value}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function messageTitle(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function messageBody(item: PlayerMessage) {
|
||||
const deposit = depositPayload.value;
|
||||
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_approved_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
approvedAmount: formatMoney(
|
||||
deposit.approvedAmount ?? deposit.amount ?? '0',
|
||||
locale.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_rejected_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
|
||||
});
|
||||
}
|
||||
return item.body;
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!messageId.value) return;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
message.value = await loadMessageDetail(messageId.value);
|
||||
if (message.value && !message.value.isRead) {
|
||||
await markMessageRead(messageId.value);
|
||||
message.value = { ...message.value, isRead: true };
|
||||
}
|
||||
} catch {
|
||||
error.value = true;
|
||||
message.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleteConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteMessage(messageId.value);
|
||||
deleteConfirmVisible.value = false;
|
||||
router.replace(hubRoute.value);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
watch(messageId, () => {
|
||||
void fetchDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('messages.detail_title') }}</h1>
|
||||
<button
|
||||
v-if="message"
|
||||
type="button"
|
||||
class="delete-header-btn"
|
||||
:aria-label="t('messages.delete')"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('messages.delete') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !message" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !message" class="empty">
|
||||
<p>{{ t('messages.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<span class="status-badge" :class="{ unread: !message.isRead }">
|
||||
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||
</span>
|
||||
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
||||
<p class="detail-body">{{ messageBody(message) }}</p>
|
||||
|
||||
<div
|
||||
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||
class="reason-box"
|
||||
>
|
||||
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
|
||||
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="contentPromoId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="viewContentPromo"
|
||||
>
|
||||
{{ t('messages.content_promo_view') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="depositPayload?.depositOrderId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="router.push('/wallet/recharge/history')"
|
||||
>
|
||||
{{ t('messages.view_recharge_history') }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
:title="t('messages.delete')"
|
||||
:message="t('messages.delete_confirm')"
|
||||
:confirm-text="t('messages.delete')"
|
||||
danger
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user