feat(theme-4): sync inbox/announcements/presence/deposit 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(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-article {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
margin: 16px -16px 0;
|
||||
padding: 0;
|
||||
background: var(--bg-body);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.detail-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.detail-body-wrap {
|
||||
padding: 18px 0 0;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.detail-body :deep(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 14px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-body :deep(ul),
|
||||
.detail-body :deep(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detail-body :deep(a) {
|
||||
color: var(--primary-light);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-address {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--primary-light);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-action {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-action--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
178
apps/player/src/views/AnnouncementListView.vue
Normal file
178
apps/player/src/views/AnnouncementListView.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { stripHtml } from '../utils/html';
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { announcementItems, loading, load } = usePlayerHome();
|
||||
|
||||
const items = computed(() => announcementItems.value);
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push(`/announcements/${id}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function itemTitle(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
if (title) return title;
|
||||
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
|
||||
}
|
||||
|
||||
function itemPreview(item: (typeof items.value)[number]) {
|
||||
const title = item.translation?.title?.trim();
|
||||
const bodyText = stripHtml(item.translation?.body ?? '');
|
||||
if (bodyText && bodyText !== title) return bodyText;
|
||||
return '';
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void load(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announce-page">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('announcements.title') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !items.length" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
<p>{{ t('announcements.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="list-row"
|
||||
@click="openDetail(item.id)"
|
||||
>
|
||||
<span class="row-main">
|
||||
<span class="title">{{ itemTitle(item) }}</span>
|
||||
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
|
||||
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
|
||||
</span>
|
||||
<span class="chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announce-page {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onActivated } from 'vue';
|
||||
import { onActivated, computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/card-bg.webp';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import VsBadge from '../components/VsBadge.vue';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import TeamEmblem from '../components/TeamEmblem.vue';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import type { PlayerHomeMatch } from '../composables/usePlayerHome';
|
||||
|
||||
type HotTab = 'hot' | 'upcoming';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, loading, load } = usePlayerHome();
|
||||
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||
const activeTab = ref<HotTab>('hot');
|
||||
|
||||
const bannerFallbackTo = computed(() => {
|
||||
const id = announcementItems.value[0]?.id;
|
||||
return id ? `/announcements/${id}` : '/announcements';
|
||||
});
|
||||
|
||||
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
|
||||
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
|
||||
);
|
||||
|
||||
const emptyMessage = computed(() =>
|
||||
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
|
||||
);
|
||||
|
||||
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
||||
onRefresh: async () => { await load(true); },
|
||||
@@ -47,14 +64,35 @@ function formatKickoff(startTime: string) {
|
||||
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
||||
</div>
|
||||
|
||||
<BannerCarousel :banners="banners" />
|
||||
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
|
||||
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div class="match-card-list">
|
||||
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hot-tab"
|
||||
:class="{ active: activeTab === 'hot' }"
|
||||
:aria-selected="activeTab === 'hot'"
|
||||
@click="activeTab = 'hot'"
|
||||
>
|
||||
{{ t('home.hot_tab') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hot-tab"
|
||||
:class="{ active: activeTab === 'upcoming' }"
|
||||
:aria-selected="activeTab === 'upcoming'"
|
||||
@click="activeTab = 'upcoming'"
|
||||
>
|
||||
{{ t('home.upcoming_tab') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(match, index) in hotMatches"
|
||||
v-for="(match, index) in displayedMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
@@ -68,7 +106,31 @@ function formatKickoff(startTime: string) {
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<VsBadge size="md" />
|
||||
<div class="vs-arena">
|
||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`hzBoltGrad-${match.id}`" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
||||
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
||||
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="hz-path hz-path-main"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
||||
/>
|
||||
<path
|
||||
class="hz-path hz-path-sub"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hz-beam" aria-hidden="true" />
|
||||
<img :src="vsImg" alt="" class="vs-img" />
|
||||
</div>
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
:team-code="match.awayTeamCode"
|
||||
@@ -77,11 +139,10 @@ function formatKickoff(startTime: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !hotMatches.length" class="empty">
|
||||
<div v-if="!loading && !displayedMatches.length" class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
<p>{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,15 +156,34 @@ function formatKickoff(startTime: string) {
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: var(--space-section);
|
||||
margin-bottom: var(--space-section);
|
||||
.hot-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.match-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-section);
|
||||
.hot-tab {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.hot-tab.active {
|
||||
font-weight: 700;
|
||||
background: var(--surface-active);
|
||||
border-color: var(--primary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hot-tab:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
@@ -113,7 +193,7 @@ function formatKickoff(startTime: string) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -152,10 +232,10 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.match-teams {
|
||||
font-weight: 600;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.match-time {
|
||||
@@ -172,6 +252,189 @@ function formatKickoff(startTime: string) {
|
||||
max-width: 46%;
|
||||
}
|
||||
|
||||
.vs-arena {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hz-lightning {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hz-path {
|
||||
fill: none;
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-path-main {
|
||||
animation: hz-strike-main 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-path-sub {
|
||||
stroke-width: 1.6;
|
||||
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.match-card--live-anim .hz-beam {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(94, 184, 255, 0) 0%,
|
||||
rgba(184, 236, 255, 0.95) 28%,
|
||||
#fff 50%,
|
||||
rgba(255, 208, 128, 0.95) 72%,
|
||||
rgba(255, 144, 64, 0) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
filter: blur(0.4px);
|
||||
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hz-beam {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vs-img {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 48px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.match-card--live-anim .vs-img {
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-strike-main {
|
||||
0%,
|
||||
72%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
74% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-strike-sub {
|
||||
0%,
|
||||
74%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
77% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-beam-flash {
|
||||
0%,
|
||||
71%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(0.6);
|
||||
}
|
||||
|
||||
73% {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.15;
|
||||
transform: translateY(-50%) scaleX(0.95);
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.75;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vs-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.82;
|
||||
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter:
|
||||
drop-shadow(0 0 3px rgba(255, 230, 140, 0.7))
|
||||
drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vs-img {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.hz-path,
|
||||
.hz-beam {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
|
||||
315
apps/player/src/views/InboxHubView.vue
Normal file
315
apps/player/src/views/InboxHubView.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import MessageListPanel from '../components/MessageListPanel.vue';
|
||||
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
type HubTab = 'messages' | 'support';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { inboxEnabled, hubTitleKey } = useInboxFeature();
|
||||
const {
|
||||
unreadCount,
|
||||
messages,
|
||||
listLoaded,
|
||||
refreshUnreadCount,
|
||||
markAllRead,
|
||||
deleteAllMessages,
|
||||
} = usePlayerMessages();
|
||||
|
||||
const markingAll = ref(false);
|
||||
const deletingAll = ref(false);
|
||||
const deleteAllConfirmVisible = ref(false);
|
||||
|
||||
const activeTab = computed<HubTab>(() => {
|
||||
if (!inboxEnabled.value) return 'support';
|
||||
return route.query.tab === 'support' ? 'support' : 'messages';
|
||||
});
|
||||
|
||||
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
|
||||
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
|
||||
const showMessageActions = computed(
|
||||
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
|
||||
);
|
||||
|
||||
function switchTab(tab: HubTab) {
|
||||
if (!inboxEnabled.value || tab === activeTab.value) return;
|
||||
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
|
||||
}
|
||||
|
||||
async function onMarkAllRead() {
|
||||
if (!unreadInList.value || markingAll.value) return;
|
||||
markingAll.value = true;
|
||||
try {
|
||||
await markAllRead();
|
||||
await refreshUnreadCount();
|
||||
} finally {
|
||||
markingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deleteAllConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteAll() {
|
||||
if (deletingAll.value || !messages.value.length) return;
|
||||
deletingAll.value = true;
|
||||
try {
|
||||
await deleteAllMessages();
|
||||
await refreshUnreadCount();
|
||||
deleteAllConfirmVisible.value = false;
|
||||
} finally {
|
||||
deletingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSupportTabWhenDisabled() {
|
||||
if (inboxEnabled.value || route.path !== '/messages') return;
|
||||
if (route.query.tab === 'support') return;
|
||||
void router.replace({ path: '/messages', query: { tab: 'support' } });
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
watch(inboxEnabled, (enabled, prev) => {
|
||||
if (prev && !enabled) ensureSupportTabWhenDisabled();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(ensureSupportTabWhenDisabled);
|
||||
|
||||
onActivated(() => {
|
||||
if (inboxEnabled.value) {
|
||||
void refreshUnreadCount();
|
||||
return;
|
||||
}
|
||||
ensureSupportTabWhenDisabled();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
|
||||
<header v-if="!inboxEnabled" class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t(hubTitleKey) }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="inboxEnabled" class="hub-top-bar">
|
||||
<button type="button" class="hub-back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<nav class="hub-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'messages' }"
|
||||
:aria-selected="activeTab === 'messages'"
|
||||
@click="switchTab('messages')"
|
||||
>
|
||||
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
|
||||
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'support' }"
|
||||
:aria-selected="activeTab === 'support'"
|
||||
@click="switchTab('support')"
|
||||
>
|
||||
{{ t('inbox_hub.tab_support') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div v-if="showMessageActions" class="message-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:disabled="!unreadInList || markingAll"
|
||||
@click="onMarkAllRead"
|
||||
>
|
||||
{{ t('messages.mark_all_read') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn danger"
|
||||
:disabled="deletingAll"
|
||||
@click="onDeleteAll"
|
||||
>
|
||||
{{ t('messages.delete_all') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
|
||||
<CustomerServicePanel v-if="activeTab === 'support'" />
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteAllConfirmVisible"
|
||||
:title="t('messages.delete_all')"
|
||||
:message="t('messages.delete_all_confirm')"
|
||||
:confirm-text="t('messages.delete_all')"
|
||||
danger
|
||||
:loading="deletingAll"
|
||||
@confirm="confirmDeleteAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbox-hub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.inbox-hub--tabs {
|
||||
margin: -12px -16px 0;
|
||||
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hub-top-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hub-back-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hub-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hub-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hub-tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-tab-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hub-tab-badge {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 0 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
}
|
||||
</style>
|
||||
360
apps/player/src/views/MessageDetailView.vue
Normal file
360
apps/player/src/views/MessageDetailView.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import {
|
||||
usePlayerMessages,
|
||||
type DepositMessagePayload,
|
||||
type BannerPromoPayload,
|
||||
type PlayerMessage,
|
||||
} from '../composables/usePlayerMessages';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const messageId = computed(() => String(route.params.id ?? ''));
|
||||
const message = ref<PlayerMessage | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirmVisible = ref(false);
|
||||
|
||||
const depositPayload = computed(() => {
|
||||
if (
|
||||
!message.value ||
|
||||
message.value.type === 'BANNER_PROMO' ||
|
||||
message.value.type === 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (message.value.payload ?? null) as DepositMessagePayload | null;
|
||||
});
|
||||
const contentPromoId = computed(() => {
|
||||
if (
|
||||
message.value?.type !== 'BANNER_PROMO' &&
|
||||
message.value?.type !== 'ANNOUNCEMENT_PROMO'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (message.value.payload as BannerPromoPayload | null)?.contentId;
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function goList() {
|
||||
router.push(hubRoute.value);
|
||||
}
|
||||
|
||||
function viewContentPromo() {
|
||||
if (!contentPromoId.value) return;
|
||||
router.push(`/announcements/${contentPromoId.value}`);
|
||||
}
|
||||
|
||||
function formatDate(createdAt?: string) {
|
||||
if (!createdAt) return '';
|
||||
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function messageTitle(item: PlayerMessage) {
|
||||
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
|
||||
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
|
||||
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function messageBody(item: PlayerMessage) {
|
||||
const deposit = depositPayload.value;
|
||||
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_approved_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
approvedAmount: formatMoney(
|
||||
deposit.approvedAmount ?? deposit.amount ?? '0',
|
||||
locale.value,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
|
||||
return t('messages.deposit_rejected_body', {
|
||||
orderNo: deposit.orderNo,
|
||||
amount: formatMoney(deposit.amount ?? '0', locale.value),
|
||||
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
|
||||
});
|
||||
}
|
||||
return item.body;
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!messageId.value) return;
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
message.value = await loadMessageDetail(messageId.value);
|
||||
if (message.value && !message.value.isRead) {
|
||||
await markMessageRead(messageId.value);
|
||||
message.value = { ...message.value, isRead: true };
|
||||
}
|
||||
} catch {
|
||||
error.value = true;
|
||||
message.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleteConfirmVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!messageId.value || deleting.value) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteMessage(messageId.value);
|
||||
deleteConfirmVisible.value = false;
|
||||
router.replace(hubRoute.value);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
void fetchDetail();
|
||||
});
|
||||
|
||||
watch(messageId, () => {
|
||||
void fetchDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-detail">
|
||||
<header class="page-header">
|
||||
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack">‹</button>
|
||||
<h1>{{ t('messages.detail_title') }}</h1>
|
||||
<button
|
||||
v-if="message"
|
||||
type="button"
|
||||
class="delete-header-btn"
|
||||
:aria-label="t('messages.delete')"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('messages.delete') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && !message" class="state">
|
||||
<GoldSpinner :size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !message" class="empty">
|
||||
<p>{{ t('messages.not_found') }}</p>
|
||||
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
|
||||
</div>
|
||||
|
||||
<article v-else class="detail-article">
|
||||
<span class="status-badge" :class="{ unread: !message.isRead }">
|
||||
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
|
||||
</span>
|
||||
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
|
||||
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
|
||||
<p class="detail-body">{{ messageBody(message) }}</p>
|
||||
|
||||
<div
|
||||
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
|
||||
class="reason-box"
|
||||
>
|
||||
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
|
||||
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="contentPromoId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="viewContentPromo"
|
||||
>
|
||||
{{ t('messages.content_promo_view') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="depositPayload?.depositOrderId"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@click="router.push('/wallet/recharge/history')"
|
||||
>
|
||||
{{ t('messages.view_recharge_history') }}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
:title="t('messages.delete')"
|
||||
:message="t('messages.delete_confirm')"
|
||||
:confirm-text="t('messages.delete')"
|
||||
danger
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-detail {
|
||||
min-height: 100%;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.delete-header-btn {
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-header-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.status-badge.unread {
|
||||
color: var(--text);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
|
||||
.state,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-article {
|
||||
padding: 18px 0 0;
|
||||
}
|
||||
|
||||
.detail-date {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.reason-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.reason-label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(239, 68, 68, 0.95);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reason-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-active);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -12,12 +12,14 @@ import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
||||
import { sumCashbackFromStats } from '../utils/walletStats';
|
||||
import WalletBalanceCard from '../components/WalletBalanceCard.vue';
|
||||
import { useInboxFeature } from '../composables/useInboxFeature';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const { locales, setLocale, initFromUser } = useAppLocale();
|
||||
const { profileRaw, refreshProfile } = usePlayerProfile();
|
||||
const { hubRoute } = useInboxFeature();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
@@ -141,6 +143,17 @@ function logout() {
|
||||
</span>
|
||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="hubRoute" class="settings-cell settings-cell--accent-entry">
|
||||
<span class="cell-main">
|
||||
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
|
||||
<path d="m4 7 8 5.5L20 7" />
|
||||
</svg>
|
||||
<span class="cell-label">{{ t('messages.title') }}</span>
|
||||
</span>
|
||||
<span class="cell-chevron" aria-hidden="true">›</span>
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="settings-group settings-group--profile">
|
||||
|
||||
Reference in New Issue
Block a user