feat(theme-2): replace quick entries with bento grid, implement hot match carousel, limit upcoming matches list, and fix layout details

This commit is contained in:
2026-06-18 10:42:09 +08:00
parent 740799a820
commit 75bd5c3512
6 changed files with 525 additions and 116 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

View File

@@ -25,6 +25,9 @@ export default {
banner_next: 'Next slide',
banner_slide: 'Slide {n}',
banner_fallback: 'Banner',
bento_desc: 'Explore hot events worldwide',
view_all_matches: 'View all matches',
view_bet_history: 'View bet history',
},
announcements: {
title: 'Announcements',

View File

@@ -31,6 +31,9 @@ export default {
banner_next: 'Slaid seterusnya',
banner_slide: 'Slaid {n}',
banner_fallback: 'Banner',
bento_desc: 'Teroka acara sukan hangat',
view_all_matches: 'Lihat semua perlawanan',
view_bet_history: 'Lihat sejarah pertaruhan',
},
announcements: {
title: 'Pusat Pengumuman',

View File

@@ -25,6 +25,9 @@ export default {
banner_next: '下一张',
banner_slide: '第 {n} 张',
banner_fallback: 'Banner',
bento_desc: '探索全球热门体育赛事',
view_all_matches: '查看全部赛事',
view_bet_history: '查看历史投注',
},
announcements: {
title: '公告中心',

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { onActivated, computed, ref } from 'vue';
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';
@@ -16,29 +17,82 @@ 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 activeTab = ref<HotTab>('hot');
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[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const displayedMatches = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const limitedMatches = computed(() => displayedMatches.value.slice(0, 6));
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const emptyMessage = computed(() => t('home.upcoming_empty'));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
});
onActivated(() => { void load(true); });
onActivated(() => {
void load(true);
startFeaturedRotation();
});
onDeactivated(() => {
stopFeaturedRotation();
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
@@ -66,52 +120,126 @@ function formatKickoff(startTime: string) {
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<div class="quick-entries">
<button type="button" class="qe-item" @click="router.push('/bet')">
<span class="qe-icon qe-icon--bet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
<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="qe-label">{{ t('nav.bet') }}</span>
<span class="bento-title bento-title--sm">{{ t('recharge.title') }}</span>
</button>
<button type="button" class="qe-item" @click="router.push('/wallet/recharge')">
<span class="qe-icon qe-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M12 15h4"/></svg>
<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="qe-label">{{ t('recharge.title') }}</span>
</button>
<button type="button" class="qe-item" @click="router.push('/wallet/detail')">
<span class="qe-icon qe-icon--bill">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22"><path d="M9 5H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V9"/><path d="M9 5a2 2 0 012-2h2a2 2 0 012 2v0M9 12h6M9 16h4"/></svg>
</span>
<span class="qe-label">{{ t('wallet.view_all') }}</span>
<span class="bento-title bento-title--sm">{{ t('home.view_bet_history') }}</span>
</button>
</div>
<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 class="section-header">
<div class="section-title">{{ t('home.upcoming_tab') }}</div>
</div>
<div
v-for="(match, index) in displayedMatches"
v-for="(match, index) in limitedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
@@ -162,6 +290,12 @@ function formatKickoff(startTime: string) {
</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>
@@ -178,92 +312,324 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-tabs {
display: flex;
gap: 0;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
.section-header {
margin-top: 14px;
margin-bottom: 10px;
}
.hot-tab {
flex: 1;
padding: 8px 4px;
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;
}
.hot-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.quick-entries {
.quick-bento {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 54px 54px;
gap: 8px;
margin-bottom: 14px;
margin-bottom: 16px;
padding: 0 2px;
}
.qe-item {
.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: 6px;
padding: 12px 4px 10px;
border-radius: 10px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
gap: 2px;
flex: 1;
min-width: 0;
}
.qe-item:active {
transform: scale(0.96);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.qe-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.qe-icon--bet {
background: rgba(0, 61, 107, 0.1);
color: #003D6B;
}
.qe-icon--recharge {
background: rgba(5, 150, 105, 0.1);
color: #059669;
}
.qe-icon--bill {
background: rgba(248, 151, 31, 0.1);
color: #E8870A;
}
.qe-label {
font-size: 11px;
font-weight: 600;
.featured-team-name {
font-size: 9px;
font-weight: 800;
color: #1A1A2E;
text-align: center;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
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 {
@@ -537,4 +903,38 @@ function formatKickoff(startTime: string) {
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>

View File

@@ -453,7 +453,7 @@ function onPickSelection(selId: string, marketId: string) {
<style scoped>
.detail-page {
margin: 0 -16px;
padding-top: 8px;
padding-top: 0;
padding-bottom: 16px;
background: #E8EDF2;
}