docs(game): 添加游戏模块数据
- 新增 useGameBoardVm 数据层实施说明文档 - 添加 36字花核心玩法与前端规则摘要 - 创建游戏模块数据与界面分层第一阶段实施稿 - 定义四层架构:api/dto、store、view-model hooks、ui层 - 规范 PC 与 Mobile 共享业务逻辑的改造方案 - 明确各层职责边界和组件改造顺序
This commit is contained in:
@@ -11,48 +11,47 @@ import reduce from '@/assets/game/reduce.webp'
|
||||
import totalBg from '@/assets/game/total-bg.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { SmartImage } from '@/components/smart-image.tsx'
|
||||
import { ACTION_OPTIONS, CHIP_OPTIONS } from '@/constants'
|
||||
import { ACTION_OPTIONS } from '@/constants'
|
||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DesktopControl() {
|
||||
const [chips, setChips] = useState(CHIP_OPTIONS)
|
||||
const [selectedChipId, setSelectedChipId] = useState(
|
||||
CHIP_OPTIONS[CHIP_OPTIONS.length - 1]?.id ?? '',
|
||||
)
|
||||
const {
|
||||
canClear,
|
||||
chips,
|
||||
onChipSelect,
|
||||
onClearSelections,
|
||||
selectedChipAmountLabel,
|
||||
selectedChipId,
|
||||
selectedCountLabel,
|
||||
totalBetAmountLabel,
|
||||
} = useGameControlVm()
|
||||
|
||||
const [clickedId, setClickedId] = useState<string | null>(null)
|
||||
const [hidingId, setHidingId] = useState<string | null>(null)
|
||||
const [confirmClicked, setConfirmClicked] = useState(false)
|
||||
|
||||
const selectedChip =
|
||||
chips.find((chip) => chip.id === selectedChipId) ?? CHIP_OPTIONS[0]
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
setSelectedChipId(chipId)
|
||||
setChips((current) => {
|
||||
const next = [...current]
|
||||
const index = next.findIndex((chip) => chip.id === chipId)
|
||||
|
||||
if (index === -1 || index === next.length - 1) {
|
||||
return next
|
||||
}
|
||||
|
||||
const [selected] = next.splice(index, 1)
|
||||
next.push(selected)
|
||||
|
||||
return next
|
||||
})
|
||||
onChipSelect(chipId)
|
||||
}
|
||||
|
||||
const handleActionClick = useCallback((id: string) => {
|
||||
setClickedId(id)
|
||||
setTimeout(() => {
|
||||
setClickedId(null)
|
||||
setHidingId(id)
|
||||
const handleActionClick = useCallback(
|
||||
(id: string) => {
|
||||
if (id === 'clear' && canClear) {
|
||||
onClearSelections()
|
||||
}
|
||||
|
||||
setClickedId(id)
|
||||
setTimeout(() => {
|
||||
setHidingId(null)
|
||||
}, 180)
|
||||
}, 200)
|
||||
}, [])
|
||||
setClickedId(null)
|
||||
setHidingId(id)
|
||||
setTimeout(() => {
|
||||
setHidingId(null)
|
||||
}, 180)
|
||||
}, 200)
|
||||
},
|
||||
[canClear, onClearSelections],
|
||||
)
|
||||
|
||||
const handleConfirmClick = useCallback(() => {
|
||||
setConfirmClicked(true)
|
||||
@@ -202,7 +201,7 @@ export function DesktopControl() {
|
||||
>
|
||||
<motion.img
|
||||
src={chip.src}
|
||||
alt={`chip-${chip.value}`}
|
||||
alt={`chip-${chip.amount}`}
|
||||
draggable={false}
|
||||
className={'h-design-70 w-design-70 object-contain'}
|
||||
/>
|
||||
@@ -225,7 +224,7 @@ export function DesktopControl() {
|
||||
<div
|
||||
className={'w-design-80 h-full flex items-center justify-center'}
|
||||
>
|
||||
{selectedChip.value}
|
||||
{selectedChipAmountLabel}
|
||||
</div>
|
||||
<SmartImage
|
||||
src={reduce}
|
||||
@@ -241,8 +240,8 @@ export function DesktopControl() {
|
||||
'desktop-control-total relative flex flex-col items-center justify-center z-10 h-full w-design-435 shrink-0 bg-center bg-no-repeat'
|
||||
}
|
||||
>
|
||||
<div>SELECTED:3/5</div>
|
||||
<div>Total Bet:150</div>
|
||||
<div>SELECTED:{selectedCountLabel}</div>
|
||||
<div>Total Bet:{totalBetAmountLabel}</div>
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
src={controlBg}
|
||||
|
||||
@@ -1,119 +1,9 @@
|
||||
import historyBg from '@/assets/system/history-bg.png'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
||||
|
||||
export function DesktopGameHistory() {
|
||||
const data = [
|
||||
{
|
||||
order_no: 'BET202604290001',
|
||||
period_no: '202604290101',
|
||||
numbers: [3, 8, 12],
|
||||
bet_amount: '100.00',
|
||||
total_amount: '100.00',
|
||||
result_number: 8,
|
||||
win_amount: '330.00',
|
||||
status: 'won',
|
||||
create_time: 1745881200,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290002',
|
||||
period_no: '202604290102',
|
||||
numbers: [5],
|
||||
bet_amount: '50.00',
|
||||
total_amount: '50.00',
|
||||
result_number: 11,
|
||||
win_amount: '0.00',
|
||||
status: 'lost',
|
||||
create_time: 1745882100,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290003',
|
||||
period_no: '202604290103',
|
||||
numbers: [1, 7],
|
||||
bet_amount: '88.00',
|
||||
total_amount: '88.00',
|
||||
result_number: 7,
|
||||
win_amount: '176.00',
|
||||
status: 'won',
|
||||
create_time: 1745883000,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290004',
|
||||
period_no: '202604290104',
|
||||
numbers: [9, 10, 15],
|
||||
bet_amount: '120.00',
|
||||
total_amount: '120.00',
|
||||
result_number: 4,
|
||||
win_amount: '0.00',
|
||||
status: 'settled',
|
||||
create_time: 1745883900,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290005',
|
||||
period_no: '202604290105',
|
||||
numbers: [6],
|
||||
bet_amount: '66.00',
|
||||
total_amount: '66.00',
|
||||
result_number: null,
|
||||
win_amount: '0.00',
|
||||
status: 'pending',
|
||||
create_time: 1745884800,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290006',
|
||||
period_no: '202604290106',
|
||||
numbers: [2, 14],
|
||||
bet_amount: '200.00',
|
||||
total_amount: '200.00',
|
||||
result_number: 14,
|
||||
win_amount: '400.00',
|
||||
status: 'won',
|
||||
create_time: 1745885700,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290007',
|
||||
period_no: '202604290107',
|
||||
numbers: [13],
|
||||
bet_amount: '30.00',
|
||||
total_amount: '30.00',
|
||||
result_number: 13,
|
||||
win_amount: '99.00',
|
||||
status: 'won',
|
||||
create_time: 1745886600,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290008',
|
||||
period_no: '202604290108',
|
||||
numbers: [4, 16],
|
||||
bet_amount: '150.00',
|
||||
total_amount: '150.00',
|
||||
result_number: 1,
|
||||
win_amount: '0.00',
|
||||
status: 'lost',
|
||||
create_time: 1745887500,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290009',
|
||||
period_no: '202604290109',
|
||||
numbers: [11, 18, 20],
|
||||
bet_amount: '300.00',
|
||||
total_amount: '300.00',
|
||||
result_number: null,
|
||||
win_amount: '0.00',
|
||||
status: 'pending',
|
||||
create_time: 1745888400,
|
||||
},
|
||||
{
|
||||
order_no: 'BET202604290010',
|
||||
period_no: '202604290110',
|
||||
numbers: [17],
|
||||
bet_amount: '80.00',
|
||||
total_amount: '80.00',
|
||||
result_number: 17,
|
||||
win_amount: '264.00',
|
||||
status: 'won',
|
||||
create_time: 1745889300,
|
||||
},
|
||||
]
|
||||
const { emptyText, isEmpty, items } = useGameHistoryVm()
|
||||
|
||||
return (
|
||||
<SmartBackground
|
||||
@@ -133,49 +23,66 @@ export function DesktopGameHistory() {
|
||||
'history-scroll-hidden z-10 flex min-h-0 flex-1 w-full flex-col gap-design-10 overflow-y-auto overflow-x-hidden px-design-20 py-design-20'
|
||||
}
|
||||
>
|
||||
{data.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.order_no}
|
||||
className={
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
|
||||
}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={
|
||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||
'common-neon-inset flex w-full flex-col items-center !p-0 text-[#FFE375]'
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
||||
<span className={'text-[#C0E7EB]'}>{item.order_no}</span>
|
||||
<div
|
||||
className={
|
||||
'common-neon-inset w-full !rounded-b-none text-center text-design-20'
|
||||
}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Animals Bet: </span>
|
||||
<span>{item.numbers.join(', ')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Total Bet Amount: </span>
|
||||
<span className={'text-[#FFE375]'}>{item.bet_amount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}> Winning Result:</span>
|
||||
<span className={'text-[#FF7575]'}>
|
||||
{' '}
|
||||
{item.result_number === null ? '--' : item.result_number}
|
||||
</span>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col gap-design-5 px-design-10 py-design-10 text-design-16'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Round ID: </span>
|
||||
<span className={'text-[#C0E7EB]'}>{item.roundId}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Settled At: </span>
|
||||
<span>{item.settledAtLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>
|
||||
Total Pool Amount:{' '}
|
||||
</span>
|
||||
<span className={'text-[#FFE375]'}>
|
||||
{item.totalPoolAmountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Winning Result: </span>
|
||||
<span className={'text-[#FF7575]'}>
|
||||
{item.winningCellIdLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={'text-[#84A2A2]'}>Payout: </span>
|
||||
<span>{item.payoutMultiplierLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SmartBackground>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,20 @@ import statusLine from '@/assets/system/status-line.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
||||
|
||||
export function DesktopStatusLine() {
|
||||
const {
|
||||
countdownMs,
|
||||
limitLabel,
|
||||
oddsLabel,
|
||||
phaseDescription,
|
||||
phaseLabel,
|
||||
phaseToneClassName,
|
||||
roundId,
|
||||
streakLabel,
|
||||
} = useGameStatusVm()
|
||||
|
||||
return (
|
||||
<div className={'relative w-full flex flex-col text-design-22'}>
|
||||
<SmartBackground
|
||||
@@ -12,10 +24,12 @@ export function DesktopStatusLine() {
|
||||
size="100% 100%"
|
||||
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center"
|
||||
>
|
||||
<div className={'flex-1 flex items-center justify-center'}>
|
||||
<div>Odds: 1:33</div>
|
||||
<div>Streak: X2</div>
|
||||
<div>Limit: 100</div>
|
||||
<div
|
||||
className={'flex-1 flex items-center justify-center gap-design-24'}
|
||||
>
|
||||
<div>Odds: {oddsLabel}</div>
|
||||
<div>Streak: {streakLabel}</div>
|
||||
<div>Limit: {limitLabel}</div>
|
||||
</div>
|
||||
<SmartBackground
|
||||
src={statusCenter}
|
||||
@@ -23,22 +37,22 @@ export function DesktopStatusLine() {
|
||||
size="contain"
|
||||
>
|
||||
<DesktopCountdown
|
||||
initialSeconds={30}
|
||||
initialMs={countdownMs}
|
||||
onComplete={() => {
|
||||
console.log('countdown finished')
|
||||
}}
|
||||
/>
|
||||
</SmartBackground>
|
||||
<div className={'flex-1 flex items-center justify-center gap-10'}>
|
||||
<div>Round ID:20241026120</div>
|
||||
<div>Round ID:{roundId}</div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div
|
||||
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
|
||||
></div>
|
||||
<div className={'text-[#78FF7F]'}>OPEN</div>
|
||||
<div className={phaseToneClassName}>{phaseLabel}</div>
|
||||
</div>
|
||||
<div>(Menerima Taruhan)</div>
|
||||
<div>{phaseDescription}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartBackground>
|
||||
|
||||
@@ -1,5 +1,615 @@
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const AVAILABLE_BALANCE = 6628
|
||||
const MYR_PER_100_DIAMONDS = 1
|
||||
const USDT_TO_MYR_RATE = 4.049
|
||||
const VND_PER_DIAMOND = 10
|
||||
|
||||
const QUICK_AMOUNTS = [
|
||||
{ diamonds: 210, preview: 'MYR 3' },
|
||||
{ diamonds: 2250, preview: 'MYR 30' },
|
||||
{ diamonds: 4000, preview: 'MYR 50' },
|
||||
{ diamonds: 8000, preview: 'MYR 100' },
|
||||
{ diamonds: 17000, preview: 'MYR 200' },
|
||||
{ diamonds: 45000, preview: 'MYR 500' },
|
||||
] as const
|
||||
|
||||
const CURRENCY_OPTIONS = ['MYR'] as const
|
||||
|
||||
const PAYMENT_CHANNELS = [
|
||||
{
|
||||
id: 'alipay-primary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-secondary',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
{
|
||||
id: 'alipay-third',
|
||||
label: 'Alipay',
|
||||
glyph: '支',
|
||||
},
|
||||
] as const
|
||||
|
||||
const BANK_OPTIONS = [
|
||||
{
|
||||
id: 'bca',
|
||||
label: 'BCA',
|
||||
brand: 'BCA',
|
||||
subtitle: 'Bank Central Asia',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(251,252,255,0.98),rgba(224,239,255,0.96))] text-[#1E53A4]',
|
||||
},
|
||||
{
|
||||
id: 'mandiri',
|
||||
label: 'Mandiri',
|
||||
brand: 'mandiri',
|
||||
subtitle: 'Mandiri',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(26,53,93,0.98),rgba(9,22,43,0.96))] text-[#F5C247]',
|
||||
},
|
||||
{
|
||||
id: 'bni',
|
||||
label: 'BNI',
|
||||
brand: 'BNI',
|
||||
subtitle: 'BNI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(254,253,252,0.98),rgba(239,242,247,0.96))] text-[#E1742B]',
|
||||
},
|
||||
{
|
||||
id: 'bri',
|
||||
label: 'BRI',
|
||||
brand: 'BRI',
|
||||
subtitle: 'BRI',
|
||||
surface:
|
||||
'bg-[linear-gradient(180deg,rgba(253,254,255,0.98),rgba(234,243,255,0.96))] text-[#0E56A5]',
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaymentChannelId = (typeof PAYMENT_CHANNELS)[number]['id']
|
||||
type BankId = (typeof BANK_OPTIONS)[number]['id']
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US')
|
||||
const fixedTwoFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const fixedSixFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 6,
|
||||
maximumFractionDigits: 6,
|
||||
})
|
||||
|
||||
const PANEL_CLASS =
|
||||
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,225,238,0.08),0_0_calc(var(--design-unit)*10)_rgba(32,163,186,0.12)]'
|
||||
|
||||
const SELECTABLE_CARD_CLASS =
|
||||
'flex shrink-0 cursor-pointer flex-col items-center justify-between rounded-[calc(var(--design-unit)*6)] border px-design-8 py-design-8 transition'
|
||||
|
||||
const SELECTABLE_CARD_ACTIVE_CLASS =
|
||||
'border-[#D18A43] bg-[linear-gradient(180deg,rgba(65,45,28,0.92),rgba(39,26,16,0.9))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
|
||||
const SELECTABLE_CARD_IDLE_CLASS =
|
||||
'border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(8,34,48,0.92),rgba(5,19,29,0.94))] hover:border-[rgba(170,247,255,0.7)]'
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatFixedTwo(value: number) {
|
||||
return fixedTwoFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatFixedSix(value: number) {
|
||||
return fixedSixFormatter.format(value)
|
||||
}
|
||||
|
||||
function WithdrawField({
|
||||
label,
|
||||
children,
|
||||
alignStart = true,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
alignStart?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-design-14">
|
||||
<div className="flex w-design-108 shrink-0 items-center justify-end text-right text-design-16 font-medium uppercase leading-[1.15] tracking-[0.04em] text-[#6FD4DA]">
|
||||
<span>{label}</span>
|
||||
<span className="pl-design-4">:</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1',
|
||||
alignStart ? 'pt-design-2' : 'flex items-center',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AmountShell({
|
||||
amount,
|
||||
onMinus,
|
||||
onPlus,
|
||||
}: {
|
||||
amount: number
|
||||
onMinus: () => void
|
||||
onPlus: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-6">
|
||||
<div className="flex h-design-52 items-center gap-design-10 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-10 shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(93,239,255,0.08)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinus}
|
||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Minus className="h-design-16 w-design-16" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center text-design-24 font-medium tracking-[0.04em] text-[#A1EBF3]">
|
||||
{formatNumber(amount)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlus}
|
||||
className="flex h-design-34 w-design-34 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||
>
|
||||
<Plus className="h-design-16 w-design-16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-design-8 text-design-14 text-[#6DAAB0]">
|
||||
Saldo Tersedia: {formatNumber(AVAILABLE_BALANCE)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickAmountCard({
|
||||
amount,
|
||||
preview,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
amount: number
|
||||
preview: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-design-68 w-design-104 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border transition',
|
||||
active
|
||||
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(84,48,24,0.92),rgba(60,34,18,0.88))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18)]'
|
||||
: 'border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(10,44,58,0.84),rgba(5,21,32,0.92))] hover:border-[rgba(170,247,255,0.7)]',
|
||||
)}
|
||||
>
|
||||
<div className="text-design-24 font-semibold leading-none text-[#FFE229]">
|
||||
{amount}
|
||||
</div>
|
||||
<div className="pt-design-6 text-design-12 uppercase leading-none tracking-[0.04em] text-[#63AEB6]">
|
||||
{preview}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PaymentCard({
|
||||
active,
|
||||
label,
|
||||
glyph,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
glyph: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-92 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-58 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-42 font-semibold leading-none',
|
||||
active
|
||||
? 'bg-[linear-gradient(180deg,#1F9DE8,#0E6BCF)] text-white'
|
||||
: 'bg-[linear-gradient(180deg,#1C96DF,#0B6ECF)] text-white',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
<div className="text-design-14 text-[#AEE8EE]">{label}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BankCard({
|
||||
active,
|
||||
brand,
|
||||
subtitle,
|
||||
surface,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
brand: string
|
||||
subtitle: string
|
||||
surface: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
SELECTABLE_CARD_CLASS,
|
||||
'h-design-86 w-design-86',
|
||||
active ? SELECTABLE_CARD_ACTIVE_CLASS : SELECTABLE_CARD_IDLE_CLASS,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-design-52 w-full items-center justify-center rounded-[calc(var(--design-unit)*4)] text-design-20 font-bold uppercase',
|
||||
surface,
|
||||
)}
|
||||
>
|
||||
{brand}
|
||||
</div>
|
||||
<div className="text-design-13 text-[#AEE8EE]">{subtitle}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InputShell({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
errorMessage,
|
||||
uppercase = false,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
error?: boolean
|
||||
errorMessage?: string
|
||||
uppercase?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-design-5">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-design-42 rounded-[calc(var(--design-unit)*5)] border px-design-14 text-design-16',
|
||||
uppercase && 'uppercase',
|
||||
error
|
||||
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
||||
)}
|
||||
/>
|
||||
{error && errorMessage ? (
|
||||
<div className="pl-design-2 text-design-13 text-[#F44F4F]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string
|
||||
value: ReactNode
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
||||
<div className="flex w-[44%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-14 py-design-20 text-design-16 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center justify-end px-design-14 py-design-20 text-right text-design-16 text-[#E6FFFF]',
|
||||
highlight && 'text-design-18 font-semibold text-[#6DFF83]',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopWithdraw() {
|
||||
return <div>DesktopWithdraw</div>
|
||||
const [amount, setAmount] = useState(6626)
|
||||
const [currency, setCurrency] =
|
||||
useState<(typeof CURRENCY_OPTIONS)[number]>('MYR')
|
||||
const [paymentChannel, setPaymentChannel] =
|
||||
useState<PaymentChannelId>('alipay-primary')
|
||||
const [bank, setBank] = useState<BankId>('bca')
|
||||
const [holderName, setHolderName] = useState('')
|
||||
const [bankAccount, setBankAccount] = useState('')
|
||||
const [receiverEmail, setReceiverEmail] = useState('')
|
||||
const [receiverPhone, setReceiverPhone] = useState('')
|
||||
|
||||
const withdrawMyr = amount / 100
|
||||
const withdrawVnd = amount * VND_PER_DIAMOND
|
||||
const withdrawUsdt = withdrawMyr / USDT_TO_MYR_RATE
|
||||
|
||||
const selectedBank = BANK_OPTIONS.find((item) => item.id === bank)
|
||||
const holderNameError = holderName.trim().length === 0
|
||||
const bankAccountError = bankAccount.trim().length === 0
|
||||
|
||||
function handleAmountChange(nextAmount: number) {
|
||||
setAmount(Math.max(0, nextAmount))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full px-design-12 pb-design-12 text-[#D9FFFF]">
|
||||
<div
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
'flex h-full min-h-0 w-full min-w-0 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-full min-w-0 flex-[1.7] flex-col px-design-16 py-design-14">
|
||||
<div className="flex flex-col gap-design-12">
|
||||
<WithdrawField label="Jumlah Penarikan Berlian">
|
||||
<AmountShell
|
||||
amount={amount}
|
||||
onMinus={() => handleAmountChange(amount - 1)}
|
||||
onPlus={() => handleAmountChange(amount + 1)}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Jenis Mata Uang" alignStart={false}>
|
||||
<Select
|
||||
value={currency}
|
||||
onValueChange={(value) =>
|
||||
setCurrency(value as (typeof CURRENCY_OPTIONS)[number])
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-design-52 w-full rounded-[calc(var(--design-unit)*6)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-16 text-left text-design-20 font-semibold text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(94,237,255,0.08)] data-[size=default]:h-design-52 [&_svg]:h-design-18 [&_svg]:w-design-18 [&_svg]:text-[#79DFEA]"
|
||||
aria-label="Currency selection"
|
||||
>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="min-w-(--radix-select-trigger-width) rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(8,36,48,0.98),rgba(4,18,28,0.98))] text-[#CFFDFF] shadow-[0_0_calc(var(--design-unit)*16)_rgba(56,241,255,0.12)]"
|
||||
>
|
||||
{CURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
className="rounded-[calc(var(--design-unit)*4)] px-design-12 py-design-10 text-design-18 focus:bg-[rgba(53,154,171,0.2)] focus:text-white"
|
||||
>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</WithdrawField>
|
||||
|
||||
<div className="flex gap-design-14">
|
||||
<div className="w-design-108 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-design-10">
|
||||
{QUICK_AMOUNTS.map((option) => (
|
||||
<QuickAmountCard
|
||||
key={option.diamonds}
|
||||
amount={option.diamonds}
|
||||
preview={option.preview}
|
||||
active={option.diamonds === amount}
|
||||
onClick={() => handleAmountChange(option.diamonds)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WithdrawField label="Saluran Pembayaran">
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{PAYMENT_CHANNELS.map((channel) => (
|
||||
<PaymentCard
|
||||
key={channel.id}
|
||||
active={channel.id === paymentChannel}
|
||||
label={channel.label}
|
||||
glyph={channel.glyph}
|
||||
onClick={() => setPaymentChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Kode Bank">
|
||||
<div className="flex flex-col gap-design-10">
|
||||
<div className="flex h-design-40 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.28)] bg-[linear-gradient(180deg,rgba(12,61,72,0.78),rgba(6,28,39,0.88))] px-design-12 text-design-15 uppercase tracking-[0.02em] text-[#A4EAF2] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.07)]">
|
||||
{`014${selectedBank?.label ?? 'BCA'} (${selectedBank?.subtitle ?? 'BANK CENTRAL ASIA'}): 014`}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-design-10">
|
||||
{BANK_OPTIONS.map((option) => (
|
||||
<BankCard
|
||||
key={option.id}
|
||||
active={option.id === bank}
|
||||
brand={option.brand}
|
||||
subtitle={option.label}
|
||||
surface={option.surface}
|
||||
onClick={() => setBank(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nama Pemegang Kartu">
|
||||
<InputShell
|
||||
value={holderName}
|
||||
onChange={setHolderName}
|
||||
placeholder="Mohon masukkan nama pemegang kartu."
|
||||
error={holderNameError}
|
||||
errorMessage="Mohon masukkan nama pemegang kartu."
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nomor Rekening Bank">
|
||||
<InputShell
|
||||
value={bankAccount}
|
||||
onChange={setBankAccount}
|
||||
placeholder="Silakan masukkan nomor rekening bank Anda."
|
||||
error={bankAccountError}
|
||||
errorMessage="Silakan masukkan nomor rekening bank Anda."
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Email Penerima" alignStart={false}>
|
||||
<InputShell
|
||||
value={receiverEmail}
|
||||
onChange={setReceiverEmail}
|
||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||
uppercase={true}
|
||||
/>
|
||||
</WithdrawField>
|
||||
|
||||
<WithdrawField label="Nomor Ponsel Penerima" alignStart={false}>
|
||||
<InputShell
|
||||
value={receiverPhone}
|
||||
onChange={setReceiverPhone}
|
||||
placeholder="SILAKAN MASUKKAN ALAMAT EMAIL PENERIMA."
|
||||
uppercase={true}
|
||||
/>
|
||||
</WithdrawField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px shrink-0 bg-[linear-gradient(180deg,rgba(89,209,223,0)_0%,rgba(89,209,223,0.4)_12%,rgba(89,209,223,0.5)_88%,rgba(89,209,223,0)_100%)]" />
|
||||
|
||||
<div className="flex min-h-full min-w-0 w-design-520 shrink-0 flex-col">
|
||||
<div className="flex h-design-44 items-center border-b border-[rgba(89,209,223,0.2)] bg-[linear-gradient(90deg,rgba(18,99,110,0.8),rgba(7,68,79,0.9))] px-design-12 text-design-20 font-semibold uppercase tracking-[0.04em] text-[#9AF5FB]">
|
||||
Pratinjau Penukaran
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-design-12 px-design-10 py-design-10">
|
||||
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||
<PreviewRow label="Jumlah Berlian" value={formatNumber(amount)} />
|
||||
<PreviewRow
|
||||
label="Kurs (MYR)"
|
||||
value={`${100 * MYR_PER_100_DIAMONDS} BERLIAN = 1 MYR`}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Dapat Ditukarkan MYR"
|
||||
value={`RM ${formatFixedTwo(withdrawMyr)}`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Nilai Tukar USDT/MYR"
|
||||
value={`1 USDT = RM ${USDT_TO_MYR_RATE}`}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Nilai Tukar (VND)"
|
||||
value={`${VND_PER_DIAMOND} BERLIAN = 1 VND`}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Dapat Dikonversi ke VND"
|
||||
value={`${formatNumber(withdrawVnd)} VND`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Dapat Ditukarkan dengan USDT"
|
||||
value={`${formatFixedSix(withdrawUsdt)} USDT`}
|
||||
highlight={true}
|
||||
/>
|
||||
<PreviewRow
|
||||
label="Jumlah Berlian Nilai Tukar Tetap"
|
||||
value="0-0-0 0:0:0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-12 py-design-10 text-design-16 leading-[1.35] text-[#F0B44A]">
|
||||
Nilai tukar berfungsi sebagai harga acuan; nilai tukar aktual yang
|
||||
berlaku ditentukan pada saat penarikan.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-design-8 px-design-2 text-design-16 uppercase leading-[1.35] text-[#7AD8E0]">
|
||||
<div>
|
||||
Dompet Elektronik:{' '}
|
||||
<span className="text-[#B9F4F8]">Minimal RM10</span>
|
||||
</div>
|
||||
<div>
|
||||
Bank: <span className="text-[#B9F4F8]">Minimal RM10</span>
|
||||
</div>
|
||||
<div>
|
||||
Waktu Pengerjaan:{' '}
|
||||
<span className="text-[#77FF76]">
|
||||
Dana Tiba Hanya Dalam 9 Detik.
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[#B9F4F8]">
|
||||
Melihat: Transaksi antara RM10 dan RM99,99 akan dikenakan biaya
|
||||
penarikan minimum sebesar RM1.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-design-10 pt-design-10">
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthGreenBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-18 font-bold uppercase tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
Membatalkan
|
||||
</SmartBackground>
|
||||
<SmartBackground
|
||||
as="button"
|
||||
type="button"
|
||||
src={lengthBlueBtn}
|
||||
size="100% 100%"
|
||||
className="flex h-design-64 w-design-200 shrink-0 cursor-pointer items-center justify-center pb-design-4 text-center text-design-17 font-bold uppercase leading-[1.05] tracking-[0.03em] text-[#F0FFFF] transition hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
Konfirmasi
|
||||
<br />
|
||||
Penarikan
|
||||
</SmartBackground>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DesktopWithdraw
|
||||
|
||||
Reference in New Issue
Block a user