This commit is contained in:
pradeepkumar
2026-01-24 21:06:13 +05:30
parent 0f84e3d4d7
commit eba83e2961
9 changed files with 585 additions and 26 deletions

View File

@@ -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) => (
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={value.includes(option.value)}
onChange={() => handleToggle(option.value)}
className="w-4 h-4 rounded border-[#00293D]/20 text-[#E58625] focus:ring-[#E58625]"
/>
<span className="text-[14px] font-serif text-[#00293D]">{option.label}</span>
</label>
))}
{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={isChecked}
onChange={() => handleToggle(option.value)}
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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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';

View File

@@ -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 {

View 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();