feat: auto-save agent profile drafts with local backup and save-status indicator
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
@@ -29,6 +37,185 @@ export default function EditProfilePage() {
|
||||
const [repeatableErrors, setRepeatableErrors] = useState<Record<string, Record<string, Record<string, string>>>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Auto-save state
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>('idle');
|
||||
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
|
||||
const [draftRestoredAt, setDraftRestoredAt] = useState<Date | null>(null);
|
||||
|
||||
// Auto-save bookkeeping. Refs (not state) so debounce timers and
|
||||
// beforeunload handlers always see the latest values without re-binding.
|
||||
const formDataRef = useRef<Record<string, unknown>>({});
|
||||
const repeatableDataRef = useRef<Record<string, RepeatableEntryData[]>>({});
|
||||
// Dirty (unsaved) field slugs; repeatable sections tracked as `${slug}_entries`
|
||||
const dirtySlugsRef = useRef<Set<string>>(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<string | null>(null);
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveInFlightRef = useRef(false);
|
||||
const saveQueuedRef = useRef(false);
|
||||
const originalTitleRef = useRef<string | null>(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<string, unknown>[] });
|
||||
} 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<string, unknown> = {};
|
||||
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<string, unknown> };
|
||||
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 */}
|
||||
<div className="space-y-6 p-2">
|
||||
{/* Auto-save status */}
|
||||
{(autoSaveStatus !== 'idle' || lastSavedAt) && (
|
||||
<div className="flex items-center justify-end gap-2 px-1">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
autoSaveStatus === 'saving'
|
||||
? 'bg-[#E58625] animate-pulse'
|
||||
: autoSaveStatus === 'saved' || (autoSaveStatus === 'idle' && lastSavedAt)
|
||||
? 'bg-green-500'
|
||||
: autoSaveStatus === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-[12px] font-serif text-[#00293D]/60">
|
||||
{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)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restored draft banner */}
|
||||
{draftRestoredAt && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-[15px] p-4 flex items-center justify-between gap-3">
|
||||
<p className="text-[14px] font-serif text-[#00293D]">
|
||||
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.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDraftRestoredAt(null)}
|
||||
className="text-[12px] font-semibold font-serif text-[#00293D]/60 hover:text-[#00293D] flex-shrink-0"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-[15px] p-4 flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user