feat: auto-save agent profile drafts with local backup and save-status indicator
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { QuickLinks } from './components';
|
import { QuickLinks } from './components';
|
||||||
import DynamicSection from './components/DynamicSection';
|
import DynamicSection from './components/DynamicSection';
|
||||||
@@ -11,6 +11,14 @@ import { validateFieldValue } from '@/utils/validateFieldValue';
|
|||||||
import { usersService } from '@/services/users.service';
|
import { usersService } from '@/services/users.service';
|
||||||
import { MobileBackButton } from '@/components/layout/MobileBackButton';
|
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() {
|
export default function EditProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
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 [repeatableErrors, setRepeatableErrors] = useState<Record<string, Record<string, Record<string, string>>>>({});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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
|
// Fetch agent profile and sections on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -46,6 +233,14 @@ export default function EditProfilePage() {
|
|||||||
return;
|
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) {
|
if (!profile.agentTypeId) {
|
||||||
setError('Your profile does not have an agent type assigned. Please contact support.');
|
setError('Your profile does not have an agent type assigned. Please contact support.');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -155,6 +350,30 @@ export default function EditProfilePage() {
|
|||||||
console.log('No existing field values found, using defaults');
|
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);
|
setFormData(initialData);
|
||||||
|
|
||||||
// Initialize repeatable sections data
|
// Initialize repeatable sections data
|
||||||
@@ -217,6 +436,9 @@ export default function EditProfilePage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
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({});
|
setFormErrors({});
|
||||||
setRepeatableErrors({});
|
setRepeatableErrors({});
|
||||||
|
|
||||||
@@ -313,6 +535,14 @@ export default function EditProfilePage() {
|
|||||||
await agentsService.updateProfile(profileUpdates);
|
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)
|
// Auto-submit for verification (status: NONE or REJECTED → PENDING_REVIEW)
|
||||||
const status = agentProfile?.verificationStatus;
|
const status = agentProfile?.verificationStatus;
|
||||||
if (!status || status === 'NONE' || status === 'REJECTED') {
|
if (!status || status === 'NONE' || status === 'REJECTED') {
|
||||||
@@ -334,6 +564,7 @@ export default function EditProfilePage() {
|
|||||||
|
|
||||||
const handleFieldChange = (fieldSlug: string, value: unknown) => {
|
const handleFieldChange = (fieldSlug: string, value: unknown) => {
|
||||||
setFormData(prev => ({ ...prev, [fieldSlug]: value }));
|
setFormData(prev => ({ ...prev, [fieldSlug]: value }));
|
||||||
|
markDirty(fieldSlug);
|
||||||
// Clear error when field is modified
|
// Clear error when field is modified
|
||||||
if (formErrors[fieldSlug]) {
|
if (formErrors[fieldSlug]) {
|
||||||
setFormErrors(prev => {
|
setFormErrors(prev => {
|
||||||
@@ -346,6 +577,7 @@ export default function EditProfilePage() {
|
|||||||
|
|
||||||
const handleRepeatableChange = (sectionSlug: string, entries: RepeatableEntryData[]) => {
|
const handleRepeatableChange = (sectionSlug: string, entries: RepeatableEntryData[]) => {
|
||||||
setRepeatableData(prev => ({ ...prev, [sectionSlug]: entries }));
|
setRepeatableData(prev => ({ ...prev, [sectionSlug]: entries }));
|
||||||
|
markDirty(`${sectionSlug}_entries`);
|
||||||
// Clear errors for this section when modified
|
// Clear errors for this section when modified
|
||||||
if (repeatableErrors[sectionSlug]) {
|
if (repeatableErrors[sectionSlug]) {
|
||||||
setRepeatableErrors(prev => {
|
setRepeatableErrors(prev => {
|
||||||
@@ -405,6 +637,57 @@ export default function EditProfilePage() {
|
|||||||
>
|
>
|
||||||
{/* Inner container with padding for shadow visibility */}
|
{/* Inner container with padding for shadow visibility */}
|
||||||
<div className="space-y-6 p-2">
|
<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 Banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-[15px] p-4 flex items-center gap-3">
|
<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