diff --git a/apps/player/src/components/BackToTopButton.vue b/apps/player/src/components/BackToTopButton.vue
new file mode 100644
index 0000000..0088661
--- /dev/null
+++ b/apps/player/src/components/BackToTopButton.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/player/src/composables/useBackToTop.ts b/apps/player/src/composables/useBackToTop.ts
new file mode 100644
index 0000000..3c8fc40
--- /dev/null
+++ b/apps/player/src/composables/useBackToTop.ts
@@ -0,0 +1,38 @@
+import { onMounted, onUnmounted, ref, watch, type Ref } from 'vue';
+
+export function useBackToTop(
+ scrollEl: Ref,
+ options?: { threshold?: number },
+) {
+ const visible = ref(false);
+ const threshold = options?.threshold ?? 320;
+
+ function onScroll() {
+ const el = scrollEl.value;
+ visible.value = !!el && el.scrollTop > threshold;
+ }
+
+ function scrollToTop() {
+ scrollEl.value?.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+
+ function bind(el: HTMLElement | null) {
+ el?.addEventListener('scroll', onScroll, { passive: true });
+ onScroll();
+ }
+
+ function unbind(el: HTMLElement | null) {
+ el?.removeEventListener('scroll', onScroll);
+ }
+
+ onMounted(() => bind(scrollEl.value));
+
+ onUnmounted(() => unbind(scrollEl.value));
+
+ watch(scrollEl, (el, prev) => {
+ unbind(prev);
+ bind(el);
+ });
+
+ return { visible, scrollToTop, onScroll };
+}
diff --git a/apps/player/src/i18n/en-US.ts b/apps/player/src/i18n/en-US.ts
index 5a89357..580607e 100644
--- a/apps/player/src/i18n/en-US.ts
+++ b/apps/player/src/i18n/en-US.ts
@@ -7,6 +7,7 @@ export default {
no_more: 'No more',
load_failed: 'Failed to load',
retry: 'Retry',
+ back_to_top: 'Back to top',
},
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
home: {
diff --git a/apps/player/src/i18n/ms-MY.ts b/apps/player/src/i18n/ms-MY.ts
index b32de58..3d5869b 100644
--- a/apps/player/src/i18n/ms-MY.ts
+++ b/apps/player/src/i18n/ms-MY.ts
@@ -7,6 +7,7 @@ export default {
no_more: 'Tiada lagi',
load_failed: 'Gagal dimuat',
retry: 'Cuba lagi',
+ back_to_top: 'Kembali ke atas',
},
nav: {
home: 'Laman Utama',
diff --git a/apps/player/src/i18n/zh-CN.ts b/apps/player/src/i18n/zh-CN.ts
index 7d92d2e..f1646eb 100644
--- a/apps/player/src/i18n/zh-CN.ts
+++ b/apps/player/src/i18n/zh-CN.ts
@@ -7,6 +7,7 @@ export default {
no_more: '没有更多了',
load_failed: '加载失败',
retry: '重试',
+ back_to_top: '回到顶部',
},
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
home: {
diff --git a/apps/player/src/layouts/MainLayout.vue b/apps/player/src/layouts/MainLayout.vue
index 339d694..ce93df2 100644
--- a/apps/player/src/layouts/MainLayout.vue
+++ b/apps/player/src/layouts/MainLayout.vue
@@ -9,6 +9,7 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
+import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
@@ -178,6 +179,8 @@ watch(
+
+