refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
229
src/modal/desktop/desktop-auto-setting-modal.tsx
Normal file
229
src/modal/desktop/desktop-auto-setting-modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useModalStore } from '@/store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
type AutoHostingStopRules,
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseAmount(value: string) {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function DesktopAutoSettingModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
|
||||
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
|
||||
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
|
||||
)
|
||||
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (selections.length === 0) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
const rules: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
amount: parseAmount(balanceLimitValue),
|
||||
enabled: balanceLimitEnabled,
|
||||
},
|
||||
stopIfSingleWinAbove: {
|
||||
amount: parseAmount(singleWinLimitValue),
|
||||
enabled: singleWinLimitEnabled,
|
||||
},
|
||||
stopOnJackpot: jackpotStopEnabled,
|
||||
}
|
||||
|
||||
startHosting({
|
||||
balanceAfterBet: balance,
|
||||
rules,
|
||||
selections,
|
||||
})
|
||||
notify.success(t('commonUi.toast.autoHostingStarted'))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.autoSetting.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-835 !h-design-500'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col gap-design-26'}>
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={balanceLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={balanceLimitEnabled}
|
||||
onCheckedChange={setBalanceLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={singleWinLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={singleWinLimitEnabled}
|
||||
onCheckedChange={setSingleWinLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={jackpotStopEnabled}
|
||||
onCheckedChange={setJackpotStopEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className={
|
||||
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.startAutoSpin')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopAutoSettingModal
|
||||
178
src/modal/desktop/desktop-finance-records-tab.tsx
Normal file
178
src/modal/desktop/desktop-finance-records-tab.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useFinanceRecordsVm } from '@/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useFinanceRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 72,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
|
||||
}
|
||||
>
|
||||
{vm.recordTypes.map((recordType) => {
|
||||
const isActive = recordType.key === vm.recordType
|
||||
|
||||
return (
|
||||
<button
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
vm.selectRecordType(recordType.key)
|
||||
rowVirtualizer.scrollToOffset(0)
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="finance-record-type-active"
|
||||
className={
|
||||
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={'relative z-10'}>{recordType.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={
|
||||
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
|
||||
}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={'relative w-full'}
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className={'absolute left-0 top-0 w-full pb-design-10'}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className={
|
||||
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate font-medium text-white'}>
|
||||
{item.orderNoLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#FEEEB0]'}>
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#7CFFCF]'}>
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopFinanceRecordsTab
|
||||
101
src/modal/desktop/desktop-language-modal.tsx
Normal file
101
src/modal/desktop/desktop-language-modal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopLanguageModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLanguage)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopLanguage', false)
|
||||
}
|
||||
|
||||
const handleSelectLanguage = async (
|
||||
language: (typeof languageOptions)[number]['code'],
|
||||
) => {
|
||||
await selectLanguage(language)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-30'}>
|
||||
{t('language.label')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-560 w-design-620"
|
||||
>
|
||||
<div className="flex h-full flex-col px-design-24 pb-design-28 pt-design-10">
|
||||
<div className="grid flex-1 grid-cols-2 gap-design-16">
|
||||
{languageOptions.map((option: (typeof languageOptions)[number]) => {
|
||||
const isActive = option.code === currentLanguage
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => void handleSelectLanguage(option.code)}
|
||||
className={cn(
|
||||
'group relative flex h-full min-h-design-150 w-full flex-col justify-between overflow-hidden rounded-[18px] border px-design-18 py-design-18 text-left transition-all duration-200',
|
||||
isActive
|
||||
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_18px_rgba(128,223,231,0.55),0_0_22px_rgba(66,227,255,0.2)]'
|
||||
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_14px_rgba(128,223,231,0.18)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_18px_rgba(128,223,231,0.3),0_0_18px_rgba(66,227,255,0.12)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
|
||||
isActive
|
||||
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
|
||||
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex items-start justify-between gap-design-12">
|
||||
<SmartImage
|
||||
src={option.icon}
|
||||
alt={t(option.labelKey)}
|
||||
className="h-design-32 w-design-32 shrink-0 rounded-[10px] object-cover shadow-[0_8px_18px_rgba(0,0,0,0.28)]"
|
||||
/>
|
||||
{isActive ? (
|
||||
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-12 py-design-6 text-design-14 font-semibold uppercase tracking-[0.14em] text-[#C9FCFF] shadow-[0_0_14px_rgba(66,227,255,0.18)]">
|
||||
{t('gameDesktop.control.selected')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative mt-design-18">
|
||||
<div className="text-design-24 font-semibold text-[#F3FFFF]">
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
<div className="mt-design-8 text-design-15 uppercase tracking-[0.2em] text-[#7EDAE3]">
|
||||
{option.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-design-16 h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
|
||||
|
||||
<div className="relative mt-design-12 flex items-center justify-between text-design-15 text-[#98D6DC]">
|
||||
<span>{t('language.label')}</span>
|
||||
<span className="text-[#D8FDFF]">{option.code}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopLanguageModal
|
||||
31
src/modal/desktop/desktop-login-modal.tsx
Normal file
31
src/modal/desktop/desktop-login-modal.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopLoginForm } from '@/features/auth/components/desktop/desktop-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopLoginModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopLogin', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopLogin', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>{t('game.modals.login.title')}</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-540'}
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<DesktopLoginForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopLoginModal
|
||||
240
src/modal/desktop/desktop-notice-modal.tsx
Normal file
240
src/modal/desktop/desktop-notice-modal.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNoticeDetail, getNoticeList } from '@/api'
|
||||
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
type NoticeViewState = 'detail' | 'list'
|
||||
|
||||
function DesktopNoticeModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopNotice)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
|
||||
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
|
||||
|
||||
const noticeListQuery = useQuery({
|
||||
queryKey: ['game', 'notice-list'],
|
||||
queryFn: () => getNoticeList(),
|
||||
enabled: open && noticeView === 'list',
|
||||
})
|
||||
|
||||
const noticeDetailQuery = useQuery({
|
||||
queryKey: ['game', 'notice-detail', selectedNoticeId],
|
||||
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
|
||||
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
|
||||
})
|
||||
|
||||
const noticeItems = useMemo(
|
||||
() => noticeListQuery.data?.list ?? [],
|
||||
[noticeListQuery.data],
|
||||
)
|
||||
|
||||
async function handleReturnToList() {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
await noticeListQuery.refetch()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopNotice', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
{t('game.modals.userInfo.message.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-980 h-design-690'}
|
||||
>
|
||||
<div className={'flex h-full w-full flex-col'}>
|
||||
{noticeView === 'detail' ? (
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center mx-design-10 my-design-10 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'flex h-design-40 w-design-40 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]'
|
||||
}
|
||||
>
|
||||
<ArrowLeft className={'h-design-22 w-design-22'} />
|
||||
</span>
|
||||
<span className={'text-design-20 font-medium tracking-wide'}>
|
||||
{t('game.modals.userInfo.message.back')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={'h-full w-full overflow-auto rounded-md'}>
|
||||
{noticeView === 'list' ? (
|
||||
<div
|
||||
className={
|
||||
'flex h-full w-full flex-col gap-design-10 p-design-10'
|
||||
}
|
||||
>
|
||||
{noticeListQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeItems.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.message.empty')}
|
||||
</div>
|
||||
) : (
|
||||
noticeItems.map((item) => (
|
||||
<button
|
||||
key={item.notice_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedNoticeId(item.notice_id)
|
||||
setNoticeView('detail')
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-design-95 w-design-95 items-center justify-center rounded-md text-design-18 font-bold',
|
||||
item.notice_type === 'popout'
|
||||
? 'bg-[#203C49] text-[#FEEEB0]'
|
||||
: 'bg-[#111111] text-[#6CCDCF]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -right-design-8 top-design-8 z-10 min-w-design-50 -rotate-[8deg] rounded-[calc(var(--design-unit)*4)] border px-design-6 py-design-4 text-center text-design-11 font-semibold leading-none shadow-[0_0_calc(var(--design-unit)*8)_rgba(0,0,0,0.2)]',
|
||||
item.is_read
|
||||
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
|
||||
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
|
||||
)}
|
||||
>
|
||||
{item.is_read
|
||||
? t('game.modals.userInfo.message.read')
|
||||
: t('game.modals.userInfo.message.unread')}
|
||||
</span>
|
||||
{item.notice_type.toUpperCase()}
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<div className={'text-design-18 text-[#BFEAEC]'}>
|
||||
{dayjs(item.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'mt-design-4 flex items-center gap-design-12'
|
||||
}
|
||||
>
|
||||
<div className={'truncate text-design-20 text-white'}>
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={blueBtnBg}
|
||||
size="100% 100%"
|
||||
className={
|
||||
'flex h-design-64 w-design-150 items-center justify-center text-design-20 font-bold'
|
||||
}
|
||||
>
|
||||
{t('game.modals.userInfo.message.check')}
|
||||
</SmartBackground>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex h-full w-full flex-col gap-design-16 p-design-10'
|
||||
}
|
||||
>
|
||||
{noticeDetailQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeDetailQuery.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeDetailQuery.data ? (
|
||||
<div
|
||||
className={
|
||||
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-24 shadow-[0_0_24px_rgba(14,108,132,0.16)]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mb-design-14 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-14 py-design-6 text-design-16 text-[#9CE8F2]'
|
||||
}
|
||||
>
|
||||
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'text-design-28 font-semibold leading-tight text-white'
|
||||
}
|
||||
>
|
||||
{noticeDetailQuery.data.title}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'mt-design-18 whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]'
|
||||
}
|
||||
>
|
||||
{noticeDetailQuery.data.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.message.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopNoticeModal
|
||||
181
src/modal/desktop/desktop-period-history-drawer.tsx
Normal file
181
src/modal/desktop/desktop-period-history-drawer.tsx
Normal file
@@ -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 '@/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>
|
||||
)
|
||||
}
|
||||
90
src/modal/desktop/desktop-procedures-modal.tsx
Normal file
90
src/modal/desktop/desktop-procedures-modal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||
import topupBtnBg from '@/assets/system/topup.webp'
|
||||
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
function DesktopProceduresModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopProcedures)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const setWithdrawTopupType = useModalStore(
|
||||
(state) => state.setWithdrawTopupType,
|
||||
)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
}
|
||||
|
||||
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
setWithdrawTopupType(type)
|
||||
setModalOpen('desktopWithdrawTopup', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.procedures.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-1000 h-design-610'}
|
||||
>
|
||||
<SmartBackground
|
||||
src={proceduresBg}
|
||||
repeat="no-repeat"
|
||||
size="cover"
|
||||
className={
|
||||
'h-[95%] w-full rounded-md flex flex-col items-center justify-between'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-design-170 ml-design-120 flex items-center gap-design-50'
|
||||
}
|
||||
>
|
||||
<SmartImage className={'w-design-80'} alt={'diamond'} src={diamond} />
|
||||
<div
|
||||
className={
|
||||
'modal-title-gold-glow text-[#F7DC7A] text-design-32 font-bold tracking-[0.08em]'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={'flex items-center ml-design-180'}>
|
||||
<SmartBackground
|
||||
src={withdrawBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||
className={
|
||||
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-10 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.withdraw')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
src={topupBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||
className={
|
||||
'w-design-400 h-design-195 flex cursor-pointer items-center justify-center pb-design-20 text-design-32 font-bold transition-[transform,filter] duration-150 hover:scale-[1.02] hover:brightness-110 active:translate-y-[calc(var(--design-unit)*2)] active:scale-[0.97] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.topup')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopProceduresModal
|
||||
33
src/modal/desktop/desktop-register-modal.tsx
Normal file
33
src/modal/desktop/desktop-register-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop/desktop-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopRegisterModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopRegister', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopRegister', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>
|
||||
{t('game.modals.register.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className={'w-design-980 h-design-840'}
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<DesktopRegisterForm onSuccess={handleSubmit} />
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRegisterModal
|
||||
53
src/modal/desktop/desktop-rules-modal.tsx
Normal file
53
src/modal/desktop/desktop-rules-modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopRulesModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRules)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopRules', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-28 '}>
|
||||
{t('game.modals.rules.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className={'w-design-1040 h-design-720'}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-design-24 px-design-28 pb-design-30 pt-design-10">
|
||||
<div className="flex-1 overflow-y-auto rounded-[12px] bg-black/35 p-design-20 text-design-18 leading-[1.8] text-[#B9E7EA] whitespace-pre-line">
|
||||
{t('game.modals.rules.content')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 90%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
onClick={handleClose}
|
||||
className="modal-title-glow flex h-design-85 w-design-270 items-center justify-center pb-design-5 text-design-20 font-bold"
|
||||
>
|
||||
{t('game.modals.rules.confirm')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopRulesModal
|
||||
75
src/modal/desktop/desktop-support-modal.tsx
Normal file
75
src/modal/desktop/desktop-support-modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const SUPPORT_CHAT_URL =
|
||||
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
|
||||
const IFRAME_READY_DELAY_MS = 2_000
|
||||
|
||||
function DesktopSupportModal() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const readyTimerRef = useRef<number | null>(null)
|
||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const clearReadyTimer = useCallback(() => {
|
||||
if (readyTimerRef.current === null) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(readyTimerRef.current)
|
||||
readyTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopSupport', false)
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
clearReadyTimer()
|
||||
readyTimerRef.current = window.setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
readyTimerRef.current = null
|
||||
}, IFRAME_READY_DELAY_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
clearReadyTimer()
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return clearReadyTimer
|
||||
}, [clearReadyTimer, open])
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
titleAlign="left"
|
||||
title={<div className="modal-title-glow text-design-30">在线客服</div>}
|
||||
className="h-design-760 w-design-980"
|
||||
>
|
||||
<div className="h-full px-design-24 pb-design-40 pt-design-10">
|
||||
<div className="relative h-full overflow-hidden rounded-[calc(var(--design-unit)*14)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*22)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*18)_rgba(31,156,174,0.14)]">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||
<DataLoadingIndicator label="客服连线中" />
|
||||
</div>
|
||||
) : null}
|
||||
<iframe
|
||||
title="customer-service-chat"
|
||||
src={SUPPORT_CHAT_URL}
|
||||
onLoad={handleLoaded}
|
||||
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopSupportModal
|
||||
337
src/modal/desktop/desktop-userInfo-modal.tsx
Normal file
337
src/modal/desktop/desktop-userInfo-modal.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
ReceiptText,
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { logoutWithPassword } from '@/api'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
||||
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import DesktopFinanceRecordsTab from '@/modal/desktop/desktop-finance-records-tab'
|
||||
import DesktopWalletRecordsTab from '@/modal/desktop/desktop-wallet-records-tab'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
key: UserInfoTabKey
|
||||
labelKey: string
|
||||
icon: typeof CircleUserRound
|
||||
}> = [
|
||||
{
|
||||
key: 'profile',
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'financeRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'walletRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.walletRecords',
|
||||
icon: WalletCards,
|
||||
},
|
||||
]
|
||||
|
||||
function createRegisterInviteUrl(inviteCode: string) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy')
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
function DesktopUserInfoModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopUserInfo)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
|
||||
const logoutUsername =
|
||||
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logoutWithPassword,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab('profile')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink() {
|
||||
if (!inviteCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
|
||||
notify.success(t('commonUi.toast.inviteLinkCopied'))
|
||||
} catch {
|
||||
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (logoutMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutMutation.mutateAsync({
|
||||
password: '',
|
||||
username: logoutUsername,
|
||||
})
|
||||
notify.success(t('commonUi.toast.logoutSuccess'))
|
||||
} catch {
|
||||
notify.warning(t('commonUi.toast.logoutLocalOnly'))
|
||||
} finally {
|
||||
clearAuthenticatedSession({ clearBrowserStorage: true })
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26'}>
|
||||
{t('game.modals.userInfo.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-980 h-design-590'}
|
||||
>
|
||||
<div className={'relative flex h-[96%] w-full'}>
|
||||
<div className={'relative w-design-230 shrink-0'}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-0 h-full w-[calc(var(--design-unit)*2)] bg-[linear-gradient(180deg,rgba(68,244,255,0)_0%,rgba(68,244,255,0.55)_12%,rgba(130,255,255,0.95)_50%,rgba(68,244,255,0.55)_88%,rgba(68,244,255,0)_100%)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(49,208,255,0.45)]"
|
||||
/>
|
||||
|
||||
{USER_INFO_TABS.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = tab.key === activeTab
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#FEEEB0]'
|
||||
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-bg"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 transition',
|
||||
isActive &&
|
||||
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.06 : 1,
|
||||
y: isActive ? -2 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<Icon className={'h-design-40 w-design-40'} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 text-center text-design-20 leading-tight',
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.04 : 1,
|
||||
y: isActive ? -1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={'flex-1'}>
|
||||
{activeTab === 'profile' ? (
|
||||
<SmartBackground
|
||||
src={userInfoBg}
|
||||
size="120% 100%"
|
||||
className={
|
||||
'flex flex-col h-full w-full items-start justify-between bg-top bg-no-repeat px-design-40 py-design-32 text-[#6CCDCF] text-design-24 gap-design-80'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center gap-design-30'}>
|
||||
<SmartImage
|
||||
className={'h-design-100 w-design-100'}
|
||||
src={currentUser?.headImage || avatar}
|
||||
alt={'avatar'}
|
||||
/>
|
||||
<div className={'flex flex-col gap-design-30 text-[#6CCDCF]'}>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.name')} :
|
||||
{currentUser?.name ?? '--'}
|
||||
</div>
|
||||
<div>
|
||||
{t('game.modals.userInfo.profile.tel')} :{' '}
|
||||
{currentUser?.phone ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'w-design-600 flex-1 text-design-18 rounded-md bg-[#000000]/40 flex flex-col gap-design-20 p-design-20'
|
||||
}
|
||||
>
|
||||
<div className={'text-[#6CCDCF]'}>
|
||||
{t('game.modals.userInfo.profile.registeredAt')}:
|
||||
<span
|
||||
className={'text-design-18 text-[#599AA3] ml-design-10'}
|
||||
>
|
||||
{currentUser?.createTime
|
||||
? dayjs
|
||||
.unix(currentUser.createTime)
|
||||
.format('YYYY-MM-DD HH:mm:ss')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center text-[#6CCDCF]'}>
|
||||
<span>{t('auth.register.fields.inviteCode.label')}</span>
|
||||
<span
|
||||
className={'text-design-18 text-[#599AA3] ml-design-10'}
|
||||
>
|
||||
{inviteCode || '--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleCopyInviteLink()
|
||||
}}
|
||||
disabled={!inviteCode}
|
||||
aria-label={t(
|
||||
'game.modals.userInfo.profile.copyInviteLink',
|
||||
)}
|
||||
title={t('game.modals.userInfo.profile.copyInviteLink')}
|
||||
className="ml-design-10 flex h-design-30 w-design-30 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<ClipboardList className="h-design-18 w-design-18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'w-full flex justify-end'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="mt-auto inline-flex h-design-44 min-w-design-170 cursor-pointer items-center justify-center gap-design-10 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-18 text-design-18 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<LogOut className="h-design-20 w-design-20" />
|
||||
<span>
|
||||
{logoutMutation.isPending
|
||||
? t('game.modals.userInfo.profile.loggingOut')
|
||||
: t('game.modals.userInfo.profile.logout')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<DesktopFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<DesktopWalletRecordsTab
|
||||
enabled={open && activeTab === 'walletRecords'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopUserInfoModal
|
||||
145
src/modal/desktop/desktop-wallet-records-tab.tsx
Normal file
145
src/modal/desktop/desktop-wallet-records-tab.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useWalletRecordsVm } from '@/hooks/use-wallet-records-vm'
|
||||
|
||||
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useWalletRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 72,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className={'flex h-full w-full flex-col p-design-10'}>
|
||||
<div
|
||||
className={
|
||||
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
|
||||
}
|
||||
>
|
||||
<div className={'text-design-20 font-medium text-[#BFEAEC]'}>
|
||||
{vm.headers.type}
|
||||
</div>
|
||||
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className={'min-h-0 flex-1 rounded-md'}>
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
|
||||
}
|
||||
>
|
||||
<div>{vm.headers.time}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.balanceBefore}</div>
|
||||
<div>{vm.headers.balanceAfter}</div>
|
||||
<div>{vm.headers.remark}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={
|
||||
'mt-design-10 max-h-[calc(var(--design-unit)*320)] min-h-0 overflow-auto pr-design-4'
|
||||
}
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={'relative w-full'}
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className={'absolute left-0 top-0 w-full pb-design-10'}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className={
|
||||
'grid h-[calc(var(--design-unit)*62)] grid-cols-[minmax(0,1.1fr)_minmax(0,0.75fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-17 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
|
||||
}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className={'truncate text-[#BFEAEC]'}>
|
||||
{item.timeLabel}
|
||||
</div>
|
||||
<div className={'truncate font-medium text-[#FEEEB0]'}>
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#86DAE7]'}>
|
||||
{item.balanceBeforeLabel}
|
||||
</div>
|
||||
<div className={'truncate text-[#7CFFCF]'}>
|
||||
{item.balanceAfterLabel}
|
||||
</div>
|
||||
<div className={'truncate text-white'}>
|
||||
{item.remarkLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-[calc(var(--design-unit)*62)] rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWalletRecordsTab
|
||||
39
src/modal/desktop/desktop-withdraw-topup-modal.tsx
Normal file
39
src/modal/desktop/desktop-withdraw-topup-modal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenterModal } from '@/components/center-modal.tsx'
|
||||
import DesktopTopup from '@/features/game/components/desktop/desktop-topup.tsx'
|
||||
import DesktopWithdraw from '@/features/game/components/desktop/desktop-withdraw.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function DesktopWithdrawTopupModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||
const type = useModalStore((state) => state.withdrawTopupType)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{type === 'withdraw'
|
||||
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||
: t('game.modals.withdrawTopup.applyTopup')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className={'w-design-1200 h-design-700'}
|
||||
>
|
||||
<div className={'w-full h-[96%]'}>
|
||||
{type === 'withdraw' ? <DesktopWithdraw /> : <DesktopTopup />}
|
||||
</div>
|
||||
</CenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWithdrawTopupModal
|
||||
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { useModalStore } from '@/store'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import {
|
||||
type AutoHostingStopRules,
|
||||
selectSelectionTotal,
|
||||
useGameAutoHostingStore,
|
||||
useGameRoundStore,
|
||||
useGameSessionStore,
|
||||
} from '@/store/game'
|
||||
|
||||
function parseAmount(value: string) {
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
}
|
||||
|
||||
function parseBalance(value: string | number | null | undefined) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function MobileAutoSettingModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const round = useGameRoundStore((state) => state.round)
|
||||
const selections = useGameRoundStore((state) => state.selections)
|
||||
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||
const tableLimitMax = useGameSessionStore(
|
||||
(state) => state.dashboard.tableLimitMax,
|
||||
)
|
||||
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
|
||||
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
|
||||
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
|
||||
)
|
||||
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
setModalOpen('desktopAutoSetting', false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (round.phase !== 'betting' || !round.id) {
|
||||
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (selections.length === 0) {
|
||||
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const balance = parseBalance(currentUser?.coin)
|
||||
|
||||
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalBetAmount > balance) {
|
||||
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
|
||||
const rules: AutoHostingStopRules = {
|
||||
stopIfBalanceBelow: {
|
||||
amount: parseAmount(balanceLimitValue),
|
||||
enabled: balanceLimitEnabled,
|
||||
},
|
||||
stopIfSingleWinAbove: {
|
||||
amount: parseAmount(singleWinLimitValue),
|
||||
enabled: singleWinLimitEnabled,
|
||||
},
|
||||
stopOnJackpot: jackpotStopEnabled,
|
||||
}
|
||||
|
||||
startHosting({
|
||||
balanceAfterBet: balance,
|
||||
rules,
|
||||
selections,
|
||||
})
|
||||
notify.success(t('commonUi.toast.autoHostingStarted'))
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||
{t('game.modals.autoSetting.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
|
||||
}
|
||||
>
|
||||
<div className={'flex w-full flex-col gap-design-26'}>
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={balanceLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={balanceLimitEnabled}
|
||||
onCheckedChange={setBalanceLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={singleWinLimitValue}
|
||||
inputMode="decimal"
|
||||
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||
className={
|
||||
'game-setting-input h-full w-design-280 text-design-18'
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={singleWinLimitEnabled}
|
||||
onCheckedChange={setSingleWinLimitEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center justify-between gap-design-30'}>
|
||||
<div
|
||||
className={
|
||||
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||
<Switch
|
||||
size={'sm'}
|
||||
checked={jackpotStopEnabled}
|
||||
onCheckedChange={setJackpotStopEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className={
|
||||
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||
}
|
||||
>
|
||||
{t('game.modals.autoSetting.startAutoSpin')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileAutoSettingModal
|
||||
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useFinanceRecordsVm } from '@/hooks/use-finance-records-vm'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function maskOrderNo(value: string) {
|
||||
const text = value.trim()
|
||||
|
||||
if (text.length <= 12) {
|
||||
return text
|
||||
}
|
||||
|
||||
return `${text.slice(0, 6)}**${text.slice(-4)}`
|
||||
}
|
||||
|
||||
function MobileFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useFinanceRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 52,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col p-design-4">
|
||||
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<div className="relative grid min-w-0 grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-3">
|
||||
{vm.recordTypes.map((recordType) => {
|
||||
const isActive = recordType.key === vm.recordType
|
||||
|
||||
return (
|
||||
<button
|
||||
key={recordType.key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
vm.selectRecordType(recordType.key)
|
||||
rowVirtualizer.scrollToOffset(0)
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-design-24 min-w-design-82 cursor-pointer rounded-md px-design-8 text-design-12 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-white'
|
||||
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="finance-record-type-active"
|
||||
className={
|
||||
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 420,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 text-design-10">
|
||||
{recordType.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||
{vm.pageLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||
<div className="min-w-design-330">
|
||||
<div className="grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-6 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-10 py-design-8 text-design-12 text-[#7ECAD1]">
|
||||
<div>{vm.headers.orderNo}</div>
|
||||
<div>{vm.headers.amount}</div>
|
||||
<div>{vm.headers.bonusAmount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 top-0 w-full pb-design-7"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className="grid h-design-45 grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-6 rounded-md bg-[#0A4252] px-design-10 py-design-8 text-design-12 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="truncate font-medium text-white"
|
||||
title={item.orderNoLabel}
|
||||
>
|
||||
{maskOrderNo(item.orderNoLabel)}
|
||||
</div>
|
||||
<div className="truncate text-[#FEEEB0]">
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className="truncate text-[#7CFFCF]">
|
||||
{item.bonusAmountLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-design-45 rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileFinanceRecordsTab
|
||||
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileLanguageModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLanguage)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopLanguage', false)
|
||||
}
|
||||
|
||||
const handleSelectLanguage = async (
|
||||
language: (typeof languageOptions)[number]['code'],
|
||||
) => {
|
||||
await selectLanguage(language)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('language.label')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="!h-design-350"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
|
||||
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">
|
||||
{languageOptions.map((option: (typeof languageOptions)[number]) => {
|
||||
const isActive = option.code === currentLanguage
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => void handleSelectLanguage(option.code)}
|
||||
className={cn(
|
||||
'group relative w-[calc(50%-var(--design-unit)*5))] flex h-design-130 min-w-0 flex-col justify-between overflow-hidden rounded-[calc(var(--design-unit)*12)] border px-design-12 py-design-12 text-left transition-all duration-200',
|
||||
isActive
|
||||
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(128,223,231,0.52),0_0_calc(var(--design-unit)*18)_rgba(66,227,255,0.18)]'
|
||||
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(128,223,231,0.16)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(128,223,231,0.28),0_0_calc(var(--design-unit)*14)_rgba(66,227,255,0.1)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
|
||||
isActive
|
||||
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
|
||||
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex min-w-0 items-start justify-between">
|
||||
<SmartImage
|
||||
src={option.icon}
|
||||
alt={t(option.labelKey)}
|
||||
className="h-design-26 w-design-26 shrink-0 rounded-[calc(var(--design-unit)*8)] object-cover shadow-[0_calc(var(--design-unit)*5)_calc(var(--design-unit)*12)_rgba(0,0,0,0.28)]"
|
||||
/>
|
||||
{isActive ? (
|
||||
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-8 py-design-3 text-design-10 font-semibold uppercase text-[#C9FCFF] shadow-[0_0_calc(var(--design-unit)*10)_rgba(66,227,255,0.18)]">
|
||||
{t('gameDesktop.control.selected')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative min-w-0 w-full">
|
||||
<div className="w-full truncate text-design-17 font-semibold text-[#F3FFFF]">
|
||||
{t(option.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
|
||||
|
||||
<div className="relative mt-design-8 flex min-w-0 items-center justify-between text-design-11 text-[#98D6DC]">
|
||||
<span className="min-w-0 truncate">
|
||||
{t('language.label')}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#D8FDFF]">{option.code}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileLanguageModal
|
||||
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { MobileLoginForm } from '@/features/auth/components/mobile/mobile-login-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileLoginModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopLogin', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopLogin', false)}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.login.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className="!h-design-360"
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<MobileLoginForm onSuccess={handleSubmit} />
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileLoginModal
|
||||
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getNoticeDetail, getNoticeList } from '@/api'
|
||||
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
type NoticeViewState = 'detail' | 'list'
|
||||
|
||||
function MobileNoticeModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopNotice)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
|
||||
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
|
||||
|
||||
const noticeListQuery = useQuery({
|
||||
queryKey: ['game', 'notice-list'],
|
||||
queryFn: () => getNoticeList(),
|
||||
enabled: open && noticeView === 'list',
|
||||
})
|
||||
|
||||
const noticeDetailQuery = useQuery({
|
||||
queryKey: ['game', 'notice-detail', selectedNoticeId],
|
||||
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
|
||||
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
|
||||
})
|
||||
|
||||
const noticeItems = useMemo(
|
||||
() => noticeListQuery.data?.list ?? [],
|
||||
[noticeListQuery.data],
|
||||
)
|
||||
|
||||
async function handleReturnToList() {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
await noticeListQuery.refetch()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNoticeView('list')
|
||||
setSelectedNoticeId(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopNotice', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-14">
|
||||
{t('game.modals.userInfo.message.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="!h-design-330"
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col px-design-6 pb-design-8 pt-design-2">
|
||||
{noticeView === 'detail' ? (
|
||||
<div className="mb-design-8 flex shrink-0 items-center rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleReturnToList()
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-design-7 text-[#86DAE7] transition hover:text-white"
|
||||
>
|
||||
<span className="flex h-design-28 w-design-28 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]">
|
||||
<ArrowLeft className="h-design-16 w-design-16" />
|
||||
</span>
|
||||
<span className="text-design-13 font-medium">
|
||||
{t('game.modals.userInfo.message.back')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md">
|
||||
{noticeView === 'list' ? (
|
||||
<div className="flex h-full w-full flex-col gap-design-8 p-design-4">
|
||||
{noticeListQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeListQuery.isError ? (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeItems.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.empty')}
|
||||
</div>
|
||||
) : (
|
||||
noticeItems.map((item) => (
|
||||
<button
|
||||
key={item.notice_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedNoticeId(item.notice_id)
|
||||
setNoticeView('detail')
|
||||
}}
|
||||
className="flex min-h-design-62 cursor-pointer items-center gap-design-9 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-left transition hover:bg-[#0E576D]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-design-6 text-design-11 text-[#BFEAEC]">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-[calc(var(--design-unit)*4)] border px-design-5 py-design-2 text-center text-design-9 font-semibold leading-none',
|
||||
item.is_read
|
||||
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
|
||||
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
|
||||
)}
|
||||
>
|
||||
{item.is_read
|
||||
? t('game.modals.userInfo.message.read')
|
||||
: t('game.modals.userInfo.message.unread')}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{dayjs(item.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-design-3 flex items-center gap-design-8">
|
||||
<div className="truncate text-design-14 text-white">
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={blueBtnBg}
|
||||
size="100% 100%"
|
||||
className="flex h-design-34 w-design-78 shrink-0 items-center justify-center text-design-12 font-bold"
|
||||
>
|
||||
{t('game.modals.userInfo.message.check')}
|
||||
</SmartBackground>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-design-10 p-design-4">
|
||||
{noticeDetailQuery.isLoading ? (
|
||||
<DataLoadingIndicator
|
||||
label={t('game.modals.userInfo.message.loading')}
|
||||
/>
|
||||
) : noticeDetailQuery.isError ? (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.loadFailed')}
|
||||
</div>
|
||||
) : noticeDetailQuery.data ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-12 shadow-[0_0_calc(var(--design-unit)*18)_rgba(14,108,132,0.16)]">
|
||||
<div className="mb-design-10 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-10 py-design-4 text-design-11 text-[#9CE8F2]">
|
||||
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}
|
||||
</div>
|
||||
<div className="text-design-18 font-semibold leading-tight text-white">
|
||||
{noticeDetailQuery.data.title}
|
||||
</div>
|
||||
<div className="mt-design-12 whitespace-pre-wrap text-design-14 leading-[1.62] text-[#C4F2F7]">
|
||||
{noticeDetailQuery.data.content}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.message.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNoticeModal
|
||||
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
@@ -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 '@/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 MobilePeriodHistoryDrawerViewProps {
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
items: PeriodHistoryDisplayItem[]
|
||||
labels: PeriodHistoryDrawerLabels
|
||||
onClose: () => void
|
||||
onRetry: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function MobilePeriodHistoryDrawer() {
|
||||
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 (
|
||||
<MobilePeriodHistoryDrawerView
|
||||
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 MobilePeriodHistoryDrawerView({
|
||||
isError,
|
||||
isLoading,
|
||||
items,
|
||||
labels,
|
||||
onClose,
|
||||
onRetry,
|
||||
open,
|
||||
}: MobilePeriodHistoryDrawerViewProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import diamond from '@/assets/system/diamond.webp'
|
||||
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||
import topupBtnBg from '@/assets/system/topup.webp'
|
||||
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
function MobileProceduresModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopProcedures)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const setWithdrawTopupType = useModalStore(
|
||||
(state) => state.setWithdrawTopupType,
|
||||
)
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
}
|
||||
|
||||
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||
setModalOpen('desktopProcedures', false)
|
||||
setWithdrawTopupType(type)
|
||||
setModalOpen('desktopWithdrawTopup', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-16'}>
|
||||
{t('game.modals.procedures.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-280"
|
||||
>
|
||||
<div className={'h-full flex flex-col px-design-5'}>
|
||||
<SmartBackground
|
||||
src={proceduresBg}
|
||||
repeat="no-repeat"
|
||||
size="cover"
|
||||
className={
|
||||
'flex-1 flex h-full min-h-0 w-full flex-col items-center justify-between overflow-hidden rounded-md px-design-14 py-design-18'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center gap-design-14 pl-design-50 py-design-8'
|
||||
}
|
||||
>
|
||||
<SmartImage
|
||||
className={'w-design-40 mt-design-15 ml-design-10'}
|
||||
alt={'diamond'}
|
||||
src={diamond}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'modal-title-gold-glow mt-design-15 text-design-22 font-bold tracking-[0.06em] text-[#F7DC7A]'
|
||||
}
|
||||
>
|
||||
{currentUser?.coin || 0}
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
<div
|
||||
className={
|
||||
'h-design-74 flex w-full items-center justify-center gap-design-30'
|
||||
}
|
||||
>
|
||||
<SmartBackground
|
||||
src={withdrawBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||
className={
|
||||
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-6 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.withdraw')}
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
src={topupBtnBg}
|
||||
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||
className={
|
||||
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-10 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||
}
|
||||
>
|
||||
{t('game.modals.procedures.topup')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileProceduresModal
|
||||
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { MobileRegisterForm } from '@/features/auth/components/mobile/mobile-register-form'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileRegisterModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopRegister', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={() => setModalOpen('desktopRegister', false)}
|
||||
title={
|
||||
<div className={'modal-title-glow'}>
|
||||
{t('game.modals.register.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="center"
|
||||
className="!h-[min(calc(var(--design-unit)*520),calc(100dvh-var(--design-unit)*28))]"
|
||||
backdropClassName="backdrop-blur-none"
|
||||
>
|
||||
<MobileRegisterForm onSuccess={handleSubmit} />
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileRegisterModal
|
||||
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileRulesModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopRules)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopRules', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.rules.title')}
|
||||
</div>
|
||||
}
|
||||
titleAlign="left"
|
||||
className="!h-design-320"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col gap-design-12 px-design-14 pb-design-16 pt-design-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-[calc(var(--design-unit)*10)] bg-black/35 px-design-14 py-design-12 text-design-12 leading-[1.62] text-[#B9E7EA] whitespace-pre-line">
|
||||
{t('game.modals.rules.content')}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-center">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
repeat="no-repeat"
|
||||
position="center"
|
||||
onClick={handleClose}
|
||||
className="modal-title-glow flex h-design-42 w-design-120 cursor-pointer items-center justify-center mt-design-1 pb-design-3 pl-design-5 text-design-14 font-bold"
|
||||
>
|
||||
{t('game.modals.rules.confirm')}
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileRulesModal
|
||||
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
const SUPPORT_CHAT_URL =
|
||||
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
|
||||
const IFRAME_READY_DELAY_MS = 2_000
|
||||
|
||||
function MobileSupportModal() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const readyTimerRef = useRef<number | null>(null)
|
||||
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
const clearReadyTimer = useCallback(() => {
|
||||
if (readyTimerRef.current === null) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(readyTimerRef.current)
|
||||
readyTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen('desktopSupport', false)
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
clearReadyTimer()
|
||||
readyTimerRef.current = window.setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
readyTimerRef.current = null
|
||||
}, IFRAME_READY_DELAY_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
clearReadyTimer()
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return clearReadyTimer
|
||||
}, [clearReadyTimer, open])
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
isNormalBg={true}
|
||||
onClose={handleClose}
|
||||
titleAlign="left"
|
||||
title={<div className="modal-title-glow text-design-16">在线客服</div>}
|
||||
className="h-design-500"
|
||||
>
|
||||
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
|
||||
<div className="relative h-full min-h-0 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*10)_rgba(31,156,174,0.14)]">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||
<DataLoadingIndicator
|
||||
label="客服连线中"
|
||||
className="text-design-12"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<iframe
|
||||
title="customer-service-chat"
|
||||
src={SUPPORT_CHAT_URL}
|
||||
onLoad={handleLoaded}
|
||||
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileSupportModal
|
||||
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
CircleUserRound,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
ReceiptText,
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { logoutWithPassword } from '@/api'
|
||||
import avatar from '@/assets/system/avatar.webp'
|
||||
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
||||
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||
import { notify } from '@/lib/notify'
|
||||
import { cn } from '@/lib/utils'
|
||||
import MobileFinanceRecordsTab from '@/modal/mobile/mobile-finance-records-tab'
|
||||
import MobileWalletRecordsTab from '@/modal/mobile/mobile-wallet-records-tab'
|
||||
import { useAuthStore, useModalStore } from '@/store'
|
||||
|
||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||
|
||||
const USER_INFO_TABS: Array<{
|
||||
key: UserInfoTabKey
|
||||
labelKey: string
|
||||
icon: typeof CircleUserRound
|
||||
}> = [
|
||||
{
|
||||
key: 'profile',
|
||||
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||
icon: CircleUserRound,
|
||||
},
|
||||
{
|
||||
key: 'financeRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||
icon: ReceiptText,
|
||||
},
|
||||
{
|
||||
key: 'walletRecords',
|
||||
labelKey: 'game.modals.userInfo.tabs.walletRecords',
|
||||
icon: WalletCards,
|
||||
},
|
||||
]
|
||||
|
||||
function createRegisterInviteUrl(inviteCode: string) {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy')
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
|
||||
function MobileUserInfoModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopUserInfo)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
|
||||
const currentUser = useAuthStore((state) => state.currentUser)
|
||||
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
|
||||
const logoutUsername =
|
||||
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logoutWithPassword,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab('profile')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink() {
|
||||
if (!inviteCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
|
||||
notify.success(t('commonUi.toast.inviteLinkCopied'))
|
||||
} catch {
|
||||
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (logoutMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutMutation.mutateAsync({
|
||||
password: '',
|
||||
username: logoutUsername,
|
||||
})
|
||||
notify.success(t('commonUi.toast.logoutSuccess'))
|
||||
} catch {
|
||||
notify.warning(t('commonUi.toast.logoutLocalOnly'))
|
||||
} finally {
|
||||
clearAuthenticatedSession({ clearBrowserStorage: true })
|
||||
setModalOpen('desktopUserInfo', false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className="modal-title-glow text-design-16">
|
||||
{t('game.modals.userInfo.title')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-420"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col px-design-8 pb-design-10 pt-design-2">
|
||||
<div className="relative mb-design-10 grid h-design-40 shrink-0 grid-cols-3 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[#2B8CA3]/35 bg-[#031B24]/75 p-design-3">
|
||||
{USER_INFO_TABS.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = tab.key === activeTab
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'relative flex min-w-0 cursor-pointer items-center justify-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*8)] px-design-5 transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#FEEEB0]'
|
||||
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-bg"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-[calc(var(--design-unit)*8)] bg-[linear-gradient(180deg,rgba(254,238,176,0.34)_0%,rgba(254,238,176,0.15)_100%)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="user-info-tab-active-indicator"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-design-10 bottom-0 h-[calc(var(--design-unit)*2)] rounded-full bg-[linear-gradient(90deg,rgba(255,248,214,0.2),rgba(254,238,176,0.95),rgba(255,248,214,0.2))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 430,
|
||||
damping: 36,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 transition',
|
||||
isActive &&
|
||||
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.06 : 1,
|
||||
y: isActive ? -2 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
<Icon className="h-design-17 w-design-17" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative z-10 min-w-0 truncate text-center text-design-11 leading-tight',
|
||||
isActive && 'modal-title-gold-glow',
|
||||
)}
|
||||
animate={{
|
||||
scale: isActive ? 1.04 : 1,
|
||||
y: isActive ? -1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</motion.div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{activeTab === 'profile' ? (
|
||||
<SmartBackground
|
||||
src={userInfoBg}
|
||||
size="140% 100%"
|
||||
className={
|
||||
'relative flex h-design-250 min-h-0 w-full flex-col overflow-hidden bg-top bg-no-repeat px-design-14 py-design-14 text-[#6CCDCF]'
|
||||
}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center gap-design-12">
|
||||
<SmartImage
|
||||
className="h-design-58 w-design-58 shrink-0"
|
||||
src={currentUser?.headImage || avatar}
|
||||
alt={'avatar'}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-design-14 leading-[1.55] text-[#6CCDCF]">
|
||||
<div className="truncate">
|
||||
{t('game.modals.userInfo.profile.name')} :
|
||||
{currentUser?.name ?? '--'}
|
||||
</div>
|
||||
<div className="truncate mt-design-5">
|
||||
{t('game.modals.userInfo.profile.tel')} :{' '}
|
||||
{currentUser?.phone ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-24 min-h-0 w-full flex-1 overflow-hidden px-design-12 py-design-10 text-design-13 leading-[1.6]">
|
||||
<div className="text-[#6CCDCF]">
|
||||
{t('game.modals.userInfo.profile.registeredAt')}:
|
||||
<span className="ml-design-8 text-design-12 text-[#599AA3]">
|
||||
{currentUser?.createTime
|
||||
? dayjs
|
||||
.unix(currentUser.createTime)
|
||||
.format('YYYY-MM-DD HH:mm:ss')
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-10 flex min-w-0 items-center text-[#6CCDCF]">
|
||||
<span className="shrink-0">
|
||||
{t('auth.register.fields.inviteCode.label')}
|
||||
</span>
|
||||
<span className="ml-design-8 min-w-0 flex-1 truncate text-design-12 text-[#599AA3]">
|
||||
{inviteCode || '--'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleCopyInviteLink()
|
||||
}}
|
||||
disabled={!inviteCode}
|
||||
aria-label={t(
|
||||
'game.modals.userInfo.profile.copyInviteLink',
|
||||
)}
|
||||
title={t('game.modals.userInfo.profile.copyInviteLink')}
|
||||
className="ml-design-8 flex h-design-26 w-design-26 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<ClipboardList className="h-design-15 w-design-15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-design-30 flex w-full shrink-0 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="inline-flex h-design-36 min-w-design-126 cursor-pointer items-center justify-center gap-design-7 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-12 text-design-13 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<LogOut className="h-design-15 w-design-15" />
|
||||
<span>
|
||||
{logoutMutation.isPending
|
||||
? t('game.modals.userInfo.profile.loggingOut')
|
||||
: t('game.modals.userInfo.profile.logout')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
) : activeTab === 'financeRecords' ? (
|
||||
<MobileFinanceRecordsTab
|
||||
enabled={open && activeTab === 'financeRecords'}
|
||||
/>
|
||||
) : (
|
||||
<MobileWalletRecordsTab
|
||||
enabled={open && activeTab === 'walletRecords'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileUserInfoModal
|
||||
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||
import { useWalletRecordsVm } from '@/hooks/use-wallet-records-vm'
|
||||
|
||||
function MobileWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||
const vm = useWalletRecordsVm({ enabled })
|
||||
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||
estimateSize: () => 52,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 6,
|
||||
})
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (
|
||||
!lastItem ||
|
||||
lastItem.index < vm.items.length - 1 ||
|
||||
!vm.hasNextPage ||
|
||||
vm.isFetchingNextPage
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void vm.fetchNextPage()
|
||||
}, [
|
||||
virtualItems,
|
||||
vm.fetchNextPage,
|
||||
vm.hasNextPage,
|
||||
vm.isFetchingNextPage,
|
||||
vm.items.length,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-col p-design-4">
|
||||
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||
<div className="text-design-14 font-medium text-[#BFEAEC]">
|
||||
{vm.headers.type}
|
||||
</div>
|
||||
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||
{vm.pageLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||
<div className="min-w-design-520">
|
||||
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] gap-design-5 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-9 py-design-8 text-design-11 text-[#7ECAD1]">
|
||||
<div className="text-center">{vm.headers.amount}</div>
|
||||
<div>{vm.headers.balanceBefore}</div>
|
||||
<div>{vm.headers.balanceAfter}</div>
|
||||
<div>{vm.headers.time}</div>
|
||||
<div>{vm.headers.remark}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||
>
|
||||
{vm.isLoading ? (
|
||||
<DataLoadingIndicator label={vm.loadingText} />
|
||||
) : vm.isError ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.loadFailedText}
|
||||
</div>
|
||||
) : vm.items.length === 0 ? (
|
||||
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||
{vm.emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = vm.items[virtualRow.index]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 top-0 w-full pb-design-7"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<motion.div
|
||||
className="grid h-design-45 grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] items-center gap-design-5 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-design-11 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.16,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
<div className="truncate text-center font-medium text-[#FEEEB0]">
|
||||
{item.amountLabel}
|
||||
</div>
|
||||
<div className="truncate text-[#86DAE7]">
|
||||
{item.balanceBeforeLabel}
|
||||
</div>
|
||||
<div className="truncate text-[#7CFFCF]">
|
||||
{item.balanceAfterLabel}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-[#BFEAEC]">
|
||||
{item.timeLabel}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-white"
|
||||
title={item.remarkLabel}
|
||||
>
|
||||
{item.remarkLabel}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<DataLoadingIndicator
|
||||
compact
|
||||
label={vm.loadingText}
|
||||
className="h-design-45 rounded-md bg-[#0A4252]/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileWalletRecordsTab
|
||||
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||
import MobileTopup from '@/features/game/components/mobile/mobile-topup.tsx'
|
||||
import MobileWithdraw from '@/features/game/components/mobile/mobile-withdraw.tsx'
|
||||
import { useModalStore } from '@/store'
|
||||
|
||||
function MobileWithdrawTopupModal() {
|
||||
const { t } = useTranslation()
|
||||
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||
const type = useModalStore((state) => state.withdrawTopupType)
|
||||
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||
|
||||
function handleSubmit() {
|
||||
setModalOpen('desktopWithdrawTopup', false)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCenterModal
|
||||
open={open}
|
||||
onClose={handleSubmit}
|
||||
title={
|
||||
<div className={'modal-title-glow text-design-16 uppercase'}>
|
||||
{type === 'withdraw'
|
||||
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||
: t('game.modals.withdrawTopup.applyTopup')}
|
||||
</div>
|
||||
}
|
||||
isNormalBg={true}
|
||||
titleAlign="left"
|
||||
className="h-design-510"
|
||||
>
|
||||
<div className={'h-full min-h-0 w-full'}>
|
||||
{type === 'withdraw' ? <MobileWithdraw /> : <MobileTopup />}
|
||||
</div>
|
||||
</MobileCenterModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileWithdrawTopupModal
|
||||
Reference in New Issue
Block a user