feat: restore profile settings API integration

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 11:35:54 +00:00
parent 7c616d412d
commit 549eb714c1
3 changed files with 396 additions and 63 deletions

View File

@@ -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<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const fileInputRef = useRef<HTMLInputElement>(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<ProfileFormData>({
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<string, any> || {}),
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 (
<div className="space-y-6">
<div className="text-center py-12">
<h2 className="text-2xl font-bold">Profile Settings</h2>
<p className="text-muted-foreground mt-2">Profile form temporarily disabled during migration.</p>
<div>
<h2 className="text-2xl font-semibold text-text-primary">
{t('settings.profile.title')}
</h2>
<p className="mt-1 text-sm text-text-tertiary">
{t('settings.profile.updateInfo')}
</p>
</div>
{saveStatus === 'success' && (
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>{t('settings.profile.changesSaved')}</span>
</div>
)}
{(saveStatus === 'error' || errorMessage) && (
<div className="rounded-lg bg-accent-error/10 border border-accent-error p-3 flex items-center gap-2 text-sm text-accent-error">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{errorMessage || t('settings.profile.failedToUpdate')}</span>
</div>
)}
<Card bordered>
<CardHeader>
<CardTitle>{t('settings.profile.changeAvatar')}</CardTitle>
<CardDescription>{t('settings.profile.updateInfo')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="h-24 w-24 rounded-full bg-bg-primary border border-border-primary flex items-center justify-center overflow-hidden">
{avatarPreview ? (
<img
src={avatarPreview}
alt={t('settings.profile.changeAvatar')}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full flex items-center justify-center text-lg font-semibold text-text-secondary">
{getUserInitials()}
</div>
)}
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2"
>
<UserCircle2 className="h-4 w-4" />
{t('settings.profile.changeAvatar')}
</Button>
<Button
variant="primary"
onClick={handleAvatarUpload}
isLoading={isUploadingAvatar}
isDisabled={!avatarFile}
className="flex items-center gap-2"
>
<Upload className="h-4 w-4" />
{t('settings.profile.uploadAvatar')}
</Button>
</div>
{avatarFile && (
<p className="text-xs text-text-tertiary">{avatarFile.name}</p>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</CardContent>
</Card>
<Card bordered>
<CardHeader>
<CardTitle>{t('settings.profile.title')}</CardTitle>
<CardDescription>{t('settings.profile.updateInfo')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('settings.profile.firstName')}
placeholder={t('settings.profile.enterFirstName')}
value={watch('firstName') ?? ''}
onChange={(value) => setValue('firstName', value, { shouldDirty: true })}
onBlur={register('firstName').onBlur}
errorMessage={errors.firstName?.message}
isInvalid={!!errors.firstName}
/>
<Input
label={t('settings.profile.lastName')}
placeholder={t('settings.profile.enterLastName')}
value={watch('lastName') ?? ''}
onChange={(value) => setValue('lastName', value, { shouldDirty: true })}
onBlur={register('lastName').onBlur}
errorMessage={errors.lastName?.message}
isInvalid={!!errors.lastName}
/>
</div>
<Input
label={t('settings.profile.phone')}
placeholder={t('settings.profile.enterPhone')}
value={watch('phone') ?? ''}
onChange={(value) => setValue('phone', value, { shouldDirty: true })}
onBlur={register('phone').onBlur}
errorMessage={errors.phone?.message}
isInvalid={!!errors.phone}
/>
<div>
<label
htmlFor="bio"
className="block text-sm font-medium text-text-secondary mb-2"
>
{t('settings.profile.bio')}
</label>
<textarea
id="bio"
rows={4}
value={watch('bio') ?? ''}
onChange={(event) =>
setValue('bio', event.target.value, { shouldDirty: true })
}
onBlur={register('bio').onBlur}
placeholder={t('settings.profile.bioPlaceholder')}
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary placeholder:text-text-tertiary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:border-accent-primary focus-visible:ring-accent-primary"
/>
{errors.bio && (
<p className="mt-1.5 text-sm text-accent-error">
{errors.bio.message}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('settings.profile.email')}
value={user?.email ?? ''}
isDisabled
description={t('settings.profile.emailCannotChange')}
/>
<Input
label={t('settings.profile.username')}
value={username}
isDisabled
description={t('settings.profile.usernameCannotChange')}
/>
</div>
<div className="pt-2">
<Button
type="submit"
variant="primary"
isLoading={isSaving}
isDisabled={!isDirty}
>
{t('settings.profile.saveChanges')}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { ApiResponse } from '@goodgo/types';
import { apiClient } from './client';
/**
* EN: Raw upload response from storage service.
* VI: Response upload thô từ storage service.
*/
interface UploadFileResult {
success: boolean;
fileId?: string;
objectKey?: string;
error?: string;
}
/**
* EN: CDN URL response payload.
* VI: Payload response URL CDN.
*/
interface CdnUrlResult {
url: string;
isCDN: boolean;
description: string;
}
/**
* EN: Normalized avatar upload response.
* VI: Response upload avatar đã chuẩn hóa.
*/
export interface UploadAvatarResult {
fileId: string;
objectKey?: string;
url: string;
}
/**
* EN: Storage API for avatar upload flow.
* VI: Storage API cho luồng upload avatar.
*/
export const storageApi = {
/**
* EN: Upload avatar as a public file and resolve CDN/fallback URL.
* VI: Upload avatar ở chế độ public và lấy URL CDN/fallback.
*/
uploadAvatar: async (file: File): Promise<ApiResponse<UploadAvatarResult>> => {
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await apiClient.post<UploadFileResult>(
'/files/upload?accessLevel=public',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
const uploadedFileId = uploadResponse.data?.fileId;
if (!uploadResponse.success || !uploadResponse.data?.success || !uploadedFileId) {
throw new Error(
uploadResponse.data?.error ||
uploadResponse.error?.message ||
'Failed to upload avatar'
);
}
let resolvedUrl = '';
try {
const cdnUrlResponse = await apiClient.get<CdnUrlResult>(
`/files/${uploadedFileId}/cdn-url`
);
resolvedUrl = cdnUrlResponse.data?.url ?? '';
} catch {
// EN: Fallback keeps profile update possible even if CDN URL fetch fails.
// VI: Fallback vẫn cho phép cập nhật profile nếu lấy CDN URL thất bại.
resolvedUrl = '';
}
return {
success: true,
data: {
fileId: uploadedFileId,
objectKey: uploadResponse.data.objectKey,
url: resolvedUrl,
},
timestamp: new Date().toISOString(),
};
},
};

View File

@@ -9,16 +9,21 @@ import { apiClient } from './client';
export interface UserProfile {
id: string;
userId: string;
firstName?: string;
lastName?: string;
phone?: string;
phoneVerified?: boolean;
bio?: string;
avatarUrl?: string;
customFields?: Record<string, unknown>;
preferences?: Record<string, unknown>;
metadata?: Record<string, unknown>;
timezone?: string;
locale?: string;
phoneNumber?: {
countryCode: string;
nationalNumber: string;
};
attributes: Array<{
key: string;
value: string;
valueType: string;
}>;
createdAt: string;
updatedAt: string;
updatedAt?: string;
}
/**
@@ -26,13 +31,19 @@ export interface UserProfile {
* VI: DTO cập nhật profile người dùng
*/
export interface UpdateUserProfileDto {
firstName?: string;
lastName?: string;
phone?: string;
avatarUrl?: string;
customFields?: Record<string, unknown>;
preferences?: Record<string, unknown>;
metadata?: Record<string, unknown>;
bio?: string;
timezone?: string;
locale?: string;
avatarUrl?: string | null;
}
/**
* EN: Set profile attribute DTO.
* VI: DTO đặt thuộc tính profile.
*/
export interface SetProfileAttributeDto {
value: string;
valueType?: 'String' | 'Number' | 'Boolean' | 'Date' | 'Json';
}
/**
@@ -47,7 +58,7 @@ export const userApi = {
* @param userId - User ID / ID người dùng
*/
getProfile: async (userId: string): Promise<ApiResponse<UserProfile>> => {
return apiClient.get(`/identity/users/${userId}/profile`);
return apiClient.get(`/users/${userId}/profile`);
},
/**
@@ -61,7 +72,25 @@ export const userApi = {
userId: string,
data: UpdateUserProfileDto
): Promise<ApiResponse<UserProfile>> => {
return apiClient.put(`/identity/users/${userId}/profile`, data);
return apiClient.put(`/users/${userId}/profile`, data);
},
/**
* EN: Set custom profile attribute.
* VI: Đặt thuộc tính tùy chỉnh cho profile.
*/
setProfileAttribute: async (
userId: string,
key: string,
payload: SetProfileAttributeDto
): Promise<ApiResponse<{ key: string; value: string; valueType: string }>> => {
return apiClient.put(
`/users/${userId}/profile/attributes/${encodeURIComponent(key)}`,
{
value: payload.value,
valueType: payload.valueType ?? 'String',
}
);
},
/**
@@ -75,9 +104,7 @@ export const userApi = {
userId: string,
avatarUrl: string
): Promise<ApiResponse<UserProfile>> => {
return apiClient.post(`/identity/users/${userId}/profile/avatar`, {
avatarUrl,
});
return apiClient.put(`/users/${userId}/profile`, { avatarUrl });
},
/**
@@ -87,6 +114,6 @@ export const userApi = {
* @param userId - User ID / ID người dùng
*/
deleteAvatar: async (userId: string): Promise<ApiResponse> => {
return apiClient.delete(`/identity/users/${userId}/profile/avatar`);
return apiClient.put(`/users/${userId}/profile`, { avatarUrl: null });
},
};