feat: auto-save agent profile drafts with local backup and save-status indicator

This commit is contained in:
pradeepkumar
2026-06-11 21:41:41 +05:30
parent fb14c4d37c
commit dca0d54e2e

View File

@@ -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">