fix: discard button in sticky save bar instead of browser confirm
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user