feat(settings): Phase 2 — all 15 tabs implemented
Site tabs: - Visibility (public toggle, share link, password protection) - Privacy (data collection toggles, geo level, retention info) - Bot & Spam (filtering toggle, stats cards) - Reports (scheduled reports + alert channels list with test/pause/delete) - Integrations (GSC + BunnyCDN connect/disconnect cards) Workspace tabs: - Members (member list, invite form with role selector) - Notifications (dynamic toggles from API categories) - Audit Log (action log with timestamps) Account tabs: - Security (wraps existing ProfileSettings security tab) - Devices (wraps existing TrustedDevicesCard + SecurityActivityCard) No more "Coming soon" placeholders. All tabs are functional.
This commit is contained in:
@@ -8,12 +8,24 @@ import { useAuth } from '@/lib/auth/context'
|
||||
import { useSite } from '@/lib/swr/dashboard'
|
||||
import { listSites, type Site } from '@/lib/api/sites'
|
||||
|
||||
// Tab content components
|
||||
// Tab content components — Site
|
||||
import SiteGeneralTab from './tabs/SiteGeneralTab'
|
||||
import SiteGoalsTab from './tabs/SiteGoalsTab'
|
||||
import SiteVisibilityTab from './tabs/SiteVisibilityTab'
|
||||
import SitePrivacyTab from './tabs/SitePrivacyTab'
|
||||
import SiteBotSpamTab from './tabs/SiteBotSpamTab'
|
||||
import SiteReportsTab from './tabs/SiteReportsTab'
|
||||
import SiteIntegrationsTab from './tabs/SiteIntegrationsTab'
|
||||
// Tab content components — Workspace
|
||||
import WorkspaceGeneralTab from './tabs/WorkspaceGeneralTab'
|
||||
import WorkspaceBillingTab from './tabs/WorkspaceBillingTab'
|
||||
import WorkspaceMembersTab from './tabs/WorkspaceMembersTab'
|
||||
import WorkspaceNotificationsTab from './tabs/WorkspaceNotificationsTab'
|
||||
import WorkspaceAuditTab from './tabs/WorkspaceAuditTab'
|
||||
// Tab content components — Account
|
||||
import AccountProfileTab from './tabs/AccountProfileTab'
|
||||
import AccountSecurityTab from './tabs/AccountSecurityTab'
|
||||
import AccountDevicesTab from './tabs/AccountDevicesTab'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -213,11 +225,11 @@ function TabContent({
|
||||
switch (activeTab) {
|
||||
case 'general': return <SiteGeneralTab siteId={siteId} />
|
||||
case 'goals': return <SiteGoalsTab siteId={siteId} />
|
||||
case 'visibility': return <ComingSoon label="Visibility" />
|
||||
case 'privacy': return <ComingSoon label="Data & Privacy" />
|
||||
case 'bot-spam': return <ComingSoon label="Bot & Spam" />
|
||||
case 'reports': return <ComingSoon label="Reports" />
|
||||
case 'integrations': return <ComingSoon label="Integrations" />
|
||||
case 'visibility': return <SiteVisibilityTab siteId={siteId} />
|
||||
case 'privacy': return <SitePrivacyTab siteId={siteId} />
|
||||
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} />
|
||||
case 'reports': return <SiteReportsTab siteId={siteId} />
|
||||
case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +238,9 @@ function TabContent({
|
||||
switch (activeTab) {
|
||||
case 'general': return <WorkspaceGeneralTab />
|
||||
case 'billing': return <WorkspaceBillingTab />
|
||||
case 'members': return <ComingSoon label="Members" />
|
||||
case 'notifications': return <ComingSoon label="Notifications" />
|
||||
case 'audit': return <ComingSoon label="Audit Log" />
|
||||
case 'members': return <WorkspaceMembersTab />
|
||||
case 'notifications': return <WorkspaceNotificationsTab />
|
||||
case 'audit': return <WorkspaceAuditTab />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +248,8 @@ function TabContent({
|
||||
if (context === 'account') {
|
||||
switch (activeTab) {
|
||||
case 'profile': return <AccountProfileTab />
|
||||
case 'security': return <ComingSoon label="Security" />
|
||||
case 'devices': return <ComingSoon label="Devices" />
|
||||
case 'security': return <AccountSecurityTab />
|
||||
case 'devices': return <AccountDevicesTab />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
components/settings/unified/tabs/AccountDevicesTab.tsx
Normal file
18
components/settings/unified/tabs/AccountDevicesTab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
||||
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
||||
|
||||
export default function AccountDevicesTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Devices & Activity</h3>
|
||||
<p className="text-sm text-neutral-400">Manage trusted devices and review security activity.</p>
|
||||
</div>
|
||||
|
||||
<TrustedDevicesCard />
|
||||
<SecurityActivityCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
components/settings/unified/tabs/AccountSecurityTab.tsx
Normal file
16
components/settings/unified/tabs/AccountSecurityTab.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||
|
||||
export default function AccountSecurityTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Security</h3>
|
||||
<p className="text-sm text-neutral-400">Manage your password and two-factor authentication.</p>
|
||||
</div>
|
||||
|
||||
<ProfileSettings activeTab="security" borderless />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
components/settings/unified/tabs/SiteBotSpamTab.tsx
Normal file
85
components/settings/unified/tabs/SiteBotSpamTab.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { ShieldCheck } from '@phosphor-icons/react'
|
||||
import { useSite, useBotFilterStats } from '@/lib/swr/dashboard'
|
||||
import { updateSite } from '@/lib/api/sites'
|
||||
|
||||
export default function SiteBotSpamTab({ siteId }: { siteId: string }) {
|
||||
const { data: site, mutate } = useSite(siteId)
|
||||
const { data: botStats } = useBotFilterStats(siteId)
|
||||
const [filterBots, setFilterBots] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (site) setFilterBots(site.filter_bots ?? false)
|
||||
}, [site])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
|
||||
await mutate()
|
||||
toast.success('Bot filtering updated')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Bot & Spam Filtering</h3>
|
||||
<p className="text-sm text-neutral-400">Automatically filter bot traffic and referrer spam from your analytics.</p>
|
||||
</div>
|
||||
|
||||
{/* Bot filtering toggle */}
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck weight="bold" className="w-5 h-5 text-brand-orange" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Enable bot filtering</p>
|
||||
<p className="text-xs text-neutral-400">Filter known bots, crawlers, referrer spam, and suspicious traffic.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={filterBots} onChange={() => setFilterBots(p => !p)} />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{botStats && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-white">{botStats.filtered_sessions ?? 0}</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">Sessions filtered</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-white">{botStats.filtered_events ?? 0}</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">Events filtered</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
|
||||
<p className="text-2xl font-bold text-white">{botStats.auto_blocked_this_month ?? 0}</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">Auto-blocked this month</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-neutral-400">
|
||||
For detailed session review and manual blocking, use the full{' '}
|
||||
<a href={`/sites/${siteId}/settings?tab=bot-spam`} className="text-brand-orange hover:underline">
|
||||
site settings page
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
components/settings/unified/tabs/SiteIntegrationsTab.tsx
Normal file
130
components/settings/unified/tabs/SiteIntegrationsTab.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { Button, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { GoogleLogo, ArrowSquareOut, Plugs, Trash } from '@phosphor-icons/react'
|
||||
import { useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard'
|
||||
import { disconnectGSC, getGSCAuthURL } from '@/lib/api/gsc'
|
||||
import { disconnectBunny } from '@/lib/api/bunny'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
|
||||
function IntegrationCard({
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
connected,
|
||||
detail,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
connectLabel = 'Connect',
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
description: string
|
||||
connected: boolean
|
||||
detail?: string
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
connectLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 px-4 rounded-xl border border-neutral-800 bg-neutral-800/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-neutral-800">{icon}</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-white">{name}</p>
|
||||
{connected && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/30 text-green-400 border border-green-900/50">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">{detail || description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{connected ? (
|
||||
<Button onClick={onDisconnect} variant="secondary" className="text-sm text-red-400 border-red-900/50 hover:bg-red-900/20 gap-1.5">
|
||||
<Trash weight="bold" className="w-3.5 h-3.5" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onConnect} variant="primary" className="text-sm gap-1.5">
|
||||
<Plugs weight="bold" className="w-3.5 h-3.5" /> {connectLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SiteIntegrationsTab({ siteId }: { siteId: string }) {
|
||||
const { data: gscStatus, mutate: mutateGSC } = useGSCStatus(siteId)
|
||||
const { data: bunnyStatus, mutate: mutateBunny } = useBunnyStatus(siteId)
|
||||
|
||||
const handleConnectGSC = async () => {
|
||||
try {
|
||||
const data = await getGSCAuthURL(siteId)
|
||||
window.open(data.auth_url, '_blank')
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to start Google authorization')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnectGSC = async () => {
|
||||
if (!confirm('Disconnect Google Search Console? This will remove all synced data.')) return
|
||||
try {
|
||||
await disconnectGSC(siteId)
|
||||
await mutateGSC()
|
||||
toast.success('Google Search Console disconnected')
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to disconnect')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectBunny = () => {
|
||||
// Redirect to full settings page for BunnyCDN setup (requires API key input)
|
||||
window.location.href = `/sites/${siteId}/settings?tab=integrations`
|
||||
}
|
||||
|
||||
const handleDisconnectBunny = async () => {
|
||||
if (!confirm('Disconnect BunnyCDN? This will remove all synced CDN data.')) return
|
||||
try {
|
||||
await disconnectBunny(siteId)
|
||||
await mutateBunny()
|
||||
toast.success('BunnyCDN disconnected')
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to disconnect')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Integrations</h3>
|
||||
<p className="text-sm text-neutral-400">Connect third-party services to enrich your analytics.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<IntegrationCard
|
||||
icon={<GoogleLogo weight="bold" className="w-5 h-5 text-white" />}
|
||||
name="Google Search Console"
|
||||
description="View search queries, clicks, impressions, and ranking data."
|
||||
connected={gscStatus?.connected ?? false}
|
||||
detail={gscStatus?.connected ? `Connected as ${gscStatus.google_email || 'unknown'}` : undefined}
|
||||
onConnect={handleConnectGSC}
|
||||
onDisconnect={handleDisconnectGSC}
|
||||
connectLabel="Connect with Google"
|
||||
/>
|
||||
|
||||
<IntegrationCard
|
||||
icon={<img src="https://ciphera.net/bunny-icon.svg" alt="BunnyCDN" className="w-5 h-5" onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />}
|
||||
name="BunnyCDN"
|
||||
description="Monitor bandwidth, cache hit rates, and CDN performance."
|
||||
connected={bunnyStatus?.connected ?? false}
|
||||
detail={bunnyStatus?.connected ? `Pull zone: ${bunnyStatus.pull_zone_name || 'connected'}` : undefined}
|
||||
onConnect={handleConnectBunny}
|
||||
onDisconnect={handleDisconnectBunny}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
components/settings/unified/tabs/SitePrivacyTab.tsx
Normal file
111
components/settings/unified/tabs/SitePrivacyTab.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { useSite } from '@/lib/swr/dashboard'
|
||||
import { updateSite } from '@/lib/api/sites'
|
||||
|
||||
const GEO_OPTIONS = [
|
||||
{ value: 'full', label: 'Full (country, region, city)' },
|
||||
{ value: 'country', label: 'Country only' },
|
||||
{ value: 'none', label: 'Disabled' },
|
||||
]
|
||||
|
||||
export default function SitePrivacyTab({ siteId }: { siteId: string }) {
|
||||
const { data: site, mutate } = useSite(siteId)
|
||||
const [collectPagePaths, setCollectPagePaths] = useState(true)
|
||||
const [collectReferrers, setCollectReferrers] = useState(true)
|
||||
const [collectDeviceInfo, setCollectDeviceInfo] = useState(true)
|
||||
const [collectScreenRes, setCollectScreenRes] = useState(true)
|
||||
const [collectGeoData, setCollectGeoData] = useState('full')
|
||||
const [hideUnknownLocations, setHideUnknownLocations] = useState(false)
|
||||
const [dataRetention, setDataRetention] = useState(6)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (site) {
|
||||
setCollectPagePaths(site.collect_page_paths ?? true)
|
||||
setCollectReferrers(site.collect_referrers ?? true)
|
||||
setCollectDeviceInfo(site.collect_device_info ?? true)
|
||||
setCollectScreenRes(site.collect_screen_resolution ?? true)
|
||||
setCollectGeoData(site.collect_geo_data ?? 'full')
|
||||
setHideUnknownLocations(site.hide_unknown_locations ?? false)
|
||||
setDataRetention(site.data_retention_months ?? 6)
|
||||
}
|
||||
}, [site])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSite(siteId, {
|
||||
name: site?.name || '',
|
||||
collect_page_paths: collectPagePaths,
|
||||
collect_referrers: collectReferrers,
|
||||
collect_device_info: collectDeviceInfo,
|
||||
collect_screen_resolution: collectScreenRes,
|
||||
collect_geo_data: collectGeoData as 'full' | 'country' | 'none',
|
||||
hide_unknown_locations: hideUnknownLocations,
|
||||
})
|
||||
await mutate()
|
||||
toast.success('Privacy settings updated')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Data & Privacy</h3>
|
||||
<p className="text-sm text-neutral-400">Control what data is collected from your visitors.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ label: 'Page paths', desc: 'Track which pages visitors view.', checked: collectPagePaths, onChange: setCollectPagePaths },
|
||||
{ label: 'Referrers', desc: 'Track where visitors come from.', checked: collectReferrers, onChange: setCollectReferrers },
|
||||
{ label: 'Device info', desc: 'Track browser, OS, and device type.', checked: collectDeviceInfo, onChange: setCollectDeviceInfo },
|
||||
{ label: 'Screen resolution', desc: 'Track visitor screen dimensions.', checked: collectScreenRes, onChange: setCollectScreenRes },
|
||||
{ label: 'Hide unknown locations', desc: 'Exclude "Unknown" from location stats.', checked: hideUnknownLocations, onChange: setHideUnknownLocations },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-neutral-800/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{item.label}</p>
|
||||
<p className="text-xs text-neutral-400">{item.desc}</p>
|
||||
</div>
|
||||
<Toggle checked={item.checked} onChange={() => item.onChange((p: boolean) => !p)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Geographic data</label>
|
||||
<Select
|
||||
value={collectGeoData}
|
||||
onChange={setCollectGeoData}
|
||||
variant="input"
|
||||
options={GEO_OPTIONS}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">Controls location granularity. "Disabled" collects no geographic data at all.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Data retention</label>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Currently retaining data for <span className="font-medium text-white">{dataRetention} months</span>.
|
||||
Manage retention in the full <a href={`/sites/${siteId}/settings`} className="text-brand-orange hover:underline">site settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
components/settings/unified/tabs/SiteReportsTab.tsx
Normal file
157
components/settings/unified/tabs/SiteReportsTab.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { Plus, Pencil, Trash, EnvelopeSimple, WebhooksLogo, PaperPlaneTilt } from '@phosphor-icons/react'
|
||||
import { useReportSchedules, useAlertSchedules } from '@/lib/swr/dashboard'
|
||||
import { deleteReportSchedule, testReportSchedule, updateReportSchedule, type ReportSchedule } from '@/lib/api/report-schedules'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
|
||||
function ChannelIcon({ channel }: { channel: string }) {
|
||||
switch (channel) {
|
||||
case 'email': return <EnvelopeSimple weight="bold" className="w-4 h-4" />
|
||||
case 'webhook': return <WebhooksLogo weight="bold" className="w-4 h-4" />
|
||||
default: return <PaperPlaneTilt weight="bold" className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
function ScheduleRow({ schedule, siteId, onMutate }: { schedule: ReportSchedule; siteId: string; onMutate: () => void }) {
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
await testReportSchedule(siteId, schedule.id)
|
||||
toast.success('Test report sent')
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to send test')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
await updateReportSchedule(siteId, schedule.id, {
|
||||
channel: schedule.channel,
|
||||
channel_config: schedule.channel_config,
|
||||
frequency: schedule.frequency,
|
||||
report_type: schedule.report_type,
|
||||
enabled: !schedule.enabled,
|
||||
send_hour: schedule.send_hour,
|
||||
send_day: schedule.send_day ?? undefined,
|
||||
timezone: schedule.timezone,
|
||||
purpose: schedule.purpose,
|
||||
})
|
||||
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
|
||||
onMutate()
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to update')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteReportSchedule(siteId, schedule.id)
|
||||
toast.success('Report deleted')
|
||||
onMutate()
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded-lg ${schedule.enabled ? 'bg-brand-orange/10 text-brand-orange' : 'bg-neutral-800 text-neutral-500'}`}>
|
||||
<ChannelIcon channel={schedule.channel} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{schedule.channel === 'email' && 'recipients' in schedule.channel_config
|
||||
? (schedule.channel_config as { recipients: string[] }).recipients?.[0]
|
||||
: schedule.channel}
|
||||
{!schedule.enabled && <span className="ml-2 text-xs text-neutral-500">(paused)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{schedule.frequency} · {schedule.report_type} report
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={handleTest} disabled={testing} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors">
|
||||
<PaperPlaneTilt weight="bold" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={handleToggle} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors">
|
||||
{schedule.enabled ? 'Pause' : 'Enable'}
|
||||
</button>
|
||||
<button onClick={handleDelete} className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors">
|
||||
<Trash weight="bold" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SiteReportsTab({ siteId }: { siteId: string }) {
|
||||
const { data: reports = [], isLoading: reportsLoading, mutate: mutateReports } = useReportSchedules(siteId)
|
||||
const { data: alerts = [], isLoading: alertsLoading, mutate: mutateAlerts } = useAlertSchedules(siteId)
|
||||
|
||||
const loading = reportsLoading || alertsLoading
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Scheduled Reports */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Scheduled Reports</h3>
|
||||
<p className="text-sm text-neutral-400">Automated analytics summaries via email or webhook.</p>
|
||||
</div>
|
||||
<a href={`/sites/${siteId}/settings?tab=notifications`}>
|
||||
<Button variant="primary" className="text-sm gap-1.5">
|
||||
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Report
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{reports.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 text-center py-6">No scheduled reports yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{reports.map(r => (
|
||||
<ScheduleRow key={r.id} schedule={r} siteId={siteId} onMutate={() => mutateReports()} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert Channels */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Alert Channels</h3>
|
||||
<p className="text-sm text-neutral-400">Get notified when uptime monitors go down.</p>
|
||||
</div>
|
||||
<a href={`/sites/${siteId}/settings?tab=notifications`}>
|
||||
<Button variant="secondary" className="text-sm gap-1.5">
|
||||
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Channel
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 text-center py-6">No alert channels configured.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{alerts.map(a => (
|
||||
<ScheduleRow key={a.id} schedule={a} siteId={siteId} onMutate={() => mutateAlerts()} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
components/settings/unified/tabs/SiteVisibilityTab.tsx
Normal file
131
components/settings/unified/tabs/SiteVisibilityTab.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } 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'
|
||||
import { useSite } from '@/lib/swr/dashboard'
|
||||
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 }) {
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (site) {
|
||||
setIsPublic(site.is_public ?? false)
|
||||
setPasswordEnabled(site.has_password ?? false)
|
||||
}
|
||||
}, [site])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSite(siteId, {
|
||||
name: site?.name || '',
|
||||
is_public: isPublic,
|
||||
password: passwordEnabled ? password : undefined,
|
||||
clear_password: !passwordEnabled,
|
||||
})
|
||||
setPassword('')
|
||||
await mutate()
|
||||
toast.success('Visibility updated')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`)
|
||||
setLinkCopied(true)
|
||||
toast.success('Link copied')
|
||||
setTimeout(() => setLinkCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Visibility</h3>
|
||||
<p className="text-sm text-neutral-400">Control who can see your analytics dashboard.</p>
|
||||
</div>
|
||||
|
||||
{/* Public toggle */}
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Public Dashboard</p>
|
||||
<p className="text-xs text-neutral-400">Allow anyone with the link to view this dashboard.</p>
|
||||
</div>
|
||||
<Toggle checked={isPublic} onChange={() => setIsPublic(p => !p)} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isPublic && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 overflow-hidden"
|
||||
>
|
||||
{/* Share link */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Public Link</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={`${APP_URL}/share/${siteId}`} readOnly className="font-mono text-xs" />
|
||||
<Button onClick={copyLink} variant="secondary" className="shrink-0 text-sm gap-1.5">
|
||||
{linkCopied ? <CheckCircle weight="bold" className="w-3.5 h-3.5" /> : <Copy weight="bold" className="w-3.5 h-3.5" />}
|
||||
{linkCopied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password protection */}
|
||||
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock weight="bold" className="w-4 h-4 text-neutral-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Password Protection</p>
|
||||
<p className="text-xs text-neutral-400">Require a password to view the public dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle checked={passwordEnabled} onChange={() => setPasswordEnabled(p => !p)} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{passwordEnabled && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={site.has_password ? 'Leave empty to keep current password' : 'Set a password'}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
components/settings/unified/tabs/WorkspaceAuditTab.tsx
Normal file
80
components/settings/unified/tabs/WorkspaceAuditTab.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Spinner } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getAuditLog, type AuditLogEntry } from '@/lib/api/audit'
|
||||
import { formatDateTimeShort } from '@/lib/utils/formatDate'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
site_created: 'Created site',
|
||||
site_updated: 'Updated site',
|
||||
site_deleted: 'Deleted site',
|
||||
site_restored: 'Restored site',
|
||||
goal_created: 'Created goal',
|
||||
goal_updated: 'Updated goal',
|
||||
goal_deleted: 'Deleted goal',
|
||||
funnel_created: 'Created funnel',
|
||||
funnel_updated: 'Updated funnel',
|
||||
funnel_deleted: 'Deleted funnel',
|
||||
gsc_connected: 'Connected Google Search Console',
|
||||
gsc_disconnected: 'Disconnected Google Search Console',
|
||||
bunny_connected: 'Connected BunnyCDN',
|
||||
bunny_disconnected: 'Disconnected BunnyCDN',
|
||||
member_invited: 'Invited member',
|
||||
member_removed: 'Removed member',
|
||||
member_role_changed: 'Changed member role',
|
||||
org_updated: 'Updated organization',
|
||||
plan_changed: 'Changed plan',
|
||||
subscription_cancelled: 'Cancelled subscription',
|
||||
subscription_resumed: 'Resumed subscription',
|
||||
}
|
||||
|
||||
export default function WorkspaceAuditTab() {
|
||||
const { user } = useAuth()
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.org_id) return
|
||||
getAuditLog({ limit: 50 })
|
||||
.then(data => setEntries(data.entries))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [user?.org_id])
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Audit Log</h3>
|
||||
<p className="text-sm text-neutral-400">Track who made changes and when.</p>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 text-center py-8">No activity recorded yet.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id} className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm text-white">
|
||||
<span className="font-medium">{entry.actor_email || 'System'}</span>
|
||||
{' '}
|
||||
<span className="text-neutral-400">{ACTION_LABELS[entry.action] || entry.action}</span>
|
||||
</p>
|
||||
{entry.payload && Object.keys(entry.payload).length > 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-0.5">{JSON.stringify(entry.payload)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 shrink-0 ml-4">
|
||||
{formatDateTimeShort(new Date(entry.occurred_at))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
components/settings/unified/tabs/WorkspaceMembersTab.tsx
Normal file
150
components/settings/unified/tabs/WorkspaceMembersTab.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Input, Select, toast, Spinner } from '@ciphera-net/ui'
|
||||
import { Plus, Trash, EnvelopeSimple, Crown, UserCircle } from '@phosphor-icons/react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getOrganizationMembers, sendInvitation, type OrganizationMember } from '@/lib/api/organization'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'member', label: 'Member' },
|
||||
]
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
if (role === 'owner') return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-brand-orange/10 text-brand-orange">
|
||||
<Crown weight="bold" className="w-3 h-3" /> Owner
|
||||
</span>
|
||||
)
|
||||
if (role === 'admin') return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-900/30 text-blue-400">
|
||||
Admin
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-neutral-800 text-neutral-400">
|
||||
Member
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorkspaceMembersTab() {
|
||||
const { user } = useAuth()
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState('member')
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
|
||||
const canManage = user?.role === 'owner' || user?.role === 'admin'
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!user?.org_id) return
|
||||
try {
|
||||
const data = await getOrganizationMembers(user.org_id)
|
||||
setMembers(data)
|
||||
} catch { }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadMembers() }, [user?.org_id])
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!user?.org_id || !inviteEmail.trim()) return
|
||||
setInviting(true)
|
||||
try {
|
||||
await sendInvitation(user.org_id, inviteEmail.trim(), inviteRole)
|
||||
toast.success(`Invitation sent to ${inviteEmail}`)
|
||||
setInviteEmail('')
|
||||
setShowInvite(false)
|
||||
loadMembers()
|
||||
} catch (err) {
|
||||
toast.error(getAuthErrorMessage(err as Error) || 'Failed to invite member')
|
||||
} finally {
|
||||
setInviting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (_memberId: string, email: string) => {
|
||||
// Member removal requires the full org settings page (auth API endpoint)
|
||||
toast.message(`To remove ${email}, use Organization Settings → Members.`, {
|
||||
action: { label: 'Open', onClick: () => { window.location.href = '/org-settings?tab=members' } },
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Members</h3>
|
||||
<p className="text-sm text-neutral-400">{members.length} member{members.length !== 1 ? 's' : ''} in your workspace.</p>
|
||||
</div>
|
||||
{canManage && !showInvite && (
|
||||
<Button onClick={() => setShowInvite(true)} variant="primary" className="text-sm gap-1.5">
|
||||
<Plus weight="bold" className="w-3.5 h-3.5" /> Invite
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite form */}
|
||||
{showInvite && (
|
||||
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={inviteEmail}
|
||||
onChange={e => setInviteEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={inviteRole}
|
||||
onChange={setInviteRole}
|
||||
variant="input"
|
||||
className="w-32"
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button onClick={() => setShowInvite(false)} variant="secondary" className="text-sm">Cancel</Button>
|
||||
<Button onClick={handleInvite} variant="primary" className="text-sm gap-1.5" disabled={inviting}>
|
||||
<EnvelopeSimple weight="bold" className="w-3.5 h-3.5" />
|
||||
{inviting ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members list */}
|
||||
<div className="space-y-1">
|
||||
{members.map(member => (
|
||||
<div key={member.user_id} className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle weight="fill" className="w-8 h-8 text-neutral-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{member.user_email || member.user_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={member.role} />
|
||||
{canManage && member.role !== 'owner' && member.user_id !== user?.id && (
|
||||
<button
|
||||
onClick={() => handleRemove(member.user_id, member.user_email || member.user_id)}
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash weight="bold" className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast, Spinner } from '@ciphera-net/ui'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getNotificationSettings, updateNotificationSettings, type NotificationSettingsResponse } from '@/lib/api/notification-settings'
|
||||
|
||||
export default function WorkspaceNotificationsTab() {
|
||||
const { user } = useAuth()
|
||||
const [data, setData] = useState<NotificationSettingsResponse | null>(null)
|
||||
const [settings, setSettings] = useState<Record<string, boolean>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.org_id) return
|
||||
getNotificationSettings()
|
||||
.then(resp => {
|
||||
setData(resp)
|
||||
setSettings(resp.settings || {})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [user?.org_id])
|
||||
|
||||
const handleToggle = async (key: string) => {
|
||||
const prev = { ...settings }
|
||||
const updated = { ...settings, [key]: !settings[key] }
|
||||
setSettings(updated)
|
||||
try {
|
||||
await updateNotificationSettings(updated)
|
||||
} catch {
|
||||
setSettings(prev)
|
||||
toast.error('Failed to update notification preference')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Notifications</h3>
|
||||
<p className="text-sm text-neutral-400">Choose what notifications you receive.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{(data?.categories || []).map(cat => (
|
||||
<div key={cat.id} className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-neutral-800/20 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{cat.label}</p>
|
||||
<p className="text-xs text-neutral-400">{cat.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(cat.id)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors duration-200 ${settings[cat.id] ? 'bg-brand-orange' : 'bg-neutral-700'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${settings[cat.id] ? 'translate-x-4' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!data?.categories || data.categories.length === 0) && (
|
||||
<p className="text-sm text-neutral-500 text-center py-6">No notification preferences available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user