feat(settings): unsaved changes guard with inline confirmation bar
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
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 { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { useSite } from '@/lib/swr/dashboard'
|
import { useSite } from '@/lib/swr/dashboard'
|
||||||
@@ -172,19 +172,21 @@ function TabContent({
|
|||||||
context,
|
context,
|
||||||
activeTab,
|
activeTab,
|
||||||
siteId,
|
siteId,
|
||||||
|
onDirtyChange,
|
||||||
}: {
|
}: {
|
||||||
context: SettingsContext
|
context: SettingsContext
|
||||||
activeTab: string
|
activeTab: string
|
||||||
siteId: string | null
|
siteId: string | null
|
||||||
|
onDirtyChange: (dirty: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
// Site tabs
|
// Site tabs
|
||||||
if (context === 'site' && siteId) {
|
if (context === 'site' && siteId) {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'general': return <SiteGeneralTab siteId={siteId} />
|
case 'general': return <SiteGeneralTab siteId={siteId} onDirtyChange={onDirtyChange} />
|
||||||
case 'goals': return <SiteGoalsTab siteId={siteId} />
|
case 'goals': return <SiteGoalsTab siteId={siteId} />
|
||||||
case 'visibility': return <SiteVisibilityTab siteId={siteId} />
|
case 'visibility': return <SiteVisibilityTab siteId={siteId} onDirtyChange={onDirtyChange} />
|
||||||
case 'privacy': return <SitePrivacyTab siteId={siteId} />
|
case 'privacy': return <SitePrivacyTab siteId={siteId} onDirtyChange={onDirtyChange} />
|
||||||
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} />
|
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} onDirtyChange={onDirtyChange} />
|
||||||
case 'reports': return <SiteReportsTab siteId={siteId} />
|
case 'reports': return <SiteReportsTab siteId={siteId} />
|
||||||
case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
|
case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
|
||||||
}
|
}
|
||||||
@@ -227,6 +229,39 @@ 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 & 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
|
// Apply initial tab when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && initTab) {
|
if (isOpen && initTab) {
|
||||||
@@ -239,24 +274,30 @@ export default function UnifiedSettingsModal() {
|
|||||||
}
|
}
|
||||||
}, [isOpen, initTab])
|
}, [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
|
// Detect site from URL and load sites list when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !user?.org_id) return
|
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') {
|
if (typeof window !== 'undefined') {
|
||||||
const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/)
|
const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/)
|
||||||
if (match) {
|
if (match) {
|
||||||
setActiveSiteId(match[1])
|
setActiveSiteId(match[1])
|
||||||
setContext('site')
|
setContext('site')
|
||||||
} else {
|
} else {
|
||||||
// Not on a site page — default to organization context
|
|
||||||
setActiveSiteId(null)
|
setActiveSiteId(null)
|
||||||
if (!initTab?.context) setContext('workspace')
|
if (!initTab?.context) setContext('workspace')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load sites for domain display
|
|
||||||
listSites().then(data => {
|
listSites().then(data => {
|
||||||
setSites(Array.isArray(data) ? data : [])
|
setSites(Array.isArray(data) ? data : [])
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
@@ -270,28 +311,42 @@ export default function UnifiedSettingsModal() {
|
|||||||
|
|
||||||
if (e.key === ',' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === ',' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (isOpen) closeSettings()
|
if (isOpen) guardedAction(closeSettings)
|
||||||
else openUnifiedSettings()
|
else openUnifiedSettings()
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape' && isOpen) {
|
if (e.key === 'Escape' && isOpen) {
|
||||||
closeSettings()
|
if (showDirtyBar) handleStayHere()
|
||||||
|
else guardedAction(closeSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('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 tabs = context === 'site' ? SITE_TABS : context === 'workspace' ? WORKSPACE_TABS : ACCOUNT_TABS
|
||||||
const activeTab = context === 'site' ? siteTabs : context === 'workspace' ? workspaceTabs : accountTabs
|
const activeTab = context === 'site' ? siteTabs : context === 'workspace' ? workspaceTabs : accountTabs
|
||||||
const setActiveTab = context === 'site' ? setSiteTabs : context === 'workspace' ? setWorkspaceTabs : setAccountTabs
|
const setActiveTab = context === 'site' ? setSiteTabs : context === 'workspace' ? setWorkspaceTabs : setAccountTabs
|
||||||
|
|
||||||
const handleContextChange = useCallback((ctx: SettingsContext) => {
|
const handleContextChange = useCallback((ctx: SettingsContext) => {
|
||||||
setContext(ctx)
|
guardedAction(() => {
|
||||||
// Reset tabs to defaults when switching context
|
setContext(ctx)
|
||||||
if (ctx === 'site') setSiteTabs('general')
|
if (ctx === 'site') setSiteTabs('general')
|
||||||
else if (ctx === 'workspace') setWorkspaceTabs('general')
|
else if (ctx === 'workspace') setWorkspaceTabs('general')
|
||||||
else if (ctx === 'account') setAccountTabs('profile')
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -304,7 +359,7 @@ export default function UnifiedSettingsModal() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm"
|
||||||
onClick={closeSettings}
|
onClick={handleBackdropClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
@@ -324,7 +379,7 @@ export default function UnifiedSettingsModal() {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-white">Settings</h2>
|
<h2 className="text-lg font-semibold text-white">Settings</h2>
|
||||||
<button
|
<button
|
||||||
onClick={closeSettings}
|
onClick={handleClose}
|
||||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
|
className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
|
||||||
>
|
>
|
||||||
<X weight="bold" className="w-4 h-4" />
|
<X weight="bold" className="w-4 h-4" />
|
||||||
@@ -340,11 +395,45 @@ export default function UnifiedSettingsModal() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TabBar tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
<TabBar tabs={tabs} activeTab={activeTab} onChange={handleTabChange} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content — parent has fixed h-[85vh] so this fills remaining space without jumping */}
|
{/* Unsaved changes bar */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDirtyBar && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 px-6 py-2.5 bg-amber-900/20 border-b border-amber-800/40">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-200">
|
||||||
|
<Warning weight="bold" className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
You have unsaved changes.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleDiscardAndContinue}
|
||||||
|
className="px-3 py-1 text-xs font-medium text-amber-300 hover:text-white bg-amber-800/30 hover:bg-amber-800/50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Discard & continue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStayHere}
|
||||||
|
className="px-3 py-1 text-xs font-medium text-neutral-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Stay here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -355,7 +444,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} />
|
<TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
|
import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
|
||||||
import { ShieldCheck } from '@phosphor-icons/react'
|
import { ShieldCheck } from '@phosphor-icons/react'
|
||||||
import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard'
|
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 }: { siteId: string }) {
|
export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => 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)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const initialFilterRef = useRef<boolean | null>(null)
|
||||||
|
|
||||||
const [botView, setBotView] = useState<'review' | 'blocked'>('review')
|
const [botView, setBotView] = useState<'review' | 'blocked'>('review')
|
||||||
const [suspiciousOnly, setSuspiciousOnly] = useState(true)
|
const [suspiciousOnly, setSuspiciousOnly] = useState(true)
|
||||||
@@ -22,14 +23,25 @@ export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
|
|||||||
const sessions = sessionsData?.sessions
|
const sessions = sessionsData?.sessions
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (site) setFilterBots(site.filter_bots ?? false)
|
if (site) {
|
||||||
|
setFilterBots(site.filter_bots ?? false)
|
||||||
|
initialFilterRef.current = site.filter_bots ?? false
|
||||||
|
}
|
||||||
}, [site])
|
}, [site])
|
||||||
|
|
||||||
|
// Track dirty state
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialFilterRef.current === null) return
|
||||||
|
onDirtyChange?.(filterBots !== initialFilterRef.current)
|
||||||
|
}, [filterBots, onDirtyChange])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
|
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
|
||||||
await mutate()
|
await mutate()
|
||||||
|
initialFilterRef.current = filterBots
|
||||||
|
onDirtyChange?.(false)
|
||||||
toast.success('Bot filtering updated')
|
toast.success('Bot filtering updated')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save')
|
toast.error('Failed to save')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui'
|
import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui'
|
||||||
import { useSite } from '@/lib/swr/dashboard'
|
import { useSite } from '@/lib/swr/dashboard'
|
||||||
@@ -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 }: { siteId: string }) {
|
export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: string; onDirtyChange?: (dirty: boolean) => void }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
|
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
|
||||||
@@ -40,20 +40,31 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) {
|
|||||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||||
|
|
||||||
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
||||||
|
const initialRef = useRef('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (site) {
|
if (site) {
|
||||||
setName(site.name || '')
|
setName(site.name || '')
|
||||||
setTimezone(site.timezone || 'UTC')
|
setTimezone(site.timezone || 'UTC')
|
||||||
|
initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC' })
|
||||||
}
|
}
|
||||||
}, [site])
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!site) return
|
if (!site) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await updateSite(siteId, { name, timezone })
|
await updateSite(siteId, { name, timezone })
|
||||||
await mutate()
|
await mutate()
|
||||||
|
initialRef.current = JSON.stringify({ name, timezone })
|
||||||
|
onDirtyChange?.(false)
|
||||||
toast.success('Site updated')
|
toast.success('Site updated')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save')
|
toast.error('Failed to save')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||||
import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard'
|
import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard'
|
||||||
import { updateSite } from '@/lib/api/sites'
|
import { updateSite } from '@/lib/api/sites'
|
||||||
@@ -16,7 +16,7 @@ const GEO_OPTIONS = [
|
|||||||
{ value: 'none', label: 'Disabled' },
|
{ 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: 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)
|
||||||
@@ -30,6 +30,7 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) {
|
|||||||
const [excludedPaths, setExcludedPaths] = useState('')
|
const [excludedPaths, setExcludedPaths] = useState('')
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const initialRef = useRef('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (site) {
|
if (site) {
|
||||||
@@ -41,9 +42,26 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) {
|
|||||||
setHideUnknownLocations(site.hide_unknown_locations ?? false)
|
setHideUnknownLocations(site.hide_unknown_locations ?? false)
|
||||||
setDataRetention(site.data_retention_months ?? 6)
|
setDataRetention(site.data_retention_months ?? 6)
|
||||||
setExcludedPaths((site.excluded_paths || []).join('\n'))
|
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])
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +77,8 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) {
|
|||||||
excluded_paths: excludedPaths.split('\n').map(p => p.trim()).filter(Boolean),
|
excluded_paths: excludedPaths.split('\n').map(p => p.trim()).filter(Boolean),
|
||||||
})
|
})
|
||||||
await mutate()
|
await mutate()
|
||||||
|
initialRef.current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths })
|
||||||
|
onDirtyChange?.(false)
|
||||||
toast.success('Privacy settings updated')
|
toast.success('Privacy settings updated')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save')
|
toast.error('Failed to save')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||||
import { Copy, CheckCircle, Lock } from '@phosphor-icons/react'
|
import { Copy, CheckCircle, Lock } from '@phosphor-icons/react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
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'
|
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 { data: site, mutate } = useSite(siteId)
|
||||||
const [isPublic, setIsPublic] = useState(false)
|
const [isPublic, setIsPublic] = useState(false)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [passwordEnabled, setPasswordEnabled] = useState(false)
|
const [passwordEnabled, setPasswordEnabled] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [linkCopied, setLinkCopied] = useState(false)
|
const [linkCopied, setLinkCopied] = useState(false)
|
||||||
|
const initialRef = useRef('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (site) {
|
if (site) {
|
||||||
setIsPublic(site.is_public ?? false)
|
setIsPublic(site.is_public ?? false)
|
||||||
setPasswordEnabled(site.has_password ?? false)
|
setPasswordEnabled(site.has_password ?? false)
|
||||||
|
initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false })
|
||||||
}
|
}
|
||||||
}, [site])
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +45,8 @@ export default function SiteVisibilityTab({ siteId }: { siteId: string }) {
|
|||||||
})
|
})
|
||||||
setPassword('')
|
setPassword('')
|
||||||
await mutate()
|
await mutate()
|
||||||
|
initialRef.current = JSON.stringify({ isPublic, passwordEnabled })
|
||||||
|
onDirtyChange?.(false)
|
||||||
toast.success('Visibility updated')
|
toast.success('Visibility updated')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save')
|
toast.error('Failed to save')
|
||||||
|
|||||||
Reference in New Issue
Block a user