feat: Implement dynamic agent profile editing by introducing new services and dynamic UI components.

This commit is contained in:
pradeepkumar
2026-01-24 20:36:22 +05:30
parent cb2e17c759
commit 5e2b3d8e83
12 changed files with 1133 additions and 705 deletions

View File

@@ -0,0 +1,345 @@
'use client';
import React from 'react';
import { ProfileField } from '@/services/profile-sections.service';
import { FormInput } from './FormInput';
import { FormCheckbox } from './FormCheckbox';
import { FormTextarea } from './FormTextarea';
import { FormSelect } from './FormSelect';
import { TagInput } from './TagInput';
interface DynamicFieldProps {
field: ProfileField;
value: unknown;
onChange: (fieldSlug: string, value: unknown) => void;
error?: string;
}
export default function DynamicField({ field, value, onChange, error }: DynamicFieldProps) {
const handleChange = (newValue: unknown) => {
onChange(field.slug, newValue);
};
const renderField = () => {
switch (field.fieldType) {
case 'TEXT':
return (
<FormInput
label={field.name}
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
required={field.isRequired}
error={error}
/>
);
case 'TEXTAREA':
return (
<FormTextarea
label={field.name}
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
required={field.isRequired}
rows={4}
/>
);
case 'NUMBER':
return (
<FormInput
label={field.name}
type="number"
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
required={field.isRequired}
error={error}
min={field.validation?.min}
max={field.validation?.max}
/>
);
case 'DATE':
return (
<FormInput
label={field.name}
type="date"
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
required={field.isRequired}
error={error}
/>
);
case 'CHECKBOX':
return (
<FormCheckbox
label={field.name}
checked={Boolean(value)}
onChange={(checked: boolean) => handleChange(checked)}
/>
);
case 'CHECKBOX_GROUP':
return (
<CheckboxGroup
field={field}
value={(value as string[]) || []}
onChange={handleChange}
/>
);
case 'RADIO':
return (
<RadioGroup
field={field}
value={(value as string) || ''}
onChange={handleChange}
/>
);
case 'SELECT':
return (
<FormSelect
label={field.name}
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
options={field.options?.map(opt => ({ value: opt.value, label: opt.label })) || []}
placeholder={field.placeholder || `Select ${field.name.toLowerCase()}`}
/>
);
case 'MULTI_SELECT':
return (
<MultiSelect
field={field}
value={(value as string[]) || []}
onChange={handleChange}
/>
);
case 'RANGE':
return (
<RangeSlider
field={field}
value={(value as number) || field.rangeConfig?.min || 0}
onChange={handleChange}
/>
);
case 'TAG_INPUT':
return (
<div className="mb-4">
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
{field.name}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<TagInput
tags={(value as string[]) || []}
onChange={(tags: string[]) => handleChange(tags)}
placeholder={field.placeholder || `Add ${field.name.toLowerCase()}`}
/>
</div>
);
default:
return (
<FormInput
label={field.name}
placeholder={field.placeholder || `Enter ${field.name.toLowerCase()}`}
value={(value as string) || ''}
onChange={(val: string) => handleChange(val)}
required={field.isRequired}
error={error}
/>
);
}
};
return (
<div className="dynamic-field" data-field-slug={field.slug}>
{renderField()}
</div>
);
}
// Checkbox Group Component
interface CheckboxGroupProps {
field: ProfileField;
value: string[];
onChange: (value: string[]) => void;
}
function CheckboxGroup({ field, value, onChange }: CheckboxGroupProps) {
const handleToggle = (optionValue: string) => {
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue];
onChange(newValue);
};
const columns = field.uiConfig?.columns || 2;
return (
<div className="mb-4">
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
{field.name}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div
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>
))}
</div>
</div>
);
}
// Radio Group Component
interface RadioGroupProps {
field: ProfileField;
value: string;
onChange: (value: string) => void;
}
function RadioGroup({ field, value, onChange }: RadioGroupProps) {
return (
<div className="mb-4">
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
{field.name}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="flex flex-wrap gap-4">
{field.options?.map((option) => (
<label key={option.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={field.slug}
checked={value === option.value}
onChange={() => onChange(option.value)}
className="w-4 h-4 border-[#00293D]/20 text-[#E58625] focus:ring-[#E58625]"
/>
<span className="text-[14px] font-serif text-[#00293D]">{option.label}</span>
</label>
))}
</div>
</div>
);
}
// Multi-Select Component
interface MultiSelectProps {
field: ProfileField;
value: string[];
onChange: (value: string[]) => void;
}
function MultiSelect({ field, value, onChange }: MultiSelectProps) {
const handleToggle = (optionValue: string) => {
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue];
onChange(newValue);
};
return (
<div className="mb-4">
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
{field.name}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="border border-[#00293D]/20 rounded-[15px] p-3 max-h-[200px] overflow-y-auto">
{field.options?.map((option) => (
<label
key={option.value}
className="flex items-center gap-2 cursor-pointer py-1 hover:bg-gray-50 px-2 rounded"
>
<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>
))}
</div>
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{value.map((v) => {
const option = field.options?.find(o => o.value === v);
return (
<span
key={v}
className="inline-flex items-center gap-1 px-2 py-1 bg-[#E58625]/10 text-[#E58625] rounded-full text-[12px]"
>
{option?.label || v}
<button
type="button"
onClick={() => handleToggle(v)}
className="hover:text-[#E58625]/70"
>
×
</button>
</span>
);
})}
</div>
)}
</div>
);
}
// Range Slider Component
interface RangeSliderProps {
field: ProfileField;
value: number;
onChange: (value: number) => void;
}
function RangeSlider({ field, value, onChange }: RangeSliderProps) {
const min = field.rangeConfig?.min || 0;
const max = field.rangeConfig?.max || 100;
const step = field.rangeConfig?.step || 1;
const unit = field.rangeConfig?.unit || '';
return (
<div className="mb-4">
<label className="block text-[14px] font-serif text-[#00293D] mb-2">
{field.name}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="flex items-center gap-4">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#E58625]"
/>
<span className="text-[14px] font-serif text-[#00293D] min-w-[60px] text-right">
{value}{unit}
</span>
</div>
<div className="flex justify-between text-[12px] text-gray-500 mt-1">
<span>{min}{unit}</span>
<span>{max}{unit}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import React from 'react';
import Image from 'next/image';
import { ProfileSection } from '@/services/profile-sections.service';
import DynamicField from './DynamicField';
// Map section slugs to icon paths
const sectionIcons: Record<string, string> = {
'basic-information': '/assets/icons/basic-information-icon.svg',
'contact-information': '/assets/icons/email-orange-icon.svg',
'location': '/assets/icons/location-orange-icon.svg',
'professional-info': '/assets/icons/certification-icon.svg',
'specialization': '/assets/icons/home-icon.svg',
'experience': '/assets/icons/star-icon.svg',
'availability': '/assets/icons/availability-clock-icon.svg',
'biography': '/assets/icons/bio-icon.svg',
'attached-documents': '/assets/icons/attachment-icon.svg',
'hobbies': '/assets/icons/heart-icon.svg',
'testimonials': '/assets/icons/quote-icon.svg',
'certification': '/assets/icons/certification-icon.svg',
};
// Map icon names (from database) to icon paths
const iconNameToPath: Record<string, string> = {
'star': '/assets/icons/star-icon.svg',
'home': '/assets/icons/home-icon.svg',
'location': '/assets/icons/location-orange-icon.svg',
'heart': '/assets/icons/heart-icon.svg',
'clock': '/assets/icons/availability-clock-icon.svg',
'user': '/assets/icons/basic-information-icon.svg',
'email': '/assets/icons/email-orange-icon.svg',
'phone': '/assets/icons/phone-orange-icon.svg',
'document': '/assets/icons/attachment-icon.svg',
'certificate': '/assets/icons/certification-icon.svg',
'certification': '/assets/icons/certification-icon.svg',
'quote': '/assets/icons/quote-icon.svg',
'bio': '/assets/icons/bio-icon.svg',
'professional': '/assets/icons/professional-icon.svg',
'wallet': '/assets/icons/wallet-icon.svg',
'chart': '/assets/icons/chart-icon.svg',
'dollar': '/assets/icons/dollar-icon.svg',
'calendar': '/assets/icons/calendar-icon.svg',
};
// Default icon for sections without a specific icon
const defaultIcon = '/assets/icons/professional-icon.svg';
// Helper to check if a string is an emoji
const isEmojiString = (str: string): boolean => {
// Emoji regex pattern - matches most common emojis
const emojiRegex = /^[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
return emojiRegex.test(str);
};
interface DynamicSectionProps {
section: ProfileSection;
formData: Record<string, unknown>;
onChange: (fieldSlug: string, value: unknown) => void;
errors?: Record<string, string>;
}
export default function DynamicSection({ section, formData, onChange, errors }: DynamicSectionProps) {
// Determine icon source
let iconPath: string = defaultIcon;
let isEmoji = false;
if (section.icon) {
if (section.icon.startsWith('/') || section.icon.startsWith('http')) {
// It's a valid URL path
iconPath = section.icon;
} else if (isEmojiString(section.icon)) {
// It's an emoji
isEmoji = true;
} else if (iconNameToPath[section.icon.toLowerCase()]) {
// It's an icon name like "star", "home", etc.
iconPath = iconNameToPath[section.icon.toLowerCase()];
} else {
// Fallback to slug mapping or default
iconPath = sectionIcons[section.slug] || defaultIcon;
}
} else {
// No icon set, use slug mapping or default
iconPath = sectionIcons[section.slug] || defaultIcon;
}
// Sort fields by sortOrder
const sortedFields = [...(section.fields || [])].sort((a, b) => a.sortOrder - b.sortOrder);
const activeFields = sortedFields.filter(f => f.isActive && !f.isSearchableOnly);
if (activeFields.length === 0) {
return null;
}
return (
<div
id={section.slug}
className="bg-white rounded-[20px] p-6 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] scroll-mt-[100px]"
>
{/* Section Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-[40px] h-[40px] flex items-center justify-center">
{isEmoji ? (
<span className="text-2xl">{section.icon}</span>
) : (
<Image
src={iconPath}
alt={section.name}
width={24}
height={24}
className="object-contain"
/>
)}
</div>
<div>
<h2 className="text-[18px] font-bold font-serif text-[#00293D]">
{section.name}
{section.isRequired && <span className="text-red-500 ml-1">*</span>}
</h2>
{section.description && (
<p className="text-[12px] text-gray-500">{section.description}</p>
)}
</div>
</div>
{/* Section Fields */}
<div className="space-y-4">
{activeFields.map((field) => (
<DynamicField
key={field.id}
field={field}
value={formData[field.slug]}
onChange={onChange}
error={errors?.[field.slug]}
/>
))}
</div>
</div>
);
}

View File

@@ -7,13 +7,18 @@ interface FormInputProps {
placeholder?: string;
type?: string;
disabled?: boolean;
required?: boolean;
error?: string;
min?: number;
max?: number;
}
export function FormInput({ label, value, onChange, placeholder, type = 'text', disabled }: 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}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type={type}
@@ -21,8 +26,13 @@ export function FormInput({ label, value, onChange, placeholder, type = 'text',
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full h-[40px] px-4 border border-[#00293D]/20 rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625] disabled:bg-gray-50 disabled:text-[#00293D]/50"
min={min}
max={max}
className={`w-full h-[40px] px-4 border rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none disabled:bg-gray-50 disabled:text-[#00293D]/50 ${
error ? 'border-red-500 focus:border-red-500' : 'border-[#00293D]/20 focus:border-[#E58625]'
}`}
/>
{error && <p className="text-[12px] text-red-500 mt-1">{error}</p>}
</div>
);
}

View File

@@ -7,14 +7,16 @@ interface FormTextareaProps {
placeholder?: string;
rows?: number;
maxLength?: number;
required?: boolean;
}
export function FormTextarea({ label, value, onChange, placeholder, rows = 4, maxLength }: FormTextareaProps) {
export function FormTextarea({ label, value, onChange, placeholder, rows = 4, maxLength, required }: FormTextareaProps) {
return (
<div className="flex-1">
{label && (
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<textarea

View File

@@ -1,31 +1,39 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { ProfileSection } from '@/services/profile-sections.service';
const links = [
{ id: 'basic-info', label: 'Basic Information' },
{ id: 'contact-info', label: 'Contact Information' },
{ id: 'professional-types', label: 'Professional Types' },
{ id: 'upload-documents', label: 'Upload Documents' },
{ id: 'licensing-areas', label: 'Licensing & Areas' },
{ id: 'biography', label: 'Biography' },
{ id: 'expertise', label: 'Expertise' },
{ id: 'years-business', label: 'Years In Business' },
{ id: 'areas-expertise-years', label: 'Areas in expertise & Years' },
{ id: 'contracts-closed', label: 'Contracts Closed' },
{ id: 'price-point', label: 'Price Point' },
{ id: 'specialization', label: 'Specialization' },
{ id: 'hobbies', label: 'Hobbies' },
{ id: 'certifications', label: 'Certifications' },
{ id: 'availability', label: 'Availability' },
];
interface QuickLink {
id: string;
label: string;
}
interface QuickLinksProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
sections?: ProfileSection[];
}
export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
const [activeLink, setActiveLink] = useState('basic-info');
// Convert ProfileSection array to QuickLink array
const sectionsToLinks = (sections: ProfileSection[]): QuickLink[] => {
return sections
.filter(s => s.isActive && s.fields && s.fields.length > 0)
.sort((a, b) => (a.effectiveSortOrder || a.sortOrder) - (b.effectiveSortOrder || b.sortOrder))
.map(section => ({
id: section.slug,
label: section.name,
}));
};
// Fallback links if no sections provided
const defaultLinks: QuickLink[] = [
{ id: 'basic-information', label: 'Basic Information' },
{ id: 'contact-information', label: 'Contact Information' },
{ id: 'professional-info', label: 'Professional Info' },
];
export function QuickLinks({ scrollContainerRef, sections }: QuickLinksProps) {
const links = sections && sections.length > 0 ? sectionsToLinks(sections) : defaultLinks;
const [activeLink, setActiveLink] = useState(links[0]?.id || 'basic-information');
const isClickScrolling = useRef(false);
useEffect(() => {
@@ -38,7 +46,7 @@ export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
const containerRect = container.getBoundingClientRect();
const containerTop = containerRect.top;
let currentSection = 'basic-info';
let currentSection = links[0]?.id || 'basic-information';
for (const link of links) {
const element = document.getElementById(link.id);
@@ -56,7 +64,7 @@ export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [scrollContainerRef]);
}, [scrollContainerRef, links]);
const scrollToSection = (id: string) => {
const container = scrollContainerRef.current;

View File

@@ -6,3 +6,5 @@ export { FormTextarea } from './FormTextarea';
export { FormSelect } from './FormSelect';
export { FileUpload, AttachedFile } from './FileUpload';
export { TagInput } from './TagInput';
export { default as DynamicField } from './DynamicField';
export { default as DynamicSection } from './DynamicSection';

View File

@@ -1,708 +1,288 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
QuickLinks,
FormSection,
FormInput,
FormCheckbox,
FormTextarea,
FormSelect,
FileUpload,
AttachedFile,
TagInput,
} from './components';
// Initial form data - in production this would come from API
const initialFormData = {
firstName: 'Brian',
lastName: 'Neeland',
email: 'brian@requestnetwork.com',
phone: '+9195003837493',
professionalTypes: {
firstTime: true,
solo: false,
team: false,
},
attachedDocuments: ['Brian_Photo_1_Blue_Coat.pdf'],
state: 'Colorado',
licensedAreas: ['Colorado Springs', 'Monument'],
expertiseYears: [
{ area: 'Colorado', years: '10 yrs' },
{ area: 'Falcon', years: '10 yrs' },
],
biography: "Brian brings eight years of hands-on experience helping clients confidently buy, sell, and invest in real estate. He combines strong analytical skills with deep market knowledge to identify the right opportunities and guide clients through every step of the process. With a data-driven approach and clear communication, Brian ensures smooth transactions and informed decisions tailored to each client's goals.",
testimonialHighlight: "The most amazing experience I've had as a real estate professional is helping a family secure their dream home and seeing their happiness when they received the keys.",
expertise: ['Buyers', 'Sellers', 'Divorce', 'Luxury', 'Retirement', 'Historical', 'Condo', 'Rural'],
yearsInBusiness: {
totalYears: '10 Years',
totalTransactions: '100+',
},
contractsClosed: {
lessThan10: false,
between10And25: false,
between26And50: true,
between51And75: false,
moreThan75: false,
},
pricePoint: {
under100k: false,
between100kAnd200k: false,
between200kAnd400k: true,
between400kAnd600k: true,
between600kAnd800k: false,
between800kAnd1m: false,
above1m: false,
},
specialization: {
clientLocalization: {
local: true,
national: false,
international: false,
},
clientTypeWorkedWith: {
firstTimeBuyer: true,
firstTimeSeller: false,
},
propertyType: {
sfr: true,
condo: false,
luxury: false,
townhome: false,
commercial: false,
},
transactionType: {
relocation: true,
probate: false,
taxLien: false,
traditional: false,
newConstruction: false,
},
loanType: {
conventional: true,
fha: false,
va: false,
usda: false,
reverse: false,
},
lifestyleSpecialties: {
boating: true,
horses: false,
rv: false,
automotive: false,
aviation: false,
},
},
hobbies: ['Boating', 'Horses'],
certifications: [
{ name: 'Certified Residential Specialist (CRS)', org: 'Residential Real Estate Council' },
],
availability: {
officeHours: ['MON-FRI 8am-6pm', 'Sat 10am-2pm'],
contactMethod: {
email: true,
call: false,
},
},
};
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';
export default function EditProfilePage() {
const router = useRouter();
const [formData, setFormData] = useState(initialFormData);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [stateInput, setStateInput] = useState('');
const [officeHoursInput, setOfficeHoursInput] = useState('');
// State for agent profile and dynamic sections
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
const [sections, setSections] = useState<ProfileSection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Form data keyed by field slug
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
// Fetch agent profile and sections on mount
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// First, fetch the agent's profile to get their agentTypeId
const profile = await agentsService.getMyProfile();
setAgentProfile(profile);
if (!profile.agentTypeId) {
setError('Your profile does not have an agent type assigned. Please contact support.');
setLoading(false);
return;
}
// Now fetch the sections for this agent type
const response: AgentTypeSectionsResponse = await profileSectionsService.getSectionsForAgentType(profile.agentTypeId);
// Sort sections by effectiveSortOrder
const sortedSections = [...response.sections].sort(
(a, b) => (a.effectiveSortOrder || a.sortOrder) - (b.effectiveSortOrder || b.sortOrder)
);
setSections(sortedSections);
// Initialize form data with existing profile data + default values from fields
const initialData: Record<string, unknown> = {};
// Pre-fill with existing profile data
if (profile.firstName) initialData['first-name'] = profile.firstName;
if (profile.lastName) initialData['last-name'] = profile.lastName;
if (profile.email) initialData['email'] = profile.email;
if (profile.phone) initialData['phone'] = profile.phone;
if (profile.bio) initialData['bio'] = profile.bio;
if (profile.licenseNumber) initialData['license-number'] = profile.licenseNumber;
if (profile.experience) initialData['experience'] = profile.experience;
if (profile.specializations?.length) initialData['specializations'] = profile.specializations;
if (profile.languages?.length) initialData['languages'] = profile.languages;
if (profile.serviceAreas?.length) initialData['service-areas'] = profile.serviceAreas;
// Then apply field default values where no existing data
sortedSections.forEach(section => {
section.fields?.forEach(field => {
if (field.defaultValue && initialData[field.slug] === undefined) {
// Parse default value based on field type
switch (field.fieldType) {
case 'CHECKBOX':
initialData[field.slug] = field.defaultValue === 'true';
break;
case 'NUMBER':
case 'RANGE':
initialData[field.slug] = parseFloat(field.defaultValue) || 0;
break;
case 'CHECKBOX_GROUP':
case 'MULTI_SELECT':
case 'TAG_INPUT':
try {
initialData[field.slug] = JSON.parse(field.defaultValue);
} catch {
initialData[field.slug] = [];
}
break;
default:
initialData[field.slug] = field.defaultValue;
}
}
});
});
setFormData(initialData);
} catch (err: unknown) {
console.error('Failed to fetch data:', err);
// Check for specific error types
const error = err as { response?: { status?: number } };
if (error.response?.status === 401) {
setError('You are not logged in or your session has expired. Please log in again.');
} else if (error.response?.status === 404) {
setError('Agent profile not found. Please complete your registration first.');
} else {
setError('Failed to load profile. Please try again.');
}
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleCancel = () => {
router.push('/agent/dashboard');
};
const handleSave = () => {
// In production, this would save to API
console.log('Saving form data:', formData);
router.push('/agent/dashboard');
};
const handleSave = async () => {
try {
setIsSaving(true);
setFormErrors({});
const updateField = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Validate required fields
const errors: Record<string, string> = {};
sections.forEach(section => {
section.fields?.forEach(field => {
if (field.isRequired && field.isActive && !field.isSearchableOnly) {
const value = formData[field.slug];
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
const handleAddLicensedArea = (value: string) => {
const trimmedValue = value.trim();
if (trimmedValue && !formData.licensedAreas.includes(trimmedValue)) {
updateField('licensedAreas', [...formData.licensedAreas, trimmedValue]);
}
setStateInput('');
};
if (isEmpty) {
errors[field.slug] = `${field.name} is required`;
}
}
});
});
const handleStateInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
handleAddLicensedArea(stateInput);
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
// Scroll to first error
const firstErrorField = Object.keys(errors)[0];
const errorElement = document.querySelector(`[data-field-slug="${firstErrorField}"]`);
errorElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
// TODO: Call API to save form data
console.log('Saving form data:', formData);
// Simulate save
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/agent/dashboard');
} catch (err) {
console.error('Failed to save:', err);
setError('Failed to save profile. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleAddOfficeHours = (value: string) => {
const trimmedValue = value.trim();
if (trimmedValue && !formData.availability.officeHours.includes(trimmedValue)) {
updateField('availability', {
...formData.availability,
officeHours: [...formData.availability.officeHours, trimmedValue],
const handleFieldChange = (fieldSlug: string, value: unknown) => {
setFormData(prev => ({ ...prev, [fieldSlug]: value }));
// Clear error when field is modified
if (formErrors[fieldSlug]) {
setFormErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldSlug];
return newErrors;
});
}
setOfficeHoursInput('');
};
const handleOfficeHoursKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
handleAddOfficeHours(officeHoursInput);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#E58625]"></div>
<p className="text-[14px] font-serif text-[#00293D]/70">Loading profile sections...</p>
</div>
</div>
);
}
const handleRemoveOfficeHours = (hour: string) => {
updateField('availability', {
...formData.availability,
officeHours: formData.availability.officeHours.filter((h) => h !== hour),
});
};
if (error && sections.length === 0) {
return (
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
<div className="bg-white rounded-[20px] p-8 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] max-w-md text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 className="text-[18px] font-bold font-serif text-[#00293D] mb-2">Unable to Load Profile</h2>
<p className="text-[14px] font-serif text-[#00293D]/70 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-2 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="flex gap-6 h-[calc(100vh-180px)]">
{/* Left Sidebar - Quick Links */}
<div className="w-[220px] flex-shrink-0 hidden lg:block h-full overflow-y-auto scrollbar-thin">
<QuickLinks scrollContainerRef={scrollContainerRef} />
<QuickLinks scrollContainerRef={scrollContainerRef} sections={sections} />
</div>
{/* Main Content - Scrollable */}
<div ref={scrollContainerRef} className="flex-1 space-y-6 overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
{/* Basic Information */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="basic-info" icon="/assets/icons/basic-information-icon.svg" title="Basic Information">
<div className="flex flex-col md:flex-row gap-4">
<FormInput
label="First Name"
value={formData.firstName}
onChange={(value) => updateField('firstName', value)}
placeholder="Enter first name"
/>
<FormInput
label="Last Name"
value={formData.lastName}
onChange={(value) => updateField('lastName', value)}
placeholder="Enter last name"
/>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
>
{/* Inner container with padding for shadow visibility */}
<div className="space-y-6 p-2">
{/* Error Banner */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-[15px] p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-[14px] font-serif text-red-700">{error}</p>
</div>
)}
{/* Dynamic Sections */}
{sections.map((section) => (
<DynamicSection
key={section.id}
section={section}
formData={formData}
onChange={handleFieldChange}
errors={formErrors}
/>
))}
{/* Empty State */}
{sections.length === 0 && !loading && (
<div className="bg-white rounded-[20px] p-8 shadow-[0px_10px_20px_rgba(217,217,217,0.5)] text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</FormSection>
</div>
{/* Contact Information */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="contact-info" icon="/assets/icons/contact-icon.svg" title="Contact Information">
<div className="flex flex-col md:flex-row gap-4">
<FormInput
label="Email Address"
value={formData.email}
onChange={(value) => updateField('email', value)}
placeholder="Enter email"
type="email"
/>
<FormInput
label="Phone Number"
value={formData.phone}
onChange={(value) => updateField('phone', value)}
placeholder="Enter phone"
type="tel"
/>
</div>
</FormSection>
</div>
{/* Professional Types */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="professional-types" icon="/assets/icons/professional-icon.svg" title="Professional Types">
<div className="flex flex-wrap gap-6">
<FormCheckbox
label="First Time"
checked={formData.professionalTypes.firstTime}
onChange={(checked) =>
updateField('professionalTypes', { ...formData.professionalTypes, firstTime: checked })
}
/>
<FormCheckbox
label="Solo"
checked={formData.professionalTypes.solo}
onChange={(checked) =>
updateField('professionalTypes', { ...formData.professionalTypes, solo: checked })
}
/>
<FormCheckbox
label="Team"
checked={formData.professionalTypes.team}
onChange={(checked) =>
updateField('professionalTypes', { ...formData.professionalTypes, team: checked })
}
/>
</div>
</FormSection>
</div>
{/* Upload Documents */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="upload-documents" icon="/assets/icons/upload-icon.svg" title="Upload Documents">
<FileUpload onFileSelect={(files) => console.log('Files selected:', files)} />
{/* Attached Documents */}
{formData.attachedDocuments.length > 0 && (
<div className="mt-6">
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul 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">
{formData.attachedDocuments.map((doc, idx) => (
<AttachedFile
key={idx}
fileName={doc}
onRemove={() => {
const newDocs = [...formData.attachedDocuments];
newDocs.splice(idx, 1);
updateField('attachedDocuments', newDocs);
}}
/>
))}
</div>
</div>
)}
</FormSection>
</div>
{/* Licensing & Areas */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="licensing-areas" icon="/assets/icons/professional-icon.svg" title="Licensing & Areas">
<div className="space-y-4">
<div>
<label className="block text-[14px] font-semibold text-[#00293D] font-serif mb-2">
What state(s) are you licensed in?
</label>
<div className="relative">
<div className="absolute left-4 top-1/2 -translate-y-1/2">
<Image
src="/assets/icons/location-icon.svg"
alt="Location"
width={16}
height={16}
/>
</div>
<input
type="text"
value={stateInput}
onChange={(e) => setStateInput(e.target.value)}
onKeyDown={handleStateInputKeyDown}
placeholder="Enter state"
className="w-full h-[40px] pl-10 pr-4 border border-[#00293D]/20 rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625]"
/>
</div>
<p className="text-[12px] text-[#00293D]/60 font-serif mt-2">
Press Enter or comma to add a state.
</p>
</div>
<div className="flex flex-wrap gap-2">
{formData.licensedAreas.map((area) => (
<span
key={area}
className="inline-flex items-center gap-2 px-3 h-[32px] rounded-[15px] text-[14px] font-medium font-serif border border-[#00293d] text-[#00293d]"
>
{area}
<button
onClick={() => {
const newAreas = formData.licensedAreas.filter((a) => a !== area);
updateField('licensedAreas', newAreas);
}}
className="text-[#00293d] hover:text-[#E58625] transition-colors"
>
×
</button>
</span>
))}
</div>
</div>
</FormSection>
</div>
{/* Biography */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="biography" icon="/assets/icons/bio-icon.svg" title="Biography">
<div className="space-y-4">
<FormTextarea
value={formData.biography}
onChange={(value) => updateField('biography', value)}
placeholder="Write your biography..."
rows={5}
maxLength={500}
/>
<FormTextarea
label="Write a few sentences that explain yourself and also a good memorable thought."
value={formData.testimonialHighlight}
onChange={(value) => updateField('testimonialHighlight', value)}
placeholder="Write your memorable experience..."
rows={3}
maxLength={250}
/>
</div>
</FormSection>
</div>
{/* Expertise */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="expertise" icon="/assets/icons/professional-icon.svg" title="Expertise">
<TagInput
tags={formData.expertise}
onChange={(tags) => updateField('expertise', tags)}
placeholder="Add expertise..."
/>
</FormSection>
</div>
{/* Years In Business */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="years-business" icon="/assets/icons/professional-icon.svg" title="Years In Business">
<div className="flex flex-col md:flex-row gap-4">
<FormInput
label="Total Years"
value={formData.yearsInBusiness.totalYears}
onChange={(value) =>
updateField('yearsInBusiness', { ...formData.yearsInBusiness, totalYears: value })
}
/>
<FormInput
label="Total Transactions"
value={formData.yearsInBusiness.totalTransactions}
onChange={(value) =>
updateField('yearsInBusiness', { ...formData.yearsInBusiness, totalTransactions: value })
}
/>
</div>
</FormSection>
</div>
{/* Areas in expertise & Years */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection
id="areas-expertise-years"
icon="/assets/icons/basic-information-icon.svg"
title="Areas in expertise & Years"
showEdit
onEdit={() => console.log('Edit areas')}
>
<div className="flex flex-col md:flex-row gap-4">
<FormSelect
label="Select State"
value={formData.state}
onChange={(value) => updateField('state', value)}
options={[
{ value: 'Colorado', label: 'Colorado' },
{ value: 'Texas', label: 'Texas' },
]}
/>
<FormSelect
label="Select Years"
value="10"
onChange={() => {}}
options={[
{ value: '5', label: '5 Years' },
{ value: '10', label: '10 Years' },
{ value: '15', label: '15 Years' },
]}
/>
</div>
</FormSection>
</div>
{/* Contracts Closed */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="contracts-closed" icon="/assets/icons/professional-icon.svg" title="Contracts Closed">
<div className="space-y-3">
<FormCheckbox
label="< 10"
checked={formData.contractsClosed.lessThan10}
onChange={(checked) =>
updateField('contractsClosed', { ...formData.contractsClosed, lessThan10: checked })
}
/>
<FormCheckbox
label="10 to 25"
checked={formData.contractsClosed.between10And25}
onChange={(checked) =>
updateField('contractsClosed', { ...formData.contractsClosed, between10And25: checked })
}
/>
<FormCheckbox
label="26 to 50"
checked={formData.contractsClosed.between26And50}
onChange={(checked) =>
updateField('contractsClosed', { ...formData.contractsClosed, between26And50: checked })
}
/>
<FormCheckbox
label="51 to 75"
checked={formData.contractsClosed.between51And75}
onChange={(checked) =>
updateField('contractsClosed', { ...formData.contractsClosed, between51And75: checked })
}
/>
<FormCheckbox
label="75+"
checked={formData.contractsClosed.moreThan75}
onChange={(checked) =>
updateField('contractsClosed', { ...formData.contractsClosed, moreThan75: checked })
}
/>
</div>
</FormSection>
</div>
{/* Price Point */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="price-point" icon="/assets/icons/basic-information-icon.svg" title="Price Point">
<div className="space-y-3">
<FormCheckbox
label="Under $100K"
checked={formData.pricePoint.under100k}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, under100k: checked })
}
/>
<FormCheckbox
label="$100K $200K"
checked={formData.pricePoint.between100kAnd200k}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, between100kAnd200k: checked })
}
/>
<FormCheckbox
label="$200K $400K"
checked={formData.pricePoint.between200kAnd400k}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, between200kAnd400k: checked })
}
/>
<FormCheckbox
label="$400K $600K"
checked={formData.pricePoint.between400kAnd600k}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, between400kAnd600k: checked })
}
/>
<FormCheckbox
label="$600K $800K"
checked={formData.pricePoint.between600kAnd800k}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, between600kAnd800k: checked })
}
/>
<FormCheckbox
label="$800K $1M"
checked={formData.pricePoint.between800kAnd1m}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, between800kAnd1m: checked })
}
/>
<FormCheckbox
label="$1M and above"
checked={formData.pricePoint.above1m}
onChange={(checked) =>
updateField('pricePoint', { ...formData.pricePoint, above1m: checked })
}
/>
</div>
</FormSection>
</div>
{/* Specialization */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="specialization" icon="/assets/icons/basic-information-icon.svg" title="Specialization">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Client Localization */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Client Localization</h4>
<div className="space-y-2">
<FormCheckbox label="Local" checked={formData.specialization.clientLocalization.local} onChange={() => {}} />
<FormCheckbox label="National" checked={formData.specialization.clientLocalization.national} onChange={() => {}} />
<FormCheckbox label="International" checked={formData.specialization.clientLocalization.international} onChange={() => {}} />
</div>
</div>
{/* Client Type Worked With */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Client Type Worked With</h4>
<div className="space-y-2">
<FormCheckbox label="First Time Buyer" checked={formData.specialization.clientTypeWorkedWith.firstTimeBuyer} onChange={() => {}} />
<FormCheckbox label="First Time Seller" checked={formData.specialization.clientTypeWorkedWith.firstTimeSeller} onChange={() => {}} />
</div>
</div>
{/* Property Type */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Property Type</h4>
<div className="space-y-2">
<FormCheckbox label="SFR" checked={formData.specialization.propertyType.sfr} onChange={() => {}} />
<FormCheckbox label="Condo" checked={formData.specialization.propertyType.condo} onChange={() => {}} />
<FormCheckbox label="Luxury" checked={formData.specialization.propertyType.luxury} onChange={() => {}} />
<FormCheckbox label="Townhome" checked={formData.specialization.propertyType.townhome} onChange={() => {}} />
<FormCheckbox label="Commercial" checked={formData.specialization.propertyType.commercial} onChange={() => {}} />
</div>
</div>
{/* Transaction Type */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Transaction Type</h4>
<div className="space-y-2">
<FormCheckbox label="Relocation" checked={formData.specialization.transactionType.relocation} onChange={() => {}} />
<FormCheckbox label="Probate" checked={formData.specialization.transactionType.probate} onChange={() => {}} />
<FormCheckbox label="Tax Lien" checked={formData.specialization.transactionType.taxLien} onChange={() => {}} />
<FormCheckbox label="Traditional" checked={formData.specialization.transactionType.traditional} onChange={() => {}} />
<FormCheckbox label="New Construction" checked={formData.specialization.transactionType.newConstruction} onChange={() => {}} />
</div>
</div>
{/* Loan Type */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Loan Type</h4>
<div className="space-y-2">
<FormCheckbox label="Conventional" checked={formData.specialization.loanType.conventional} onChange={() => {}} />
<FormCheckbox label="FHA" checked={formData.specialization.loanType.fha} onChange={() => {}} />
<FormCheckbox label="VA" checked={formData.specialization.loanType.va} onChange={() => {}} />
<FormCheckbox label="USDA" checked={formData.specialization.loanType.usda} onChange={() => {}} />
<FormCheckbox label="Reverse" checked={formData.specialization.loanType.reverse} onChange={() => {}} />
</div>
</div>
{/* Lifestyle Specialties */}
<div>
<h4 className="text-[14px] font-semibold text-[#00293D] font-fractul mb-3">Lifestyle Specialties</h4>
<div className="space-y-2">
<FormCheckbox label="Boating" checked={formData.specialization.lifestyleSpecialties.boating} onChange={() => {}} />
<FormCheckbox label="Horses" checked={formData.specialization.lifestyleSpecialties.horses} onChange={() => {}} />
<FormCheckbox label="RV" checked={formData.specialization.lifestyleSpecialties.rv} onChange={() => {}} />
<FormCheckbox label="Automotive" checked={formData.specialization.lifestyleSpecialties.automotive} onChange={() => {}} />
<FormCheckbox label="Aviation" checked={formData.specialization.lifestyleSpecialties.aviation} onChange={() => {}} />
</div>
</div>
</div>
</FormSection>
</div>
{/* Special Interests & Hobbies */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="hobbies" icon="/assets/icons/hobbies-icon.svg" title="Special Interests & Hobbies">
<TagInput
tags={formData.hobbies}
onChange={(tags) => updateField('hobbies', tags)}
placeholder="Add hobby..."
/>
</FormSection>
</div>
{/* Certifications */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="certifications" icon="/assets/icons/certification-icon.svg" title="Professional Info">
<p className="text-[12px] font-medium text-[#00293D]/70 font-serif mb-3">Certifications / Designations</p>
<div className="space-y-2">
{formData.certifications.map((cert, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-[10px]">
<p className="text-[14px] font-semibold text-[#00293D] font-serif">{cert.name}</p>
<p className="text-[12px] text-[#00293D]/70 font-serif">{cert.org}</p>
</div>
))}
</div>
</FormSection>
</div>
{/* Availability */}
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
<FormSection id="availability" icon="/assets/icons/availability-clock-icon.svg" title="Availability">
<div className="flex flex-col lg:flex-row gap-6">
{/* Typical Office Hours */}
<div className="flex-1">
<label className="block text-[14px] font-semibold text-[#00293D] font-serif mb-2">
Typical Office Hours
</label>
<div className="flex items-center gap-2 h-[40px] px-4 border border-[#00293D]/20 rounded-[15px]">
{formData.availability.officeHours.map((hour) => (
<span
key={hour}
className="inline-flex items-center gap-1 text-[14px] font-semibold font-serif text-[#00293D]"
>
{hour}
<button
onClick={() => handleRemoveOfficeHours(hour)}
className="text-[#00293d]/50 hover:text-[#E58625] transition-colors ml-1"
>
×
</button>
</span>
))}
<input
type="text"
value={officeHoursInput}
onChange={(e) => setOfficeHoursInput(e.target.value)}
onKeyDown={handleOfficeHoursKeyDown}
placeholder={formData.availability.officeHours.length === 0 ? "Add office hours" : ""}
className="flex-1 h-full text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none bg-transparent"
/>
</div>
</div>
{/* Preferred Contact Method */}
<div className="lg:w-[250px]">
<label className="block text-[14px] font-semibold text-[#00293D] font-serif mb-2">
Preferred Contact Method
</label>
<div className="flex items-center gap-6 h-[40px]">
<FormCheckbox
label="Email"
checked={formData.availability.contactMethod.email}
onChange={(checked) =>
updateField('availability', {
...formData.availability,
contactMethod: { ...formData.availability.contactMethod, email: checked },
})
}
/>
<FormCheckbox
label="Call"
checked={formData.availability.contactMethod.call}
onChange={(checked) =>
updateField('availability', {
...formData.availability,
contactMethod: { ...formData.availability.contactMethod, call: checked },
})
}
/>
</div>
</div>
</div>
</FormSection>
</div>
<h2 className="text-[18px] font-bold font-serif text-[#00293D] mb-2">No Profile Sections Available</h2>
<p className="text-[14px] font-serif text-[#00293D]/70">
Profile sections have not been configured for your agent type yet.
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex justify-end gap-4 pb-6">
<button
onClick={handleCancel}
className="px-8 py-3 border border-[#00293D]/20 rounded-full text-[14px] font-semibold font-serif text-[#00293D] hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-8 py-3 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors"
>
Save
</button>
{sections.length > 0 && (
<div className="flex justify-end gap-4 pb-6">
<button
onClick={handleCancel}
disabled={isSaving}
className="px-8 py-3 border border-[#00293D]/20 rounded-full text-[14px] font-semibold font-serif text-[#00293D] hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-8 py-3 bg-[#E58625] rounded-full text-[14px] font-semibold font-serif text-white hover:bg-[#E58625]/90 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSaving && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
)}
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,55 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { SessionProvider as NextAuthSessionProvider, useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
// Component that syncs tokens from NextAuth session to localStorage
function TokenSync() {
const { data: session, status } = useSession();
const hasInitialized = useRef(false);
useEffect(() => {
if (status === "authenticated" && session?.user) {
const user = session.user as { accessToken?: string; refreshToken?: string };
// Only sync tokens from NextAuth to localStorage if:
// 1. This is the first time we're initializing (fresh login)
// 2. OR localStorage doesn't have tokens yet
// This prevents overwriting tokens that were refreshed by the API interceptor
const existingAccessToken = localStorage.getItem("accessToken");
const existingRefreshToken = localStorage.getItem("refreshToken");
// If localStorage is empty, this is likely a fresh login - sync tokens
if (!existingAccessToken && !existingRefreshToken) {
if (user.accessToken) {
localStorage.setItem("accessToken", user.accessToken);
}
if (user.refreshToken) {
localStorage.setItem("refreshToken", user.refreshToken);
}
hasInitialized.current = true;
}
// If we have tokens in localStorage but haven't initialized yet,
// it means the API interceptor has refreshed tokens - don't overwrite
else if (!hasInitialized.current) {
hasInitialized.current = true;
}
} else if (status === "unauthenticated") {
// Clear tokens when logged out
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
hasInitialized.current = false;
}
}, [session, status]);
return null;
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
return (
<NextAuthSessionProvider>
<TokenSync />
{children}
</NextAuthSessionProvider>
);
}

View File

@@ -0,0 +1,66 @@
import api from './api';
// Types
export interface AgentType {
id: string;
name: string;
slug: string;
description: string | null;
}
export interface AgentProfile {
id: string;
userId: string;
firstName: string;
lastName: string;
slug: string;
email: string | null;
phone: string | null;
bio: string | null;
avatar: string | null;
licenseNumber: string | null;
experience: number | null;
specializations: string[];
languages: string[];
serviceAreas: string[];
rating: number | null;
reviewCount: number;
isVerified: boolean;
isFeatured: boolean;
isActive: boolean;
agentTypeId: string | null;
agentType: AgentType | null;
user?: {
id: string;
email: string;
avatar: string | null;
};
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// Agents Service for Web
class AgentsService {
private basePath = '/agents';
async getMyProfile(): Promise<AgentProfile> {
const response = await api.get<ApiResponse<AgentProfile>>(`${this.basePath}/profile/me`);
return response.data.data;
}
async getAgentById(id: string): Promise<AgentProfile> {
const response = await api.get<ApiResponse<AgentProfile>>(`${this.basePath}/${id}`);
return response.data.data;
}
async updateProfile(data: Partial<AgentProfile>): Promise<AgentProfile> {
const response = await api.put<ApiResponse<AgentProfile>>(`${this.basePath}/profile`, data);
return response.data.data;
}
}
export const agentsService = new AgentsService();

View File

@@ -1,4 +1,4 @@
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
// API Response types
export interface ApiResponse<T = unknown> {
@@ -19,6 +19,23 @@ export interface ApiErrorResponse {
errors?: Array<{ field: string; errors: string[] }>;
}
// Token refresh state
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: AxiosResponse) => void;
reject: (error: AxiosError) => void;
config: InternalAxiosRequestConfig;
}> = [];
const processQueue = (error: AxiosError | null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
}
});
failedQueue = [];
};
// Create axios instance
const api: AxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1',
@@ -45,22 +62,100 @@ api.interceptors.request.use(
}
);
// Response interceptor
// Response interceptor with token refresh
api.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError<ApiErrorResponse>) => {
// Handle common errors
if (error.response) {
const { status } = error.response;
async (error: AxiosError<ApiErrorResponse>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle 401 Unauthorized
if (status === 401) {
// Handle 401 Unauthorized
if (error.response?.status === 401 && !originalRequest._retry) {
// Don't try to refresh if this was the refresh token request itself
if (originalRequest.url?.includes('/auth/refresh')) {
// Refresh token is also invalid, clear everything and sign out properly
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Use NextAuth's signout endpoint to properly clear the session
window.location.href = '/api/auth/signout?callbackUrl=/login';
}
return Promise.reject(error);
}
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject, config: originalRequest });
}).then(() => {
// Retry with new token
const newToken = localStorage.getItem('accessToken');
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null;
if (!refreshToken) {
// No refresh token available, sign out properly
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Use NextAuth's signout endpoint to properly clear the session
window.location.href = '/api/auth/signout?callbackUrl=/login';
}
isRefreshing = false;
return Promise.reject(error);
}
try {
// Try to refresh the token
const response = await axios.post<ApiResponse<{ accessToken: string; refreshToken: string }>>(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/auth/refresh`,
{ refreshToken },
{ headers: { 'Content-Type': 'application/json' } }
);
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data;
// Save new tokens
if (typeof window !== 'undefined') {
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('refreshToken', newRefreshToken);
}
// Update the failed request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
// Process queued requests
processQueue(null);
isRefreshing = false;
// Retry original request
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and sign out properly
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// Use NextAuth's signout endpoint to properly clear the session
window.location.href = '/api/auth/signout?callbackUrl=/login';
}
processQueue(refreshError as AxiosError);
isRefreshing = false;
return Promise.reject(refreshError);
}
}

View File

@@ -14,3 +14,20 @@ export type {
VerifyEmailRequest,
MessageResponse,
} from './auth.service';
// Profile Sections Service
export { profileSectionsService } from './profile-sections.service';
export type {
FieldType,
FieldOption,
RangeConfig,
FieldValidation,
FieldUiConfig,
ProfileField,
ProfileSection,
AgentTypeSectionsResponse,
} from './profile-sections.service';
// Agents Service
export { agentsService } from './agents.service';
export type { AgentProfile, AgentType } from './agents.service';

View File

@@ -0,0 +1,115 @@
import api from './api';
// Field Types - matching backend enum
export type FieldType =
| 'TEXT'
| 'TEXTAREA'
| 'CHECKBOX'
| 'CHECKBOX_GROUP'
| 'RADIO'
| 'SELECT'
| 'MULTI_SELECT'
| 'RANGE'
| 'NUMBER'
| 'DATE'
| 'TAG_INPUT';
// Types
export interface FieldOption {
value: string;
label: string;
}
export interface RangeConfig {
min: number;
max: number;
step?: number;
unit?: string;
}
export interface FieldValidation {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
}
export interface FieldUiConfig {
columns?: number;
showInPreview?: boolean;
helpText?: string;
}
export interface ProfileField {
id: string;
sectionId: string;
name: string;
slug: string;
fieldType: FieldType;
description: string | null;
placeholder: string | null;
defaultValue: string | null;
sortOrder: number;
isActive: boolean;
isRequired: boolean;
isSearchableOnly: boolean;
validation: FieldValidation | null;
options: FieldOption[] | null;
rangeConfig: RangeConfig | null;
uiConfig: FieldUiConfig | null;
}
export interface ProfileSection {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
sortOrder: number;
isActive: boolean;
isGlobal: boolean;
isSystem: boolean;
fields?: ProfileField[];
isRequired?: boolean;
effectiveSortOrder?: number;
hasCustomOrder?: boolean;
}
export interface AgentTypeSectionsResponse {
agentType: {
id: string;
name: string;
description: string | null;
};
sections: ProfileSection[];
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// Profile Sections Service for Web
class ProfileSectionsService {
private basePath = '/profile-sections';
async getSectionsForAgentType(agentTypeId: string, includeFields = true): Promise<AgentTypeSectionsResponse> {
const url = includeFields
? `${this.basePath}/agent-type/${agentTypeId}`
: `${this.basePath}/agent-type/${agentTypeId}?includeFields=false`;
const response = await api.get<ApiResponse<AgentTypeSectionsResponse>>(url);
return response.data.data;
}
async getAll(includeInactive = false): Promise<ProfileSection[]> {
const url = includeInactive
? `${this.basePath}?includeInactive=true`
: this.basePath;
const response = await api.get<ApiResponse<ProfileSection[]>>(url);
return response.data.data;
}
}
export const profileSectionsService = new ProfileSectionsService();