feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon
2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
84 changed files with 1174 additions and 731 deletions
@@ -2,15 +2,8 @@ import { Share2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { CopyButton } from '@/components/copy-button'
import type { UserWalletData } from '../types'
@@ -31,33 +24,14 @@ export function AffiliateRewardsCard({
const { t } = useTranslation()
if (loading) {
return (
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
</CardHeader>
<CardContent className='space-y-6 pt-6'>
{/* Statistics Skeleton */}
<div className='grid grid-cols-1 gap-3'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='rounded-lg border p-3'>
<Skeleton className='h-3 w-16' />
<Skeleton className='mt-2 h-8 w-24' />
</div>
))}
<Card className='bg-muted/20 py-0'>
<CardContent className='grid gap-4 p-3 sm:p-4 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,0.72fr)_minmax(320px,1.15fr)] lg:items-center'>
<div>
<Skeleton className='h-5 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
</div>
{/* Affiliate Link Skeleton */}
<div className='space-y-3'>
<Skeleton className='h-3 w-32' />
<div className='flex gap-2'>
<Skeleton className='h-10 flex-1' />
<Skeleton className='size-9' />
</div>
</div>
{/* Info Section Skeleton */}
<Skeleton className='h-20 w-full rounded-lg' />
<Skeleton className='h-14 rounded-lg' />
<Skeleton className='h-10 rounded-lg' />
</CardContent>
</Card>
)
@@ -66,89 +40,64 @@ export function AffiliateRewardsCard({
const hasRewards = (user?.aff_quota ?? 0) > 0
return (
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Share2 className='h-4 w-4' />
<Card className='bg-muted/20 py-0'>
<CardContent className='grid gap-3 p-3 sm:gap-4 sm:p-4 lg:grid-cols-[minmax(200px,1fr)_minmax(180px,0.65fr)_minmax(280px,1fr)] lg:items-center'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='bg-background flex size-8 shrink-0 items-center justify-center rounded-lg border'>
<Share2 className='text-muted-foreground size-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
<h3 className='truncate text-sm font-semibold'>
{t('Referral Program')}
</CardTitle>
<CardDescription>
{t('Share your link and earn rewards')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='space-y-6 pt-6'>
{/* Statistics */}
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3 xl:grid-cols-1'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Pending')}
</div>
<div className='mt-2 text-2xl font-semibold break-all'>
{formatQuota(user?.aff_quota ?? 0)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Total Earned')}
</div>
<div className='mt-2 text-2xl font-semibold break-all'>
{formatQuota(user?.aff_history_quota ?? 0)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Invites')}
</div>
<div className='mt-2 text-2xl font-semibold'>
{user?.aff_count ?? 0}
</div>
</h3>
<p className='text-muted-foreground line-clamp-1 text-xs'>
{t(
'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
)}
</p>
</div>
</div>
{/* Transfer Button */}
{hasRewards && (
<Button onClick={onTransfer} className='w-full' variant='default'>
{t('Transfer to Balance')}
</Button>
)}
{/* Affiliate Link */}
<div className='space-y-3'>
<Label className='text-muted-foreground text-xs tracking-wider uppercase'>
{t('Your Referral Link')}
</Label>
<div className='flex gap-2'>
<Input
value={affiliateLink}
readOnly
className='border-muted bg-muted/30 font-mono text-sm'
/>
<CopyButton
value={affiliateLink}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy referral link')}
aria-label={t('Copy referral link')}
/>
</div>
<div className='grid grid-cols-3 gap-1.5 text-center'>
{[
[t('Pending'), formatQuota(user?.aff_quota ?? 0)],
[t('Total Earned'), formatQuota(user?.aff_history_quota ?? 0)],
[t('Invites'), String(user?.aff_count ?? 0)],
].map(([label, value]) => (
<div key={label}>
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
{label}
</div>
<div className='mt-0.5 truncate text-sm font-semibold tabular-nums'>
{value}
</div>
</div>
))}
</div>
{/* Info */}
<div className='bg-muted/30 space-y-2 rounded-lg p-4'>
<p className='text-muted-foreground text-sm leading-relaxed'>
{t(
'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
)}
</p>
<div className='flex items-center gap-2'>
<Input
value={affiliateLink}
readOnly
className='border-muted bg-background/70 h-9 min-w-0 flex-1 font-mono text-xs'
/>
<CopyButton
value={affiliateLink}
variant='outline'
className='size-9 shrink-0 bg-background'
iconClassName='size-4'
tooltip={t('Copy referral link')}
aria-label={t('Copy referral link')}
/>
{hasRewards && (
<Button
onClick={onTransfer}
className='h-9 shrink-0 px-3'
size='sm'
>
{t('Transfer to Balance')}
</Button>
)}
</div>
</CardContent>
</Card>
@@ -20,7 +20,7 @@ export function CreemProductsSection({
if (loading) {
return (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
<div className='grid grid-cols-2 gap-2 sm:grid-cols-2 sm:gap-3 md:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-24 rounded-lg' />
))}
@@ -33,14 +33,14 @@ export function CreemProductsSection({
}
return (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
<div className='grid grid-cols-2 gap-2 sm:grid-cols-2 sm:gap-3 md:grid-cols-3'>
{products.map((product) => (
<Card
key={product.productId}
className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md'
onClick={() => onProductSelect(product)}
>
<CardContent className='p-4 text-center'>
<CardContent className='p-3 text-center sm:p-4'>
<div className='mb-2 text-lg font-medium'>{product.name}</div>
<div className='text-muted-foreground mb-2 text-sm'>
{t('Quota')}: {formatNumber(product.quota)}
@@ -83,7 +83,7 @@ export function BillingHistoryDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-4xl'>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>{t('Billing History')}</DialogTitle>
<DialogDescription>
@@ -91,7 +91,7 @@ export function BillingHistoryDialog({
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='min-h-0 flex-1 space-y-3 sm:space-y-4'>
{/* Search and Filter Bar */}
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
@@ -100,14 +100,14 @@ export function BillingHistoryDialog({
placeholder={t('Search by order number...')}
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
className='pl-10'
className='h-9 pl-10'
/>
</div>
<Select
value={pageSize.toString()}
onValueChange={(value) => handlePageSizeChange(parseInt(value))}
>
<SelectTrigger className='w-32'>
<SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -120,11 +120,11 @@ export function BillingHistoryDialog({
</div>
{/* Records List */}
<ScrollArea className='h-[500px] pr-4'>
<ScrollArea className='h-[calc(100dvh-15rem)] pr-3 sm:h-[500px] sm:pr-4'>
{loading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='rounded-lg border p-4'>
<div key={i} className='rounded-lg border p-3 sm:p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' />
@@ -132,7 +132,7 @@ export function BillingHistoryDialog({
</div>
<Skeleton className='h-5 w-16' />
</div>
<div className='mt-3 grid grid-cols-3 gap-4'>
<div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
@@ -141,7 +141,7 @@ export function BillingHistoryDialog({
))}
</div>
) : records.length === 0 ? (
<div className='text-muted-foreground flex h-[400px] flex-col items-center justify-center text-center'>
<div className='text-muted-foreground flex h-[320px] flex-col items-center justify-center text-center sm:h-[400px]'>
<p className='text-sm font-medium'>
{t('No billing records found')}
</p>
@@ -158,13 +158,13 @@ export function BillingHistoryDialog({
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-4 transition-colors'
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between'>
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 space-y-1'>
<div className='flex items-center gap-2'>
<code className='text-foreground font-mono text-sm'>
<div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground truncate font-mono text-sm'>
{record.trade_no}
</code>
<Button
@@ -201,7 +201,7 @@ export function BillingHistoryDialog({
</div>
{/* Details Grid */}
<div className='mt-4 grid grid-cols-3 gap-4'>
<div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
Payment Method
@@ -34,7 +34,7 @@ export function CreemConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[425px]'>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
<DialogDescription>
@@ -42,7 +42,7 @@ export function CreemConfirmDialog({
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span>
@@ -59,7 +59,7 @@ export function CreemConfirmDialog({
</div>
</div>
<DialogFooter>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -48,7 +48,7 @@ export function PaymentConfirmDialog({
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-md'>
<AlertDialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle className='text-xl font-semibold'>
{t('Confirm Payment')}
@@ -58,7 +58,7 @@ export function PaymentConfirmDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Topup Amount')}
@@ -121,7 +121,7 @@ export function PaymentConfirmDialog({
</div>
</div>
<AlertDialogFooter>
<AlertDialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<AlertDialogCancel disabled={processing}>
{t('Cancel')}
</AlertDialogCancel>
@@ -49,7 +49,7 @@ export function TransferDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-md'>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-xl font-semibold'>
{t('Transfer Rewards')}
@@ -59,7 +59,7 @@ export function TransferDialog({
</DialogDescription>
</DialogHeader>
<div className='space-y-6 py-4'>
<div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')}
@@ -92,7 +92,7 @@ export function TransferDialog({
</div>
</div>
<DialogFooter>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -8,13 +8,12 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import {
Tooltip,
TooltipContent,
@@ -125,13 +124,13 @@ export function RechargeFormCard({
if (loading) {
return (
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
</CardHeader>
<CardContent className='space-y-6 pt-6'>
<div className='space-y-6'>
<CardContent className='space-y-4 p-3 sm:space-y-6 sm:p-5'>
<div className='space-y-4 sm:space-y-6'>
{/* Preset Amounts Skeleton */}
<div className='space-y-3'>
<Skeleton className='h-3 w-16' />
@@ -173,23 +172,12 @@ export function RechargeFormCard({
}
return (
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex min-w-0 items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<WalletCards className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Add Funds')}
</CardTitle>
<CardDescription>
{t('Choose an amount and payment method')}
</CardDescription>
</div>
</div>
{onOpenBilling && (
<TitledCard
title={t('Add Funds')}
description={t('Choose an amount and payment method')}
icon={<WalletCards className='h-4 w-4' />}
action={
onOpenBilling ? (
<Button
variant='outline'
size='sm'
@@ -199,21 +187,21 @@ export function RechargeFormCard({
<Receipt className='h-4 w-4' />
{t('Order History')}
</Button>
)}
</div>
</CardHeader>
<CardContent className='space-y-6 pt-6'>
) : null
}
contentClassName='space-y-4 sm:space-y-6'
>
{/* Online Topup Section */}
{hasAnyTopup ? (
<div className='space-y-6'>
<div className='space-y-4 sm:space-y-6'>
{hasConfigurableTopup && (
<>
{presetAmounts.length > 0 && (
<div className='space-y-3'>
<div className='space-y-2.5 sm:space-y-3'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Amount')}
</Label>
<div className='grid grid-cols-2 gap-3 md:grid-cols-4'>
<div className='grid grid-cols-2 gap-1.5 sm:gap-3 md:grid-cols-4'>
{presetAmounts.map((preset, index) => {
const discount =
preset.discount ||
@@ -235,7 +223,7 @@ export function RechargeFormCard({
key={index}
variant='outline'
className={cn(
'hover:border-foreground flex h-auto flex-col items-start rounded-lg p-4 text-left whitespace-normal',
'hover:border-foreground flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
selectedPreset === preset.value
? 'border-foreground bg-foreground/5'
: 'border-muted'
@@ -243,7 +231,7 @@ export function RechargeFormCard({
onClick={() => onSelectPreset(preset)}
>
<div className='flex w-full items-center justify-between'>
<div className='text-lg font-semibold'>
<div className='text-base font-semibold sm:text-lg'>
{formatNumber(displayValue)}
</div>
{hasDiscount && (
@@ -252,7 +240,7 @@ export function RechargeFormCard({
</div>
)}
</div>
<div className='text-muted-foreground mt-2 w-full text-xs'>
<div className='text-muted-foreground mt-1.5 w-full text-xs sm:mt-2'>
Pay {formatCurrency(actualPrice)}
{hasDiscount && savedAmount > 0 && (
<span className='text-green-600'>
@@ -268,14 +256,14 @@ export function RechargeFormCard({
</div>
)}
<div className='space-y-3'>
<div className='space-y-2.5 sm:space-y-3'>
<Label
htmlFor='topup-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
>
{t('Custom Amount')}
</Label>
<div className='grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center'>
<div className='grid grid-cols-[minmax(0,1fr)_minmax(110px,0.55fr)] gap-2 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center'>
<Input
id='topup-amount'
type='number'
@@ -283,10 +271,10 @@ export function RechargeFormCard({
onChange={(e) => handleAmountChange(e.target.value)}
min={minTopup}
placeholder={`Minimum ${minTopup}`}
className='text-lg'
className='h-9 text-base sm:h-10 sm:text-lg'
/>
<div className='bg-muted/30 flex min-h-10 items-center justify-between gap-3 rounded-md border px-3 lg:min-w-52'>
<span className='text-muted-foreground text-xs'>
<div className='bg-muted/30 flex min-h-9 items-center justify-between gap-2 rounded-md border px-3 lg:min-w-52'>
<span className='text-muted-foreground truncate text-xs'>
{t('Amount to pay:')}
</span>
{calculating ? (
@@ -300,12 +288,12 @@ export function RechargeFormCard({
</div>
</div>
<div className='space-y-3'>
<div className='space-y-2.5 sm:space-y-3'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Payment Method')}
</Label>
{hasStandardPaymentMethods ? (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<div className='grid grid-cols-2 gap-1.5 sm:gap-3 lg:grid-cols-3'>
{topupInfo?.pay_methods?.map((method) => {
const minTopup = method.min_topup || 0
const disabled = minTopup > topupAmount
@@ -316,7 +304,7 @@ export function RechargeFormCard({
variant='outline'
onClick={() => onPaymentMethodSelect(method)}
disabled={disabled || !!paymentLoading}
className='justify-start gap-2 rounded-lg'
className='h-9 min-w-0 justify-start gap-2 rounded-lg px-3'
>
{paymentLoading === method.type ? (
<Loader2 className='h-4 w-4 animate-spin' />
@@ -328,7 +316,7 @@ export function RechargeFormCard({
method.name
)
)}
{method.name}
<span className='truncate'>{method.name}</span>
</Button>
)
@@ -362,11 +350,11 @@ export function RechargeFormCard({
{enableWaffoTopup &&
hasWaffoPaymentMethods &&
onWaffoMethodSelect && (
<div className='space-y-3'>
<div className='space-y-2.5 sm:space-y-3'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Waffo Payment')}
</Label>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<div className='grid grid-cols-2 gap-1.5 sm:gap-3 lg:grid-cols-3'>
{waffoPayMethods?.map((method, index) => {
const loadingKey = `waffo-${index}`
const waffoMin = waffoMinTopup || 0
@@ -378,7 +366,7 @@ export function RechargeFormCard({
variant='outline'
onClick={() => onWaffoMethodSelect(method, index)}
disabled={belowMin || !!paymentLoading}
className='justify-start gap-2 rounded-lg'
className='h-9 min-w-0 justify-start gap-2 rounded-lg px-3'
>
{paymentLoading === loadingKey ? (
<Loader2 className='h-4 w-4 animate-spin' />
@@ -391,7 +379,7 @@ export function RechargeFormCard({
) : (
getPaymentIcon('waffo')
)}
{method.name}
<span className='truncate'>{method.name}</span>
</Button>
)
@@ -433,7 +421,7 @@ export function RechargeFormCard({
Array.isArray(creemProducts) &&
creemProducts.length > 0 &&
onCreemProductSelect && (
<div className='space-y-3 border-t pt-6'>
<div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Creem Payment')}
</Label>
@@ -445,7 +433,7 @@ export function RechargeFormCard({
)}
{/* Redemption Code Section */}
<div className='space-y-3 border-t pt-6'>
<div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
<div className='flex items-center gap-2'>
<Gift className='text-muted-foreground h-4 w-4' />
<Label
@@ -455,19 +443,19 @@ export function RechargeFormCard({
{t('Have a Code?')}
</Label>
</div>
<div className='flex flex-col gap-2 sm:flex-row'>
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Input
id='redemption-code'
value={redemptionCode}
onChange={(e) => onRedemptionCodeChange(e.target.value)}
placeholder={t('Enter your redemption code')}
className='flex-1'
className='h-9 min-w-0'
/>
<Button
onClick={onRedeem}
disabled={redeeming}
variant='outline'
className='sm:w-auto'
className='h-9 px-4'
>
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Redeem')}
@@ -488,7 +476,6 @@ export function RechargeFormCard({
</p>
)}
</div>
</CardContent>
</Card>
</TitledCard>
)
}
@@ -9,9 +9,7 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import {
@@ -23,6 +21,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import {
Tooltip,
TooltipContent,
@@ -48,6 +47,7 @@ import type { PaymentMethod, TopupInfo } from '../types'
interface SubscriptionPlansCardProps {
topupInfo: TopupInfo | null
onAvailabilityChange?: (available: boolean) => void
}
function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
@@ -56,7 +56,10 @@ function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
)
}
export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
export function SubscriptionPlansCard({
topupInfo,
onAvailabilityChange,
}: SubscriptionPlansCardProps) {
const { t } = useTranslation()
const { status } = useStatus()
@@ -76,11 +79,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
const [selectedPlan, setSelectedPlan] = useState<PlanRecord | null>(null)
const enableStripe = !!status?.enable_stripe_topup
const enableCreem = !!props.topupInfo?.enable_creem_topup
const enableCreem = !!topupInfo?.enable_creem_topup
const enableOnlineTopUp = !!status?.enable_online_topup
const epayMethods = useMemo(
() => getEpayMethods(props.topupInfo?.pay_methods),
[props.topupInfo?.pay_methods]
() => getEpayMethods(topupInfo?.pay_methods),
[topupInfo?.pay_methods]
)
const fetchPlans = useCallback(async () => {
@@ -148,6 +151,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
const hasActive = activeSubscriptions.length > 0
const hasAny = allSubscriptions.length > 0
const isAvailable = loading || plans.length > 0 || hasAny
const disablePref = !hasActive
const isSubPref =
billingPreference === 'subscription_first' ||
@@ -165,6 +169,10 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return map
}, [allSubscriptions])
useEffect(() => {
onAvailabilityChange?.(isAvailable)
}, [isAvailable, onAvailabilityChange])
const planTitleMap = useMemo(() => {
const map = new Map<number, string>()
for (const p of plans) {
@@ -191,11 +199,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
if (loading) {
return (
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
</CardHeader>
<CardContent className='space-y-4 pt-6'>
<CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-20 w-full' />
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => (
@@ -213,27 +221,16 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return (
<>
<Card className='overflow-hidden'>
<CardHeader className='border-b'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
<Crown className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'>
{t('Subscription Plans')}
</CardTitle>
<CardDescription>
{t('Purchase a plan to enjoy model benefits')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='space-y-5 pt-6'>
<TitledCard
title={t('Subscription Plans')}
description={t('Purchase a plan to enjoy model benefits')}
icon={<Crown className='h-4 w-4' />}
contentClassName='space-y-4 sm:space-y-5'
>
{/* My subscriptions & billing preference */}
<div className='rounded-xl border p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<div className='rounded-xl border p-3 sm:p-4'>
<div className='flex flex-wrap items-center justify-between gap-2.5 sm:gap-3'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<span className='text-sm font-medium'>
{t('My Subscriptions')}
</span>
@@ -265,12 +262,12 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
)}
</span>
</div>
<div className='flex items-center gap-2'>
<div className='flex w-full items-center gap-2 sm:w-auto'>
<Select
value={displayPref}
onValueChange={handlePreferenceChange}
>
<SelectTrigger className='h-8 w-[140px] text-xs'>
<SelectTrigger className='h-8 flex-1 text-xs sm:w-[140px] sm:flex-none'>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -452,7 +449,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
{/* Available plans grid */}
{plans.length > 0 ? (
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
<div className='grid grid-cols-1 gap-3 2xl:grid-cols-2 2xl:gap-4'>
{plans.map((p, index) => {
const plan = p?.plan
if (!plan) return null
@@ -485,7 +482,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
isPopular && 'border-primary/70 shadow-sm'
)}
>
<CardContent className='flex h-full flex-col p-4'>
<CardContent className='flex h-full flex-col p-3.5 sm:p-4'>
<div className='mb-2 flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h4 className='truncate font-semibold'>
@@ -568,8 +565,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
{t('No plans available')}
</p>
)}
</CardContent>
</Card>
</TitledCard>
<SubscriptionPurchaseDialog
open={purchaseOpen}
@@ -14,9 +14,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
if (props.loading) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<div key={i} className='px-3 py-3 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-20' />
<Skeleton className='mt-2 h-7 w-28' />
<Skeleton className='mt-1.5 h-3.5 w-24' />
@@ -50,9 +50,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{stats.map((item) => (
<div key={item.label} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<div key={item.label} className='px-3 py-3 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
@@ -60,7 +60,7 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
</div>
</div>
<div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight break-all tabular-nums'>
<div className='text-foreground mt-1.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:mt-2 sm:text-2xl'>
{item.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
+28 -13
View File
@@ -54,6 +54,7 @@ export function Wallet(props: WalletProps) {
const [creemDialogOpen, setCreemDialogOpen] = useState(false)
const [selectedCreemProduct, setSelectedCreemProduct] =
useState<CreemProduct | null>(null)
const [showSubscriptionPanel, setShowSubscriptionPanel] = useState(true)
const { status } = useStatus()
const { currency } = useSystemConfig()
@@ -231,6 +232,13 @@ export function Wallet(props: WalletProps) {
return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE
}, [topupInfo, topupAmount])
const handleSubscriptionAvailabilityChange = useCallback(
(available: boolean) => {
setShowSubscriptionPanel(available)
},
[]
)
return (
<>
<SectionPageLayout>
@@ -239,13 +247,17 @@ export function Wallet(props: WalletProps) {
{t('Manage your balance and payment methods')}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4'>
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-5'>
<WalletStatsCard user={user} loading={userLoading} />
<SubscriptionPlansCard topupInfo={topupInfo} />
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'>
<div className='min-w-0'>
<div
className={
showSubscriptionPanel
? 'grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.95fr)] xl:items-start'
: 'grid gap-4'
}
>
<div id='wallet-add-funds' className='scroll-mt-4'>
<RechargeFormCard
topupInfo={topupInfo}
presetAmounts={presetAmounts}
@@ -279,15 +291,18 @@ export function Wallet(props: WalletProps) {
/>
</div>
<div className='xl:sticky xl:top-6'>
<AffiliateRewardsCard
user={user}
affiliateLink={affiliateLink}
onTransfer={() => setTransferDialogOpen(true)}
loading={affiliateLoading}
/>
</div>
<SubscriptionPlansCard
topupInfo={topupInfo}
onAvailabilityChange={handleSubscriptionAvailabilityChange}
/>
</div>
<AffiliateRewardsCard
user={user}
affiliateLink={affiliateLink}
onTransfer={() => setTransferDialogOpen(true)}
loading={affiliateLoading}
/>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>