From 549ac273a14b9e95f4690f8d64a54b8ed64af241 Mon Sep 17 00:00:00 2001
From: Usman Baig
Date: Wed, 25 Mar 2026 21:23:42 +0100
Subject: [PATCH] refactor(settings): move save bar to modal level, remove from
tabs
---
.../settings/unified/tabs/SiteBotSpamTab.tsx | 42 ++++---------------
.../settings/unified/tabs/SiteGeneralTab.tsx | 42 ++++---------------
.../unified/tabs/SiteVisibilityTab.tsx | 41 ++++--------------
3 files changed, 25 insertions(+), 100 deletions(-)
diff --git a/components/settings/unified/tabs/SiteBotSpamTab.tsx b/components/settings/unified/tabs/SiteBotSpamTab.tsx
index cf8a618..464ba9a 100644
--- a/components/settings/unified/tabs/SiteBotSpamTab.tsx
+++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx
@@ -1,18 +1,16 @@
'use client'
-import { useState, useEffect, useRef } from 'react'
-import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { 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, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
+export default function SiteBotSpamTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => 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 [isDirty, setIsDirty] = useState(false)
const initialFilterRef = useRef(null)
const [botView, setBotView] = useState<'review' | 'blocked'>('review')
@@ -35,12 +33,10 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
useEffect(() => {
if (initialFilterRef.current === null) return
const dirty = filterBots !== initialFilterRef.current
- setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [filterBots, onDirtyChange])
- const handleSave = async () => {
- setSaving(true)
+ const handleSave = useCallback(async () => {
try {
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
await mutate()
@@ -49,10 +45,12 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
toast.success('Bot filtering updated')
} catch {
toast.error('Failed to save')
- } finally {
- setSaving(false)
}
- }
+ }, [siteId, site?.name, filterBots, mutate, onDirtyChange])
+
+ useEffect(() => {
+ onRegisterSave?.(handleSave)
+ }, [handleSave, onRegisterSave])
const handleBotFilter = async (sessionIds: string[]) => {
try {
@@ -223,28 +221,6 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
- {/* Sticky save bar */}
- {isDirty && (
-
-
- {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
-
-
- {hasPendingAction && (
-
- )}
-
-
-
- )}
)
}
diff --git a/components/settings/unified/tabs/SiteGeneralTab.tsx b/components/settings/unified/tabs/SiteGeneralTab.tsx
index 2d6faa9..086f227 100644
--- a/components/settings/unified/tabs/SiteGeneralTab.tsx
+++ b/components/settings/unified/tabs/SiteGeneralTab.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, useCallback } 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,21 +28,19 @@ const TIMEZONES = [
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' },
]
-export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
+export default function SiteGeneralTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise) => void }) {
const router = useRouter()
const { user } = useAuth()
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
const { data: site, mutate } = useSite(siteId)
const [name, setName] = useState('')
const [timezone, setTimezone] = useState('UTC')
- const [saving, setSaving] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false)
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const initialRef = useRef('')
const hasInitialized = useRef(false)
- const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (!site || hasInitialized.current) return
@@ -50,7 +48,6 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
setTimezone(site.timezone || 'UTC')
initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC' })
hasInitialized.current = true
- setIsDirty(false)
}, [site])
// Track dirty state
@@ -58,13 +55,11 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
if (!initialRef.current) return
const current = JSON.stringify({ name, timezone })
const dirty = current !== initialRef.current
- setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [name, timezone, onDirtyChange])
- const handleSave = async () => {
+ const handleSave = useCallback(async () => {
if (!site) return
- setSaving(true)
try {
await updateSite(siteId, { name, timezone })
await mutate()
@@ -73,10 +68,12 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
toast.success('Site updated')
} catch {
toast.error('Failed to save')
- } finally {
- setSaving(false)
}
- }
+ }, [site, siteId, name, timezone, mutate, onDirtyChange])
+
+ useEffect(() => {
+ onRegisterSave?.(handleSave)
+ }, [handleSave, onRegisterSave])
const handleResetData = async () => {
if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) return
@@ -177,29 +174,6 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
- {/* Sticky save bar */}
- {isDirty && (
-
-
- {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
-
-
- {hasPendingAction && (
-
- )}
-
-
-
- )}
-
{/* Danger Zone */}
{canEdit && (
diff --git a/components/settings/unified/tabs/SiteVisibilityTab.tsx b/components/settings/unified/tabs/SiteVisibilityTab.tsx
index e63c3b7..b88d712 100644
--- a/components/settings/unified/tabs/SiteVisibilityTab.tsx
+++ b/components/settings/unified/tabs/SiteVisibilityTab.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, useCallback } 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,14 +9,12 @@ import { updateSite } from '@/lib/api/sites'
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
-export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
+export default function SiteVisibilityTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise
) => 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 [isDirty, setIsDirty] = useState(false)
const initialRef = useRef('')
const hasInitialized = useRef(false)
@@ -26,7 +24,6 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
setPasswordEnabled(site.has_password ?? false)
initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false })
hasInitialized.current = true
- setIsDirty(false)
}, [site])
// Track dirty state
@@ -34,12 +31,10 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
if (!initialRef.current) return
const current = JSON.stringify({ isPublic, passwordEnabled })
const dirty = current !== initialRef.current || password.length > 0
- setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [isPublic, passwordEnabled, password, onDirtyChange])
- const handleSave = async () => {
- setSaving(true)
+ const handleSave = useCallback(async () => {
try {
await updateSite(siteId, {
name: site?.name || '',
@@ -54,10 +49,12 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
toast.success('Visibility updated')
} catch {
toast.error('Failed to save')
- } finally {
- setSaving(false)
}
- }
+ }, [siteId, site?.name, isPublic, passwordEnabled, password, mutate, onDirtyChange])
+
+ useEffect(() => {
+ onRegisterSave?.(handleSave)
+ }, [handleSave, onRegisterSave])
const copyLink = () => {
navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`)
@@ -146,28 +143,6 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
)}
- {/* Sticky save bar */}
- {isDirty && (
-
-
- {hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
-
-
- {hasPendingAction && (
-
- )}
-
-
-
- )}
)
}