feat: Implement dynamic agent profile editing by introducing new services and dynamic UI components.
This commit is contained in:
345
src/app/(agent)/agent/edit/components/DynamicField.tsx
Normal file
345
src/app/(agent)/agent/edit/components/DynamicField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/app/(agent)/agent/edit/components/DynamicSection.tsx
Normal file
140
src/app/(agent)/agent/edit/components/DynamicSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,18 @@ interface FormInputProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
disabled?: boolean;
|
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 (
|
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-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
|
||||||
{label}
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@@ -21,8 +26,13 @@ export function FormInput({ label, value, onChange, placeholder, type = 'text',
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ interface FormTextareaProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
maxLength?: 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 (
|
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-[12px] font-medium text-[#00293D]/70 font-serif mb-1">
|
||||||
{label}
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { ProfileSection } from '@/services/profile-sections.service';
|
||||||
|
|
||||||
const links = [
|
interface QuickLink {
|
||||||
{ id: 'basic-info', label: 'Basic Information' },
|
id: string;
|
||||||
{ id: 'contact-info', label: 'Contact Information' },
|
label: string;
|
||||||
{ 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 QuickLinksProps {
|
interface QuickLinksProps {
|
||||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
sections?: ProfileSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
|
// Convert ProfileSection array to QuickLink array
|
||||||
const [activeLink, setActiveLink] = useState('basic-info');
|
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);
|
const isClickScrolling = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +46,7 @@ export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
|
|||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const containerTop = containerRect.top;
|
const containerTop = containerRect.top;
|
||||||
|
|
||||||
let currentSection = 'basic-info';
|
let currentSection = links[0]?.id || 'basic-information';
|
||||||
|
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
const element = document.getElementById(link.id);
|
const element = document.getElementById(link.id);
|
||||||
@@ -56,7 +64,7 @@ export function QuickLinks({ scrollContainerRef }: QuickLinksProps) {
|
|||||||
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
container.addEventListener('scroll', handleScroll);
|
||||||
return () => container.removeEventListener('scroll', handleScroll);
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef, links]);
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
const scrollToSection = (id: string) => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ export { FormTextarea } from './FormTextarea';
|
|||||||
export { FormSelect } from './FormSelect';
|
export { FormSelect } from './FormSelect';
|
||||||
export { FileUpload, AttachedFile } from './FileUpload';
|
export { FileUpload, AttachedFile } from './FileUpload';
|
||||||
export { TagInput } from './TagInput';
|
export { TagInput } from './TagInput';
|
||||||
|
export { default as DynamicField } from './DynamicField';
|
||||||
|
export { default as DynamicSection } from './DynamicSection';
|
||||||
|
|||||||
@@ -1,709 +1,289 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import { QuickLinks } from './components';
|
||||||
import {
|
import DynamicSection from './components/DynamicSection';
|
||||||
QuickLinks,
|
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
|
||||||
FormSection,
|
import { agentsService, AgentProfile } from '@/services/agents.service';
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EditProfilePage() {
|
export default function EditProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [formData, setFormData] = useState(initialFormData);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
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 = () => {
|
const handleCancel = () => {
|
||||||
router.push('/agent/dashboard');
|
router.push('/agent/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
// In production, this would save to API
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setFormErrors({});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
errors[field.slug] = `${field.name} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
console.log('Saving form data:', formData);
|
||||||
|
|
||||||
|
// Simulate save
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
router.push('/agent/dashboard');
|
router.push('/agent/dashboard');
|
||||||
};
|
} catch (err) {
|
||||||
|
console.error('Failed to save:', err);
|
||||||
const updateField = (field: string, value: any) => {
|
setError('Failed to save profile. Please try again.');
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
} finally {
|
||||||
};
|
setIsSaving(false);
|
||||||
|
|
||||||
const handleAddLicensedArea = (value: string) => {
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
if (trimmedValue && !formData.licensedAreas.includes(trimmedValue)) {
|
|
||||||
updateField('licensedAreas', [...formData.licensedAreas, trimmedValue]);
|
|
||||||
}
|
|
||||||
setStateInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStateInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ',') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAddLicensedArea(stateInput);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOfficeHours = (value: string) => {
|
const handleFieldChange = (fieldSlug: string, value: unknown) => {
|
||||||
const trimmedValue = value.trim();
|
setFormData(prev => ({ ...prev, [fieldSlug]: value }));
|
||||||
if (trimmedValue && !formData.availability.officeHours.includes(trimmedValue)) {
|
// Clear error when field is modified
|
||||||
updateField('availability', {
|
if (formErrors[fieldSlug]) {
|
||||||
...formData.availability,
|
setFormErrors(prev => {
|
||||||
officeHours: [...formData.availability.officeHours, trimmedValue],
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[fieldSlug];
|
||||||
|
return newErrors;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setOfficeHoursInput('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOfficeHoursKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
if (loading) {
|
||||||
if (e.key === 'Enter' || e.key === ',') {
|
return (
|
||||||
e.preventDefault();
|
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
|
||||||
handleAddOfficeHours(officeHoursInput);
|
<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) => {
|
if (error && sections.length === 0) {
|
||||||
updateField('availability', {
|
return (
|
||||||
...formData.availability,
|
<div className="flex items-center justify-center h-[calc(100vh-180px)]">
|
||||||
officeHours: formData.availability.officeHours.filter((h) => h !== hour),
|
<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 (
|
return (
|
||||||
<div className="flex gap-6 h-[calc(100vh-180px)]">
|
<div className="flex gap-6 h-[calc(100vh-180px)]">
|
||||||
{/* Left Sidebar - Quick Links */}
|
{/* Left Sidebar - Quick Links */}
|
||||||
<div className="w-[220px] flex-shrink-0 hidden lg:block h-full overflow-y-auto scrollbar-thin">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Scrollable */}
|
{/* 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">
|
<div
|
||||||
{/* Basic Information */}
|
ref={scrollContainerRef}
|
||||||
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
|
className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
|
||||||
<FormSection id="basic-info" icon="/assets/icons/basic-information-icon.svg" title="Basic Information">
|
>
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
{/* Inner container with padding for shadow visibility */}
|
||||||
<FormInput
|
<div className="space-y-6 p-2">
|
||||||
label="First Name"
|
{/* Error Banner */}
|
||||||
value={formData.firstName}
|
{error && (
|
||||||
onChange={(value) => updateField('firstName', value)}
|
<div className="bg-red-50 border border-red-200 rounded-[15px] p-4 flex items-center gap-3">
|
||||||
placeholder="Enter first name"
|
<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" />
|
||||||
<FormInput
|
</svg>
|
||||||
label="Last Name"
|
<p className="text-[14px] font-serif text-red-700">{error}</p>
|
||||||
value={formData.lastName}
|
|
||||||
onChange={(value) => updateField('lastName', value)}
|
|
||||||
placeholder="Enter last name"
|
|
||||||
/>
|
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Licensing & Areas */}
|
{/* Dynamic Sections */}
|
||||||
<div className="bg-white rounded-[15px] border border-[#00293D]/20 shadow-[0px_10px_20px_#D9D9D9] p-6">
|
{sections.map((section) => (
|
||||||
<FormSection id="licensing-areas" icon="/assets/icons/professional-icon.svg" title="Licensing & Areas">
|
<DynamicSection
|
||||||
<div className="space-y-4">
|
key={section.id}
|
||||||
<div>
|
section={section}
|
||||||
<label className="block text-[14px] font-semibold text-[#00293D] font-serif mb-2">
|
formData={formData}
|
||||||
What state(s) are you licensed in?
|
onChange={handleFieldChange}
|
||||||
</label>
|
errors={formErrors}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
<input
|
<h2 className="text-[18px] font-bold font-serif text-[#00293D] mb-2">No Profile Sections Available</h2>
|
||||||
type="text"
|
<p className="text-[14px] font-serif text-[#00293D]/70">
|
||||||
value={stateInput}
|
Profile sections have not been configured for your agent type yet.
|
||||||
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>
|
</p>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
{sections.length > 0 && (
|
||||||
<div className="flex justify-end gap-4 pb-6">
|
<div className="flex justify-end gap-4 pb-6">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
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"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Save
|
{isSaving && (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
)}
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
"use client";
|
"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 }) {
|
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||||
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
|
return (
|
||||||
|
<NextAuthSessionProvider>
|
||||||
|
<TokenSync />
|
||||||
|
{children}
|
||||||
|
</NextAuthSessionProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/services/agents.service.ts
Normal file
66
src/services/agents.service.ts
Normal 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();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
@@ -19,6 +19,23 @@ export interface ApiErrorResponse {
|
|||||||
errors?: Array<{ field: string; errors: string[] }>;
|
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
|
// Create axios instance
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1',
|
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(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error: AxiosError<ApiErrorResponse>) => {
|
async (error: AxiosError<ApiErrorResponse>) => {
|
||||||
// Handle common errors
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
if (error.response) {
|
|
||||||
const { status } = error.response;
|
|
||||||
|
|
||||||
// Handle 401 Unauthorized
|
// Handle 401 Unauthorized
|
||||||
if (status === 401) {
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,20 @@ export type {
|
|||||||
VerifyEmailRequest,
|
VerifyEmailRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
} from './auth.service';
|
} 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';
|
||||||
|
|||||||
115
src/services/profile-sections.service.ts
Normal file
115
src/services/profile-sections.service.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user