diff --git a/apps/web-client/src/app/(dashboard)/settings/profile/page.tsx b/apps/web-client/src/app/(dashboard)/settings/profile/page.tsx index a739f3da..7ae22eb0 100644 --- a/apps/web-client/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web-client/src/app/(dashboard)/settings/profile/page.tsx @@ -4,9 +4,25 @@ import { useState, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; +import { + AlertCircle, + CheckCircle2, + Upload, + UserCircle2, +} from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { userApi, type UserProfile, type UpdateUserProfileDto } from '@/services/api/user.api'; +import { storageApi } from '@/services/api/storage.api'; import { useTranslations } from 'next-intl'; +import { Button } from '@/features/shared/components/ui/button'; +import { Input } from '@/features/shared/components/ui/input'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/features/shared/components/ui/card'; /** * EN: Create profile schema with translated messages @@ -55,9 +71,11 @@ export default function ProfilePage() { const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [avatarPreview, setAvatarPreview] = useState(null); const [avatarFile, setAvatarFile] = useState(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); const fileInputRef = useRef(null); // EN: Create schema with translations / VI: Tạo schema với translations @@ -70,6 +88,7 @@ export default function ProfilePage() { formState: { errors, isDirty }, reset, setValue, + watch, } = useForm({ resolver: zodResolver(profileSchema), defaultValues: { @@ -87,21 +106,28 @@ export default function ProfilePage() { try { setIsLoading(true); + setErrorMessage(''); const response = await userApi.getProfile(user.id); - + if (response.success && response.data) { setProfile(response.data); - setValue('firstName', response.data.firstName || ''); - setValue('lastName', response.data.lastName || ''); - setValue('phone', response.data.phone || ''); - setValue('bio', (response.data.customFields?.bio as string) || ''); - + const attributes = response.data.attributes ?? []; + const firstName = attributes.find((attr) => attr.key === 'firstName')?.value ?? ''; + const lastName = attributes.find((attr) => attr.key === 'lastName')?.value ?? ''; + const phone = attributes.find((attr) => attr.key === 'phone')?.value ?? ''; + + setValue('firstName', firstName, { shouldDirty: false }); + setValue('lastName', lastName, { shouldDirty: false }); + setValue('phone', phone, { shouldDirty: false }); + setValue('bio', response.data.bio ?? '', { shouldDirty: false }); + if (response.data.avatarUrl) { setAvatarPreview(response.data.avatarUrl); } } } catch (error) { console.error('Failed to fetch profile:', error); + setErrorMessage(t('settings.profile.failedToFetch')); } finally { setIsLoading(false); } @@ -142,25 +168,32 @@ export default function ProfilePage() { if (!user?.id || !avatarFile) return; try { - // EN: In a real implementation, you would upload the file to a storage service - // and get the URL. For now, we'll use a data URL as placeholder. - // VI: Trong implementation thực tế, bạn sẽ upload file lên storage service - // và lấy URL. Hiện tại, chúng ta sẽ sử dụng data URL làm placeholder. - - // EN: TODO: Implement actual file upload to storage service - // VI: TODO: Implement upload file thực tế lên storage service - const avatarUrl = avatarPreview || ''; + setIsUploadingAvatar(true); + setSaveStatus('idle'); + setErrorMessage(''); + + const uploadResponse = await storageApi.uploadAvatar(avatarFile); + const avatarUrl = + uploadResponse.data?.url || + `/files/${uploadResponse.data?.fileId}/cdn-url`; const response = await userApi.uploadAvatar(user.id, avatarUrl); - - if (response.success && response.data) { - setProfile(response.data); - setAvatarFile(null); - alert(t('settings.profile.uploadSuccess')); + + if (!response.success || !response.data) { + throw new Error(t('settings.profile.failedToUpload')); } + + setProfile(response.data); + setAvatarPreview(response.data.avatarUrl ?? avatarPreview); + setAvatarFile(null); + setSaveStatus('success'); + setTimeout(() => setSaveStatus('idle'), 3000); } catch (error) { console.error('Failed to upload avatar:', error); - alert(t('settings.profile.failedToUpload')); + setSaveStatus('error'); + setErrorMessage(t('settings.profile.failedToUpload')); + } finally { + setIsUploadingAvatar(false); } }; @@ -171,32 +204,40 @@ export default function ProfilePage() { try { setIsSaving(true); setSaveStatus('idle'); + setErrorMessage(''); const updateData: UpdateUserProfileDto = { - firstName: data.firstName || undefined, - lastName: data.lastName || undefined, - phone: data.phone || undefined, - customFields: { - ...(profile?.customFields as Record || {}), - bio: data.bio || undefined, - }, + bio: data.bio || undefined, }; const response = await userApi.updateProfile(user.id, updateData); - - if (response.success && response.data) { - setProfile(response.data); - reset(data); - setSaveStatus('success'); - - // EN: Auto-hide success message after 3 seconds / VI: Tự động ẩn thông báo thành công sau 3 giây - setTimeout(() => setSaveStatus('idle'), 3000); - } else { - setSaveStatus('error'); + if (!response.success || !response.data) { + throw new Error(t('settings.profile.failedToUpdate')); } + + await Promise.all([ + userApi.setProfileAttribute(user.id, 'firstName', { + value: data.firstName ?? '', + valueType: 'String', + }), + userApi.setProfileAttribute(user.id, 'lastName', { + value: data.lastName ?? '', + valueType: 'String', + }), + userApi.setProfileAttribute(user.id, 'phone', { + value: data.phone ?? '', + valueType: 'String', + }), + ]); + + setProfile(response.data); + reset(data); + setSaveStatus('success'); + setTimeout(() => setSaveStatus('idle'), 3000); } catch (error) { console.error('Failed to update profile:', error); setSaveStatus('error'); + setErrorMessage(t('settings.profile.failedToUpdate')); } finally { setIsSaving(false); } @@ -205,8 +246,9 @@ export default function ProfilePage() { // EN: Get user initials for avatar fallback / VI: Lấy chữ cái đầu cho avatar fallback const getUserInitials = () => { if (!user) return 'U'; - const firstName = profile?.firstName || user.email.split('@')[0]; - const lastName = profile?.lastName || ''; + const attributes = profile?.attributes ?? []; + const firstName = attributes.find((attr) => attr.key === 'firstName')?.value || user.email.split('@')[0]; + const lastName = attributes.find((attr) => attr.key === 'lastName')?.value || ''; return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || 'U'; }; @@ -223,12 +265,186 @@ export default function ProfilePage() { ); } + const username = user?.email?.split('@')[0] ?? t('settings.profile.notSet'); + return (
-
-

Profile Settings

-

Profile form temporarily disabled during migration.

+
+

+ {t('settings.profile.title')} +

+

+ {t('settings.profile.updateInfo')} +

+ + {saveStatus === 'success' && ( +
+ + {t('settings.profile.changesSaved')} +
+ )} + + {(saveStatus === 'error' || errorMessage) && ( +
+ + {errorMessage || t('settings.profile.failedToUpdate')} +
+ )} + + + + {t('settings.profile.changeAvatar')} + {t('settings.profile.updateInfo')} + + +
+
+ {avatarPreview ? ( + {t('settings.profile.changeAvatar')} + ) : ( +
+ {getUserInitials()} +
+ )} +
+ +
+
+ + + +
+ + {avatarFile && ( +

{avatarFile.name}

+ )} +
+
+ + +
+
+ + + + {t('settings.profile.title')} + {t('settings.profile.updateInfo')} + + +
+
+ setValue('firstName', value, { shouldDirty: true })} + onBlur={register('firstName').onBlur} + errorMessage={errors.firstName?.message} + isInvalid={!!errors.firstName} + /> + + setValue('lastName', value, { shouldDirty: true })} + onBlur={register('lastName').onBlur} + errorMessage={errors.lastName?.message} + isInvalid={!!errors.lastName} + /> +
+ + setValue('phone', value, { shouldDirty: true })} + onBlur={register('phone').onBlur} + errorMessage={errors.phone?.message} + isInvalid={!!errors.phone} + /> + +
+ +