style(ui): align wallet and profile interactions
This commit is contained in:
@@ -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()
|
||||
|
||||
+19
-12
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user