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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user