diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx index 4f133ec..09f462d 100644 --- a/components/settings/unified/UnifiedSettingsModal.tsx +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -1,8 +1,8 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { AnimatePresence, motion } from 'framer-motion' -import { X, GearSix, Buildings, User } from '@phosphor-icons/react' +import { X, GearSix, Buildings, User, Warning } from '@phosphor-icons/react' import { useUnifiedSettings } from '@/lib/unified-settings-context' import { useAuth } from '@/lib/auth/context' import { useSite } from '@/lib/swr/dashboard' @@ -172,19 +172,21 @@ function TabContent({ context, activeTab, siteId, + onDirtyChange, }: { context: SettingsContext activeTab: string siteId: string | null + onDirtyChange: (dirty: boolean) => void }) { // Site tabs if (context === 'site' && siteId) { switch (activeTab) { - case 'general': return + case 'general': return case 'goals': return - case 'visibility': return - case 'privacy': return - case 'bot-spam': return + case 'visibility': return + case 'privacy': return + case 'bot-spam': return case 'reports': return case 'integrations': return } @@ -227,6 +229,39 @@ export default function UnifiedSettingsModal() { const [sites, setSites] = useState([]) const [activeSiteId, setActiveSiteId] = useState(null) + // ─── Dirty state & pending navigation ──────────────────────── + const isDirtyRef = useRef(false) + const [showDirtyBar, setShowDirtyBar] = useState(false) + const pendingActionRef = useRef<(() => void) | null>(null) + + const handleDirtyChange = useCallback((dirty: boolean) => { + isDirtyRef.current = dirty + // Hide the bar if user saves (dirty becomes false) while bar is showing + if (!dirty) setShowDirtyBar(false) + }, []) + + /** Try to execute an action — if dirty, show confirmation bar instead */ + const guardedAction = useCallback((action: () => void) => { + if (isDirtyRef.current) { + pendingActionRef.current = action + setShowDirtyBar(true) + } else { + action() + } + }, []) + + const handleDiscardAndContinue = useCallback(() => { + isDirtyRef.current = false + setShowDirtyBar(false) + pendingActionRef.current?.() + pendingActionRef.current = null + }, []) + + const handleStayHere = useCallback(() => { + setShowDirtyBar(false) + pendingActionRef.current = null + }, []) + // Apply initial tab when modal opens useEffect(() => { if (isOpen && initTab) { @@ -239,24 +274,30 @@ export default function UnifiedSettingsModal() { } }, [isOpen, initTab]) + // Reset dirty state when modal opens + useEffect(() => { + if (isOpen) { + isDirtyRef.current = false + setShowDirtyBar(false) + pendingActionRef.current = null + } + }, [isOpen]) + // Detect site from URL and load sites list when modal opens useEffect(() => { if (!isOpen || !user?.org_id) return - // Pick up site ID from URL — this is the only site the user can configure if (typeof window !== 'undefined') { const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/) if (match) { setActiveSiteId(match[1]) setContext('site') } else { - // Not on a site page — default to organization context setActiveSiteId(null) if (!initTab?.context) setContext('workspace') } } - // Load sites for domain display listSites().then(data => { setSites(Array.isArray(data) ? data : []) }).catch(() => {}) @@ -270,28 +311,42 @@ export default function UnifiedSettingsModal() { if (e.key === ',' && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault() - if (isOpen) closeSettings() + if (isOpen) guardedAction(closeSettings) else openUnifiedSettings() } if (e.key === 'Escape' && isOpen) { - closeSettings() + if (showDirtyBar) handleStayHere() + else guardedAction(closeSettings) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [isOpen, openUnifiedSettings, closeSettings]) + }, [isOpen, openUnifiedSettings, closeSettings, guardedAction, showDirtyBar, handleStayHere]) const tabs = context === 'site' ? SITE_TABS : context === 'workspace' ? WORKSPACE_TABS : ACCOUNT_TABS const activeTab = context === 'site' ? siteTabs : context === 'workspace' ? workspaceTabs : accountTabs const setActiveTab = context === 'site' ? setSiteTabs : context === 'workspace' ? setWorkspaceTabs : setAccountTabs const handleContextChange = useCallback((ctx: SettingsContext) => { - setContext(ctx) - // Reset tabs to defaults when switching context - if (ctx === 'site') setSiteTabs('general') - else if (ctx === 'workspace') setWorkspaceTabs('general') - else if (ctx === 'account') setAccountTabs('profile') - }, []) + guardedAction(() => { + setContext(ctx) + if (ctx === 'site') setSiteTabs('general') + else if (ctx === 'workspace') setWorkspaceTabs('general') + else if (ctx === 'account') setAccountTabs('profile') + }) + }, [guardedAction]) + + const handleTabChange = useCallback((tabId: string) => { + guardedAction(() => setActiveTab(tabId)) + }, [guardedAction, setActiveTab]) + + const handleClose = useCallback(() => { + guardedAction(closeSettings) + }, [guardedAction, closeSettings]) + + const handleBackdropClick = useCallback(() => { + guardedAction(closeSettings) + }, [guardedAction, closeSettings]) return ( @@ -304,7 +359,7 @@ export default function UnifiedSettingsModal() { exit={{ opacity: 0 }} transition={{ duration: 0.2 }} className="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm" - onClick={closeSettings} + onClick={handleBackdropClick} /> {/* Modal */} @@ -324,7 +379,7 @@ export default function UnifiedSettingsModal() {

Settings

- {/* Content — parent has fixed h-[85vh] so this fills remaining space without jumping */} + {/* Unsaved changes bar */} + + {showDirtyBar && ( + +
+
+ + You have unsaved changes. +
+
+ + +
+
+
+ )} +
+ + {/* Content */}
- +
diff --git a/components/settings/unified/tabs/SiteBotSpamTab.tsx b/components/settings/unified/tabs/SiteBotSpamTab.tsx index 7055e73..bfd736a 100644 --- a/components/settings/unified/tabs/SiteBotSpamTab.tsx +++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx @@ -1,17 +1,18 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui' import { ShieldCheck } from '@phosphor-icons/react' import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard' import { updateSite } from '@/lib/api/sites' import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' -export default function SiteBotSpamTab({ siteId }: { siteId: string }) { +export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { const { data: site, mutate } = useSite(siteId) const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) const [filterBots, setFilterBots] = useState(false) const [saving, setSaving] = useState(false) + const initialFilterRef = useRef(null) const [botView, setBotView] = useState<'review' | 'blocked'>('review') const [suspiciousOnly, setSuspiciousOnly] = useState(true) @@ -22,14 +23,25 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) { const sessions = sessionsData?.sessions useEffect(() => { - if (site) setFilterBots(site.filter_bots ?? false) + if (site) { + setFilterBots(site.filter_bots ?? false) + initialFilterRef.current = site.filter_bots ?? false + } }, [site]) + // Track dirty state + useEffect(() => { + if (initialFilterRef.current === null) return + onDirtyChange?.(filterBots !== initialFilterRef.current) + }, [filterBots, onDirtyChange]) + const handleSave = async () => { setSaving(true) try { await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots }) await mutate() + initialFilterRef.current = filterBots + onDirtyChange?.(false) toast.success('Bot filtering updated') } catch { toast.error('Failed to save') diff --git a/components/settings/unified/tabs/SiteGeneralTab.tsx b/components/settings/unified/tabs/SiteGeneralTab.tsx index eb91777..4ada9ca 100644 --- a/components/settings/unified/tabs/SiteGeneralTab.tsx +++ b/components/settings/unified/tabs/SiteGeneralTab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui' import { useSite } from '@/lib/swr/dashboard' @@ -28,7 +28,7 @@ const TIMEZONES = [ { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, ] -export default function SiteGeneralTab({ siteId }: { siteId: string }) { +export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { const router = useRouter() const { user } = useAuth() const { closeUnifiedSettings: closeSettings } = useUnifiedSettings() @@ -40,20 +40,31 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) { const [showVerificationModal, setShowVerificationModal] = useState(false) const canEdit = user?.role === 'owner' || user?.role === 'admin' + const initialRef = useRef('') useEffect(() => { if (site) { setName(site.name || '') setTimezone(site.timezone || 'UTC') + initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC' }) } }, [site]) + // Track dirty state + useEffect(() => { + if (!initialRef.current) return + const current = JSON.stringify({ name, timezone }) + onDirtyChange?.(current !== initialRef.current) + }, [name, timezone, onDirtyChange]) + const handleSave = async () => { if (!site) return setSaving(true) try { await updateSite(siteId, { name, timezone }) await mutate() + initialRef.current = JSON.stringify({ name, timezone }) + onDirtyChange?.(false) toast.success('Site updated') } catch { toast.error('Failed to save') diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx index 001e214..3fc8446 100644 --- a/components/settings/unified/tabs/SitePrivacyTab.tsx +++ b/components/settings/unified/tabs/SitePrivacyTab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui' import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard' import { updateSite } from '@/lib/api/sites' @@ -16,7 +16,7 @@ const GEO_OPTIONS = [ { value: 'none', label: 'Disabled' }, ] -export default function SitePrivacyTab({ siteId }: { siteId: string }) { +export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { const { data: site, mutate } = useSite(siteId) const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription() const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId) @@ -30,6 +30,7 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { const [excludedPaths, setExcludedPaths] = useState('') const [snippetCopied, setSnippetCopied] = useState(false) const [saving, setSaving] = useState(false) + const initialRef = useRef('') useEffect(() => { if (site) { @@ -41,9 +42,26 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { setHideUnknownLocations(site.hide_unknown_locations ?? false) setDataRetention(site.data_retention_months ?? 6) setExcludedPaths((site.excluded_paths || []).join('\n')) + initialRef.current = JSON.stringify({ + collectPagePaths: site.collect_page_paths ?? true, + collectReferrers: site.collect_referrers ?? true, + collectDeviceInfo: site.collect_device_info ?? true, + collectScreenRes: site.collect_screen_resolution ?? true, + collectGeoData: site.collect_geo_data ?? 'full', + hideUnknownLocations: site.hide_unknown_locations ?? false, + dataRetention: site.data_retention_months ?? 6, + excludedPaths: (site.excluded_paths || []).join('\n'), + }) } }, [site]) + // Track dirty state + useEffect(() => { + if (!initialRef.current) return + const current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths }) + onDirtyChange?.(current !== initialRef.current) + }, [collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, onDirtyChange]) + const handleSave = async () => { setSaving(true) try { @@ -59,6 +77,8 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { excluded_paths: excludedPaths.split('\n').map(p => p.trim()).filter(Boolean), }) await mutate() + initialRef.current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths }) + onDirtyChange?.(false) toast.success('Privacy settings updated') } catch { toast.error('Failed to save') diff --git a/components/settings/unified/tabs/SiteVisibilityTab.tsx b/components/settings/unified/tabs/SiteVisibilityTab.tsx index 33d80e0..87ab2b9 100644 --- a/components/settings/unified/tabs/SiteVisibilityTab.tsx +++ b/components/settings/unified/tabs/SiteVisibilityTab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui' import { Copy, CheckCircle, Lock } from '@phosphor-icons/react' import { AnimatePresence, motion } from 'framer-motion' @@ -9,21 +9,31 @@ import { updateSite } from '@/lib/api/sites' const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' -export default function SiteVisibilityTab({ siteId }: { siteId: string }) { +export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { const { data: site, mutate } = useSite(siteId) const [isPublic, setIsPublic] = useState(false) const [password, setPassword] = useState('') const [passwordEnabled, setPasswordEnabled] = useState(false) const [saving, setSaving] = useState(false) const [linkCopied, setLinkCopied] = useState(false) + const initialRef = useRef('') useEffect(() => { if (site) { setIsPublic(site.is_public ?? false) setPasswordEnabled(site.has_password ?? false) + initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false }) } }, [site]) + // Track dirty state + useEffect(() => { + if (!initialRef.current) return + const current = JSON.stringify({ isPublic, passwordEnabled }) + const dirty = current !== initialRef.current || password.length > 0 + onDirtyChange?.(dirty) + }, [isPublic, passwordEnabled, password, onDirtyChange]) + const handleSave = async () => { setSaving(true) try { @@ -35,6 +45,8 @@ export default function SiteVisibilityTab({ siteId }: { siteId: string }) { }) setPassword('') await mutate() + initialRef.current = JSON.stringify({ isPublic, passwordEnabled }) + onDirtyChange?.(false) toast.success('Visibility updated') } catch { toast.error('Failed to save')