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

This commit is contained in:
2026-06-18 09:38:33 +08:00
parent ec51b675e3
commit e56029225e
86 changed files with 6828 additions and 928 deletions

View File

@@ -0,0 +1,323 @@
<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;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #002847);
}
.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-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.detail-article {
margin: 0;
padding: 0;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: var(--bg-body);
line-height: 0;
border-radius: 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(--primary-dark, #002847);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #374151;
}
.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: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.link-action--outline {
background: transparent;
border: 1px solid var(--border-active);
color: var(--primary);
}
</style>