fix
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'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 Select, { MultiValue, StylesConfig } from 'react-select';
|
||||||
import { ProfileField } from '@/services/profile-sections.service';
|
import { ProfileField } from '@/services/profile-sections.service';
|
||||||
import { FormInput } from './FormInput';
|
import { FormInput } from './FormInput';
|
||||||
@@ -139,7 +140,7 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
|||||||
case 'TAG_INPUT':
|
case 'TAG_INPUT':
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<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.name}
|
||||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -151,6 +152,16 @@ export default function DynamicField({ field, value, onChange, error }: DynamicF
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'FILE':
|
||||||
|
case 'FILE_UPLOAD':
|
||||||
|
return (
|
||||||
|
<FileUpload
|
||||||
|
field={field}
|
||||||
|
value={(value as UploadedFile[]) || []}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -191,7 +202,7 @@ function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<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.name}
|
||||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -199,17 +210,33 @@ function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
|
|||||||
className="grid gap-2"
|
className="grid gap-2"
|
||||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
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">
|
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={value.includes(option.value)}
|
checked={isChecked}
|
||||||
onChange={() => handleToggle(option.value)}
|
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>
|
<span className="text-[14px] font-serif text-[#00293D]">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -225,7 +252,7 @@ interface RadioGroupProps {
|
|||||||
function RadioGroup({ field, value, onChange }: RadioGroupProps) {
|
function RadioGroup({ field, value, onChange }: RadioGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<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.name}
|
||||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -339,7 +366,7 @@ function MultiSelect({ field, value, onChange }: MultiSelectProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<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.name}
|
||||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -375,7 +402,7 @@ function RangeSlider({ field, value, onChange }: RangeSliderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<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.name}
|
||||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@@ -400,3 +427,316 @@ function RangeSlider({ field, value, onChange }: RangeSliderProps) {
|
|||||||
</div>
|
</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) {
|
export function FormInput({ label, value, onChange, placeholder, type = 'text', disabled, required, error, min, max }: FormInputProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<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}
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface FormSelectProps {
|
|||||||
export function FormSelect({ label, value, onChange, options, placeholder }: FormSelectProps) {
|
export function FormSelect({ label, value, onChange, options, placeholder }: FormSelectProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<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}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function FormTextarea({ label, value, onChange, placeholder, rows = 4, ma
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{label && (
|
{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}
|
{label}
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { QuickLinks } from './components';
|
import { QuickLinks } from './components';
|
||||||
import DynamicSection from './components/DynamicSection';
|
import DynamicSection from './components/DynamicSection';
|
||||||
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
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() {
|
export default function EditProfilePage() {
|
||||||
const router = useRouter();
|
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);
|
setFormData(initialData);
|
||||||
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -151,11 +168,14 @@ export default function EditProfilePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Call API to save form data
|
// Convert formData to field values array
|
||||||
console.log('Saving form data:', formData);
|
const fieldValues: FieldValueInput[] = Object.entries(formData).map(([fieldSlug, value]) => ({
|
||||||
|
fieldSlug,
|
||||||
|
value: value as string | number | boolean | string[] | Record<string, unknown> | null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Simulate save
|
// Save field values to API
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await agentsService.saveFieldValues(fieldValues);
|
||||||
|
|
||||||
router.push('/agent/dashboard');
|
router.push('/agent/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -43,6 +43,31 @@ interface ApiResponse<T> {
|
|||||||
message?: string;
|
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
|
// Agents Service for Web
|
||||||
class AgentsService {
|
class AgentsService {
|
||||||
private basePath = '/agents';
|
private basePath = '/agents';
|
||||||
@@ -61,6 +86,21 @@ class AgentsService {
|
|||||||
const response = await api.put<ApiResponse<AgentProfile>>(`${this.basePath}/profile`, data);
|
const response = await api.put<ApiResponse<AgentProfile>>(`${this.basePath}/profile`, data);
|
||||||
return response.data.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();
|
export const agentsService = new AgentsService();
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ export type {
|
|||||||
// Agents Service
|
// Agents Service
|
||||||
export { agentsService } from './agents.service';
|
export { agentsService } from './agents.service';
|
||||||
export type { AgentProfile, AgentType } 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'
|
| 'RANGE'
|
||||||
| 'NUMBER'
|
| 'NUMBER'
|
||||||
| 'DATE'
|
| 'DATE'
|
||||||
| 'TAG_INPUT';
|
| 'TAG_INPUT'
|
||||||
|
| 'FILE'
|
||||||
|
| 'FILE_UPLOAD';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface FieldOption {
|
export interface FieldOption {
|
||||||
@@ -33,6 +35,8 @@ export interface FieldValidation {
|
|||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
|
allowedFormats?: string[];
|
||||||
|
maxFileSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldUiConfig {
|
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