fix: discard button in sticky save bar instead of browser confirm

This commit is contained in:
Usman Baig
2026-03-25 20:51:39 +01:00
parent 7181d68d85
commit 81fafcf711
5 changed files with 84 additions and 32 deletions

View File

@@ -173,20 +173,25 @@ function TabContent({
activeTab, activeTab,
siteId, siteId,
onDirtyChange, onDirtyChange,
hasPendingAction,
onDiscard,
}: { }: {
context: SettingsContext context: SettingsContext
activeTab: string activeTab: string
siteId: string | null siteId: string | null
onDirtyChange: (dirty: boolean) => void onDirtyChange: (dirty: boolean) => void
hasPendingAction: boolean
onDiscard: () => void
}) { }) {
const dirtyProps = { onDirtyChange, hasPendingAction, onDiscard }
// Site tabs // Site tabs
if (context === 'site' && siteId) { if (context === 'site' && siteId) {
switch (activeTab) { switch (activeTab) {
case 'general': return <SiteGeneralTab siteId={siteId} onDirtyChange={onDirtyChange} /> case 'general': return <SiteGeneralTab siteId={siteId} {...dirtyProps} />
case 'goals': return <SiteGoalsTab siteId={siteId} /> case 'goals': return <SiteGoalsTab siteId={siteId} />
case 'visibility': return <SiteVisibilityTab siteId={siteId} onDirtyChange={onDirtyChange} /> case 'visibility': return <SiteVisibilityTab siteId={siteId} {...dirtyProps} />
case 'privacy': return <SitePrivacyTab siteId={siteId} onDirtyChange={onDirtyChange} /> case 'privacy': return <SitePrivacyTab siteId={siteId} {...dirtyProps} />
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} onDirtyChange={onDirtyChange} /> case 'bot-spam': return <SiteBotSpamTab siteId={siteId} {...dirtyProps} />
case 'reports': return <SiteReportsTab siteId={siteId} /> case 'reports': return <SiteReportsTab siteId={siteId} />
case 'integrations': return <SiteIntegrationsTab siteId={siteId} /> case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
} }
@@ -229,25 +234,40 @@ export default function UnifiedSettingsModal() {
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [activeSiteId, setActiveSiteId] = useState<string | null>(null) const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
// ─── Dirty state ────────────────────────────────────────────── // ─── Dirty state & pending navigation ────────────────────────
const isDirtyRef = useRef(false) const isDirtyRef = useRef(false)
const pendingActionRef = useRef<(() => void) | null>(null)
const [hasPendingAction, setHasPendingAction] = useState(false)
const handleDirtyChange = useCallback((dirty: boolean) => { const handleDirtyChange = useCallback((dirty: boolean) => {
isDirtyRef.current = dirty isDirtyRef.current = dirty
}, []) // If user saved and there was a pending action, execute it
if (!dirty && pendingActionRef.current) {
/** Run action if clean, or confirm discard if dirty */ const action = pendingActionRef.current
const guardedAction = useCallback((action: () => void) => { pendingActionRef.current = null
if (isDirtyRef.current) { setHasPendingAction(false)
if (confirm('You have unsaved changes. Discard and continue?')) {
isDirtyRef.current = false
action() action()
} }
}, [])
/** Run action if clean, or store as pending if dirty */
const guardedAction = useCallback((action: () => void) => {
if (isDirtyRef.current) {
pendingActionRef.current = action
setHasPendingAction(true)
} else { } else {
action() action()
} }
}, []) }, [])
const handleDiscard = useCallback(() => {
isDirtyRef.current = false
setHasPendingAction(false)
const action = pendingActionRef.current
pendingActionRef.current = null
action?.()
}, [])
// Apply initial tab when modal opens // Apply initial tab when modal opens
useEffect(() => { useEffect(() => {
if (isOpen && initTab) { if (isOpen && initTab) {
@@ -262,7 +282,11 @@ export default function UnifiedSettingsModal() {
// Reset dirty state when modal opens // Reset dirty state when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) isDirtyRef.current = false if (isOpen) {
isDirtyRef.current = false
pendingActionRef.current = null
setHasPendingAction(false)
}
}, [isOpen]) }, [isOpen])
// Detect site from URL and load sites list when modal opens // Detect site from URL and load sites list when modal opens
@@ -391,7 +415,7 @@ export default function UnifiedSettingsModal() {
transition={{ duration: 0.12 }} transition={{ duration: 0.12 }}
className="p-6" className="p-6"
> >
<TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} /> <TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} hasPendingAction={hasPendingAction} onDiscard={handleDiscard} />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>

View File

@@ -7,7 +7,7 @@ import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard'
import { updateSite } from '@/lib/api/sites' import { updateSite } from '@/lib/api/sites'
import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter'
export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId)
const [filterBots, setFilterBots] = useState(false) const [filterBots, setFilterBots] = useState(false)
@@ -226,11 +226,18 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: stri
{/* Sticky save bar */} {/* Sticky save bar */}
{isDirty && ( {isDirty && (
<div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between"> <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
<span className="text-xs text-neutral-400">Unsaved changes</span> <span className="text-xs text-neutral-400">{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<button onClick={onDiscard} className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-white transition-colors">
Discard
</button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm"> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</div>
)} )}
</div> </div>
) )

View File

@@ -28,7 +28,7 @@ const TIMEZONES = [
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' },
] ]
export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
const router = useRouter() const router = useRouter()
const { user } = useAuth() const { user } = useAuth()
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings() const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
@@ -180,11 +180,18 @@ export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: stri
{/* Sticky save bar */} {/* Sticky save bar */}
{isDirty && ( {isDirty && (
<div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between"> <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
<span className="text-xs text-neutral-400">Unsaved changes</span> <span className="text-xs text-neutral-400">{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<button onClick={onDiscard} className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-white transition-colors">
Discard
</button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm"> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</div>
)} )}
{/* Danger Zone */} {/* Danger Zone */}

View File

@@ -28,7 +28,7 @@ function PrivacyToggle({ label, desc, checked, onToggle }: { label: string; desc
) )
} }
export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription() const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId) const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId)
@@ -253,11 +253,18 @@ export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: stri
{/* Sticky save bar — only visible when dirty */} {/* Sticky save bar — only visible when dirty */}
{isDirty && ( {isDirty && (
<div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between"> <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
<span className="text-xs text-neutral-400">Unsaved changes</span> <span className="text-xs text-neutral-400">{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<button onClick={onDiscard} className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-white transition-colors">
Discard
</button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm"> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</div>
)} )}
</div> </div>
) )

View File

@@ -9,7 +9,7 @@ import { updateSite } from '@/lib/api/sites'
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) { export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
const [isPublic, setIsPublic] = useState(false) const [isPublic, setIsPublic] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -149,11 +149,18 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: s
{/* Sticky save bar */} {/* Sticky save bar */}
{isDirty && ( {isDirty && (
<div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between"> <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
<span className="text-xs text-neutral-400">Unsaved changes</span> <span className="text-xs text-neutral-400">{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<button onClick={onDiscard} className="px-3 py-1.5 text-xs font-medium text-neutral-400 hover:text-white transition-colors">
Discard
</button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm"> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
</div> </div>
</div>
)} )}
</div> </div>
) )