feat(ui): improve mobile responsive layouts
This commit is contained in:
@@ -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)}
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
+3
-3
@@ -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)}
|
||||
|
||||
+3
-3
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user