Files
frontend/src/app/(agent)/agent/edit/page.tsx

457 lines
19 KiB
TypeScript

'use client';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { QuickLinks } from './components';
import DynamicSection from './components/DynamicSection';
import RepeatableSection, { RepeatableEntryData } from './components/RepeatableSection';
import { profileSectionsService, ProfileSection, AgentTypeSectionsResponse } from '@/services/profile-sections.service';
import { agentsService, AgentProfile, FieldValueInput } from '@/services/agents.service';
import { MobileBackButton } from '@/components/layout/MobileBackButton';
export default function EditProfilePage() {
const router = useRouter();
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 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>>({});
// Repeatable sections data keyed by section slug
const [repeatableData, setRepeatableData] = useState<Record<string, RepeatableEntryData[]>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [repeatableErrors, setRepeatableErrors] = useState<Record<string, Record<string, 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);
// Debug: Log all sections and their isRepeatable status
console.log('All sections:', sortedSections.map(s => ({ slug: s.slug, name: s.name, isRepeatable: s.isRepeatable })));
// 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;
// Note: phone is populated from dynamic field values (slug: phone_number)
// Don't pre-populate 'phone' here as it creates a stale shadow entry
// that conflicts with the dynamic field during save
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 => {
const sectionFields = Array.isArray(section.fields) ? section.fields : [];
sectionFields.forEach(field => {
if (!field || !field.slug) return; // Skip malformed fields
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;
}
}
});
});
// Fetch existing field values from the API
try {
const fieldValuesResponse = await agentsService.getFieldValues();
if (fieldValuesResponse.fieldValues) {
fieldValuesResponse.fieldValues.forEach(fv => {
// Only set if value exists and is not null/undefined
if (fv.value !== null && fv.value !== undefined) {
initialData[fv.fieldSlug] = fv.value;
}
});
}
} catch (fieldValueErr) {
// If field values don't exist yet, that's OK - just use defaults
console.log('No existing field values found, using defaults');
}
setFormData(initialData);
// Initialize repeatable sections data
const initialRepeatableData: Record<string, RepeatableEntryData[]> = {};
sortedSections.forEach(section => {
if (section.isRepeatable) {
// Check if there's existing data for this section (stored as `${sectionSlug}_entries`)
const entriesKey = `${section.slug}_entries`;
const existingEntries = initialData[entriesKey];
if (Array.isArray(existingEntries) && existingEntries.length > 0) {
initialRepeatableData[section.slug] = existingEntries as RepeatableEntryData[];
} else {
// Initialize with one empty entry
initialRepeatableData[section.slug] = [{}];
}
}
});
// Also check for any _entries fields that might not match a repeatable section
// This handles cases where data was saved but section isn't marked as repeatable
Object.keys(initialData).forEach(key => {
if (key.endsWith('_entries') && Array.isArray(initialData[key])) {
const sectionSlug = key.replace('_entries', '');
if (!initialRepeatableData[sectionSlug]) {
// Find the section by slug
const section = sortedSections.find(s => s.slug === sectionSlug);
if (section) {
initialRepeatableData[sectionSlug] = initialData[key] as RepeatableEntryData[];
}
}
}
});
setRepeatableData(initialRepeatableData);
} 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 = async () => {
try {
setIsSaving(true);
setFormErrors({});
setRepeatableErrors({});
// Validate required fields for regular sections
const errors: Record<string, string> = {};
const repErrors: Record<string, Record<string, Record<string, string>>> = {};
sections.forEach(section => {
const sectionFields = Array.isArray(section.fields) ? section.fields : [];
if (section.isRepeatable) {
// Validate repeatable section entries
const entries = repeatableData[section.slug] || [{}];
entries.forEach((entry, entryIndex) => {
sectionFields.forEach(field => {
if (field && field.isRequired && field.isActive && !field.isSearchableOnly) {
const value = entry[field.slug];
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
if (isEmpty) {
if (!repErrors[section.slug]) repErrors[section.slug] = {};
if (!repErrors[section.slug][entryIndex]) repErrors[section.slug][entryIndex] = {};
repErrors[section.slug][entryIndex][field.slug] = `${field.name} is required`;
}
}
});
});
} else {
// Validate regular section fields
sectionFields.forEach(field => {
if (field && 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`;
}
}
});
}
});
const hasErrors = Object.keys(errors).length > 0;
const hasRepeatableErrors = Object.keys(repErrors).length > 0;
if (hasErrors || hasRepeatableErrors) {
setFormErrors(errors);
setRepeatableErrors(repErrors);
// Scroll to first error
if (hasErrors) {
const firstErrorField = Object.keys(errors)[0];
const errorElement = document.querySelector(`[data-field-slug="${firstErrorField}"]`);
errorElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (hasRepeatableErrors) {
const firstSection = Object.keys(repErrors)[0];
const sectionElement = document.getElementById(firstSection);
sectionElement?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
// Convert formData to field values array
const fieldValues: FieldValueInput[] = Object.entries(formData).map(([fieldSlug, value]) => ({
fieldSlug,
value: value as string | number | boolean | string[] | Record<string, unknown> | null,
}));
// Add repeatable section data as a special field value
Object.entries(repeatableData).forEach(([sectionSlug, entries]) => {
// Filter out empty entries (entries with no filled fields)
const nonEmptyEntries = entries.filter(entry =>
Object.values(entry).some(v => v !== undefined && v !== null && v !== '')
);
if (nonEmptyEntries.length > 0) {
fieldValues.push({
fieldSlug: `${sectionSlug}_entries`,
value: nonEmptyEntries,
});
}
});
// Save field values to API
await agentsService.saveFieldValues(fieldValues);
// Also update main profile fields (phone, email) to keep them in sync
// This ensures that when the view page checks agentProfile.phone first,
// it gets the correct (possibly empty) value
const profileUpdates: Partial<{ phone: string | null; email: string | null }> = {};
// Sync phone field - check dynamic field slugs FIRST, then legacy 'phone'
// Dynamic fields (phone_number, etc.) are the source of truth from the form
// 'phone' is a legacy pre-populated key and should only be used as fallback
const phoneFieldSlugs = ['phone_number', 'cell_number', 'office_number', 'phone'];
for (const slug of phoneFieldSlugs) {
const phoneValue = formData[slug];
if (phoneValue !== undefined) {
// Set to null if empty, otherwise use the value
profileUpdates.phone = phoneValue === '' ? null : (phoneValue as string);
break; // Use the first found phone field
}
}
// Only update if there are changes to sync
if (Object.keys(profileUpdates).length > 0) {
await agentsService.updateProfile(profileUpdates);
}
router.push('/agent/dashboard');
} catch (err) {
console.error('Failed to save:', err);
setError('Failed to save profile. Please try again.');
} finally {
setIsSaving(false);
}
};
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;
});
}
};
const handleRepeatableChange = (sectionSlug: string, entries: RepeatableEntryData[]) => {
setRepeatableData(prev => ({ ...prev, [sectionSlug]: entries }));
// Clear errors for this section when modified
if (repeatableErrors[sectionSlug]) {
setRepeatableErrors(prev => {
const newErrors = { ...prev };
delete newErrors[sectionSlug];
return newErrors;
});
}
};
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>
);
}
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>
<MobileBackButton label="Back" fallbackHref="/agent/dashboard" alwaysShow />
<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} sections={sections} />
</div>
{/* Main Content - Scrollable */}
<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) =>
section.isRepeatable ? (
<RepeatableSection
key={section.id}
section={section}
entries={repeatableData[section.slug] || [{}]}
onChange={handleRepeatableChange}
errors={repeatableErrors[section.slug]}
/>
) : (
<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>
<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 */}
{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>
</div>
);
}