diff --git a/src/app/(agent)/agent/edit/page.tsx b/src/app/(agent)/agent/edit/page.tsx index 1f24a4d..0310d00 100644 --- a/src/app/(agent)/agent/edit/page.tsx +++ b/src/app/(agent)/agent/edit/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { QuickLinks } from './components'; import DynamicSection from './components/DynamicSection'; @@ -11,6 +11,14 @@ import { validateFieldValue } from '@/utils/validateFieldValue'; import { usersService } from '@/services/users.service'; import { MobileBackButton } from '@/components/layout/MobileBackButton'; +type AutoSaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; + +const AUTOSAVE_DEBOUNCE_MS = 2500; +const AUTOSAVE_RETRY_MS = 10000; + +const formatTime = (d: Date) => + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + export default function EditProfilePage() { const router = useRouter(); const scrollContainerRef = useRef(null); @@ -29,6 +37,185 @@ export default function EditProfilePage() { const [repeatableErrors, setRepeatableErrors] = useState>>>({}); const [isSaving, setIsSaving] = useState(false); + // Auto-save state + const [autoSaveStatus, setAutoSaveStatus] = useState('idle'); + const [lastSavedAt, setLastSavedAt] = useState(null); + const [draftRestoredAt, setDraftRestoredAt] = useState(null); + + // Auto-save bookkeeping. Refs (not state) so debounce timers and + // beforeunload handlers always see the latest values without re-binding. + const formDataRef = useRef>({}); + const repeatableDataRef = useRef>({}); + // Dirty (unsaved) field slugs; repeatable sections tracked as `${slug}_entries` + const dirtySlugsRef = useRef>(new Set()); + // Server auto-save stays off for APPROVED profiles — their field values are + // live, so changes only go out via the explicit Save button. Local drafts + // still work for everyone. + const autoSaveEnabledRef = useRef(false); + const draftKeyRef = useRef(null); + const autoSaveTimerRef = useRef | null>(null); + const retryTimerRef = useRef | null>(null); + const saveInFlightRef = useRef(false); + const saveQueuedRef = useRef(false); + const originalTitleRef = useRef(null); + + useEffect(() => { formDataRef.current = formData; }, [formData]); + useEffect(() => { repeatableDataRef.current = repeatableData; }, [repeatableData]); + + const buildDirtyFieldValues = useCallback((slugs: string[]): FieldValueInput[] => { + const values: FieldValueInput[] = []; + slugs.forEach(slug => { + if (slug.endsWith('_entries')) { + const sectionSlug = slug.slice(0, -'_entries'.length); + const entries = repeatableDataRef.current[sectionSlug]; + if (!entries) return; + const nonEmpty = entries.filter(entry => + Object.values(entry).some(v => v !== undefined && v !== null && v !== '') + ); + values.push({ fieldSlug: slug, value: nonEmpty as Record[] }); + } else if (formDataRef.current[slug] !== undefined) { + values.push({ + fieldSlug: slug, + value: formDataRef.current[slug] as FieldValueInput['value'], + }); + } + }); + return values; + }, []); + + // Mirror unsaved fields into localStorage so a logout/reload/crash can't + // lose them; cleared once everything is persisted server-side. + const syncDraftSnapshot = useCallback(() => { + const key = draftKeyRef.current; + if (!key || typeof window === 'undefined') return; + try { + if (dirtySlugsRef.current.size === 0) { + localStorage.removeItem(key); + return; + } + const fields: Record = {}; + dirtySlugsRef.current.forEach(slug => { + if (slug.endsWith('_entries')) { + fields[slug] = repeatableDataRef.current[slug.slice(0, -'_entries'.length)] ?? []; + } else { + fields[slug] = formDataRef.current[slug]; + } + }); + localStorage.setItem(key, JSON.stringify({ savedAt: new Date().toISOString(), fields })); + } catch { + // Storage unavailable/full — local draft backup is best-effort + } + }, []); + + const flushAutoSave = useCallback(async () => { + if (!autoSaveEnabledRef.current) return; + if (saveInFlightRef.current) { + saveQueuedRef.current = true; + return; + } + const slugs = Array.from(dirtySlugsRef.current); + if (slugs.length === 0) return; + const fieldValues = buildDirtyFieldValues(slugs); + if (fieldValues.length === 0) return; + dirtySlugsRef.current = new Set(); + saveInFlightRef.current = true; + setAutoSaveStatus('saving'); + try { + await agentsService.saveFieldValues(fieldValues); + setLastSavedAt(new Date()); + setAutoSaveStatus(dirtySlugsRef.current.size > 0 ? 'dirty' : 'saved'); + syncDraftSnapshot(); + } catch (err) { + console.error('Auto-save failed:', err); + // Put the fields back so the next flush (or the retry below) re-sends them + slugs.forEach(s => dirtySlugsRef.current.add(s)); + setAutoSaveStatus('error'); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + retryTimerRef.current = setTimeout(() => { void flushAutoSave(); }, AUTOSAVE_RETRY_MS); + } finally { + saveInFlightRef.current = false; + if (saveQueuedRef.current) { + saveQueuedRef.current = false; + void flushAutoSave(); + } + } + }, [buildDirtyFieldValues, syncDraftSnapshot]); + + const scheduleAutoSave = useCallback(() => { + if (!autoSaveEnabledRef.current) return; + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = setTimeout(() => { void flushAutoSave(); }, AUTOSAVE_DEBOUNCE_MS); + }, [flushAutoSave]); + + const markDirty = useCallback((slug: string) => { + dirtySlugsRef.current.add(slug); + setAutoSaveStatus('dirty'); + scheduleAutoSave(); + }, [scheduleAutoSave]); + + // Persist the local draft after every change (covers all verification + // statuses, including APPROVED where server auto-save is off) + useEffect(() => { + if (dirtySlugsRef.current.size === 0) return; + syncDraftSnapshot(); + }, [formData, repeatableData, syncDraftSnapshot]); + + // Flush pending changes when the page is being left/closed + useEffect(() => { + const flushOnExit = () => { + if (dirtySlugsRef.current.size === 0) return; + syncDraftSnapshot(); + if (!autoSaveEnabledRef.current) return; + const token = localStorage.getItem('accessToken'); + if (!token) return; + const fieldValues = buildDirtyFieldValues(Array.from(dirtySlugsRef.current)); + if (fieldValues.length === 0) return; + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + try { + // keepalive lets the request finish even after the page unloads + void fetch(`${baseUrl}/agents/profile/field-values`, { + method: 'PUT', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ fieldValues }), + }); + } catch { + // best-effort — the localStorage draft still covers us + } + }; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') flushOnExit(); + }; + window.addEventListener('beforeunload', flushOnExit); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + window.removeEventListener('beforeunload', flushOnExit); + document.removeEventListener('visibilitychange', onVisibilityChange); + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; + }, [buildDirtyFieldValues, syncDraftSnapshot]); + + // Reflect save state in the browser tab title + useEffect(() => { + if (typeof document === 'undefined') return; + if (originalTitleRef.current === null) originalTitleRef.current = document.title; + const original = originalTitleRef.current; + if (autoSaveStatus === 'saving') { + document.title = `Saving… | ${original}`; + } else if (autoSaveStatus === 'saved' && lastSavedAt) { + document.title = `Saved ${formatTime(lastSavedAt)} | ${original}`; + } else if (autoSaveStatus === 'dirty' || autoSaveStatus === 'error') { + document.title = `● Unsaved | ${original}`; + } else { + document.title = original; + } + return () => { document.title = original; }; + }, [autoSaveStatus, lastSavedAt]); + // Fetch agent profile and sections on mount useEffect(() => { const fetchData = async () => { @@ -46,6 +233,14 @@ export default function EditProfilePage() { return; } + // APPROVED profiles are live — keep their changes behind the explicit + // Save button. Everyone else gets server auto-save while they build. + autoSaveEnabledRef.current = + !profile.verificationStatus || + profile.verificationStatus === 'NONE' || + profile.verificationStatus === 'REJECTED'; + draftKeyRef.current = `agent-profile-draft:${profile.userId}`; + if (!profile.agentTypeId) { setError('Your profile does not have an agent type assigned. Please contact support.'); setLoading(false); @@ -155,6 +350,30 @@ export default function EditProfilePage() { console.log('No existing field values found, using defaults'); } + // Restore any locally drafted (never-saved) changes on top of server + // data — covers logout/session-expiry/reload mid-edit. + try { + const draftKey = draftKeyRef.current; + const rawDraft = draftKey ? localStorage.getItem(draftKey) : null; + if (rawDraft) { + const draft = JSON.parse(rawDraft) as { savedAt?: string; fields?: Record }; + if (draft.fields && typeof draft.fields === 'object') { + Object.entries(draft.fields).forEach(([slug, value]) => { + initialData[slug] = value; + dirtySlugsRef.current.add(slug); + }); + if (dirtySlugsRef.current.size > 0) { + setDraftRestoredAt(draft.savedAt ? new Date(draft.savedAt) : new Date()); + setAutoSaveStatus('dirty'); + scheduleAutoSave(); + } + } + } + } catch { + // Corrupted draft — discard it + if (draftKeyRef.current) localStorage.removeItem(draftKeyRef.current); + } + setFormData(initialData); // Initialize repeatable sections data @@ -217,6 +436,9 @@ export default function EditProfilePage() { const handleSave = async () => { try { setIsSaving(true); + // Hold off any pending auto-save while the full save runs + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); setFormErrors({}); setRepeatableErrors({}); @@ -313,6 +535,14 @@ export default function EditProfilePage() { await agentsService.updateProfile(profileUpdates); } + // Full save supersedes any pending auto-save and local draft + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + dirtySlugsRef.current = new Set(); + if (draftKeyRef.current) localStorage.removeItem(draftKeyRef.current); + setLastSavedAt(new Date()); + setAutoSaveStatus('idle'); + // Auto-submit for verification (status: NONE or REJECTED → PENDING_REVIEW) const status = agentProfile?.verificationStatus; if (!status || status === 'NONE' || status === 'REJECTED') { @@ -334,6 +564,7 @@ export default function EditProfilePage() { const handleFieldChange = (fieldSlug: string, value: unknown) => { setFormData(prev => ({ ...prev, [fieldSlug]: value })); + markDirty(fieldSlug); // Clear error when field is modified if (formErrors[fieldSlug]) { setFormErrors(prev => { @@ -346,6 +577,7 @@ export default function EditProfilePage() { const handleRepeatableChange = (sectionSlug: string, entries: RepeatableEntryData[]) => { setRepeatableData(prev => ({ ...prev, [sectionSlug]: entries })); + markDirty(`${sectionSlug}_entries`); // Clear errors for this section when modified if (repeatableErrors[sectionSlug]) { setRepeatableErrors(prev => { @@ -405,6 +637,57 @@ export default function EditProfilePage() { > {/* Inner container with padding for shadow visibility */}
+ {/* Auto-save status */} + {(autoSaveStatus !== 'idle' || lastSavedAt) && ( +
+ + + {autoSaveStatus === 'saving' && 'Saving…'} + {autoSaveStatus === 'saved' && lastSavedAt && `All changes saved at ${formatTime(lastSavedAt)}`} + {autoSaveStatus === 'error' && 'Auto-save failed — retrying'} + {autoSaveStatus === 'dirty' && + (autoSaveEnabledRef.current + ? 'Unsaved changes' + : 'Unsaved changes — remember to save')} + {autoSaveStatus === 'idle' && lastSavedAt && `Last saved at ${formatTime(lastSavedAt)}`} + +
+ )} + + {/* Restored draft banner */} + {draftRestoredAt && ( +
+

+ We restored your unsaved changes from{' '} + {draftRestoredAt.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} + {autoSaveEnabledRef.current + ? '. Keep editing — your progress is saved automatically.' + : '. Review them and press Save Changes to keep them.'} +

+ +
+ )} + {/* Error Banner */} {error && (