From 5d21a81fad39ac1407d5287146bd6711127cb659 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 21:24:06 +0100 Subject: [PATCH] =?UTF-8?q?refactor(settings):=20move=20save=20bar=20to=20?= =?UTF-8?q?modal=20level=20=E2=80=94=20always=20flush=20with=20modal=20bot?= =?UTF-8?q?tom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/unified/UnifiedSettingsModal.tsx | 62 +++++++++++++++++-- .../settings/unified/tabs/SitePrivacyTab.tsx | 47 ++++---------- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx index e042a12..cf088fe 100644 --- a/components/settings/unified/UnifiedSettingsModal.tsx +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { X, GearSix, Buildings, User } from '@phosphor-icons/react' +import { Button } from '@ciphera-net/ui' import { useUnifiedSettings } from '@/lib/unified-settings-context' import { useAuth } from '@/lib/auth/context' import { useSite } from '@/lib/swr/dashboard' @@ -173,17 +174,15 @@ function TabContent({ activeTab, siteId, onDirtyChange, - hasPendingAction, - onDiscard, + onRegisterSave, }: { context: SettingsContext activeTab: string siteId: string | null onDirtyChange: (dirty: boolean) => void - hasPendingAction: boolean - onDiscard: () => void + onRegisterSave: (fn: () => Promise) => void }) { - const dirtyProps = { onDirtyChange, hasPendingAction, onDiscard } + const dirtyProps = { onDirtyChange, onRegisterSave } // Site tabs if (context === 'site' && siteId) { switch (activeTab) { @@ -236,11 +235,15 @@ export default function UnifiedSettingsModal() { // ─── Dirty state & pending navigation ──────────────────────── const isDirtyRef = useRef(false) + const [isDirtyVisible, setIsDirtyVisible] = useState(false) const pendingActionRef = useRef<(() => void) | null>(null) const [hasPendingAction, setHasPendingAction] = useState(false) + const saveHandlerRef = useRef<(() => Promise) | null>(null) + const [saving, setSaving] = useState(false) const handleDirtyChange = useCallback((dirty: boolean) => { isDirtyRef.current = dirty + setIsDirtyVisible(dirty) // If user saved and there was a pending action, execute it if (!dirty && pendingActionRef.current) { const action = pendingActionRef.current @@ -250,6 +253,20 @@ export default function UnifiedSettingsModal() { } }, []) + const handleRegisterSave = useCallback((fn: () => Promise) => { + saveHandlerRef.current = fn + }, []) + + const handleSaveFromBar = useCallback(async () => { + if (!saveHandlerRef.current) return + setSaving(true) + try { + await saveHandlerRef.current() + } finally { + setSaving(false) + } + }, []) + /** Run action if clean, or store as pending if dirty */ const guardedAction = useCallback((action: () => void) => { if (isDirtyRef.current) { @@ -415,10 +432,43 @@ export default function UnifiedSettingsModal() { transition={{ duration: 0.12 }} className="p-6" > - + + + {/* Save bar — fixed at modal bottom, outside scroll */} + + {isDirtyVisible && ( + +
+ + {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'} + +
+ {hasPendingAction && ( + + )} + +
+
+
+ )} +
diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx index fea2a52..a6532bd 100644 --- a/components/settings/unified/tabs/SitePrivacyTab.tsx +++ b/components/settings/unified/tabs/SitePrivacyTab.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useEffect, useRef } from 'react' -import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Select, Toggle, toast, Spinner } from '@ciphera-net/ui' import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard' import { updateSite } from '@/lib/api/sites' import { updatePageSpeedConfig } from '@/lib/api/pagespeed' @@ -28,7 +28,7 @@ function PrivacyToggle({ label, desc, checked, onToggle }: { label: string; desc ) } -export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) { +export default function SitePrivacyTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => void }) { const { data: site, mutate } = useSite(siteId) const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription() const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId) @@ -41,8 +41,6 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction const [dataRetention, setDataRetention] = useState(6) const [excludedPaths, setExcludedPaths] = useState('') const [snippetCopied, setSnippetCopied] = useState(false) - const [saving, setSaving] = useState(false) - const [isDirty, setIsDirty] = useState(false) const initialRef = useRef('') // Sync form state from site data — only on first load, not on SWR revalidation @@ -68,20 +66,16 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction excludedPaths: (site.excluded_paths || []).join('\n'), }) hasInitialized.current = true - setIsDirty(false) }, [site]) // Track dirty state useEffect(() => { if (!initialRef.current) return const current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths }) - const dirty = current !== initialRef.current - setIsDirty(dirty) - onDirtyChange?.(dirty) + onDirtyChange?.(current !== initialRef.current) }, [collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, onDirtyChange]) - const handleSave = async () => { - setSaving(true) + const handleSave = useCallback(async () => { try { await updateSite(siteId, { name: site?.name || '', @@ -100,10 +94,13 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction toast.success('Privacy settings updated') } catch { toast.error('Failed to save') - } finally { - setSaving(false) } - } + }, [siteId, site?.name, collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, mutate, onDirtyChange]) + + // Register save handler with modal + useEffect(() => { + onRegisterSave?.(handleSave) + }, [handleSave, onRegisterSave]) if (!site) return
@@ -250,28 +247,6 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction - {/* Sticky save bar — only visible when dirty */} - {isDirty && ( -
- - {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'} - -
- {hasPendingAction && ( - - )} - -
-
- )} ) }