Prmbr/src/app/profile/page.tsx
2025-09-01 23:13:11 +08:00

819 lines
34 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { useBetterAuth } from '@/hooks/useBetterAuth'
import { changePassword } from '@/lib/auth-client'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { LegacyAvatar } from '@/components/ui/avatar'
import { LoadingSpinner, LoadingOverlay } from '@/components/ui/loading-spinner'
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
import { Save, Eye, EyeOff, CreditCard, Crown, Star } from 'lucide-react'
interface UserProfile {
id: string
email: string
name: string
username?: string
bio?: string
image?: string // Better Auth field
versionLimit: number
subscribePlan: string
maxVersionLimit: number
promptLimit?: number
creditBalance: number
createdAt?: string
updatedAt?: string
}
interface CreditInfo {
totalBalance: number
activeCredits: {
id: string
amount: number
type: string
note: string | null
expiresAt: Date | null
createdAt: Date
}[]
expiredCredits: {
id: string
amount: number
type: string
note: string | null
expiresAt: Date | null
createdAt: Date
}[]
}
export default function ProfilePage() {
const { user, loading, triggerProfileUpdate } = useBetterAuth()
const t = useTranslations('profile')
const tCommon = useTranslations('common')
const tAuth = useTranslations('auth')
const [profile, setProfile] = useState<UserProfile | null>(null)
const [isEditing, setIsEditing] = useState({
username: false,
email: false,
bio: false,
password: false,
versionLimit: false
})
const [formData, setFormData] = useState({
username: '',
email: '',
bio: '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
versionLimit: 3
})
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
})
const [saveStatus, setSaveStatus] = useState<{ type: 'success' | 'error' | null, message: string }>({ type: null, message: '' })
const [isLoading, setIsLoading] = useState(false)
const [profileLoading, setProfileLoading] = useState(true)
const [fieldLoading, setFieldLoading] = useState<{ [key: string]: boolean }>({})
const [creditInfo, setCreditInfo] = useState<CreditInfo | null>(null)
const loadProfile = useCallback(async () => {
if (!user) return
setProfileLoading(true)
try {
// Get profile data from our API
const [profileResponse, creditsResponse] = await Promise.all([
fetch(`/api/users/profile?userId=${user.id}`),
fetch(`/api/users/credits?userId=${user.id}`)
])
if (!profileResponse.ok) {
throw new Error('Failed to fetch profile')
}
const profileData: UserProfile = await profileResponse.json()
setProfile(profileData)
setFormData({
username: profileData.name || '', // 直接使用name字段
email: profileData.email,
bio: profileData.bio || '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
versionLimit: profileData.versionLimit
})
// Load credit information
if (creditsResponse.ok) {
const creditData: CreditInfo = await creditsResponse.json()
setCreditInfo(creditData)
}
} catch (error) {
console.error('Error loading profile:', error)
setSaveStatus({ type: 'error', message: t('failedToLoadProfile') })
} finally {
setProfileLoading(false)
}
}, [user, t])
useEffect(() => {
if (!user) return
loadProfile()
}, [user, loadProfile])
const updateProfile = async (field: string, value: string | number) => {
if (!user) return
setFieldLoading(prev => ({ ...prev, [field]: true }))
setIsLoading(true)
setSaveStatus({ type: null, message: '' })
try {
// Use our API for all fields
const updateData: Record<string, unknown> = { userId: user.id }
// 用户名字段映射到name字段
if (field === 'username') {
updateData['name'] = value // Better Auth的name字段
} else {
updateData[field] = value
}
const response = await fetch('/api/users/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to update profile')
}
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
// Reload profile and trigger cache update
await Promise.all([
loadProfile(),
triggerProfileUpdate()
])
// Reset editing state
setIsEditing(prev => ({ ...prev, [field]: false }))
} catch (error: unknown) {
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || `${t('failedToUpdate')} ${field}` })
} finally {
setIsLoading(false)
setFieldLoading(prev => ({ ...prev, [field]: false }))
}
}
const updatePassword = async () => {
if (!formData.newPassword || formData.newPassword !== formData.confirmPassword) {
setSaveStatus({ type: 'error', message: t('passwordsNotMatch') })
return
}
if (formData.newPassword.length < 6) {
setSaveStatus({ type: 'error', message: t('passwordMinLength') })
return
}
setFieldLoading(prev => ({ ...prev, password: true }))
setIsLoading(true)
setSaveStatus({ type: null, message: '' })
try {
// 使用Better Auth的changePassword方法
const { data, error } = await changePassword({
newPassword: formData.newPassword,
currentPassword: formData.currentPassword,
revokeOtherSessions: false, // 保持其他会话不被撤销
})
if (error) {
throw new Error(error.message || 'Failed to change password')
}
setSaveStatus({ type: 'success', message: t('passwordUpdatedSuccessfully') })
setFormData({ ...formData, currentPassword: '', newPassword: '', confirmPassword: '' })
} catch (error: unknown) {
setSaveStatus({ type: 'error', message: (error instanceof Error ? error.message : 'Unknown error') || t('failedToUpdatePassword') })
} finally {
setIsLoading(false)
setFieldLoading(prev => ({ ...prev, password: false }))
}
}
// Show skeleton screens immediately when auth is loading
if (loading) {
return (
<div className="min-h-screen">
<Header />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-muted-foreground">{t('subtitle')}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
<div className="lg:col-span-1 space-y-6">
<AvatarSkeleton />
<FormFieldSkeleton />
</div>
<div className="lg:col-span-2 space-y-6">
<FormFieldSkeleton />
<FormFieldSkeleton />
<TextAreaSkeleton />
<FormFieldSkeleton />
<FormFieldSkeleton />
</div>
</div>
</div>
</div>
)
}
if (!user) {
return (
<div className="min-h-screen">
<Header />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<h1 className="text-2xl font-bold text-foreground mb-4">{t('accessDenied')}</h1>
<p className="text-muted-foreground mb-4">{t('pleaseSignIn')}</p>
<Button onClick={() => window.location.href = '/signin'}>
{tAuth('signIn')}
</Button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen">
<Header />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">{t('title')}</h1>
<p className="text-muted-foreground">{t('subtitle')}</p>
</div>
{saveStatus.type && (
<div className={`mb-6 p-4 rounded-lg border ${saveStatus.type === 'success'
? 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{saveStatus.message}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
{/* Avatar and Subscription Section */}
<div className="lg:col-span-1 space-y-6">
{/* Profile Picture */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<AvatarSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-left-2 duration-500">
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<h2 className="text-xl font-semibold text-foreground mb-4">{t('profilePicture')}</h2>
<div className="flex flex-col items-center">
<LegacyAvatar
src={user?.image || profile?.image}
alt="Profile Avatar"
size={96}
className="w-24 h-24"
/>
<p className="text-sm text-muted-foreground mt-2 text-center">
{user?.image ? t('googleAvatar') : t('defaultAvatar')}
</p>
</div>
</div>
</div>
)}
{/* Subscription Status */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<FormFieldSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-left-2 duration-500 delay-100">
<div className="bg-card rounded-lg border border-border overflow-hidden transition-all duration-200 hover:shadow-sm">
<div className={`p-4 border-b border-border ${profile?.subscribePlan === 'pro'
? 'bg-gradient-to-r from-amber-50/60 to-orange-50/60 dark:from-amber-950/10 dark:to-orange-950/10'
: 'bg-gradient-to-r from-slate-50/60 to-gray-50/60 dark:from-slate-950/5 dark:to-gray-950/5'
}`}>
<div className="flex items-center space-x-3">
<div className={`p-2.5 rounded-full ${profile?.subscribePlan === 'pro'
? 'bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400'
: 'bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400'
}`}>
{profile?.subscribePlan === 'pro' ? (
<Crown className="w-4 h-4 text-white" />
) : (
<Star className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base">{t('currentPlan')}</h3>
<p className={`text-xs sm:text-sm truncate font-medium ${profile?.subscribePlan === 'pro'
? 'text-orange-700 dark:text-orange-300'
: 'text-slate-600 dark:text-slate-400'
}`}>
{profile?.subscribePlan === 'pro' ? t('proPlan') : t('freePlan')}
</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Credit Balance */}
<div className="bg-gradient-to-r from-green-50/60 to-emerald-50/60 dark:from-green-950/8 dark:to-emerald-950/8 rounded-lg p-4 border border-green-200/30 dark:border-green-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 dark:from-green-400 dark:to-emerald-500">
<CreditCard className="w-4 h-4 text-white" />
</div>
<span className="text-sm font-semibold text-foreground">{t('creditBalance')}</span>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-green-700 dark:text-green-300">
${(creditInfo?.totalBalance || 0).toFixed(2)}
</div>
<div className="text-xs font-medium text-green-600 dark:text-green-400">{t('usdCredit')}</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-green-200/30 dark:border-green-900/30">
<Button
variant="outline"
size="sm"
className="w-full text-xs border-green-200/50 dark:border-green-800/50 hover:bg-green-50 dark:hover:bg-green-900/20"
onClick={() => window.location.href = '/credits'}
>
{t('viewTransactionHistory')}
</Button>
</div>
</div>
{/* Plan Details */}
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 sm:grid-cols-1 gap-2">
<div className="flex justify-between py-1">
<span className="text-muted-foreground text-xs sm:text-sm">Max Prompts:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
{profile?.subscribePlan === 'pro' ? '500' : '20'}
</span>
</div>
<div className="flex justify-between py-1">
<span className="text-muted-foreground text-xs sm:text-sm">Max Versions:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
{profile?.maxVersionLimit}
</span>
</div>
<div className="flex justify-between py-1 col-span-2 sm:col-span-1">
<span className="text-muted-foreground text-xs sm:text-sm">Monthly Credit:</span>
<span className="font-medium text-foreground text-xs sm:text-sm">
${profile?.subscribePlan === 'pro' ? '20.00' : '0.00'}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Profile Information */}
<div className="lg:col-span-2 space-y-6">
{/* Username */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<FormFieldSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<LoadingOverlay isLoading={fieldLoading.username}>
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">{t('username')}</h3>
{!isEditing.username && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(prev => ({ ...prev, username: true }))}
disabled={isLoading}
>
{tCommon('edit')}
</Button>
)}
</div>
{isEditing.username ? (
<div className="space-y-4">
<Input
value={formData.username}
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
placeholder={t('enterUsername')}
disabled={fieldLoading.username}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateProfile('username', formData.username)}
disabled={isLoading || fieldLoading.username}
>
{fieldLoading.username ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{tCommon('save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(prev => ({ ...prev, username: false }))
setFormData(prev => ({ ...prev, username: profile?.name || '' }))
}}
disabled={fieldLoading.username}
>
{tCommon('cancel')}
</Button>
</div>
</div>
) : (
<p className="text-foreground">{profile?.name || t('noUsernameSet')}</p>
)}
</div>
</LoadingOverlay>
</div>
)}
{/* Email */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<FormFieldSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-75">
<LoadingOverlay isLoading={fieldLoading.email}>
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">{t('email')}</h3>
{!isEditing.email && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(prev => ({ ...prev, email: true }))}
disabled={isLoading}
>
{tCommon('edit')}
</Button>
)}
</div>
{isEditing.email ? (
<div className="space-y-4">
<Input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder={t('enterEmail')}
disabled={fieldLoading.email}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateProfile('email', formData.email)}
disabled={isLoading || fieldLoading.email}
>
{fieldLoading.email ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{tCommon('save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(prev => ({ ...prev, email: false }))
setFormData(prev => ({ ...prev, email: profile?.email || '' }))
}}
disabled={fieldLoading.email}
>
{tCommon('cancel')}
</Button>
</div>
</div>
) : (
<p className="text-foreground">{profile?.email}</p>
)}
</div>
</LoadingOverlay>
</div>
)}
{/* Bio */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<TextAreaSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-150">
<LoadingOverlay isLoading={fieldLoading.bio}>
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">{t('bio')}</h3>
{!isEditing.bio && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(prev => ({ ...prev, bio: true }))}
disabled={isLoading}
>
{tCommon('edit')}
</Button>
)}
</div>
{isEditing.bio ? (
<div className="space-y-4">
<Textarea
value={formData.bio}
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
placeholder={t('tellUsAboutYourself')}
className="min-h-[100px]"
maxLength={500}
disabled={fieldLoading.bio}
/>
<div className="text-sm text-muted-foreground text-right">
{formData.bio.length}/500 {t('charactersLimit')}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateProfile('bio', formData.bio)}
disabled={isLoading || fieldLoading.bio}
>
{fieldLoading.bio ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{tCommon('save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(prev => ({ ...prev, bio: false }))
setFormData(prev => ({ ...prev, bio: profile?.bio || '' }))
}}
disabled={fieldLoading.bio}
>
{tCommon('cancel')}
</Button>
</div>
</div>
) : (
<p className="text-foreground">{profile?.bio || t('noBioAdded')}</p>
)}
</div>
</LoadingOverlay>
</div>
)}
{/* Password */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<FormFieldSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-200">
<LoadingOverlay isLoading={fieldLoading.password}>
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">{tAuth('password')}</h3>
{!isEditing.password && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(prev => ({ ...prev, password: true }))}
disabled={isLoading}
>
{t('changePassword')}
</Button>
)}
</div>
{isEditing.password ? (
<div className="space-y-4">
<div>
<Label htmlFor="newPassword">{t('newPassword')}</Label>
<div className="relative mt-1">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={(e) => setFormData(prev => ({ ...prev, newPassword: e.target.value }))}
placeholder={t('enterNewPassword')}
className="pr-10"
disabled={fieldLoading.password}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={fieldLoading.password}
>
{showPasswords.new ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<Label htmlFor="confirmPassword">{t('confirmNewPassword')}</Label>
<div className="relative mt-1">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
placeholder={t('confirmNewPassword')}
className="pr-10"
disabled={fieldLoading.password}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={fieldLoading.password}
>
{showPasswords.confirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={updatePassword}
disabled={isLoading || fieldLoading.password}
>
{fieldLoading.password ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{t('updatePassword')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(prev => ({ ...prev, password: false }))
setFormData(prev => ({
...prev,
currentPassword: '',
newPassword: '',
confirmPassword: ''
}))
}}
disabled={fieldLoading.password}
>
{tCommon('cancel')}
</Button>
</div>
</div>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
</LoadingOverlay>
</div>
)}
{/* Version Limit Settings */}
{profileLoading ? (
<div className="animate-in fade-in-0 duration-300">
<FormFieldSkeleton />
</div>
) : (
<div className="animate-in fade-in-0 slide-in-from-bottom-2 duration-500 delay-300">
<LoadingOverlay isLoading={fieldLoading.versionLimit}>
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-foreground">Version Limit</h3>
<p className="text-sm text-muted-foreground mt-1">
Maximum number of versions to keep for each prompt
</p>
</div>
{!isEditing.versionLimit && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(prev => ({ ...prev, versionLimit: true }))}
disabled={isLoading}
>
{tCommon('edit')}
</Button>
)}
</div>
{isEditing.versionLimit ? (
<div className="space-y-4">
<div>
<div className="flex items-center space-x-4">
<Input
type="number"
min="1"
max={profile?.maxVersionLimit || 3}
value={formData.versionLimit}
onChange={(e) => setFormData(prev => ({
...prev,
versionLimit: Math.max(1, Math.min(parseInt(e.target.value) || 1, profile?.maxVersionLimit || 3))
}))}
className="w-24"
disabled={fieldLoading.versionLimit}
/>
<span className="text-sm text-muted-foreground">
versions (max: {profile?.maxVersionLimit || 3} for {profile?.subscribePlan || 'free'} plan)
</span>
</div>
<p className="text-xs text-muted-foreground mt-2">
When this limit is reached, oldest versions will be automatically deleted when new versions are created.
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateProfile('versionLimit', formData.versionLimit)}
disabled={isLoading || fieldLoading.versionLimit}
>
{fieldLoading.versionLimit ? (
<LoadingSpinner size="sm" className="mr-2" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{tCommon('save')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(prev => ({ ...prev, versionLimit: false }))
setFormData(prev => ({ ...prev, versionLimit: profile?.versionLimit || 3 }))
}}
disabled={fieldLoading.versionLimit}
>
{tCommon('cancel')}
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-foreground">
Current limit: <span className="font-medium">{profile?.versionLimit || 3} versions</span>
</p>
<div className="text-sm text-muted-foreground">
<p>Subscription: <span className="capitalize">{profile?.subscribePlan || 'free'}</span></p>
<p>Maximum allowed: {profile?.maxVersionLimit || 3} versions</p>
</div>
</div>
)}
</div>
</LoadingOverlay>
</div>
)}
</div>
</div>
</div>
</div>
)
}