fix(auth): 解决认证错误处理和会话管理问题
- 替换 AUTH_INVALID_TOKEN_CODE 为 AUTH_RELOGIN_REQUIRED_CODES 数组支持多种错误码 - 实现 hasClearableSessionState 和 hasRecordedUnauthorizedSession 函数优化会话清理逻辑 - 添加 clearQueryCache 选项控制查询缓存清理行为 - 修复马来西亚手机号正则验证模式导致的用户名验证问题 - 更新 API 错误消息处理优先级,优先使用服务端返回的消息 - 添加服务器消息检查函数 hasServerMessage 避免重复错误提示 - 在登录表单中实现密码可见性切换功能 - 添加密码可见性国际化文案支持 - 实现页面历史记录抽屉组件和相关动效 - 优化模态框背景遮罩样式和键盘事件处理 - 调整多个组件的 z-index 层级避免显示冲突
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (2566 symbols, 4898 relationships, 220 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (2639 symbols, 5033 relationships, 226 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (2566 symbols, 4898 relationships, 220 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (2639 symbols, 5033 relationships, 226 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
BIN
figma/img.jpg
Normal file
BIN
figma/img.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
2989
public/favicon.svg
2989
public/favicon.svg
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 2.6 MiB |
BIN
src/assets/system/chat.webp
Normal file
BIN
src/assets/system/chat.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -95,7 +95,7 @@ function AppLoadingOverlay({ progress }: { progress: number }) {
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-hidden bg-[#020913] text-[#D5FBFF]"
|
className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-[#020913] text-[#D5FBFF]"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_38%,rgba(35,221,255,0.18),transparent_30%),linear-gradient(180deg,#061827_0%,#020913_54%,#01050C_100%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_38%,rgba(35,221,255,0.18),transparent_30%),linear-gradient(180deg,#061827_0%,#020913_54%,#01050C_100%)]" />
|
||||||
<div className="boot-grid absolute inset-0 opacity-50" />
|
<div className="boot-grid absolute inset-0 opacity-50" />
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { type ReactNode, useEffect } from 'react'
|
import { type ReactNode, useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import modalBg from '@/assets/system/modal-bg.webp'
|
import modalBg from '@/assets/system/modal-bg.webp'
|
||||||
import modalClose from '@/assets/system/modal-close.webp'
|
import modalClose from '@/assets/system/modal-close.webp'
|
||||||
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
|
import modalNormalBg from '@/assets/system/modal-normal-bg.png'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { acquireBodyScrollLock } from '@/lib/dom/body-scroll-lock'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface CenterModalProps {
|
interface CenterModalProps {
|
||||||
@@ -16,6 +17,7 @@ interface CenterModalProps {
|
|||||||
isNormalBg?: boolean
|
isNormalBg?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
backdropClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODAL_HEADER_HEIGHT = 'calc(100% * 80 / 700)'
|
const MODAL_HEADER_HEIGHT = 'calc(100% * 80 / 700)'
|
||||||
@@ -30,44 +32,49 @@ export function CenterModal({
|
|||||||
isNormalBg = false,
|
isNormalBg = false,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
backdropClassName,
|
||||||
}: CenterModalProps) {
|
}: CenterModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof document === 'undefined') {
|
if (!open || typeof document === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousOverflow = document.body.style.overflow
|
const releaseBodyScrollLock = acquireBodyScrollLock()
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onClose?.()
|
onCloseRef.current?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.style.overflow = 'hidden'
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
if (onClose) {
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow
|
releaseBodyScrollLock()
|
||||||
if (onClose) {
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open, onClose])
|
}, [open])
|
||||||
|
|
||||||
if (!open || typeof document === 'undefined') {
|
if (!open || typeof document === 'undefined') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/72 backdrop-blur-sm">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 flex items-center justify-center bg-slate-950/72 backdrop-blur-sm',
|
||||||
|
backdropClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { type ReactNode, useEffect } from 'react'
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
|
||||||
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
import { LottiePlayer } from '@/components/lottie-player.tsx'
|
||||||
|
import { acquireBodyScrollLock } from '@/lib/dom/body-scroll-lock'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface FullscreenLottieOverlayProps {
|
interface FullscreenLottieOverlayProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
source: FullscreenLottieSource | null
|
source: FullscreenLottieSource | null
|
||||||
animationKey?: string
|
animationKey?: string
|
||||||
zIndex?: number
|
|
||||||
renderer?: RendererType
|
renderer?: RendererType
|
||||||
loop?: boolean | number
|
loop?: boolean | number
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
@@ -38,7 +38,6 @@ export function FullscreenLottieOverlay({
|
|||||||
open,
|
open,
|
||||||
source,
|
source,
|
||||||
animationKey,
|
animationKey,
|
||||||
zIndex = 80,
|
|
||||||
renderer = 'svg',
|
renderer = 'svg',
|
||||||
loop,
|
loop,
|
||||||
autoplay,
|
autoplay,
|
||||||
@@ -58,12 +57,7 @@ export function FullscreenLottieOverlay({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousOverflow = document.body.style.overflow
|
return acquireBodyScrollLock()
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = previousOverflow
|
|
||||||
}
|
|
||||||
}, [lockBodyScroll, open])
|
}, [lockBodyScroll, open])
|
||||||
|
|
||||||
if (!open || !source || typeof document === 'undefined') {
|
if (!open || !source || typeof document === 'undefined') {
|
||||||
@@ -75,10 +69,9 @@ export function FullscreenLottieOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 flex items-center justify-center overflow-hidden',
|
'fixed inset-0 z-40 flex items-center justify-center overflow-hidden',
|
||||||
backdropClassName,
|
backdropClassName,
|
||||||
)}
|
)}
|
||||||
style={{ zIndex }}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function AppNotificationAlert() {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="pointer-events-none fixed inset-x-0 top-[calc(var(--design-unit)*52)] z-[10000] flex justify-center px-4">
|
<div className="pointer-events-none fixed inset-x-0 top-[calc(var(--design-unit)*52)] z-50 flex justify-center px-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeDialog.id}
|
key={activeDialog.id}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export const AUTH_ENDPOINTS = {
|
|||||||
/** @description 后端返回该 code 表示登录态 token 无效或已过期。 */
|
/** @description 后端返回该 code 表示登录态 token 无效或已过期。 */
|
||||||
export const AUTH_INVALID_TOKEN_CODE = 1101
|
export const AUTH_INVALID_TOKEN_CODE = 1101
|
||||||
|
|
||||||
|
/** @description 后端返回这些 code 时需要清空当前状态并触发用户重新登录。 */
|
||||||
|
export const AUTH_RELOGIN_REQUIRED_CODES: readonly number[] = [
|
||||||
|
AUTH_INVALID_TOKEN_CODE,
|
||||||
|
1103,
|
||||||
|
303,
|
||||||
|
]
|
||||||
|
|
||||||
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
|
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
|
||||||
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const GAME_API_ENDPOINTS = {
|
|||||||
noticeConfirm: 'api/notice/noticeConfirm',
|
noticeConfirm: 'api/notice/noticeConfirm',
|
||||||
noticeDetail: 'api/notice/noticeDetail',
|
noticeDetail: 'api/notice/noticeDetail',
|
||||||
noticeList: 'api/notice/noticeList',
|
noticeList: 'api/notice/noticeList',
|
||||||
|
periodHistory: 'api/game/periodHistory',
|
||||||
placeBet: 'api/game/placeBet',
|
placeBet: 'api/game/placeBet',
|
||||||
roundFeed: 'game/round-feed',
|
roundFeed: 'game/round-feed',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
|
|||||||
export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
|
export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
|
||||||
|
|
||||||
/** @description 应用无法解析用户语言时使用的默认语言。 */
|
/** @description 应用无法解析用户语言时使用的默认语言。 */
|
||||||
export const DEFAULT_APP_LANGUAGE = 'zh-CN'
|
export const DEFAULT_APP_LANGUAGE = 'en-US'
|
||||||
|
|
||||||
/** @description 语言切换面板展示的语言选项配置。 */
|
/** @description 语言切换面板展示的语言选项配置。 */
|
||||||
export const LANGUAGE_OPTIONS: Array<{
|
export const LANGUAGE_OPTIONS: Array<{
|
||||||
@@ -112,6 +112,7 @@ export const MODAL_KEYS = [
|
|||||||
'desktopAutoSetting',
|
'desktopAutoSetting',
|
||||||
'desktopProcedures',
|
'desktopProcedures',
|
||||||
'desktopWithdrawTopup',
|
'desktopWithdrawTopup',
|
||||||
|
'desktopPeriodHistory',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** @description 全局弹窗默认可见状态。 */
|
/** @description 全局弹窗默认可见状态。 */
|
||||||
@@ -125,4 +126,5 @@ export const INITIAL_MODAL_VISIBILITY = {
|
|||||||
desktopAutoSetting: false,
|
desktopAutoSetting: false,
|
||||||
desktopProcedures: false,
|
desktopProcedures: false,
|
||||||
desktopWithdrawTopup: false,
|
desktopWithdrawTopup: false,
|
||||||
|
desktopPeriodHistory: false,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ function unwrapEnvelope<T>(
|
|||||||
|
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
data: response,
|
data: response,
|
||||||
message: fallbackErrorKey,
|
message:
|
||||||
|
typeof response.msg === 'string' && response.msg.length > 0
|
||||||
|
? response.msg
|
||||||
|
: typeof response.message === 'string' && response.message.length > 0
|
||||||
|
? response.message
|
||||||
|
: fallbackErrorKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import rightImg from '@/assets/system/right.webp'
|
import rightImg from '@/assets/system/right.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function DesktopAuthFieldRow({
|
export function DesktopAuthFieldRow({
|
||||||
@@ -67,6 +69,42 @@ export function DesktopAuthFooterLinks({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
|
||||||
|
|
||||||
|
export function DesktopAuthPasswordInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DesktopAuthPasswordInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
type={isVisible ? 'text' : 'password'}
|
||||||
|
className={cn('auth-password-input pr-design-52', className)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t(
|
||||||
|
isVisible
|
||||||
|
? 'auth.common.passwordVisibility.hide'
|
||||||
|
: 'auth.common.passwordVisibility.show',
|
||||||
|
)}
|
||||||
|
onClick={() => setIsVisible((value) => !value)}
|
||||||
|
className="absolute right-design-12 top-1/2 flex h-design-34 w-design-34 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-[#A9E8EA] transition-colors duration-200 ease-out hover:text-[#F2FFFF] focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.4)]"
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<EyeOff aria-hidden="true" className="h-design-22 w-design-22" />
|
||||||
|
) : (
|
||||||
|
<Eye aria-hidden="true" className="h-design-22 w-design-22" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DesktopAuthSubmitError({
|
export function DesktopAuthSubmitError({
|
||||||
message,
|
message,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input.tsx'
|
|||||||
import {
|
import {
|
||||||
DesktopAuthFieldRow,
|
DesktopAuthFieldRow,
|
||||||
DesktopAuthInputError,
|
DesktopAuthInputError,
|
||||||
DesktopAuthSubmitError,
|
DesktopAuthPasswordInput,
|
||||||
} from './desktop-auth-form-parts'
|
} from './desktop-auth-form-parts'
|
||||||
|
|
||||||
interface DesktopLoginFormViewProps {
|
interface DesktopLoginFormViewProps {
|
||||||
@@ -20,7 +20,6 @@ interface DesktopLoginFormViewProps {
|
|||||||
onSwitchToRegister: () => void
|
onSwitchToRegister: () => void
|
||||||
onUsernameChange: (value: string) => void
|
onUsernameChange: (value: string) => void
|
||||||
password: string
|
password: string
|
||||||
submitError?: string | null
|
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export function DesktopLoginFormView({
|
|||||||
onSwitchToRegister,
|
onSwitchToRegister,
|
||||||
onUsernameChange,
|
onUsernameChange,
|
||||||
password,
|
password,
|
||||||
submitError,
|
|
||||||
username,
|
username,
|
||||||
}: DesktopLoginFormViewProps) {
|
}: DesktopLoginFormViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -89,10 +87,9 @@ export function DesktopLoginFormView({
|
|||||||
</DesktopAuthFieldRow>
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
|
<DesktopAuthFieldRow label={t('auth.login.fields.password.label')}>
|
||||||
<Input
|
<DesktopAuthPasswordInput
|
||||||
id="desktop-login-password"
|
id="desktop-login-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => onPasswordChange(event.target.value)}
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
@@ -116,9 +113,6 @@ export function DesktopLoginFormView({
|
|||||||
</DesktopAuthFieldRow>
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-design-18">
|
<div className="mt-auto flex flex-col gap-design-18">
|
||||||
<DesktopAuthSubmitError
|
|
||||||
message={submitError ? t(submitError) : undefined}
|
|
||||||
/>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||||
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ interface DesktopLoginFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
||||||
const { form, isSubmitting, onSubmit, submitError } = useLoginForm({
|
const { form, isSubmitting, onSubmit } = useLoginForm({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
})
|
})
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
|
||||||
const usernameField = useController({
|
const usernameField = useController({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: 'username',
|
name: 'username',
|
||||||
@@ -22,8 +22,7 @@ export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleSwitchToRegister() {
|
function handleSwitchToRegister() {
|
||||||
setModalOpen('desktopLogin', false)
|
openExclusiveModal('desktopRegister')
|
||||||
setModalOpen('desktopRegister', true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +38,6 @@ export function DesktopLoginForm({ onSuccess }: DesktopLoginFormProps) {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onSwitchToRegister={handleSwitchToRegister}
|
onSwitchToRegister={handleSwitchToRegister}
|
||||||
onUsernameChange={usernameField.field.onChange}
|
onUsernameChange={usernameField.field.onChange}
|
||||||
submitError={submitError}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input.tsx'
|
|||||||
import {
|
import {
|
||||||
DesktopAuthFieldRow,
|
DesktopAuthFieldRow,
|
||||||
DesktopAuthInputError,
|
DesktopAuthInputError,
|
||||||
DesktopAuthSubmitError,
|
DesktopAuthPasswordInput,
|
||||||
} from './desktop-auth-form-parts'
|
} from './desktop-auth-form-parts'
|
||||||
|
|
||||||
interface DesktopRegisterFormViewProps {
|
interface DesktopRegisterFormViewProps {
|
||||||
@@ -26,7 +26,6 @@ interface DesktopRegisterFormViewProps {
|
|||||||
onUsernameChange: (value: string) => void
|
onUsernameChange: (value: string) => void
|
||||||
password: string
|
password: string
|
||||||
confirmPassword: string
|
confirmPassword: string
|
||||||
submitError?: string | null
|
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ export function DesktopRegisterFormView({
|
|||||||
onSwitchToLogin,
|
onSwitchToLogin,
|
||||||
onUsernameChange,
|
onUsernameChange,
|
||||||
password,
|
password,
|
||||||
submitError,
|
|
||||||
username,
|
username,
|
||||||
}: DesktopRegisterFormViewProps) {
|
}: DesktopRegisterFormViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -99,10 +97,9 @@ export function DesktopRegisterFormView({
|
|||||||
</DesktopAuthFieldRow>
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
|
<DesktopAuthFieldRow label={t('auth.register.fields.password.label')}>
|
||||||
<Input
|
<DesktopAuthPasswordInput
|
||||||
id="desktop-register-password"
|
id="desktop-register-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => onPasswordChange(event.target.value)}
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
@@ -128,10 +125,9 @@ export function DesktopRegisterFormView({
|
|||||||
<DesktopAuthFieldRow
|
<DesktopAuthFieldRow
|
||||||
label={t('auth.register.fields.confirmPassword.label')}
|
label={t('auth.register.fields.confirmPassword.label')}
|
||||||
>
|
>
|
||||||
<Input
|
<DesktopAuthPasswordInput
|
||||||
id="desktop-register-confirm-password"
|
id="desktop-register-confirm-password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
||||||
@@ -191,10 +187,6 @@ export function DesktopRegisterFormView({
|
|||||||
</DesktopAuthFieldRow>
|
</DesktopAuthFieldRow>
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col">
|
<div className="mt-auto flex flex-col">
|
||||||
<DesktopAuthSubmitError
|
|
||||||
message={submitError ? t(submitError) : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||||
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
className="flex items-center justify-center gap-design-12 text-center text-design-20 text-[#6DB5B9]"
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ interface DesktopRegisterFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
||||||
const { form, isSubmitting, onSubmit, submitError } = useRegisterForm({
|
const { form, isSubmitting, onSubmit } = useRegisterForm({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
})
|
})
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
|
||||||
const usernameField = useController({
|
const usernameField = useController({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: 'username',
|
name: 'username',
|
||||||
@@ -30,8 +30,7 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleSwitchToLogin() {
|
function handleSwitchToLogin() {
|
||||||
setModalOpen('desktopRegister', false)
|
openExclusiveModal('desktopLogin')
|
||||||
setModalOpen('desktopLogin', true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +52,6 @@ export function DesktopRegisterForm({ onSuccess }: DesktopRegisterFormProps) {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onSwitchToLogin={handleSwitchToLogin}
|
onSwitchToLogin={handleSwitchToLogin}
|
||||||
onUsernameChange={usernameField.field.onChange}
|
onUsernameChange={usernameField.field.onChange}
|
||||||
submitError={submitError}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ function fallbackKeyByContext(context: AuthSubmitContext) {
|
|||||||
: 'auth.register.errors.submitFailed'
|
: 'auth.register.errors.submitFailed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasServerMessage(error: ApiError) {
|
||||||
|
if (!error.data || typeof error.data !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = error.data
|
||||||
|
const serverMessage =
|
||||||
|
'msg' in data && typeof data.msg === 'string'
|
||||||
|
? data.msg
|
||||||
|
: 'message' in data && typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: null
|
||||||
|
|
||||||
|
return Boolean(serverMessage && serverMessage === error.message)
|
||||||
|
}
|
||||||
|
|
||||||
export function toAuthSubmitErrorKey(
|
export function toAuthSubmitErrorKey(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
context: AuthSubmitContext,
|
context: AuthSubmitContext,
|
||||||
@@ -28,6 +44,10 @@ export function toAuthSubmitErrorKey(
|
|||||||
return error.message
|
return error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasServerMessage(error)) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
if (error.status === 408) {
|
if (error.status === 408) {
|
||||||
return 'auth.errors.timeout'
|
return 'auth.errors.timeout'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const malaysiaMobilePhonePattern = /^60\d{1,9}$/
|
|
||||||
|
|
||||||
const usernameSchema = z
|
const usernameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, 'auth.validation.username.required')
|
.min(1, 'auth.validation.username.required')
|
||||||
.regex(malaysiaMobilePhonePattern, 'auth.validation.username.invalidPhone')
|
|
||||||
|
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ function unwrapGameEnvelope<T>(
|
|||||||
message:
|
message:
|
||||||
typeof response.msg === 'string' && response.msg.length > 0
|
typeof response.msg === 'string' && response.msg.length > 0
|
||||||
? response.msg
|
? response.msg
|
||||||
: fallbackMessage,
|
: typeof response.message === 'string' && response.message.length > 0
|
||||||
|
? response.message
|
||||||
|
: fallbackMessage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/features/game/api/period-history-api.ts
Normal file
47
src/features/game/api/period-history-api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { GAME_API_ENDPOINTS } from '@/constants'
|
||||||
|
import { api } from '@/lib/api/api-client'
|
||||||
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
|
import type { ApiResponse } from '@/type'
|
||||||
|
|
||||||
|
export interface GamePeriodHistoryItemDto {
|
||||||
|
open_time: number
|
||||||
|
period_no: string
|
||||||
|
result_number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GamePeriodHistoryDto {
|
||||||
|
list: GamePeriodHistoryItemDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapPeriodHistoryEnvelope(
|
||||||
|
response: ApiResponse<GamePeriodHistoryDto>,
|
||||||
|
) {
|
||||||
|
if (response.code === 1) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
data: response,
|
||||||
|
message:
|
||||||
|
typeof response.msg === 'string' && response.msg.length > 0
|
||||||
|
? response.msg
|
||||||
|
: typeof response.message === 'string' && response.message.length > 0
|
||||||
|
? response.message
|
||||||
|
: 'Failed to load period history',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGamePeriodHistory(params: { limit?: number } = {}) {
|
||||||
|
const response = await api.get<GamePeriodHistoryDto>(
|
||||||
|
GAME_API_ENDPOINTS.periodHistory,
|
||||||
|
{
|
||||||
|
searchParams: {
|
||||||
|
limit: String(params.limit ?? 30),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return unwrapPeriodHistoryEnvelope(
|
||||||
|
response as ApiResponse<GamePeriodHistoryDto>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -346,7 +346,7 @@ export function DesktopAnimalOverlay({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px] transition-opacity duration-300',
|
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px] transition-opacity duration-300',
|
||||||
isRewardFadingOut && 'pointer-events-none opacity-0',
|
isRewardFadingOut && 'pointer-events-none opacity-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -399,14 +399,14 @@ export function DesktopAnimalOverlay({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 z-50 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
|
className="absolute inset-0 z-40 bg-[rgba(2,8,14,0.72)] backdrop-blur-[2px]"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostingFlag) {
|
if (hostingFlag) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">
|
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">
|
||||||
{showStopOverlay ? (
|
{showStopOverlay ? (
|
||||||
<div className="relative h-design-170 w-design-520 max-w-[72%] overflow-visible">
|
<div className="relative h-design-170 w-design-520 max-w-[72%] overflow-visible">
|
||||||
<SmartImage
|
<SmartImage
|
||||||
@@ -477,7 +477,7 @@ export function DesktopAnimalOverlay({
|
|||||||
|
|
||||||
if (showStopOverlay) {
|
if (showStopOverlay) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
|
<div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]">
|
||||||
<div className="flex max-w-[88%] flex-col items-center justify-center gap-design-18">
|
<div className="flex max-w-[88%] flex-col items-center justify-center gap-design-18">
|
||||||
<StopBetSummary
|
<StopBetSummary
|
||||||
items={stopBetItems}
|
items={stopBetItems}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ import { SmartImage } from '@/components/smart-image.tsx'
|
|||||||
import { ACTION_OPTIONS } from '@/constants'
|
import { ACTION_OPTIONS } from '@/constants'
|
||||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
export function DesktopControl() {
|
export function DesktopControl() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
const {
|
const {
|
||||||
acceptingBets,
|
acceptingBets,
|
||||||
actionsEnabled,
|
actionsEnabled,
|
||||||
@@ -112,10 +115,14 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
src={leftBottomBg}
|
src={leftBottomBg}
|
||||||
size="100% 100%"
|
size="100% 100%"
|
||||||
|
onClick={() => setModalOpen('desktopPeriodHistory', true)}
|
||||||
|
aria-label={t('gameDesktop.periodHistory.title')}
|
||||||
className={
|
className={
|
||||||
'flex flex-col items-center gap-1 justify-center h-full w-design-110 shrink-0 bg-center bg-no-repeat'
|
'flex flex-col items-center gap-1 justify-center h-full w-design-110 shrink-0 cursor-pointer bg-center bg-no-repeat transition-[filter] duration-200 hover:brightness-125 focus-visible:ring-2 focus-visible:ring-[#4FEAFF]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'flex flex-col items-center justify-center'}>
|
<div className={'flex flex-col items-center justify-center'}>
|
||||||
@@ -254,7 +261,7 @@ export function DesktopControl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
|
transition={{ type: 'spring', stiffness: 380, damping: 24 }}
|
||||||
className={'relative z-[1]'}
|
className={'relative z-10'}
|
||||||
>
|
>
|
||||||
<motion.img
|
<motion.img
|
||||||
src={chip.src}
|
src={chip.src}
|
||||||
@@ -267,12 +274,12 @@ export function DesktopControl() {
|
|||||||
src={chipLock}
|
src={chipLock}
|
||||||
alt="chip-locked"
|
alt="chip-locked"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="pointer-events-none absolute left-1/2 top-1/2 z-[12] h-design-54 w-design-54 -translate-x-1/2 -translate-y-1/2 object-contain"
|
className="pointer-events-none absolute left-1/2 top-1/2 z-40 h-design-54 w-design-54 -translate-x-1/2 -translate-y-1/2 object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
'pointer-events-none absolute inset-x-0 top-1/2 z-[8] -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
|
'pointer-events-none absolute inset-x-0 top-1/2 z-20 -translate-y-[calc(50%-1*var(--design-unit))] text-center text-design-16 font-black leading-none tracking-[0.06em] text-[rgba(96,54,0,0.85)] blur-[1px]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{chip.valueLabel}
|
{chip.valueLabel}
|
||||||
@@ -286,7 +293,7 @@ export function DesktopControl() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
'pointer-events-none absolute inset-x-0 top-1/2 z-[11] -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
|
'pointer-events-none absolute inset-x-0 top-1/2 z-30 -translate-y-1/2 text-center text-design-16 font-black leading-none tracking-[0.06em] text-white [text-shadow:0_1px_0_rgba(255,255,255,0.6),0_2px_4px_rgba(0,0,0,0.72),0_0_10px_rgba(255,255,255,0.22)]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{chip.valueLabel}
|
{chip.valueLabel}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function DesktopStatusLine() {
|
|||||||
<div className={'relative w-full flex flex-col text-design-22'}>
|
<div className={'relative w-full flex flex-col text-design-22'}>
|
||||||
{/*<div*/}
|
{/*<div*/}
|
||||||
{/* className={*/}
|
{/* className={*/}
|
||||||
{/* 'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'*/}
|
|
||||||
{/* }*/}
|
{/* }*/}
|
||||||
{/*>*/}
|
{/*>*/}
|
||||||
{/* <DesktopTitle />*/}
|
{/* <DesktopTitle />*/}
|
||||||
@@ -66,9 +65,7 @@ export function DesktopStatusLine() {
|
|||||||
>
|
>
|
||||||
{showStreakLimitOnly && (
|
{showStreakLimitOnly && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={'pointer-events-none absolute bg-center bg-no-repeat'}
|
||||||
'pointer-events-none absolute z-0 bg-center bg-no-repeat'
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
top: 'calc(var(--design-unit)*-14)',
|
top: 'calc(var(--design-unit)*-14)',
|
||||||
right: 'calc(var(--design-unit)*-190)',
|
right: 'calc(var(--design-unit)*-190)',
|
||||||
@@ -138,7 +135,7 @@ export function DesktopStatusLine() {
|
|||||||
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
|
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
|
||||||
<SmartBackground
|
<SmartBackground
|
||||||
src={statusCenter}
|
src={statusCenter}
|
||||||
className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
|
className="pointer-events-none absolute inset-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
|
||||||
size="contain"
|
size="contain"
|
||||||
style={{
|
style={{
|
||||||
opacity: showWarningCountdown ? 0.18 : 1,
|
opacity: showWarningCountdown ? 0.18 : 1,
|
||||||
@@ -146,7 +143,7 @@ export function DesktopStatusLine() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 z-0 overflow-visible transition-all duration-500 ease-out"
|
className="pointer-events-none absolute inset-0 overflow-visible transition-all duration-500 ease-out"
|
||||||
style={{
|
style={{
|
||||||
opacity: showWarningCountdown ? 1 : 0,
|
opacity: showWarningCountdown ? 1 : 0,
|
||||||
transform: showWarningCountdown
|
transform: showWarningCountdown
|
||||||
|
|||||||
@@ -2,6 +2,30 @@ import broadcast from '@/assets/system/broadcast.webp'
|
|||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { useGameSessionStore } from '@/store/game'
|
import { useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
|
const winAmountFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
})
|
||||||
|
|
||||||
|
function maskParticipantLabel(value: number | string) {
|
||||||
|
const label = String(value).trim()
|
||||||
|
|
||||||
|
if (label.length <= 1) {
|
||||||
|
return '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLength = Math.ceil(label.length / 2)
|
||||||
|
|
||||||
|
return `${label.slice(0, visibleLength)}${'*'.repeat(
|
||||||
|
label.length - visibleLength,
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWinAmount(value: string) {
|
||||||
|
const amount = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value
|
||||||
|
}
|
||||||
|
|
||||||
export function DesktopTitle() {
|
export function DesktopTitle() {
|
||||||
const jackpotBroadcasts = useGameSessionStore(
|
const jackpotBroadcasts = useGameSessionStore(
|
||||||
(state) => state.jackpotBroadcasts,
|
(state) => state.jackpotBroadcasts,
|
||||||
@@ -10,7 +34,9 @@ export function DesktopTitle() {
|
|||||||
jackpotBroadcasts.length > 0
|
jackpotBroadcasts.length > 0
|
||||||
? jackpotBroadcasts.map((broadcast) => ({
|
? jackpotBroadcasts.map((broadcast) => ({
|
||||||
id: broadcast.id,
|
id: broadcast.id,
|
||||||
message: broadcast.message,
|
message: `Player ${maskParticipantLabel(
|
||||||
|
broadcast.userId,
|
||||||
|
)} won ${formatWinAmount(broadcast.totalWin)}`,
|
||||||
}))
|
}))
|
||||||
: [{ id: 'empty', message: '' }]
|
: [{ id: 'empty', message: '' }]
|
||||||
const marqueeTitles =
|
const marqueeTitles =
|
||||||
@@ -31,12 +57,14 @@ export function DesktopTitle() {
|
|||||||
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
|
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
jackpotBroadcasts.length > 0 ? 'desktop-title-vertical-marquee' : ''
|
jackpotBroadcasts.length > 0
|
||||||
|
? 'desktop-title-horizontal-marquee'
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{marqueeTitles.map((title) => (
|
{marqueeTitles.map((title) => (
|
||||||
<div
|
<div
|
||||||
className="flex h-design-28 items-center whitespace-nowrap !text-[#FF970F]"
|
className="flex h-design-28 shrink-0 items-center whitespace-nowrap !text-[#FF970F]"
|
||||||
key={title.id}
|
key={title.id}
|
||||||
>
|
>
|
||||||
{title.message}
|
{title.message}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { getNoticeList } from '@/features/game/api'
|
import { getNoticeList } from '@/features/game/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useModalStore } from '@/store/modal'
|
||||||
|
|
||||||
function getLastConfirmedAt(storageKey: string) {
|
function getLastConfirmedAt(storageKey: string) {
|
||||||
if (typeof localStorage === 'undefined') {
|
if (typeof localStorage === 'undefined') {
|
||||||
@@ -41,12 +42,21 @@ export function EntryNoticeGateModal() {
|
|||||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
const currentUserId = useAuthStore((state) => state.currentUser?.id)
|
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 [hasEntered, setHasEntered] = useState(false)
|
||||||
const [hasAgreed, setHasAgreed] = useState(false)
|
const [hasAgreed, setHasAgreed] = useState(false)
|
||||||
const [shouldGateEntry, setShouldGateEntry] = useState(false)
|
const [shouldGateEntry, setShouldGateEntry] = useState(false)
|
||||||
|
|
||||||
const hasStoredLoginInfo =
|
const hasStoredLoginInfo =
|
||||||
authStatus === 'authenticated' && Boolean(accessToken)
|
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}:${
|
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
|
||||||
currentUserId ?? 'authenticated'
|
currentUserId ?? 'authenticated'
|
||||||
}`
|
}`
|
||||||
@@ -60,7 +70,7 @@ export function EntryNoticeGateModal() {
|
|||||||
setHasAgreed(false)
|
setHasAgreed(false)
|
||||||
|
|
||||||
if (!hasStoredLoginInfo) {
|
if (!hasStoredLoginInfo) {
|
||||||
setShouldGateEntry(true)
|
setShouldGateEntry(!shouldSuppressEntryGate)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,12 +81,17 @@ export function EntryNoticeGateModal() {
|
|||||||
!lastConfirmedAt ||
|
!lastConfirmedAt ||
|
||||||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||||
)
|
)
|
||||||
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
|
}, [
|
||||||
|
authIsHydrated,
|
||||||
|
confirmedAtStorageKey,
|
||||||
|
hasStoredLoginInfo,
|
||||||
|
shouldSuppressEntryGate,
|
||||||
|
])
|
||||||
|
|
||||||
const noticeListQuery = useQuery({
|
const noticeListQuery = useQuery({
|
||||||
queryKey: ['game', 'entry-notice-list'],
|
queryKey: ['game', 'entry-notice-list'],
|
||||||
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
|
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
|
||||||
enabled: authIsHydrated && shouldGateEntry,
|
enabled: authIsHydrated && shouldGateEntry && !shouldSuppressEntryGate,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,6 +106,7 @@ export function EntryNoticeGateModal() {
|
|||||||
const shouldShowModal =
|
const shouldShowModal =
|
||||||
authIsHydrated &&
|
authIsHydrated &&
|
||||||
shouldGateEntry &&
|
shouldGateEntry &&
|
||||||
|
!shouldSuppressEntryGate &&
|
||||||
!hasEntered &&
|
!hasEntered &&
|
||||||
(noticeListQuery.isPending ||
|
(noticeListQuery.isPending ||
|
||||||
noticeListQuery.isError ||
|
noticeListQuery.isError ||
|
||||||
|
|||||||
113
src/features/game/components/shared/period-history-list.tsx
Normal file
113
src/features/game/components/shared/period-history-list.tsx
Normal file
@@ -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 (
|
||||||
|
<DataLoadingIndicator label={labels.loading} className="min-h-full" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col items-center justify-center gap-design-16 text-design-18 text-[#8DBCC2]">
|
||||||
|
<span>{labels.failed}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer rounded-[calc(var(--design-unit)*5)] border border-[rgba(85,236,255,0.48)] px-design-20 py-design-8 text-design-16 text-[#D5FBFF] transition-colors duration-200 hover:bg-[rgba(85,236,255,0.12)]"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{labels.retry}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center text-design-18 text-[#8DBCC2]">
|
||||||
|
{labels.empty}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid grid-cols-4 gap-design-16', className)}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.periodNo}-${item.openTime}`}
|
||||||
|
className="relative grid h-design-70 min-w-0 grid-cols-[minmax(calc(var(--design-unit)*70),1fr)_auto_auto] items-center gap-design-12 overflow-hidden rounded-[calc(var(--design-unit)*5)] border border-[rgba(92,221,242,0.38)] bg-[linear-gradient(180deg,rgba(9,28,43,0.9),rgba(4,15,26,0.92))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(70,221,255,0.12),0_0_calc(var(--design-unit)*8)_rgba(53,212,255,0.08)]"
|
||||||
|
title={item.periodNo}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-design-16 font-semibold text-white">
|
||||||
|
{item.displayPeriodNo}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-design-50 w-design-58 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] text-design-30 font-black leading-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-design-13 before:w-design-13 before:rounded-tl-[calc(var(--design-unit)*6)] before:border-l before:border-t after:pointer-events-none after:absolute after:right-0 after:top-0 after:h-design-13 after:w-design-13 after:rounded-tr-[calc(var(--design-unit)*6)] after:border-r after:border-t',
|
||||||
|
item.isOdd
|
||||||
|
? 'bg-[rgba(54,5,15,0.5)] text-[#FF3959] shadow-[0_0_calc(var(--design-unit)*12)_rgba(255,55,81,0.2),inset_0_0_calc(var(--design-unit)*10)_rgba(255,55,81,0.12)] before:border-[rgba(255,55,81,0.86)] after:border-[rgba(255,55,81,0.86)]'
|
||||||
|
: 'bg-[rgba(9,28,75,0.56)] text-[#4B91FF] shadow-[0_0_calc(var(--design-unit)*12)_rgba(69,137,255,0.22),inset_0_0_calc(var(--design-unit)*10)_rgba(69,137,255,0.16)] before:border-[rgba(69,137,255,0.9)] after:border-[rgba(69,137,255,0.9)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute bottom-0 left-0 h-design-13 w-design-13 rounded-bl-[calc(var(--design-unit)*6)] border-b border-l',
|
||||||
|
item.isOdd
|
||||||
|
? 'border-[rgba(255,55,81,0.86)]'
|
||||||
|
: 'border-[rgba(69,137,255,0.9)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute bottom-0 right-0 h-design-13 w-design-13 rounded-br-[calc(var(--design-unit)*6)] border-b border-r',
|
||||||
|
item.isOdd
|
||||||
|
? 'border-[rgba(255,55,81,0.86)]'
|
||||||
|
: 'border-[rgba(69,137,255,0.9)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.displayResultNumber}
|
||||||
|
</span>
|
||||||
|
<span className="flex h-design-50 w-design-72 shrink-0 items-center justify-end overflow-hidden">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.displayResultNumber}
|
||||||
|
draggable={false}
|
||||||
|
className="h-full w-auto max-w-none shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-design-13 text-[#668C92]">--</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export function RoundBettingStartAlert({
|
|||||||
<motion.div
|
<motion.div
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none left-1/2 top-1/2 z-50 w-[min(calc(var(--design-unit)*560),88vw)] -translate-x-1/2 -translate-y-1/2',
|
'pointer-events-none left-1/2 top-1/2 z-30 w-[min(calc(var(--design-unit)*560),88vw)] -translate-x-1/2 -translate-y-1/2',
|
||||||
placement === 'fixed' ? 'fixed' : 'absolute',
|
placement === 'fixed' ? 'fixed' : 'absolute',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export function EntryPage() {
|
|||||||
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
const syncConnection = useGameSessionStore((state) => state.syncConnection)
|
||||||
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
|
||||||
|
const isReloginRequired =
|
||||||
|
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
|
||||||
|
|
||||||
const [isHydrating, setIsHydrating] = useState(true)
|
const [isHydrating, setIsHydrating] = useState(true)
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
@@ -35,6 +38,12 @@ export function EntryPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isReloginRequired) {
|
||||||
|
setIsHydrating(false)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
void getGameLobbyInit()
|
void getGameLobbyInit()
|
||||||
@@ -115,6 +124,7 @@ export function EntryPage() {
|
|||||||
authStatus,
|
authStatus,
|
||||||
hydrateRound,
|
hydrateRound,
|
||||||
hydrateSession,
|
hydrateSession,
|
||||||
|
isReloginRequired,
|
||||||
selectChip,
|
selectChip,
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
syncConnection,
|
syncConnection,
|
||||||
|
|||||||
@@ -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 DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
|
||||||
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-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 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 DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
||||||
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
||||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
||||||
@@ -20,6 +21,7 @@ export function PcEntry() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DesktopHeader />
|
<DesktopHeader />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={'mx-auto my-design-10 w-[calc(100%-40*var(--design-unit))]'}
|
className={'mx-auto my-design-10 w-[calc(100%-40*var(--design-unit))]'}
|
||||||
>
|
>
|
||||||
@@ -68,6 +70,8 @@ export function PcEntry() {
|
|||||||
<DesktopWithdrawTopupModal />
|
<DesktopWithdrawTopupModal />
|
||||||
{/* 强制弹窗 */}
|
{/* 强制弹窗 */}
|
||||||
<EntryNoticeGateModal />
|
<EntryNoticeGateModal />
|
||||||
|
{/* 历史开奖信息弹窗 */}
|
||||||
|
<DesktopPeriodHistoryDrawer />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export function useAnimalVm(
|
|||||||
const markSoundPlaybackUnlocked = useAudioStore(
|
const markSoundPlaybackUnlocked = useAudioStore(
|
||||||
(state) => state.markSoundPlaybackUnlocked,
|
(state) => state.markSoundPlaybackUnlocked,
|
||||||
)
|
)
|
||||||
|
const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin)
|
||||||
|
const isRegisterModalOpen = useModalStore(
|
||||||
|
(state) => state.modals.desktopRegister,
|
||||||
|
)
|
||||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
const activeChipId = useGameRoundStore((state) => state.activeChipId)
|
||||||
const activeBetQuantity = useGameRoundStore(
|
const activeBetQuantity = useGameRoundStore(
|
||||||
@@ -114,6 +118,8 @@ export function useAnimalVm(
|
|||||||
shouldConnectRealtime &&
|
shouldConnectRealtime &&
|
||||||
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
(connection.status === 'connecting' || connection.status === 'reconnecting')
|
||||||
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
|
||||||
|
const isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen
|
||||||
|
const shouldAnimateStandby = showStandbyState && !isAuthModalOpen
|
||||||
const hasSubmittedCurrentRound =
|
const hasSubmittedCurrentRound =
|
||||||
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
|
||||||
const lockInteraction =
|
const lockInteraction =
|
||||||
@@ -140,7 +146,7 @@ export function useAnimalVm(
|
|||||||
}, [cellWarning])
|
}, [cellWarning])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showStandbyState) {
|
if (!shouldAnimateStandby) {
|
||||||
setMarqueeId(null)
|
setMarqueeId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -159,7 +165,7 @@ export function useAnimalVm(
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timerId)
|
window.clearTimeout(timerId)
|
||||||
}
|
}
|
||||||
}, [animalIds, showStandbyState])
|
}, [animalIds, shouldAnimateStandby])
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
if (authStatus !== 'authenticated') {
|
if (authStatus !== 'authenticated') {
|
||||||
@@ -223,7 +229,7 @@ export function useAnimalVm(
|
|||||||
handleStart,
|
handleStart,
|
||||||
isRealtimeConnecting,
|
isRealtimeConnecting,
|
||||||
lockInteraction,
|
lockInteraction,
|
||||||
marqueeId,
|
marqueeId: shouldAnimateStandby ? marqueeId : null,
|
||||||
selectionByCell,
|
selectionByCell,
|
||||||
showStandbyState,
|
showStandbyState,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useLocation } from '@tanstack/react-router'
|
import { useLocation } from '@tanstack/react-router'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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'
|
import type { AppLanguage } from '@/i18n'
|
||||||
|
|
||||||
const languagePrefixPattern = new RegExp(
|
const languagePrefixPattern = new RegExp(
|
||||||
@@ -22,11 +26,12 @@ export function useAppLanguage() {
|
|||||||
|
|
||||||
const currentLanguage = (i18n.resolvedLanguage ??
|
const currentLanguage = (i18n.resolvedLanguage ??
|
||||||
i18n.language ??
|
i18n.language ??
|
||||||
'zh-CN') as AppLanguage
|
DEFAULT_APP_LANGUAGE) as AppLanguage
|
||||||
|
|
||||||
const currentLanguageOption = useMemo(
|
const currentLanguageOption = useMemo(
|
||||||
() =>
|
() =>
|
||||||
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
|
LANGUAGE_OPTIONS.find((option) => option.code === currentLanguage) ??
|
||||||
|
LANGUAGE_OPTIONS.find((option) => option.code === DEFAULT_APP_LANGUAGE) ??
|
||||||
LANGUAGE_OPTIONS[0],
|
LANGUAGE_OPTIONS[0],
|
||||||
[currentLanguage],
|
[currentLanguage],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { DEFAULT_APP_LANGUAGE } from '@/constants'
|
||||||
import { getDepositTierList } from '@/features/game/api'
|
import { getDepositTierList } from '@/features/game/api'
|
||||||
|
|
||||||
export function useDepositTierList() {
|
export function useDepositTierList() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
|
const language =
|
||||||
|
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['finance', 'deposit-tier-list', language],
|
queryKey: ['finance', 'deposit-tier-list', language],
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { DEFAULT_APP_LANGUAGE } from '@/constants'
|
||||||
import { getDepositWithdrawConfig } from '@/features/game/api'
|
import { getDepositWithdrawConfig } from '@/features/game/api'
|
||||||
|
|
||||||
export function useDepositWithdrawConfig() {
|
export function useDepositWithdrawConfig() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const language = i18n.resolvedLanguage ?? i18n.language ?? 'zh-CN'
|
const language =
|
||||||
|
i18n.resolvedLanguage ?? i18n.language ?? DEFAULT_APP_LANGUAGE
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['finance', 'deposit-withdraw-config', language],
|
queryKey: ['finance', 'deposit-withdraw-config', language],
|
||||||
|
|||||||
@@ -678,10 +678,13 @@ function applyRealtimeMessage(message: GameSocketMessage) {
|
|||||||
export function useGameRealtimeSync() {
|
export function useGameRealtimeSync() {
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
|
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
|
||||||
const shouldConnectRealtime = useGameSessionStore(
|
const shouldConnectRealtime = useGameSessionStore(
|
||||||
(state) => state.shouldConnectRealtime,
|
(state) => state.shouldConnectRealtime,
|
||||||
)
|
)
|
||||||
const socketClientRef = useRef<GameSocketClient | null>(null)
|
const socketClientRef = useRef<GameSocketClient | null>(null)
|
||||||
|
const isReloginRequired =
|
||||||
|
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sharedSocketDisconnectTimerId !== null) {
|
if (sharedSocketDisconnectTimerId !== null) {
|
||||||
@@ -689,6 +692,26 @@ export function useGameRealtimeSync() {
|
|||||||
sharedSocketDisconnectTimerId = null
|
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 (
|
if (
|
||||||
!shouldConnectRealtime ||
|
!shouldConnectRealtime ||
|
||||||
authStatus !== 'authenticated' ||
|
authStatus !== 'authenticated' ||
|
||||||
@@ -810,10 +833,14 @@ export function useGameRealtimeSync() {
|
|||||||
}, SOCKET_DISCONNECT_DELAY_MS)
|
}, SOCKET_DISCONNECT_DELAY_MS)
|
||||||
socketClientRef.current = sharedSocketClient
|
socketClientRef.current = sharedSocketClient
|
||||||
}
|
}
|
||||||
}, [accessToken, authStatus, shouldConnectRealtime])
|
}, [accessToken, authStatus, isReloginRequired, shouldConnectRealtime])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldConnectRealtime || authStatus !== 'authenticated') {
|
if (
|
||||||
|
isReloginRequired ||
|
||||||
|
!shouldConnectRealtime ||
|
||||||
|
authStatus !== 'authenticated'
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,5 +896,5 @@ export function useGameRealtimeSync() {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearInterval(intervalId)
|
window.clearInterval(intervalId)
|
||||||
}
|
}
|
||||||
}, [authStatus, shouldConnectRealtime])
|
}, [authStatus, isReloginRequired, shouldConnectRealtime])
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/features/game/hooks/use-period-history-vm.ts
Normal file
73
src/features/game/hooks/use-period-history-vm.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ function DesktopLoginModal() {
|
|||||||
}
|
}
|
||||||
titleAlign="center"
|
titleAlign="center"
|
||||||
className={'w-design-980 h-design-540'}
|
className={'w-design-980 h-design-540'}
|
||||||
|
backdropClassName="backdrop-blur-none"
|
||||||
>
|
>
|
||||||
<DesktopLoginForm onSuccess={handleSubmit} />
|
<DesktopLoginForm onSuccess={handleSubmit} />
|
||||||
</CenterModal>
|
</CenterModal>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DesktopPeriodHistoryDrawerView
|
||||||
|
open={open}
|
||||||
|
items={vm.items}
|
||||||
|
isLoading={vm.isLoading}
|
||||||
|
isError={vm.isError}
|
||||||
|
labels={{
|
||||||
|
close: t('gameDesktop.periodHistory.close'),
|
||||||
|
empty: t('gameDesktop.periodHistory.empty'),
|
||||||
|
failed: t('gameDesktop.periodHistory.failed'),
|
||||||
|
loading: t('gameDesktop.periodHistory.loading'),
|
||||||
|
retry: t('gameDesktop.periodHistory.retry'),
|
||||||
|
title: t('gameDesktop.periodHistory.title'),
|
||||||
|
}}
|
||||||
|
onClose={handleClose}
|
||||||
|
onRetry={() => void vm.refetch()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopPeriodHistoryDrawerView({
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
labels,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
open,
|
||||||
|
}: DesktopPeriodHistoryDrawerViewProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
aria-label={labels.close}
|
||||||
|
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: prefersReducedMotion ? 0.12 : 0.26,
|
||||||
|
ease: OVERLAY_EASE,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={labels.title}
|
||||||
|
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
|
||||||
|
initial={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { x: '-100%', opacity: 0.98 }
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
|
||||||
|
}
|
||||||
|
exit={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { x: '-100%', opacity: 0.98 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
|
||||||
|
}
|
||||||
|
onAnimationStart={() => setIsDrawerAnimating(true)}
|
||||||
|
onAnimationComplete={() => setIsDrawerAnimating(false)}
|
||||||
|
style={
|
||||||
|
isDrawerAnimating
|
||||||
|
? { willChange: 'transform, opacity' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
|
||||||
|
/>
|
||||||
|
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
|
||||||
|
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
|
||||||
|
{labels.title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={labels.close}
|
||||||
|
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={32} strokeWidth={2.1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
|
||||||
|
initial={
|
||||||
|
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { duration: 0.12 }
|
||||||
|
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PeriodHistoryList
|
||||||
|
items={items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
labels={labels}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ function DesktopRegisterModal() {
|
|||||||
}
|
}
|
||||||
titleAlign="center"
|
titleAlign="center"
|
||||||
className={'w-design-980 h-design-840'}
|
className={'w-design-980 h-design-840'}
|
||||||
|
backdropClassName="backdrop-blur-none"
|
||||||
>
|
>
|
||||||
<DesktopRegisterForm onSuccess={handleSubmit} />
|
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||||
</CenterModal>
|
</CenterModal>
|
||||||
|
|||||||
@@ -17,41 +17,6 @@ export function isSupportedLanguage(
|
|||||||
return SUPPORTED_LANGUAGES.includes(value as AppLanguage)
|
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 获取应用启动时应使用的初始语言。 */
|
/** @description 获取应用启动时应使用的初始语言。 */
|
||||||
function getInitialLanguage() {
|
function getInitialLanguage() {
|
||||||
const persistedLanguage = getStoredAppLanguage()
|
const persistedLanguage = getStoredAppLanguage()
|
||||||
@@ -60,7 +25,7 @@ function getInitialLanguage() {
|
|||||||
return persistedLanguage
|
return persistedLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
return detectBrowserLanguage()
|
return DEFAULT_APP_LANGUAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @description 暴露当前应用应优先使用的语言。 */
|
/** @description 暴露当前应用应优先使用的语言。 */
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
|
|||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_REFRESH_SKEW_MS,
|
ACCESS_TOKEN_REFRESH_SKEW_MS,
|
||||||
API_ERROR_MESSAGES,
|
API_ERROR_MESSAGES,
|
||||||
AUTH_INVALID_TOKEN_CODE,
|
|
||||||
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
|
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
|
||||||
AUTH_REFRESH_ENDPOINT,
|
AUTH_REFRESH_ENDPOINT,
|
||||||
|
AUTH_RELOGIN_REQUIRED_CODES,
|
||||||
AUTH_SKIP_REFRESH_CONTEXT_KEY,
|
AUTH_SKIP_REFRESH_CONTEXT_KEY,
|
||||||
AUTH_TOKEN_CACHE_SKEW_MS,
|
AUTH_TOKEN_CACHE_SKEW_MS,
|
||||||
AUTH_TOKEN_ENDPOINT,
|
AUTH_TOKEN_ENDPOINT,
|
||||||
@@ -210,7 +210,10 @@ function getApiEnvelopeMessage(response: ApiResponse<unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertValidAuthEnvelope(data: unknown) {
|
function assertValidAuthEnvelope(data: unknown) {
|
||||||
if (!isApiEnvelope(data) || data.code !== AUTH_INVALID_TOKEN_CODE) {
|
if (
|
||||||
|
!isApiEnvelope(data) ||
|
||||||
|
!AUTH_RELOGIN_REQUIRED_CODES.includes(data.code)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const LOGIN_PROMPT_DEDUP_MS = 1200
|
|||||||
|
|
||||||
interface ClearAuthenticatedSessionOptions {
|
interface ClearAuthenticatedSessionOptions {
|
||||||
clearBrowserStorage?: boolean
|
clearBrowserStorage?: boolean
|
||||||
|
clearQueryCache?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
|
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(
|
export function registerCurrentUserInitializer(
|
||||||
initializer: CurrentUserInitializer | null,
|
initializer: CurrentUserInitializer | null,
|
||||||
) {
|
) {
|
||||||
@@ -57,11 +78,19 @@ export function isAuthenticated() {
|
|||||||
|
|
||||||
export function clearAuthenticatedSession({
|
export function clearAuthenticatedSession({
|
||||||
clearBrowserStorage = true,
|
clearBrowserStorage = true,
|
||||||
|
clearQueryCache = true,
|
||||||
}: ClearAuthenticatedSessionOptions = {}) {
|
}: ClearAuthenticatedSessionOptions = {}) {
|
||||||
useAuthStore.getState().markUnauthorized()
|
const alreadyUnauthorized = hasRecordedUnauthorizedSession()
|
||||||
queryClient.clear()
|
|
||||||
|
|
||||||
if (clearBrowserStorage) {
|
if (!alreadyUnauthorized) {
|
||||||
|
useAuthStore.getState().markUnauthorized()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearQueryCache && !alreadyUnauthorized) {
|
||||||
|
queryClient.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBrowserStorage && !alreadyUnauthorized) {
|
||||||
clearBrowserStorageData()
|
clearBrowserStorageData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,12 +100,21 @@ export function handleUnauthorizedSession({
|
|||||||
openLoginModal = false,
|
openLoginModal = false,
|
||||||
showLoginRequiredToast = false,
|
showLoginRequiredToast = false,
|
||||||
}: UnauthorizedSessionOptions = {}) {
|
}: UnauthorizedSessionOptions = {}) {
|
||||||
clearAuthenticatedSession({ clearBrowserStorage })
|
clearAuthenticatedSession({
|
||||||
|
clearBrowserStorage,
|
||||||
|
clearQueryCache: hasClearableSessionState(),
|
||||||
|
})
|
||||||
|
|
||||||
if (!openLoginModal && !showLoginRequiredToast) {
|
if (!openLoginModal && !showLoginRequiredToast) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modalStore = openLoginModal ? useModalStore.getState() : null
|
||||||
|
|
||||||
|
if (modalStore?.modals.desktopLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const shouldPrompt = now - lastLoginPromptAt > LOGIN_PROMPT_DEDUP_MS
|
const shouldPrompt = now - lastLoginPromptAt > LOGIN_PROMPT_DEDUP_MS
|
||||||
|
|
||||||
@@ -91,10 +129,7 @@ export function handleUnauthorizedSession({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (openLoginModal) {
|
if (openLoginModal) {
|
||||||
const modalStore = useModalStore.getState()
|
modalStore?.openExclusiveModal('desktopLogin')
|
||||||
|
|
||||||
modalStore.closeAllModals()
|
|
||||||
modalStore.setModalOpen('desktopLogin', true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/lib/dom/body-scroll-lock.ts
Normal file
24
src/lib/dom/body-scroll-lock.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,6 +284,10 @@ export default {
|
|||||||
actions: {
|
actions: {
|
||||||
submitting: 'Submitting...',
|
submitting: 'Submitting...',
|
||||||
},
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
hide: 'Hide password',
|
||||||
|
show: 'Show password',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -450,6 +454,14 @@ export default {
|
|||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
settled: 'Settled',
|
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: {
|
topup: {
|
||||||
title: 'Top-up Config',
|
title: 'Top-up Config',
|
||||||
platformCoinLabel: 'Platform Coin',
|
platformCoinLabel: 'Platform Coin',
|
||||||
|
|||||||
@@ -284,6 +284,10 @@ export default {
|
|||||||
actions: {
|
actions: {
|
||||||
submitting: 'Mengirim...',
|
submitting: 'Mengirim...',
|
||||||
},
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
hide: 'Sembunyikan kata sandi',
|
||||||
|
show: 'Tampilkan kata sandi',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -450,6 +454,14 @@ export default {
|
|||||||
loading: 'Memuat...',
|
loading: 'Memuat...',
|
||||||
settled: 'Selesai',
|
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: {
|
topup: {
|
||||||
title: 'Konfigurasi Isi Ulang',
|
title: 'Konfigurasi Isi Ulang',
|
||||||
platformCoinLabel: 'Koin Platform',
|
platformCoinLabel: 'Koin Platform',
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ export default {
|
|||||||
actions: {
|
actions: {
|
||||||
submitting: 'Menghantar...',
|
submitting: 'Menghantar...',
|
||||||
},
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
hide: 'Sembunyikan kata laluan',
|
||||||
|
show: 'Tunjukkan kata laluan',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -455,6 +459,14 @@ export default {
|
|||||||
loading: 'Memuatkan...',
|
loading: 'Memuatkan...',
|
||||||
settled: 'Selesai',
|
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: {
|
topup: {
|
||||||
title: 'Konfigurasi Tambah Nilai',
|
title: 'Konfigurasi Tambah Nilai',
|
||||||
platformCoinLabel: 'Syiling Platform',
|
platformCoinLabel: 'Syiling Platform',
|
||||||
|
|||||||
@@ -272,6 +272,10 @@ export default {
|
|||||||
actions: {
|
actions: {
|
||||||
submitting: '提交中...',
|
submitting: '提交中...',
|
||||||
},
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
hide: '隐藏密码',
|
||||||
|
show: '显示密码',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -436,6 +440,14 @@ export default {
|
|||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
settled: '已结算',
|
settled: '已结算',
|
||||||
},
|
},
|
||||||
|
periodHistory: {
|
||||||
|
title: '开奖结果历史',
|
||||||
|
close: '关闭开奖结果历史',
|
||||||
|
empty: '暂无开奖结果',
|
||||||
|
failed: '开奖结果加载失败',
|
||||||
|
loading: '加载中...',
|
||||||
|
retry: '重试',
|
||||||
|
},
|
||||||
topup: {
|
topup: {
|
||||||
title: '充值配置',
|
title: '充值配置',
|
||||||
platformCoinLabel: '平台币',
|
platformCoinLabel: '平台币',
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accessTokenExpiresAt: null,
|
accessTokenExpiresAt: null,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
|
lastUnauthorizedAt: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
setCurrentUser: (currentUser) => {
|
setCurrentUser: (currentUser) => {
|
||||||
@@ -149,6 +150,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
|
lastUnauthorizedAt: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
|
updateTokens: ({ accessToken, accessTokenExpiresAt, refreshToken }) => {
|
||||||
@@ -159,6 +161,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshToken: refreshToken ?? state.refreshToken,
|
refreshToken: refreshToken ?? state.refreshToken,
|
||||||
status: 'authenticated',
|
status: 'authenticated',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
|
lastUnauthorizedAt: null,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
finishHydration: () => {
|
finishHydration: () => {
|
||||||
@@ -180,6 +183,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
...initialPersistedState,
|
...initialPersistedState,
|
||||||
status: 'anonymous',
|
status: 'anonymous',
|
||||||
isHydrated: true,
|
isHydrated: true,
|
||||||
|
lastUnauthorizedAt: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
markUnauthorized: () => {
|
markUnauthorized: () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ModalStoreState {
|
|||||||
modals: ModalVisibilityMap
|
modals: ModalVisibilityMap
|
||||||
withdrawTopupType: WithdrawTopupType
|
withdrawTopupType: WithdrawTopupType
|
||||||
closeAllModals: () => void
|
closeAllModals: () => void
|
||||||
|
openExclusiveModal: (key: ModalKey) => void
|
||||||
setModalOpen: (key: ModalKey, open: boolean) => void
|
setModalOpen: (key: ModalKey, open: boolean) => void
|
||||||
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,14 @@ export const useModalStore = create<ModalStoreState>()((set) => ({
|
|||||||
closeAllModals: () => {
|
closeAllModals: () => {
|
||||||
set({ modals: INITIAL_MODAL_VISIBILITY })
|
set({ modals: INITIAL_MODAL_VISIBILITY })
|
||||||
},
|
},
|
||||||
|
openExclusiveModal: (key) => {
|
||||||
|
set({
|
||||||
|
modals: {
|
||||||
|
...INITIAL_MODAL_VISIBILITY,
|
||||||
|
[key]: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
setModalOpen: (key, open) => {
|
setModalOpen: (key, open) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
modals: {
|
modals: {
|
||||||
|
|||||||
@@ -194,6 +194,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@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 {
|
.common-neon-inset {
|
||||||
border: 1px solid rgba(128, 223, 231, 0.65);
|
border: 1px solid rgba(128, 223, 231, 0.65);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -512,20 +525,21 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-title-vertical-marquee {
|
.desktop-title-horizontal-marquee {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
width: max-content;
|
||||||
animation: desktop-title-marquee-y 7s linear infinite;
|
gap: calc(var(--design-unit) * 80);
|
||||||
|
animation: desktop-title-marquee-x 16s linear infinite;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes desktop-title-marquee-y {
|
@keyframes desktop-title-marquee-x {
|
||||||
from {
|
from {
|
||||||
transform: translateY(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: translateY(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user