fix
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Select, { MultiValue, StylesConfig } from 'react-select';
|
||||
import { ProfileField } from '@/services/profile-sections.service';
|
||||
import { FormInput } from './FormInput';
|
||||
@@ -139,7 +140,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
case 'TAG_INPUT':
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -151,6 +152,16 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'FILE':
|
||||
case 'FILE_UPLOAD':
|
||||
return (
|
||||
<FileUpload
|
||||
field={field}
|
||||
value={(value as UploadedFile[]) || []}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<FormInput
|
||||
@@ -191,7 +202,7 @@ function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -199,17 +210,33 @@ function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
{field.options?.map((option) => {
|
||||
const isChecked = value.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(option.value)}
|
||||
checked={isChecked}
|
||||
onChange={() => handleToggle(option.value)}
|
||||
className="w-4 h-4 rounded border-[#00293D]/20 text-[#E58625] focus:ring-[#E58625]"
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
isChecked
|
||||
? 'bg-[#E58625] border-[#E58625]'
|
||||
: 'bg-white border-[#00293D]/20'
|
||||
}`}>
|
||||
{isChecked && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[14px] font-serif text-[#00293D]">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -225,7 +252,7 @@ interface RadioGroupProps {
|
||||
function RadioGroup({ field, value, onChange }: RadioGroupProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -339,7 +366,7 @@ function MultiSelect({ field, value, onChange }: MultiSelectProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -375,7 +402,7 @@ function RangeSlider({ field, value, onChange }: RangeSliderProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{field.name}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -400,3 +427,316 @@ function RangeSlider({ field, value, onChange }: RangeSliderProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File Upload Types
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
url: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// File Upload Component
|
||||
interface FileUploadProps {
|
||||
field: ProfileField;
|
||||
value: UploadedFile[];
|
||||
onChange: (value: UploadedFile[]) => void;
|
||||
}
|
||||
|
||||
function FileUpload({ field, value, onChange }: FileUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||
|
||||
// Get allowed formats from field config or use defaults
|
||||
const allowedFormats = field.validation?.allowedFormats || ['PDF', 'DOCX', 'JPG', 'PNG'];
|
||||
const maxFileSize = field.validation?.maxFileSize || 10; // MB
|
||||
const acceptString = allowedFormats.map(f => `.${f.toLowerCase()}`).join(',');
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatUploadTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'Uploaded just now';
|
||||
if (diffMins < 60) return `Uploaded ${diffMins} min ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `Uploaded ${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
return `Uploaded on ${date.toLocaleDateString()}`;
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File, tempId: string) => {
|
||||
try {
|
||||
// Import dynamically to avoid SSR issues
|
||||
const { uploadService } = await import('@/services/upload.service');
|
||||
|
||||
const uploadedFile = await uploadService.uploadFile(
|
||||
file,
|
||||
'documents',
|
||||
(progress) => {
|
||||
setUploadingFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === tempId ? { ...f, progress: progress.percentage } : f
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Remove from uploading and add to uploaded
|
||||
setUploadingFiles(prev => prev.filter(f => f.id !== tempId));
|
||||
onChange([...value, uploadedFile]);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
setUploadingFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === tempId ? { ...f, error: 'Upload failed. Please try again.' } : f
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const validFiles: { file: File; tempId: string }[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
// Check file size
|
||||
if (file.size > maxFileSize * 1024 * 1024) {
|
||||
alert(`File "${file.name}" exceeds the maximum size of ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
const extension = file.name.split('.').pop()?.toUpperCase() || '';
|
||||
if (!allowedFormats.includes(extension)) {
|
||||
alert(`File type ".${extension}" is not allowed. Allowed formats: ${allowedFormats.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
validFiles.push({ file, tempId });
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
// Add files to uploading state
|
||||
setUploadingFiles(prev => [
|
||||
...prev,
|
||||
...validFiles.map(({ file, tempId }) => ({
|
||||
id: tempId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Upload each file
|
||||
for (const { file, tempId } of validFiles) {
|
||||
await uploadFile(file, tempId);
|
||||
}
|
||||
}, [value, onChange, maxFileSize, allowedFormats]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}, [handleFiles]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = async (fileId: string) => {
|
||||
try {
|
||||
// Import dynamically to avoid SSR issues
|
||||
const { uploadService } = await import('@/services/upload.service');
|
||||
|
||||
// Try to delete from S3
|
||||
await uploadService.deleteFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file from server:', error);
|
||||
// Continue to remove from local state even if server delete fails
|
||||
}
|
||||
|
||||
onChange(value.filter(f => f.id !== fileId));
|
||||
};
|
||||
|
||||
const handleRemoveUploadingFile = (tempId: string) => {
|
||||
setUploadingFiles(prev => prev.filter(f => f.id !== tempId));
|
||||
};
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const isUploading = uploadingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-[15px] p-6 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-[#E58625] bg-[#E58625]/5'
|
||||
: 'border-[#00293D]/20 hover:border-[#E58625]/50'
|
||||
} ${isUploading ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptString}
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = ''; // Reset input
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Image
|
||||
src="/assets/icons/upload-icon.svg"
|
||||
alt="Upload"
|
||||
width={32}
|
||||
height={32}
|
||||
className="opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[14px] font-serif text-[#00293D]">
|
||||
Click to upload or drag and drop
|
||||
</p>
|
||||
<p className="text-[12px] text-gray-500 mt-1">
|
||||
{allowedFormats.join(', ')} (max. {maxFileSize}MB)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFiles}
|
||||
disabled={isUploading}
|
||||
className="px-6 py-2 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Select Files'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uploading Files Progress */}
|
||||
{uploadingFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{uploadingFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="p-3 bg-blue-50 rounded-[10px] border border-blue-100"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[14px] font-serif text-[#00293D] truncate flex-1">
|
||||
{file.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveUploadingFile(file.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{file.error ? (
|
||||
<p className="text-[12px] text-red-500">{file.error}</p>
|
||||
) : (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-[#E58625] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[12px] text-gray-500 mt-1">
|
||||
{file.error ? '' : `${file.progress}% • ${formatFileSize(file.size)}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached Documents List */}
|
||||
{value.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-[14px] font-serif font-semibold text-[#00293D] mb-3 flex items-center gap-2">
|
||||
<Image
|
||||
src="/assets/icons/attachment-icon.svg"
|
||||
alt="Attached"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Attached Documents
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{value.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-[10px]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{file.url ? (
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[14px] font-serif text-[#E58625] hover:underline truncate"
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[14px] font-serif text-[#00293D] truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors flex-shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[12px] text-gray-500">
|
||||
{formatFileSize(file.size)} • {formatUploadTime(file.uploadedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface FormInputProps {
|
||||
export function FormInput({ label, value, onChange, placeholder, type = 'text', disabled, required, error, min, max }: FormInputProps) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface FormSelectProps {
|
||||
export function FormSelect({ label, value, onChange, options, placeholder }: FormSelectProps) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
|
||||
@@ -14,7 +14,7 @@ export function FormTextarea({ label, value, onChange, placeholder, rows = 4, ma
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{label && (
|
||||
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
|
||||
<label className="block text-[14px] font-bold font-serif text-[#00293D] mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { QuickLinks } from './components';
|
||||
import DynamicSection from './components/DynamicSection';
|
||||
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
||||
import { agentsService, AgentProfile } from '@/services/agents.service';
|
||||
import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service';
|
||||
|
||||
export default function EditProfilePage() {
|
||||
const router = useRouter();
|
||||
@@ -92,6 +92,23 @@ export default function EditProfilePage() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch existing field values from the API
|
||||
try {
|
||||
const fieldValuesResponse = await agentsService.getFieldValues();
|
||||
if (fieldValuesResponse.fieldValues) {
|
||||
fieldValuesResponse.fieldValues.forEach(fv => {
|
||||
// Only set if value exists and is not null/undefined
|
||||
if (fv.value !== null && fv.value !== undefined) {
|
||||
initialData[fv.fieldSlug] = fv.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (fieldValueErr) {
|
||||
// If field values don't exist yet, that's OK - just use defaults
|
||||
console.log('No existing field values found, using defaults');
|
||||
}
|
||||
|
||||
setFormData(initialData);
|
||||
|
||||
} catch (err: unknown) {
|
||||
@@ -151,11 +168,14 @@ export default function EditProfilePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Call API to save form data
|
||||
console.log('Saving form data:', formData);
|
||||
// Convert formData to field values array
|
||||
const fieldValues: FieldValueInput[] = Object.entries(formData).map(([fieldSlug, value]) => ({
|
||||
fieldSlug,
|
||||
value: value as string | number | boolean | string[] | Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
// Simulate save
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Save field values to API
|
||||
await agentsService.saveFieldValues(fieldValues);
|
||||
|
||||
router.push('/agent/dashboard');
|
||||
} catch (err) {
|
||||
|
||||
@@ -43,6 +43,31 @@ interface ApiResponse<T> {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FieldValueInput {
|
||||
fieldSlug: string;
|
||||
value: string | number | boolean | string[] | Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface FieldValueResponse {
|
||||
fieldSlug: string;
|
||||
fieldName: string;
|
||||
fieldType: string;
|
||||
sectionSlug: string;
|
||||
sectionName: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface GetFieldValuesResponse {
|
||||
agentProfileId: string;
|
||||
fieldValues: FieldValueResponse[];
|
||||
}
|
||||
|
||||
export interface SaveFieldValuesResponse {
|
||||
message: string;
|
||||
savedCount: number;
|
||||
data: unknown[];
|
||||
}
|
||||
|
||||
// Agents Service for Web
|
||||
class AgentsService {
|
||||
private basePath = '/agents';
|
||||
@@ -61,6 +86,21 @@ class AgentsService {
|
||||
const response = await api.put<ApiResponse<AgentProfile>>(`${this.basePath}/profile`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async saveFieldValues(fieldValues: FieldValueInput[]): Promise<SaveFieldValuesResponse> {
|
||||
const response = await api.put<ApiResponse<SaveFieldValuesResponse>>(
|
||||
`${this.basePath}/profile/field-values`,
|
||||
{ fieldValues }
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getFieldValues(): Promise<GetFieldValuesResponse> {
|
||||
const response = await api.get<ApiResponse<GetFieldValuesResponse>>(
|
||||
`${this.basePath}/profile/field-values`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentsService = new AgentsService();
|
||||
|
||||
@@ -31,3 +31,7 @@ export type {
|
||||
// Agents Service
|
||||
export { agentsService } from './agents.service';
|
||||
export type { AgentProfile, AgentType } from './agents.service';
|
||||
|
||||
// Upload Service
|
||||
export { uploadService } from './upload.service';
|
||||
export type { UploadedFile, UploadProgress } from './upload.service';
|
||||
|
||||
@@ -12,7 +12,9 @@ export type FieldType =
|
||||
| 'RANGE'
|
||||
| 'NUMBER'
|
||||
| 'DATE'
|
||||
| 'TAG_INPUT';
|
||||
| 'TAG_INPUT'
|
||||
| 'FILE'
|
||||
| 'FILE_UPLOAD';
|
||||
|
||||
// Types
|
||||
export interface FieldOption {
|
||||
@@ -33,6 +35,8 @@ export interface FieldValidation {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
allowedFormats?: string[];
|
||||
maxFileSize?: number;
|
||||
}
|
||||
|
||||
export interface FieldUiConfig {
|
||||
|
||||
151
src/services/upload.service.ts
Normal file
151
src/services/upload.service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import api from './api';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
url: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface PresignedUrlResponse {
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
type UploadFolder = 'avatars' | 'documents' | 'properties' | 'verification';
|
||||
|
||||
class UploadService {
|
||||
private basePath = '/upload';
|
||||
|
||||
/**
|
||||
* Get presigned URL from backend
|
||||
*/
|
||||
private async getPresignedUrl(
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder: UploadFolder = 'documents'
|
||||
): Promise<PresignedUrlResponse> {
|
||||
const response = await api.post<{ data: PresignedUrlResponse }>(
|
||||
`${this.basePath}/presigned-url`,
|
||||
{
|
||||
filename,
|
||||
contentType,
|
||||
folder,
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file directly to S3 using presigned URL
|
||||
*/
|
||||
private async uploadToS3(
|
||||
uploadUrl: string,
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
const percentage = Math.round((event.loaded * 100) / event.total);
|
||||
onProgress({
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
percentage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Upload failed'));
|
||||
});
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to S3
|
||||
*/
|
||||
async uploadFile(
|
||||
file: File,
|
||||
folder: UploadFolder = 'documents',
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
): Promise<UploadedFile> {
|
||||
// Step 1: Get presigned URL from backend
|
||||
const { uploadUrl, publicUrl, key } = await this.getPresignedUrl(
|
||||
file.name,
|
||||
file.type,
|
||||
folder
|
||||
);
|
||||
|
||||
// Step 2: Upload directly to S3
|
||||
await this.uploadToS3(uploadUrl, file, onProgress);
|
||||
|
||||
// Step 3: Return uploaded file info
|
||||
return {
|
||||
id: key, // Use the S3 key as the ID
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
url: publicUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to S3
|
||||
*/
|
||||
async uploadFiles(
|
||||
files: File[],
|
||||
folder: UploadFolder = 'documents',
|
||||
onProgress?: (fileIndex: number, progress: UploadProgress) => void
|
||||
): Promise<UploadedFile[]> {
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const uploadedFile = await this.uploadFile(
|
||||
file,
|
||||
folder,
|
||||
onProgress ? (progress) => onProgress(i, progress) : undefined
|
||||
);
|
||||
uploadedFiles.push(uploadedFile);
|
||||
}
|
||||
|
||||
return uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from S3
|
||||
*/
|
||||
async deleteFile(fileKey: string): Promise<void> {
|
||||
// The key needs to be URL encoded for path parameter
|
||||
const encodedKey = encodeURIComponent(fileKey);
|
||||
await api.delete(`${this.basePath}/${encodedKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadService = new UploadService();
|
||||
Reference in New Issue
Block a user