style(ui): align wallet and profile interactions

This commit is contained in:
t0ng7u
2026-06-16 00:44:33 +08:00
parent b36888ba9c
commit eb86311604
39 changed files with 341 additions and 314 deletions
@@ -32,6 +32,7 @@ import { formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -221,7 +222,7 @@ export function CheckinCalendarCard({
if (isLoading) {
return (
<div className='bg-card overflow-hidden rounded-2xl border'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<div className='p-6'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-center gap-3'>
@@ -234,7 +235,7 @@ export function CheckinCalendarCard({
<Skeleton className='h-9 w-28 rounded-md' />
</div>
</div>
</div>
</Card>
)
}
@@ -270,14 +271,13 @@ export function CheckinCalendarCard({
</div>
</Dialog>
<div className='bg-card overflow-hidden rounded-2xl border'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
{/* Header */}
<div className='border-b p-4 sm:p-6'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4'>
<Button
<button
type='button'
variant='ghost'
className='flex h-auto min-w-0 flex-1 items-start gap-3 p-0 text-left whitespace-normal hover:bg-transparent'
className='flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left whitespace-normal outline-none'
onClick={() => setCollapsed((v) => !v)}
>
<div className='bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-xl sm:h-11 sm:w-11'>
@@ -311,7 +311,7 @@ export function CheckinCalendarCard({
: t('Check in daily to receive random quota rewards')}
</p>
</div>
</Button>
</button>
<Button
onClick={() => doCheckin()}
disabled={checkinLoading || checkedToday}
@@ -423,7 +423,6 @@ export function CheckinCalendarCard({
'relative flex h-9 w-full flex-col items-center justify-center rounded-lg px-0 text-xs font-medium sm:h-10 sm:text-sm',
!dayObj.isCurrentMonth &&
'text-muted-foreground/40 cursor-default',
isToday && 'hover:bg-primary/90',
!isToday && isCheckedIn && 'font-semibold'
)}
>
@@ -476,7 +475,7 @@ export function CheckinCalendarCard({
</div>
</>
) : null}
</div>
</Card>
</TooltipProvider>
)
}
@@ -105,6 +105,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
title={t('Language Preferences')}
description={t('Set the language used across the interface')}
icon={<Languages className='h-4 w-4' />}
disableHoverEffect
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
<div className='space-y-1'>
@@ -187,7 +187,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
if (pageLoading || loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
@@ -208,7 +208,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
return (
<>
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Passkey Login')}
@@ -310,7 +310,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='bg-destructive text-destructive-foreground'
disabled={removing}
onClick={(event) => {
event.preventDefault()
@@ -18,12 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatCompactNumber, formatQuota } from '@/lib/format'
import { getRoleLabel } from '@/lib/roles'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { getUserInitials, getDisplayName } from '../lib'
import { getDisplayName } from '../lib'
import type { UserProfile } from '../types'
// ============================================================================
@@ -40,8 +42,8 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
if (loading) {
return (
<div className='bg-card overflow-hidden rounded-lg border'>
<div className='p-4 sm:p-5'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-4 sm:p-5'>
<div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
<Skeleton className='h-16 w-16 rounded-2xl' />
<div className='space-y-3'>
@@ -56,7 +58,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div>
</div>
</div>
</div>
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
{Array.from({ length: 3 }).map((_, i) => (
@@ -68,14 +70,16 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
))}
</div>
</div>
</div>
</Card>
)
}
if (!profile) return null
const displayName = getDisplayName(profile)
const initials = getUserInitials(profile)
const avatarName = profile.username || displayName
const avatarFallback = getUserAvatarFallback(avatarName)
const avatarFallbackStyle = getUserAvatarStyle(avatarName)
const roleLabel = getRoleLabel(profile.role)
const stats = [
{
@@ -99,12 +103,15 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
]
return (
<div className='bg-card overflow-hidden rounded-lg border'>
<div className='p-3 sm:p-5'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-3 sm:p-5'>
<div className='flex items-center gap-3 text-left sm:gap-4'>
<Avatar className='ring-background h-12 w-12 rounded-xl text-sm ring-2 sm:h-16 sm:w-16 sm:rounded-2xl sm:text-lg sm:ring-4'>
<AvatarFallback className='bg-primary/10 text-primary rounded-xl sm:rounded-2xl'>
{initials}
<AvatarFallback
className='rounded-xl font-semibold text-white sm:rounded-2xl'
style={avatarFallbackStyle}
>
{avatarFallback}
</AvatarFallback>
</Avatar>
@@ -142,7 +149,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div>
</div>
</div>
</div>
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{stats.map((item) => (
@@ -164,6 +171,6 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
))}
</div>
</div>
</div>
</Card>
)
}
@@ -47,7 +47,7 @@ export function ProfileSecurityCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' 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' />
@@ -93,6 +93,7 @@ export function ProfileSecurityCard({
title={t('Security')}
description={t('Manage your security settings and account access')}
icon={<Shield className='h-4 w-4' />}
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
{securityActions.map((item) => (
@@ -100,10 +101,8 @@ export function ProfileSecurityCard({
key={item.title}
type='button'
onClick={item.action}
className={`hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 text-left transition-colors md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive'
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
: ''
className={`flex items-center gap-3 rounded-lg border p-3 text-left md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
>
<div
@@ -47,7 +47,7 @@ export function ProfileSettingsCard({
if (loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' 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' />
@@ -67,6 +67,7 @@ export function ProfileSettingsCard({
title={t('Settings')}
description={t('Configure your account preferences and integrations')}
icon={<Settings className='h-4 w-4' />}
disableHoverEffect
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1 group-data-horizontal/tabs:h-10'>
@@ -200,7 +200,7 @@ export function SidebarModulesCard() {
}
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
@@ -240,7 +240,7 @@ export function SidebarModulesCard() {
{section.modules.map((mod) => (
<div
key={mod.key}
className={`flex min-h-16 items-center justify-between rounded-lg border p-3 transition-opacity ${
className={`flex min-h-16 items-center justify-between rounded-lg border p-3 ${
sectionEnabled ? '' : 'opacity-50'
}`}
>
@@ -350,7 +350,7 @@ export function AccountBindingsTab({
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 shrink-0 px-2.5 text-xs'
className='text-destructive h-7 shrink-0 px-2.5 text-xs'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='mr-1 h-3 w-3' />
@@ -24,8 +24,8 @@ import { ROLE } from '@/lib/roles'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { PasswordInput } from '@/components/password-input'
import { updateUserSettings } from '../../api'
import {
@@ -35,13 +35,24 @@ import {
import { parseUserSettings } from '../../lib'
import type { UserProfile, UserSettings, NotifyType } from '../../types'
const NOTIFICATION_ICONS: Record<string, typeof Mail> = {
const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
email: Mail,
webhook: Webhook,
bark: Bell,
gotify: Server,
}
const NOTIFICATION_VALUES = new Set<NotifyType>(
NOTIFICATION_METHODS.map((method) => method.value)
)
function normalizeNotifyType(value: unknown): NotifyType {
return typeof value === 'string' &&
NOTIFICATION_VALUES.has(value as NotifyType)
? (value as NotifyType)
: 'email'
}
// ============================================================================
// Settings Tab Component
// ============================================================================
@@ -82,7 +93,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
if (profile?.setting) {
const parsed = parseUserSettings(profile.setting)
setSettings({
notify_type: parsed.notify_type || 'email',
notify_type: normalizeNotifyType(parsed.notify_type),
quota_warning_threshold:
parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD,
notification_email: parsed.notification_email ?? '',
@@ -119,44 +130,42 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
}
}
const notifyType = normalizeNotifyType(settings.notify_type)
return (
<div className='space-y-4 sm:space-y-6'>
{/* Notification Type */}
<div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label>
<RadioGroup
value={settings.notify_type}
onValueChange={(value) =>
updateField('notify_type', value as NotifyType)
}
className='grid grid-cols-4 gap-1.5 sm:gap-3'
<ToggleGroup
value={[notifyType]}
onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType)
if (nextValue)
updateField('notify_type', normalizeNotifyType(nextValue))
}}
aria-label={t('Notification Method')}
variant='outline'
size='lg'
spacing={2}
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3'
>
{NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value]
const isSelected = settings.notify_type === method.value
return (
<Label
<ToggleGroupItem
key={method.value}
htmlFor={method.value}
className={`flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border p-2 text-center transition-colors sm:min-h-20 sm:gap-2 sm:border-2 sm:p-3 ${
isSelected
? 'border-primary bg-primary/5 text-primary'
: 'border-muted hover:border-muted-foreground/25 hover:bg-muted/50'
}`}
value={method.value}
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-3 sm:min-h-16'
>
<RadioGroupItem
value={method.value}
id={method.value}
className='sr-only'
/>
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
{t(method.label)}
</span>
</Label>
</ToggleGroupItem>
)
})}
</RadioGroup>
</ToggleGroup>
</div>
{/* Warning Threshold */}
@@ -178,7 +187,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div>
{/* Email Settings */}
{settings.notify_type === 'email' && (
{notifyType === 'email' && (
<div className='space-y-1.5'>
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
<Input
@@ -193,7 +202,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Webhook Settings */}
{settings.notify_type === 'webhook' && (
{notifyType === 'webhook' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
@@ -219,7 +228,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Bark Settings */}
{settings.notify_type === 'bark' && (
{notifyType === 'bark' && (
<div className='space-y-1.5'>
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
<Input
@@ -237,7 +246,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
{/* Gotify Settings */}
{settings.notify_type === 'gotify' && (
{notifyType === 'gotify' && (
<>
<div className='space-y-1.5'>
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
@@ -300,7 +309,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
className='text-primary underline underline-offset-4'
>
{t('Gotify Documentation')}
</a>
@@ -51,7 +51,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
if (pageLoading || loading) {
return (
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
@@ -65,7 +65,7 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
return (
<>
<Card className='gap-0 overflow-hidden py-0'>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Two-Factor Authentication')}