import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useEditorConfig } from './EditorConfigContext'; export type LoadState = 'loading' | 'loaded' | 'failed'; interface SaveGuardContextValue { /** Nothing may be saved until this is 'loaded' -- a failed load leaves the * canvas blank, and saving that would overwrite the customer's site. */ loadState: LoadState; setLoadState: (s: LoadState) => void; /** Server refused a save that would replace a non-empty site with an empty * one (409 empty_overwrite_refused). Holds the message while the banner is up. */ emptyRefusal: string | null; setEmptyRefusal: (message: string | null) => void; /** One-shot: the next save sends force_empty (consumed by the next successful save). */ forceEmptyNextSave: React.MutableRefObject; armForceEmptyNextSave: () => void; /** True when the editor holds changes (canvas, pages, design, header/footer) * made after the last successful save or load. Cleared only by a save. */ dirty: boolean; /** Synchronous read of `dirty`, for handlers that must not see a stale render. */ isDirty: () => boolean; /** Record a change. Ignored until the site has loaded (load itself rewrites state). */ markDirty: () => void; /** Monotonic change counter: compare two reads to learn whether anything changed in between. */ getVersion: () => number; /** Call right before serializing for a save; pass the ticket to `endSave`. */ beginSave: () => SaveTicket; /** Finish a save. Clears dirty only if it succeeded AND nothing changed since `beginSave`. */ endSave: (ticket: SaveTicket, succeeded: boolean) => void; /** True while at least one save request is in flight. */ saving: boolean; /** Skip the unsaved-changes prompt for the next unload (e.g. a deliberate "Reload latest"). */ suppressBeforeUnload: () => void; /** Server revision the editor last loaded or saved: a string, null (no project on the * server yet), or undefined (unknown -- an older backend sent none, so saves skip the check). */ getRev: () => string | null | undefined; setRev: (rev: string | null) => void; /** Server message while the "changed in another tab" modal is up; autosave stays paused. */ conflict: string | null; setConflict: (message: string | null) => void; /** One-shot: the next save sends force_overwrite (armed by "Keep mine", spent by a save that goes through). */ forceOverwriteNextSave: React.MutableRefObject; armForceOverwriteNextSave: () => void; /** Cancel an armed overwrite (the customer backed out of the follow-up empty-save prompt). */ disarmForceOverwriteNextSave: () => void; /** "Reload latest": skip the unsaved-changes prompt, then reload the whole editor. */ reloadLatest: () => void; } /** Identifies the editor state a save was built from. */ export interface SaveTicket { version: number; } // Default (no provider: standalone mode and isolated unit tests) is // permissive -- the gate only applies inside in WHP mode. const defaultValue: SaveGuardContextValue = { loadState: 'loaded', setLoadState: () => {}, emptyRefusal: null, setEmptyRefusal: () => {}, forceEmptyNextSave: { current: false }, armForceEmptyNextSave: () => {}, dirty: false, isDirty: () => false, getVersion: () => 0, markDirty: () => {}, beginSave: () => ({ version: 0 }), endSave: () => {}, saving: false, suppressBeforeUnload: () => {}, getRev: () => undefined, setRev: () => {}, conflict: null, setConflict: () => {}, forceOverwriteNextSave: { current: false }, armForceOverwriteNextSave: () => {}, disarmForceOverwriteNextSave: () => {}, reloadLatest: () => {}, }; const SaveGuardContext = createContext(defaultValue); export const useSaveGuard = () => useContext(SaveGuardContext); export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable for tests; defaults to a full page reload. */ reloadPage?: () => void }> = ({ children, reloadPage }) => { const { isWHP } = useEditorConfig(); const [loadState, setLoadState] = useState(isWHP ? 'loading' : 'loaded'); const [emptyRefusal, setEmptyRefusal] = useState(null); const forceEmptyNextSave = useRef(false); const [conflict, setConflict] = useState(null); const forceOverwriteNextSave = useRef(false); const revRef = useRef(undefined); const getRev = useCallback(() => revRef.current, []); const setRev = useCallback((rev: string | null) => { revRef.current = rev; }, []); // Dirty tracking. `versionRef` counts changes so a save that raced with an // edit cannot mark that edit clean. const [dirty, setDirty] = useState(false); const [savesInFlight, setSavesInFlight] = useState(0); const dirtyRef = useRef(false); const versionRef = useRef(0); const loadedRef = useRef(loadState === 'loaded'); const suppressRef = useRef(false); // A (re)load replaces the whole editor state: it is the new clean baseline. useEffect(() => { loadedRef.current = loadState === 'loaded'; if (loadState === 'loaded') { dirtyRef.current = false; setDirty(false); } }, [loadState]); const markDirty = useCallback(() => { if (!loadedRef.current) return; versionRef.current += 1; dirtyRef.current = true; setDirty(true); }, []); const isDirty = useCallback(() => dirtyRef.current, []); const getVersion = useCallback(() => versionRef.current, []); const beginSave = useCallback((): SaveTicket => { setSavesInFlight((n) => n + 1); return { version: versionRef.current }; }, []); const endSave = useCallback((ticket: SaveTicket, succeeded: boolean) => { setSavesInFlight((n) => Math.max(0, n - 1)); if (succeeded && ticket.version === versionRef.current) { dirtyRef.current = false; setDirty(false); } }, []); const suppressBeforeUnload = useCallback(() => { suppressRef.current = true; }, []); const reloadLatest = useCallback(() => { suppressRef.current = true; (reloadPage ?? (() => window.location.reload()))(); }, [reloadPage]); const saving = savesInFlight > 0; const needsPrompt = isWHP && (dirty || saving); useEffect(() => { if (!needsPrompt) return; const onBeforeUnload = (e: BeforeUnloadEvent) => { if (suppressRef.current) return; e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, [needsPrompt]); const value = useMemo(() => ({ loadState, setLoadState, emptyRefusal, setEmptyRefusal, forceEmptyNextSave, armForceEmptyNextSave: () => { forceEmptyNextSave.current = true; }, dirty, isDirty, getVersion, markDirty, beginSave, endSave, saving, suppressBeforeUnload, getRev, setRev, conflict, setConflict, forceOverwriteNextSave, armForceOverwriteNextSave: () => { forceOverwriteNextSave.current = true; }, disarmForceOverwriteNextSave: () => { forceOverwriteNextSave.current = false; }, reloadLatest, }), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest]); return {children}; };