feat: restore profile settings API integration
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -4,9 +4,25 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Upload,
|
||||||
|
UserCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { userApi, type UserProfile, type UpdateUserProfileDto } from '@/services/api/user.api';
|
import { userApi, type UserProfile, type UpdateUserProfileDto } from '@/services/api/user.api';
|
||||||
|
import { storageApi } from '@/services/api/storage.api';
|
||||||
import { useTranslations } from 'next-intl';
|
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
|
* EN: Create profile schema with translated messages
|
||||||
@@ -55,9 +71,11 @@ export default function ProfilePage() {
|
|||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// EN: Create schema with translations / VI: Tạo schema với translations
|
// EN: Create schema with translations / VI: Tạo schema với translations
|
||||||
@@ -70,6 +88,7 @@ export default function ProfilePage() {
|
|||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
|
watch,
|
||||||
} = useForm<ProfileFormData>({
|
} = useForm<ProfileFormData>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -87,21 +106,28 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
const response = await userApi.getProfile(user.id);
|
const response = await userApi.getProfile(user.id);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setProfile(response.data);
|
setProfile(response.data);
|
||||||
setValue('firstName', response.data.firstName || '');
|
const attributes = response.data.attributes ?? [];
|
||||||
setValue('lastName', response.data.lastName || '');
|
const firstName = attributes.find((attr) => attr.key === 'firstName')?.value ?? '';
|
||||||
setValue('phone', response.data.phone || '');
|
const lastName = attributes.find((attr) => attr.key === 'lastName')?.value ?? '';
|
||||||
setValue('bio', (response.data.customFields?.bio as string) || '');
|
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) {
|
if (response.data.avatarUrl) {
|
||||||
setAvatarPreview(response.data.avatarUrl);
|
setAvatarPreview(response.data.avatarUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch profile:', error);
|
console.error('Failed to fetch profile:', error);
|
||||||
|
setErrorMessage(t('settings.profile.failedToFetch'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -142,25 +168,32 @@ export default function ProfilePage() {
|
|||||||
if (!user?.id || !avatarFile) return;
|
if (!user?.id || !avatarFile) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// EN: In a real implementation, you would upload the file to a storage service
|
setIsUploadingAvatar(true);
|
||||||
// and get the URL. For now, we'll use a data URL as placeholder.
|
setSaveStatus('idle');
|
||||||
// VI: Trong implementation thực tế, bạn sẽ upload file lên storage service
|
setErrorMessage('');
|
||||||
// và lấy URL. Hiện tại, chúng ta sẽ sử dụng data URL làm placeholder.
|
|
||||||
|
const uploadResponse = await storageApi.uploadAvatar(avatarFile);
|
||||||
// EN: TODO: Implement actual file upload to storage service
|
const avatarUrl =
|
||||||
// VI: TODO: Implement upload file thực tế lên storage service
|
uploadResponse.data?.url ||
|
||||||
const avatarUrl = avatarPreview || '';
|
`/files/${uploadResponse.data?.fileId}/cdn-url`;
|
||||||
|
|
||||||
const response = await userApi.uploadAvatar(user.id, avatarUrl);
|
const response = await userApi.uploadAvatar(user.id, avatarUrl);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (!response.success || !response.data) {
|
||||||
setProfile(response.data);
|
throw new Error(t('settings.profile.failedToUpload'));
|
||||||
setAvatarFile(null);
|
|
||||||
alert(t('settings.profile.uploadSuccess'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProfile(response.data);
|
||||||
|
setAvatarPreview(response.data.avatarUrl ?? avatarPreview);
|
||||||
|
setAvatarFile(null);
|
||||||
|
setSaveStatus('success');
|
||||||
|
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload avatar:', 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 {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
const updateData: UpdateUserProfileDto = {
|
const updateData: UpdateUserProfileDto = {
|
||||||
firstName: data.firstName || undefined,
|
bio: data.bio || undefined,
|
||||||
lastName: data.lastName || undefined,
|
|
||||||
phone: data.phone || undefined,
|
|
||||||
customFields: {
|
|
||||||
...(profile?.customFields as Record<string, any> || {}),
|
|
||||||
bio: data.bio || undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await userApi.updateProfile(user.id, updateData);
|
const response = await userApi.updateProfile(user.id, updateData);
|
||||||
|
if (!response.success || !response.data) {
|
||||||
if (response.success && response.data) {
|
throw new Error(t('settings.profile.failedToUpdate'));
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to update profile:', error);
|
console.error('Failed to update profile:', error);
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
|
setErrorMessage(t('settings.profile.failedToUpdate'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
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
|
// EN: Get user initials for avatar fallback / VI: Lấy chữ cái đầu cho avatar fallback
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
if (!user) return 'U';
|
if (!user) return 'U';
|
||||||
const firstName = profile?.firstName || user.email.split('@')[0];
|
const attributes = profile?.attributes ?? [];
|
||||||
const lastName = profile?.lastName || '';
|
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';
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center py-12">
|
<div>
|
||||||
<h2 className="text-2xl font-bold">Profile Settings</h2>
|
<h2 className="text-2xl font-semibold text-text-primary">
|
||||||
<p className="text-muted-foreground mt-2">Profile form temporarily disabled during migration.</p>
|
{t('settings.profile.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-tertiary">
|
||||||
|
{t('settings.profile.updateInfo')}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
90
apps/web-client/src/services/api/storage.api.ts
Normal file
90
apps/web-client/src/services/api/storage.api.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,16 +9,21 @@ import { apiClient } from './client';
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
firstName?: string;
|
bio?: string;
|
||||||
lastName?: string;
|
|
||||||
phone?: string;
|
|
||||||
phoneVerified?: boolean;
|
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
customFields?: Record<string, unknown>;
|
timezone?: string;
|
||||||
preferences?: Record<string, unknown>;
|
locale?: string;
|
||||||
metadata?: Record<string, unknown>;
|
phoneNumber?: {
|
||||||
|
countryCode: string;
|
||||||
|
nationalNumber: string;
|
||||||
|
};
|
||||||
|
attributes: Array<{
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
valueType: string;
|
||||||
|
}>;
|
||||||
createdAt: 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
|
* VI: DTO cập nhật profile người dùng
|
||||||
*/
|
*/
|
||||||
export interface UpdateUserProfileDto {
|
export interface UpdateUserProfileDto {
|
||||||
firstName?: string;
|
bio?: string;
|
||||||
lastName?: string;
|
timezone?: string;
|
||||||
phone?: string;
|
locale?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string | null;
|
||||||
customFields?: Record<string, unknown>;
|
}
|
||||||
preferences?: Record<string, unknown>;
|
|
||||||
metadata?: Record<string, unknown>;
|
/**
|
||||||
|
* 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
|
* @param userId - User ID / ID người dùng
|
||||||
*/
|
*/
|
||||||
getProfile: async (userId: string): Promise<ApiResponse<UserProfile>> => {
|
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,
|
userId: string,
|
||||||
data: UpdateUserProfileDto
|
data: UpdateUserProfileDto
|
||||||
): Promise<ApiResponse<UserProfile>> => {
|
): 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,
|
userId: string,
|
||||||
avatarUrl: string
|
avatarUrl: string
|
||||||
): Promise<ApiResponse<UserProfile>> => {
|
): Promise<ApiResponse<UserProfile>> => {
|
||||||
return apiClient.post(`/identity/users/${userId}/profile/avatar`, {
|
return apiClient.put(`/users/${userId}/profile`, { avatarUrl });
|
||||||
avatarUrl,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +114,6 @@ export const userApi = {
|
|||||||
* @param userId - User ID / ID người dùng
|
* @param userId - User ID / ID người dùng
|
||||||
*/
|
*/
|
||||||
deleteAvatar: async (userId: string): Promise<ApiResponse> => {
|
deleteAvatar: async (userId: string): Promise<ApiResponse> => {
|
||||||
return apiClient.delete(`/identity/users/${userId}/profile/avatar`);
|
return apiClient.put(`/users/${userId}/profile`, { avatarUrl: null });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user