Files
thebet365/apps/player/src/views/HomeView.vue

941 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onActivated, computed, ref, watch, onDeactivated } 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 bentoBetBg from '../assets/images/bento-bet-bg.png';
import BannerCarousel from '../components/BannerCarousel.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 bentoBetCardBg = `url(${bentoBetBg})`;
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeFeaturedIndex = ref(0);
const touchStartX = ref(0);
const touchDeltaX = ref(0);
let rotateInterval: number | undefined;
function startFeaturedRotation() {
stopFeaturedRotation();
if (hotMatches.value.length > 1) {
rotateInterval = window.setInterval(() => {
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
}, 4500);
}
}
function stopFeaturedRotation() {
if (rotateInterval) {
window.clearInterval(rotateInterval);
rotateInterval = undefined;
}
}
watch(() => hotMatches.value.length, () => {
activeFeaturedIndex.value = 0;
startFeaturedRotation();
});
function onFeaturedTouchStart(e: TouchEvent) {
touchStartX.value = e.touches[0].clientX;
touchDeltaX.value = 0;
stopFeaturedRotation();
}
function onFeaturedTouchMove(e: TouchEvent) {
touchDeltaX.value = e.touches[0].clientX - touchStartX.value;
}
function onFeaturedTouchEnd() {
const threshold = 40;
if (hotMatches.value.length > 1) {
if (touchDeltaX.value > threshold) {
activeFeaturedIndex.value = (activeFeaturedIndex.value - 1 + hotMatches.value.length) % hotMatches.value.length;
} else if (touchDeltaX.value < -threshold) {
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
}
}
touchDeltaX.value = 0;
startFeaturedRotation();
}
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const limitedMatches = computed(() => displayedMatches.value.slice(0, 6));
const emptyMessage = computed(() => t('home.upcoming_empty'));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
});
onActivated(() => {
void load(true);
startFeaturedRotation();
});
onDeactivated(() => {
stopFeaturedRotation();
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
}
</script>
<template>
<div>
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<div class="quick-bento">
<div
class="bento-item bento-item--large bento-item--featured-carousel"
@touchstart.passive="onFeaturedTouchStart"
@touchmove.passive="onFeaturedTouchMove"
@touchend="onFeaturedTouchEnd"
>
<!-- Fixed header, does not swipe/scroll -->
<div v-if="hotMatches.length" class="bento-header-row fixed-header">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="12" height="12" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="carousel-viewport">
<div
class="carousel-track"
:style="{ transform: `translateX(-${activeFeaturedIndex * 100}%)` }"
>
<template v-if="hotMatches.length">
<div
v-for="match in hotMatches"
:key="match.id"
class="carousel-slide"
@click="goMatch(match.id)"
>
<div class="bento-content--vertical">
<div class="bento-header-row placeholder-header" aria-hidden="true">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="12" height="12" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="featured-vs-area">
<div class="featured-team">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.homeTeamName }}</span>
</div>
<span class="featured-vs">VS</span>
<div class="featured-team">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
<div class="featured-footer">
<span class="featured-time">{{ formatKickoff(match.startTime) }}</span>
<span class="featured-action-btn">{{ t('announcements.go_link') }} </span>
</div>
</div>
</div>
</template>
<div v-else class="carousel-slide" @click="router.push('/bet')">
<div class="bento-content--vertical">
<div class="bento-header-row">
<span class="bento-icon bento-icon--bet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="22" height="22"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20M2 12h20"/></svg>
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="bento-text">
<span class="bento-title bento-title--lg">{{ t('nav.bet') }}</span>
<span class="bento-desc">{{ t('home.bento_desc') }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="hotMatches.length > 1" class="carousel-dots">
<span
v-for="(_, i) in hotMatches"
:key="i"
class="carousel-dot"
:class="{ active: i === activeFeaturedIndex }"
/>
</div>
</div>
<button type="button" class="bento-item bento-item--small" @click="router.push('/wallet/recharge')">
<span class="bento-icon bento-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M12 15h4"/></svg>
</span>
<span class="bento-title bento-title--sm">{{ t('recharge.title') }}</span>
</button>
<button type="button" class="bento-item bento-item--small" @click="router.push('/bets')">
<span class="bento-icon bento-icon--history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M6 4h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" stroke-linejoin="round"/>
<path d="M8 9h8M8 12.5h8M8 16h5" stroke-linecap="round"/>
</svg>
</span>
<span class="bento-title bento-title--sm">{{ t('home.view_bet_history') }}</span>
</button>
</div>
<div class="section-header">
<div class="section-title">{{ t('home.upcoming_tab') }}</div>
</div>
<div
v-for="(match, index) in limitedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
</div>
<div class="match-flags" aria-hidden="true">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<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"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
</div>
</div>
<div v-if="displayedMatches.length > 6" class="view-all-container">
<button type="button" class="view-all-link" @click="router.push('/bet')">
{{ t('home.view_all_matches') }}
</button>
</div>
<div v-if="!loading && !displayedMatches.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ emptyMessage }}</p>
</div>
</div>
</template>
<style scoped>
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.section-header {
margin-top: 14px;
margin-bottom: 10px;
}
.quick-bento {
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 54px 54px;
gap: 8px;
margin-bottom: 16px;
padding: 0 2px;
}
.bento-item {
display: flex;
align-items: center;
border-radius: 12px;
border: 1px solid #E5E7EB;
background: #FFFFFF;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
padding: 10px 12px;
text-decoration: none;
box-sizing: border-box;
}
.bento-item:active {
transform: scale(0.97);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.bento-item--large {
grid-row: 1 / 3;
height: 100%;
background: linear-gradient(135deg, #F3F8FC 0%, #FFFFFF 100%);
border-color: rgba(0, 61, 107, 0.15);
position: relative;
overflow: hidden;
padding: 0;
}
.bento-item--featured-carousel {
/* Carousel container styles */
}
.carousel-viewport {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.carousel-track {
display: flex;
height: 100%;
width: 100%;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.carousel-slide {
flex: 0 0 100%;
min-width: 100%;
height: 100%;
padding: 10px 10px 8px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.carousel-dots {
position: absolute;
bottom: 4px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 4px;
z-index: 10;
pointer-events: none;
}
.carousel-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(0, 61, 107, 0.15);
transition: background 0.25s, width 0.25s;
}
.carousel-dot.active {
width: 12px;
border-radius: 2px;
background: #003D6B;
}
.featured-tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 9px;
font-weight: 700;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
padding: 3px 6px;
border-radius: 6px;
line-height: 1;
}
.tag-icon {
color: #F97316;
}
.featured-vs-area {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
margin: 6px 0;
gap: 4px;
}
.featured-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.featured-team-name {
font-size: 9px;
font-weight: 800;
color: #1A1A2E;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.featured-vs {
font-size: 11px;
font-weight: 800;
color: #F97316;
font-style: italic;
opacity: 0.95;
flex-shrink: 0;
}
.featured-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border-top: 1px dashed rgba(0, 61, 107, 0.1);
padding-top: 5px;
margin-top: 2px;
}
.featured-time {
font-size: 8px;
color: #6B7280;
font-weight: 600;
}
.featured-action-btn {
font-size: 8.5px;
font-weight: 800;
color: #003D6B;
background: rgba(0, 61, 107, 0.05);
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s;
}
.bento-item--featured:active .featured-action-btn {
background: rgba(0, 61, 107, 0.12);
}
.bento-item--large .bento-title {
color: #003D6B;
}
.bento-item--large .bento-desc {
color: #6B7280;
}
.bento-item--large .bento-badge {
background: #F97316;
color: #FFFFFF;
}
.bento-content--vertical {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
width: 100%;
}
.bento-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
}
.fixed-header {
position: absolute;
top: 10px;
left: 12px;
right: 10px;
width: auto;
z-index: 20;
pointer-events: none;
}
.placeholder-header {
visibility: hidden;
pointer-events: none;
}
.bento-badge {
font-size: 8px;
font-weight: 800;
color: #FFFFFF;
background: #003D6B;
padding: 2px 5px;
border-radius: 4px;
letter-spacing: 0.05em;
line-height: 1;
transform: translateY(2px);
}
.bento-item--large:active {
border-color: rgba(0, 61, 107, 0.3);
}
.bento-item--small {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
justify-content: flex-start;
height: 100%;
}
.bento-content {
display: flex;
align-items: center;
gap: 10px;
}
.bento-icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bento-item--large .bento-icon {
width: 40px;
height: 40px;
border-radius: 10px;
}
.bento-icon--bet {
background: rgba(0, 61, 107, 0.08);
color: #003D6B;
}
.bento-icon--recharge {
background: rgba(5, 150, 105, 0.08);
color: #059669;
}
.bento-icon--history {
background: rgba(248, 151, 31, 0.08);
color: #E8870A;
}
.bento-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.bento-title {
font-size: 13px;
font-weight: 800;
color: #1A1A2E;
text-align: left;
}
.bento-title--sm {
font-size: 12px;
font-weight: 700;
}
.bento-title--lg {
font-size: 14.5px;
font-weight: 800;
margin-bottom: 2px;
}
.bento-desc {
font-size: 9.5px;
color: #6B7280;
font-weight: 500;
text-align: left;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #1A1A2E;
margin: 0 0 8px;
padding-left: 10px;
border-left: 3px solid #003D6B;
line-height: 1.3;
}
.match-card {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 10px 12px;
min-height: 60px;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
background: #FFFFFF;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.match-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0, 61, 107, 0.02) 0%, transparent 60%);
z-index: 0;
pointer-events: none;
}
.match-card:active {
transform: scale(0.995);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.match-info,
.match-flags {
position: relative;
z-index: 1;
}
.match-info {
flex: 1;
min-width: 0;
}
.match-teams {
font-weight: 700;
margin-bottom: 4px;
font-size: 14px;
line-height: 1.3;
color: #1A1A2E;
}
.match-time {
font-size: 12px;
color: #6B7280;
font-weight: 500;
}
.match-flags {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 3px;
max-width: 46%;
}
.vs-arena {
position: relative;
flex-shrink: 0;
width: 52px;
height: 42px;
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(0, 102, 204, 0.6)) drop-shadow(0 0 8px rgba(0, 91, 159, 0.35));
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(0, 91, 159, 0) 0%,
rgba(0, 102, 204, 0.7) 28%,
#0066CC 50%,
rgba(0, 91, 159, 0.7) 72%,
rgba(0, 61, 107, 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: 40px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 0 3px rgba(0, 61, 107, 0.25));
}
.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(0, 61, 107, 0.2));
}
50% {
opacity: 1;
filter:
drop-shadow(0 0 3px rgba(0, 102, 204, 0.5))
drop-shadow(0 0 6px rgba(0, 61, 107, 0.25));
}
}
@media (prefers-reduced-motion: reduce) {
.vs-img {
animation: none;
filter: drop-shadow(0 0 3px rgba(0, 61, 107, 0.25));
}
.hz-path,
.hz-beam {
animation: none;
opacity: 0;
}
}
.empty {
text-align: center;
color: #6B7280;
padding: 28px 16px;
font-weight: 600;
}
.empty-icon {
width: 72px;
height: 72px;
margin-bottom: 10px;
}
.view-all-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 24px;
padding: 0 2px;
}
.view-all-link {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #003D6B;
cursor: pointer;
padding: 6px 12px;
outline: none;
transition: all 0.2s ease;
}
.view-all-link:hover {
opacity: 0.85;
text-decoration: underline;
}
.view-all-link:active {
transform: scale(0.97);
opacity: 0.7;
}
</style>