0 ? 'desktop-title-vertical-marquee' : ''
+ jackpotBroadcasts.length > 0
+ ? 'desktop-title-horizontal-marquee'
+ : ''
}
>
{marqueeTitles.map((title) => (
{title.message}
diff --git a/src/features/game/components/shared/entry-notice-gate-modal.tsx b/src/features/game/components/shared/entry-notice-gate-modal.tsx
index f0dc2e7..eab4d0b 100644
--- a/src/features/game/components/shared/entry-notice-gate-modal.tsx
+++ b/src/features/game/components/shared/entry-notice-gate-modal.tsx
@@ -16,6 +16,7 @@ import {
import { getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
+import { useModalStore } from '@/store/modal'
function getLastConfirmedAt(storageKey: string) {
if (typeof localStorage === 'undefined') {
@@ -41,12 +42,21 @@ export function EntryNoticeGateModal() {
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const currentUserId = useAuthStore((state) => state.currentUser?.id)
+ const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
+ const isDesktopLoginOpen = useModalStore((state) => state.modals.desktopLogin)
+ const isDesktopRegisterOpen = useModalStore(
+ (state) => state.modals.desktopRegister,
+ )
const [hasEntered, setHasEntered] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const [shouldGateEntry, setShouldGateEntry] = useState(false)
const hasStoredLoginInfo =
authStatus === 'authenticated' && Boolean(accessToken)
+ const isReloginRequired =
+ authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
+ const isAuthModalOpen = isDesktopLoginOpen || isDesktopRegisterOpen
+ const shouldSuppressEntryGate = isReloginRequired || isAuthModalOpen
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
currentUserId ?? 'authenticated'
}`
@@ -60,7 +70,7 @@ export function EntryNoticeGateModal() {
setHasAgreed(false)
if (!hasStoredLoginInfo) {
- setShouldGateEntry(true)
+ setShouldGateEntry(!shouldSuppressEntryGate)
return
}
@@ -71,12 +81,17 @@ export function EntryNoticeGateModal() {
!lastConfirmedAt ||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
)
- }, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
+ }, [
+ authIsHydrated,
+ confirmedAtStorageKey,
+ hasStoredLoginInfo,
+ shouldSuppressEntryGate,
+ ])
const noticeListQuery = useQuery({
queryKey: ['game', 'entry-notice-list'],
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
- enabled: authIsHydrated && shouldGateEntry,
+ enabled: authIsHydrated && shouldGateEntry && !shouldSuppressEntryGate,
staleTime: 0,
})
@@ -91,6 +106,7 @@ export function EntryNoticeGateModal() {
const shouldShowModal =
authIsHydrated &&
shouldGateEntry &&
+ !shouldSuppressEntryGate &&
!hasEntered &&
(noticeListQuery.isPending ||
noticeListQuery.isError ||
diff --git a/src/features/game/components/shared/period-history-list.tsx b/src/features/game/components/shared/period-history-list.tsx
new file mode 100644
index 0000000..70376af
--- /dev/null
+++ b/src/features/game/components/shared/period-history-list.tsx
@@ -0,0 +1,113 @@
+import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
+import type { PeriodHistoryDisplayItem } from '@/features/game/hooks/use-period-history-vm'
+import { cn } from '@/lib/utils'
+
+interface PeriodHistoryListLabels {
+ empty: string
+ failed: string
+ loading: string
+ retry: string
+}
+
+interface PeriodHistoryListProps {
+ className?: string
+ isError: boolean
+ isLoading: boolean
+ items: PeriodHistoryDisplayItem[]
+ labels: PeriodHistoryListLabels
+ onRetry: () => void
+}
+
+export function PeriodHistoryList({
+ className,
+ isError,
+ isLoading,
+ items,
+ labels,
+ onRetry,
+}: PeriodHistoryListProps) {
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+ {labels.failed}
+
+
+ )
+ }
+
+ if (items.length === 0) {
+ return (
+
+ {labels.empty}
+
+ )
+ }
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.displayPeriodNo}
+
+
+
+
+ {item.displayResultNumber}
+
+
+ {item.image ? (
+
+ ) : (
+ --
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/game/components/shared/round-betting-start-alert.tsx b/src/features/game/components/shared/round-betting-start-alert.tsx
index 8ecfbd9..0a09e44 100644
--- a/src/features/game/components/shared/round-betting-start-alert.tsx
+++ b/src/features/game/components/shared/round-betting-start-alert.tsx
@@ -52,7 +52,7 @@ export function RoundBettingStartAlert({
state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status)
+ const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
+ const isReloginRequired =
+ authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
const [isHydrating, setIsHydrating] = useState(true)
const [isMobile, setIsMobile] = useState(() => {
@@ -35,6 +38,12 @@ export function EntryPage() {
})
useEffect(() => {
+ if (isReloginRequired) {
+ setIsHydrating(false)
+
+ return
+ }
+
let cancelled = false
void getGameLobbyInit()
@@ -115,6 +124,7 @@ export function EntryPage() {
authStatus,
hydrateRound,
hydrateSession,
+ isReloginRequired,
selectChip,
setCurrentUser,
syncConnection,
diff --git a/src/features/game/entry/pc-entry.tsx b/src/features/game/entry/pc-entry.tsx
index aea7873..676e225 100644
--- a/src/features/game/entry/pc-entry.tsx
+++ b/src/features/game/entry/pc-entry.tsx
@@ -8,6 +8,7 @@ import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
+import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
@@ -20,6 +21,7 @@ export function PcEntry() {
return (
<>
+
@@ -68,6 +70,8 @@ export function PcEntry() {
{/* 强制弹窗 */}
+ {/* 历史开奖信息弹窗 */}
+
>
)
}
diff --git a/src/features/game/hooks/use-animal-vm.ts b/src/features/game/hooks/use-animal-vm.ts
index 5c904a3..8729d19 100644
--- a/src/features/game/hooks/use-animal-vm.ts
+++ b/src/features/game/hooks/use-animal-vm.ts
@@ -52,6 +52,10 @@ export function useAnimalVm(
const markSoundPlaybackUnlocked = useAudioStore(
(state) => state.markSoundPlaybackUnlocked,
)
+ const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin)
+ const isRegisterModalOpen = useModalStore(
+ (state) => state.modals.desktopRegister,
+ )
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
@@ -114,6 +118,8 @@ export function useAnimalVm(
shouldConnectRealtime &&
(connection.status === 'connecting' || connection.status === 'reconnecting')
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
+ const isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen
+ const shouldAnimateStandby = showStandbyState && !isAuthModalOpen
const hasSubmittedCurrentRound =
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
const lockInteraction =
@@ -140,7 +146,7 @@ export function useAnimalVm(
}, [cellWarning])
useEffect(() => {
- if (!showStandbyState) {
+ if (!shouldAnimateStandby) {
setMarqueeId(null)
return
}
@@ -159,7 +165,7 @@ export function useAnimalVm(
return () => {
window.clearTimeout(timerId)
}
- }, [animalIds, showStandbyState])
+ }, [animalIds, shouldAnimateStandby])
const handleStart = () => {
if (authStatus !== 'authenticated') {
@@ -223,7 +229,7 @@ export function useAnimalVm(
handleStart,
isRealtimeConnecting,
lockInteraction,
- marqueeId,
+ marqueeId: shouldAnimateStandby ? marqueeId : null,
selectionByCell,
showStandbyState,
}
diff --git a/src/features/game/hooks/use-app-language.ts b/src/features/game/hooks/use-app-language.ts
index ce37ee1..6f3313a 100644
--- a/src/features/game/hooks/use-app-language.ts
+++ b/src/features/game/hooks/use-app-language.ts
@@ -1,7 +1,11 @@
import { useLocation } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
-import { LANGUAGE_OPTIONS, SUPPORTED_LANGUAGES } from '@/constants'
+import {
+ DEFAULT_APP_LANGUAGE,
+ LANGUAGE_OPTIONS,
+ SUPPORTED_LANGUAGES,
+} from '@/constants'
import type { AppLanguage } from '@/i18n'
const languagePrefixPattern = new RegExp(
@@ -22,11 +26,12 @@ export function useAppLanguage() {
const currentLanguage = (i18n.resolvedLanguage ??
i18n.language ??
- 'zh-CN') as AppLanguage
+ DEFAULT_APP_LANGUAGE) as AppLanguage
const currentLanguageOption = useMemo(
() =>
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
+ LANGUAGE_OPTIONS.find((option) => option.code === DEFAULT_APP_LANGUAGE) ??
LANGUAGE_OPTIONS[0],
[currentLanguage],
)
diff --git a/src/features/game/hooks/use-deposit-tier-list.ts b/src/features/game/hooks/use-deposit-tier-list.ts
index 78c1dc5..fce2fb0 100644
--- a/src/features/game/hooks/use-deposit-tier-list.ts
+++ b/src/features/game/hooks/use-deposit-tier-list.ts
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
+import { DEFAULT_APP_LANGUAGE } from '@/constants'
import { getDepositTierList } from '@/features/game/api'
export function useDepositTierList() {
const { i18n } = useTranslation()
- const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
+ const language =
+ i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
return useQuery({
queryKey: ['finance', 'deposit-tier-list', language],
diff --git a/src/features/game/hooks/use-deposit-withdraw-config.ts b/src/features/game/hooks/use-deposit-withdraw-config.ts
index cd6a35b..e51d1fc 100644
--- a/src/features/game/hooks/use-deposit-withdraw-config.ts
+++ b/src/features/game/hooks/use-deposit-withdraw-config.ts
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
+import { DEFAULT_APP_LANGUAGE } from '@/constants'
import { getDepositWithdrawConfig } from '@/features/game/api'
export function useDepositWithdrawConfig() {
const { i18n } = useTranslation()
- const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
+ const language =
+ i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
return useQuery({
queryKey: ['finance', 'deposit-withdraw-config', language],
diff --git a/src/features/game/hooks/use-game-realtime-sync.ts b/src/features/game/hooks/use-game-realtime-sync.ts
index a6054b2..cf3183f 100644
--- a/src/features/game/hooks/use-game-realtime-sync.ts
+++ b/src/features/game/hooks/use-game-realtime-sync.ts
@@ -678,10 +678,13 @@ function applyRealtimeMessage(message: GameSocketMessage) {
export function useGameRealtimeSync() {
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
+ const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
const shouldConnectRealtime = useGameSessionStore(
(state) => state.shouldConnectRealtime,
)
const socketClientRef = useRef
(null)
+ const isReloginRequired =
+ authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
useEffect(() => {
if (sharedSocketDisconnectTimerId !== null) {
@@ -689,6 +692,26 @@ export function useGameRealtimeSync() {
sharedSocketDisconnectTimerId = null
}
+ if (isReloginRequired) {
+ sharedSocketClient?.disconnect()
+ sharedSocketClient = null
+ sharedSocketKey = null
+ socketClientRef.current = null
+
+ const gameSession = useGameSessionStore.getState()
+
+ gameSession.resetRealtimeConnectionRequest()
+ gameSession.syncConnection({
+ lastError: null,
+ latencyMs: null,
+ reconnectAttempt: 0,
+ status: 'disconnected',
+ transport: 'offline',
+ })
+
+ return
+ }
+
if (
!shouldConnectRealtime ||
authStatus !== 'authenticated' ||
@@ -810,10 +833,14 @@ export function useGameRealtimeSync() {
}, SOCKET_DISCONNECT_DELAY_MS)
socketClientRef.current = sharedSocketClient
}
- }, [accessToken, authStatus, shouldConnectRealtime])
+ }, [accessToken, authStatus, isReloginRequired, shouldConnectRealtime])
useEffect(() => {
- if (!shouldConnectRealtime || authStatus !== 'authenticated') {
+ if (
+ isReloginRequired ||
+ !shouldConnectRealtime ||
+ authStatus !== 'authenticated'
+ ) {
return
}
@@ -869,5 +896,5 @@ export function useGameRealtimeSync() {
cancelled = true
window.clearInterval(intervalId)
}
- }, [authStatus, shouldConnectRealtime])
+ }, [authStatus, isReloginRequired, shouldConnectRealtime])
}
diff --git a/src/features/game/hooks/use-period-history-vm.ts b/src/features/game/hooks/use-period-history-vm.ts
new file mode 100644
index 0000000..4c537ad
--- /dev/null
+++ b/src/features/game/hooks/use-period-history-vm.ts
@@ -0,0 +1,73 @@
+import { useQuery } from '@tanstack/react-query'
+import { useMemo } from 'react'
+
+import {
+ type GamePeriodHistoryItemDto,
+ getGamePeriodHistory,
+} from '@/features/game/api/period-history-api'
+import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
+
+export const DEFAULT_PERIOD_HISTORY_LIMIT = 36
+
+export interface PeriodHistoryDisplayItem {
+ displayPeriodNo: string
+ displayResultNumber: string
+ image: string
+ isOdd: boolean
+ openTime: number
+ periodNo: string
+ resultNumber: number
+}
+
+function formatPeriodNo(periodNo: string) {
+ const [, timeSegment] = periodNo.split('-')
+
+ return timeSegment && timeSegment.length <= 8 ? timeSegment : periodNo
+}
+
+function formatResultNumber(number: number) {
+ return String(number).padStart(2, '0')
+}
+
+export function toPeriodHistoryDisplayItem(
+ item: GamePeriodHistoryItemDto,
+): PeriodHistoryDisplayItem {
+ return {
+ displayPeriodNo: formatPeriodNo(item.period_no),
+ displayResultNumber: formatResultNumber(item.result_number),
+ image: FLOWER_IMAGE_BY_ID[item.result_number]?.animalUrl ?? '',
+ isOdd: item.result_number % 2 === 1,
+ openTime: item.open_time,
+ periodNo: item.period_no,
+ resultNumber: item.result_number,
+ }
+}
+
+export function usePeriodHistoryVm({
+ enabled,
+ limit = DEFAULT_PERIOD_HISTORY_LIMIT,
+}: {
+ enabled: boolean
+ limit?: number
+}) {
+ const query = useQuery({
+ queryKey: ['game', 'period-history', limit],
+ enabled,
+ queryFn: () => getGamePeriodHistory({ limit }),
+ refetchOnMount: 'always',
+ staleTime: 0,
+ })
+
+ const items = useMemo(
+ () =>
+ (query.data?.list ?? []).map((item) => toPeriodHistoryDisplayItem(item)),
+ [query.data?.list],
+ )
+
+ return {
+ isError: query.isError,
+ isLoading: query.isLoading,
+ items,
+ refetch: query.refetch,
+ }
+}
diff --git a/src/features/game/modal/desktop/desktop-login-modal.tsx b/src/features/game/modal/desktop/desktop-login-modal.tsx
index 9883f2a..885af14 100644
--- a/src/features/game/modal/desktop/desktop-login-modal.tsx
+++ b/src/features/game/modal/desktop/desktop-login-modal.tsx
@@ -21,6 +21,7 @@ function DesktopLoginModal() {
}
titleAlign="center"
className={'w-design-980 h-design-540'}
+ backdropClassName="backdrop-blur-none"
>
diff --git a/src/features/game/modal/desktop/desktop-period-history-drawer.tsx b/src/features/game/modal/desktop/desktop-period-history-drawer.tsx
new file mode 100644
index 0000000..44f4bef
--- /dev/null
+++ b/src/features/game/modal/desktop/desktop-period-history-drawer.tsx
@@ -0,0 +1,181 @@
+import { X } from 'lucide-react'
+import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
+import {
+ DEFAULT_PERIOD_HISTORY_LIMIT,
+ type PeriodHistoryDisplayItem,
+ usePeriodHistoryVm,
+} from '@/features/game/hooks/use-period-history-vm'
+import { useModalStore } from '@/store'
+
+const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
+const DRAWER_TRANSITION = {
+ type: 'tween',
+ duration: 0.34,
+ ease: OVERLAY_EASE,
+} as const
+
+interface PeriodHistoryDrawerLabels {
+ close: string
+ empty: string
+ failed: string
+ loading: string
+ retry: string
+ title: string
+}
+
+interface DesktopPeriodHistoryDrawerViewProps {
+ isError: boolean
+ isLoading: boolean
+ items: PeriodHistoryDisplayItem[]
+ labels: PeriodHistoryDrawerLabels
+ onClose: () => void
+ onRetry: () => void
+ open: boolean
+}
+
+export function DesktopPeriodHistoryDrawer() {
+ const { t } = useTranslation()
+ const open = useModalStore((state) => state.modals.desktopPeriodHistory)
+ const setModalOpen = useModalStore((state) => state.setModalOpen)
+ const vm = usePeriodHistoryVm({
+ enabled: open,
+ limit: DEFAULT_PERIOD_HISTORY_LIMIT,
+ })
+ const handleClose = () => {
+ setModalOpen('desktopPeriodHistory', false)
+ }
+
+ return (
+ void vm.refetch()}
+ />
+ )
+}
+
+export function DesktopPeriodHistoryDrawerView({
+ isError,
+ isLoading,
+ items,
+ labels,
+ onClose,
+ onRetry,
+ open,
+}: DesktopPeriodHistoryDrawerViewProps) {
+ const prefersReducedMotion = useReducedMotion()
+ const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
+
+ return (
+
+ {open && (
+ <>
+
+ setIsDrawerAnimating(true)}
+ onAnimationComplete={() => setIsDrawerAnimating(false)}
+ style={
+ isDrawerAnimating
+ ? { willChange: 'transform, opacity' }
+ : undefined
+ }
+ >
+
+
+
+
+
+ {labels.title}
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/features/game/modal/desktop/desktop-register-modal.tsx b/src/features/game/modal/desktop/desktop-register-modal.tsx
index 9c5a0d8..8974d06 100644
--- a/src/features/game/modal/desktop/desktop-register-modal.tsx
+++ b/src/features/game/modal/desktop/desktop-register-modal.tsx
@@ -23,6 +23,7 @@ function DesktopRegisterModal() {
}
titleAlign="center"
className={'w-design-980 h-design-840'}
+ backdropClassName="backdrop-blur-none"
>
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 293e40c..3d457da 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -17,41 +17,6 @@ export function isSupportedLanguage(
return SUPPORTED_LANGUAGES.includes(value as AppLanguage)
}
-/** @description 从浏览器设置中推断最匹配的语言。 */
-function detectBrowserLanguage() {
- if (typeof navigator === 'undefined') {
- return DEFAULT_APP_LANGUAGE
- }
-
- const browserLanguages = [...navigator.languages, navigator.language]
-
- for (const language of browserLanguages) {
- if (isSupportedLanguage(language)) {
- return language
- }
-
- const normalizedLanguage = language.toLowerCase()
-
- if (normalizedLanguage.startsWith('zh')) {
- return 'zh-CN'
- }
-
- if (normalizedLanguage.startsWith('en')) {
- return 'en-US'
- }
-
- if (normalizedLanguage.startsWith('ms')) {
- return 'ms-MY'
- }
-
- if (normalizedLanguage.startsWith('id')) {
- return 'id-ID'
- }
- }
-
- return DEFAULT_APP_LANGUAGE
-}
-
/** @description 获取应用启动时应使用的初始语言。 */
function getInitialLanguage() {
const persistedLanguage = getStoredAppLanguage()
@@ -60,7 +25,7 @@ function getInitialLanguage() {
return persistedLanguage
}
- return detectBrowserLanguage()
+ return DEFAULT_APP_LANGUAGE
}
/** @description 暴露当前应用应优先使用的语言。 */
diff --git a/src/lib/api/api-client.ts b/src/lib/api/api-client.ts
index 41327ec..edcbd8b 100644
--- a/src/lib/api/api-client.ts
+++ b/src/lib/api/api-client.ts
@@ -2,9 +2,9 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES,
- AUTH_INVALID_TOKEN_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
+ AUTH_RELOGIN_REQUIRED_CODES,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
AUTH_TOKEN_CACHE_SKEW_MS,
AUTH_TOKEN_ENDPOINT,
@@ -210,7 +210,10 @@ function getApiEnvelopeMessage(response: ApiResponse) {
}
function assertValidAuthEnvelope(data: unknown) {
- if (!isApiEnvelope(data) || data.code !== AUTH_INVALID_TOKEN_CODE) {
+ if (
+ !isApiEnvelope(data) ||
+ !AUTH_RELOGIN_REQUIRED_CODES.includes(data.code)
+ ) {
return
}
diff --git a/src/lib/auth/auth-session.ts b/src/lib/auth/auth-session.ts
index 84f65e8..13b5b15 100644
--- a/src/lib/auth/auth-session.ts
+++ b/src/lib/auth/auth-session.ts
@@ -20,6 +20,7 @@ const LOGIN_PROMPT_DEDUP_MS = 1200
interface ClearAuthenticatedSessionOptions {
clearBrowserStorage?: boolean
+ clearQueryCache?: boolean
}
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
@@ -37,6 +38,26 @@ function clearBrowserStorageData() {
}
}
+function hasClearableSessionState() {
+ const snapshot = useAuthStore.getState()
+
+ return Boolean(
+ snapshot.status !== 'anonymous' ||
+ snapshot.accessToken ||
+ snapshot.refreshToken ||
+ snapshot.currentUser ||
+ snapshot.apiAuthToken ||
+ snapshot.apiAuthTokenExpiresAt ||
+ snapshot.apiAuthServerTime,
+ )
+}
+
+function hasRecordedUnauthorizedSession() {
+ const snapshot = useAuthStore.getState()
+
+ return snapshot.status === 'anonymous' && Boolean(snapshot.lastUnauthorizedAt)
+}
+
export function registerCurrentUserInitializer(
initializer: CurrentUserInitializer | null,
) {
@@ -57,11 +78,19 @@ export function isAuthenticated() {
export function clearAuthenticatedSession({
clearBrowserStorage = true,
+ clearQueryCache = true,
}: ClearAuthenticatedSessionOptions = {}) {
- useAuthStore.getState().markUnauthorized()
- queryClient.clear()
+ const alreadyUnauthorized = hasRecordedUnauthorizedSession()
- if (clearBrowserStorage) {
+ if (!alreadyUnauthorized) {
+ useAuthStore.getState().markUnauthorized()
+ }
+
+ if (clearQueryCache && !alreadyUnauthorized) {
+ queryClient.clear()
+ }
+
+ if (clearBrowserStorage && !alreadyUnauthorized) {
clearBrowserStorageData()
}
}
@@ -71,12 +100,21 @@ export function handleUnauthorizedSession({
openLoginModal = false,
showLoginRequiredToast = false,
}: UnauthorizedSessionOptions = {}) {
- clearAuthenticatedSession({ clearBrowserStorage })
+ clearAuthenticatedSession({
+ clearBrowserStorage,
+ clearQueryCache: hasClearableSessionState(),
+ })
if (!openLoginModal && !showLoginRequiredToast) {
return
}
+ const modalStore = openLoginModal ? useModalStore.getState() : null
+
+ if (modalStore?.modals.desktopLogin) {
+ return
+ }
+
const now = Date.now()
const shouldPrompt = now - lastLoginPromptAt > LOGIN_PROMPT_DEDUP_MS
@@ -91,10 +129,7 @@ export function handleUnauthorizedSession({
}
if (openLoginModal) {
- const modalStore = useModalStore.getState()
-
- modalStore.closeAllModals()
- modalStore.setModalOpen('desktopLogin', true)
+ modalStore?.openExclusiveModal('desktopLogin')
}
}
diff --git a/src/lib/dom/body-scroll-lock.ts b/src/lib/dom/body-scroll-lock.ts
new file mode 100644
index 0000000..bd6529d
--- /dev/null
+++ b/src/lib/dom/body-scroll-lock.ts
@@ -0,0 +1,24 @@
+let lockCount = 0
+let previousBodyOverflow: string | null = null
+
+export function acquireBodyScrollLock() {
+ if (typeof document === 'undefined') {
+ return () => undefined
+ }
+
+ if (lockCount === 0) {
+ previousBodyOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ }
+
+ lockCount += 1
+
+ return () => {
+ lockCount = Math.max(0, lockCount - 1)
+
+ if (lockCount === 0) {
+ document.body.style.overflow = previousBodyOverflow ?? ''
+ previousBodyOverflow = null
+ }
+ }
+}
diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts
index 2644ffe..c3644b3 100644
--- a/src/locales/en-US/common.ts
+++ b/src/locales/en-US/common.ts
@@ -284,6 +284,10 @@ export default {
actions: {
submitting: 'Submitting...',
},
+ passwordVisibility: {
+ hide: 'Hide password',
+ show: 'Show password',
+ },
},
login: {
actions: {
@@ -450,6 +454,14 @@ export default {
loading: 'Loading...',
settled: 'Settled',
},
+ periodHistory: {
+ title: 'Draw Result History',
+ close: 'Close draw result history',
+ empty: 'No draw results yet',
+ failed: 'Failed to load draw results',
+ loading: 'Loading...',
+ retry: 'Retry',
+ },
topup: {
title: 'Top-up Config',
platformCoinLabel: 'Platform Coin',
diff --git a/src/locales/id-ID/common.ts b/src/locales/id-ID/common.ts
index 8418b8f..1a93a2f 100644
--- a/src/locales/id-ID/common.ts
+++ b/src/locales/id-ID/common.ts
@@ -284,6 +284,10 @@ export default {
actions: {
submitting: 'Mengirim...',
},
+ passwordVisibility: {
+ hide: 'Sembunyikan kata sandi',
+ show: 'Tampilkan kata sandi',
+ },
},
login: {
actions: {
@@ -450,6 +454,14 @@ export default {
loading: 'Memuat...',
settled: 'Selesai',
},
+ periodHistory: {
+ title: 'Riwayat Hasil Undian',
+ close: 'Tutup riwayat hasil undian',
+ empty: 'Belum ada hasil undian',
+ failed: 'Gagal memuat hasil undian',
+ loading: 'Memuat...',
+ retry: 'Coba lagi',
+ },
topup: {
title: 'Konfigurasi Isi Ulang',
platformCoinLabel: 'Koin Platform',
diff --git a/src/locales/ms-MY/common.ts b/src/locales/ms-MY/common.ts
index 59bc99d..ce4e866 100644
--- a/src/locales/ms-MY/common.ts
+++ b/src/locales/ms-MY/common.ts
@@ -289,6 +289,10 @@ export default {
actions: {
submitting: 'Menghantar...',
},
+ passwordVisibility: {
+ hide: 'Sembunyikan kata laluan',
+ show: 'Tunjukkan kata laluan',
+ },
},
login: {
actions: {
@@ -455,6 +459,14 @@ export default {
loading: 'Memuatkan...',
settled: 'Selesai',
},
+ periodHistory: {
+ title: 'Sejarah Keputusan Cabutan',
+ close: 'Tutup sejarah keputusan cabutan',
+ empty: 'Belum ada keputusan cabutan',
+ failed: 'Gagal memuatkan keputusan cabutan',
+ loading: 'Memuatkan...',
+ retry: 'Cuba lagi',
+ },
topup: {
title: 'Konfigurasi Tambah Nilai',
platformCoinLabel: 'Syiling Platform',
diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts
index e21951e..2ed9e5e 100644
--- a/src/locales/zh-CN/common.ts
+++ b/src/locales/zh-CN/common.ts
@@ -272,6 +272,10 @@ export default {
actions: {
submitting: '提交中...',
},
+ passwordVisibility: {
+ hide: '隐藏密码',
+ show: '显示密码',
+ },
},
login: {
actions: {
@@ -436,6 +440,14 @@ export default {
loading: '加载中...',
settled: '已结算',
},
+ periodHistory: {
+ title: '开奖结果历史',
+ close: '关闭开奖结果历史',
+ empty: '暂无开奖结果',
+ failed: '开奖结果加载失败',
+ loading: '加载中...',
+ retry: '重试',
+ },
topup: {
title: '充值配置',
platformCoinLabel: '平台币',
diff --git a/src/store/auth/auth-store.ts b/src/store/auth/auth-store.ts
index 2e7b082..17bf8ce 100644
--- a/src/store/auth/auth-store.ts
+++ b/src/store/auth/auth-store.ts
@@ -128,6 +128,7 @@ export const useAuthStore = create()(
accessTokenExpiresAt: null,
status: 'authenticated',
isHydrated: true,
+ lastUnauthorizedAt: null,
})
},
setCurrentUser: (currentUser) => {
@@ -149,6 +150,7 @@ export const useAuthStore = create()(
refreshToken,
status: 'authenticated',
isHydrated: true,
+ lastUnauthorizedAt: null,
})
},
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
@@ -159,6 +161,7 @@ export const useAuthStore = create()(
refreshToken: refreshToken ?? state.refreshToken,
status: 'authenticated',
isHydrated: true,
+ lastUnauthorizedAt: null,
}))
},
finishHydration: () => {
@@ -180,6 +183,7 @@ export const useAuthStore = create()(
...initialPersistedState,
status: 'anonymous',
isHydrated: true,
+ lastUnauthorizedAt: null,
})
},
markUnauthorized: () => {
diff --git a/src/store/modal/modal-store.ts b/src/store/modal/modal-store.ts
index 64b21ab..41abe8d 100644
--- a/src/store/modal/modal-store.ts
+++ b/src/store/modal/modal-store.ts
@@ -12,6 +12,7 @@ export interface ModalStoreState {
modals: ModalVisibilityMap
withdrawTopupType: WithdrawTopupType
closeAllModals: () => void
+ openExclusiveModal: (key: ModalKey) => void
setModalOpen: (key: ModalKey, open: boolean) => void
setWithdrawTopupType: (type: WithdrawTopupType) => void
}
@@ -22,6 +23,14 @@ export const useModalStore = create()((set) => ({
closeAllModals: () => {
set({ modals: INITIAL_MODAL_VISIBILITY })
},
+ openExclusiveModal: (key) => {
+ set({
+ modals: {
+ ...INITIAL_MODAL_VISIBILITY,
+ [key]: true,
+ },
+ })
+ },
setModalOpen: (key, open) => {
set((state) => ({
modals: {
diff --git a/src/style/index.css b/src/style/index.css
index 574dcbf..83d2c90 100644
--- a/src/style/index.css
+++ b/src/style/index.css
@@ -194,6 +194,19 @@
}
@layer utilities {
+ .auth-password-input::-ms-clear,
+ .auth-password-input::-ms-reveal {
+ display: none;
+ }
+
+ .auth-password-input::-webkit-caps-lock-indicator,
+ .auth-password-input::-webkit-contacts-auto-fill-button,
+ .auth-password-input::-webkit-credentials-auto-fill-button {
+ display: none;
+ pointer-events: none;
+ visibility: hidden;
+ }
+
.common-neon-inset {
border: 1px solid rgba(128, 223, 231, 0.65);
border-radius: 5px;
@@ -512,20 +525,21 @@
display: none;
}
- .desktop-title-vertical-marquee {
+ .desktop-title-horizontal-marquee {
display: flex;
- flex-direction: column;
- animation: desktop-title-marquee-y 7s linear infinite;
+ width: max-content;
+ gap: calc(var(--design-unit) * 80);
+ animation: desktop-title-marquee-x 16s linear infinite;
will-change: transform;
}
- @keyframes desktop-title-marquee-y {
+ @keyframes desktop-title-marquee-x {
from {
- transform: translateY(0);
+ transform: translateX(0);
}
to {
- transform: translateY(-50%);
+ transform: translateX(-50%);
}
}