From 3c17895d647b49ce6957fe85f083f7420f2de41f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 20:57:20 +0100 Subject: [PATCH 001/107] feat(settings): unified settings modal with context switcher (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New unified settings modal accessible via `,` keyboard shortcut. Three-context switcher: Site (with site dropdown), Workspace, Account. Horizontal tabs per context with animated transitions. Phase 1 tabs implemented: - Site → General (name, timezone, domain, tracking script with copy) - Site → Goals (CRUD with inline create/edit) - Workspace → General (org name, slug, danger zone) - Workspace → Billing (plan card, usage, cancel/resume, portal) - Account → Profile (wraps existing ProfileSettings) Phase 2 tabs show "Coming soon" placeholder: - Site: Visibility, Privacy, Bot & Spam, Reports, Integrations - Workspace: Members, Notifications, Audit Log - Account: Security, Devices Old settings pages and profile modal remain functional. --- app/layout-content.tsx | 8 +- components/dashboard/Sidebar.tsx | 12 +- .../settings/unified/UnifiedSettingsModal.tsx | 386 ++++++++++++++++++ .../unified/tabs/AccountProfileTab.tsx | 17 + .../settings/unified/tabs/SiteGeneralTab.tsx | 137 +++++++ .../settings/unified/tabs/SiteGoalsTab.tsx | 170 ++++++++ .../unified/tabs/WorkspaceBillingTab.tsx | 166 ++++++++ .../unified/tabs/WorkspaceGeneralTab.tsx | 98 +++++ lib/unified-settings-context.tsx | 44 ++ 9 files changed, 1034 insertions(+), 4 deletions(-) create mode 100644 components/settings/unified/UnifiedSettingsModal.tsx create mode 100644 components/settings/unified/tabs/AccountProfileTab.tsx create mode 100644 components/settings/unified/tabs/SiteGeneralTab.tsx create mode 100644 components/settings/unified/tabs/SiteGoalsTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceBillingTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceGeneralTab.tsx create mode 100644 lib/unified-settings-context.tsx diff --git a/app/layout-content.tsx b/app/layout-content.tsx index 4e644a0..8e91ea6 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -16,7 +16,9 @@ import { setSessionAction } from '@/app/actions/auth' import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context' +import { UnifiedSettingsProvider } from '@/lib/unified-settings-context' import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper' +import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -109,6 +111,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { {showOfflineBar && } {children} + ) } @@ -141,6 +144,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { {children} + ) } @@ -165,7 +169,9 @@ function LayoutInner({ children }: { children: React.ReactNode }) { export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 4f3b488..4362005 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation' import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' import { useSettingsModal } from '@/lib/settings-modal-context' +import { useUnifiedSettings } from '@/lib/unified-settings-context' import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' import { logger } from '@/lib/utils/logger' @@ -443,6 +444,7 @@ export default function Sidebar({ const pathname = usePathname() const router = useRouter() const { openSettings } = useSettingsModal() + const { openUnifiedSettings } = useUnifiedSettings() const [sites, setSites] = useState([]) const [orgs, setOrgs] = useState([]) const [pendingHref, setPendingHref] = useState(null) @@ -478,15 +480,19 @@ export default function Sidebar({ useEffect(() => { const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { - const tag = (e.target as HTMLElement)?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return e.preventDefault(); toggle() } + // `,` opens unified settings (same as GitHub/Linear) + if (e.key === ',' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); openUnifiedSettings() + } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [collapsed]) + }, [collapsed, openUnifiedSettings]) const toggle = useCallback(() => { setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next }) diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx new file mode 100644 index 0000000..dffc77b --- /dev/null +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -0,0 +1,386 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { X, GearSix, Buildings, User, CaretDown } from '@phosphor-icons/react' +import { useUnifiedSettings } from '@/lib/unified-settings-context' +import { useAuth } from '@/lib/auth/context' +import { useSite } from '@/lib/swr/dashboard' +import { listSites, type Site } from '@/lib/api/sites' + +// Tab content components +import SiteGeneralTab from './tabs/SiteGeneralTab' +import SiteGoalsTab from './tabs/SiteGoalsTab' +import WorkspaceGeneralTab from './tabs/WorkspaceGeneralTab' +import WorkspaceBillingTab from './tabs/WorkspaceBillingTab' +import AccountProfileTab from './tabs/AccountProfileTab' + +// ─── Types ────────────────────────────────────────────────────── + +type SettingsContext = 'site' | 'workspace' | 'account' + +interface TabDef { + id: string + label: string +} + +const SITE_TABS: TabDef[] = [ + { id: 'general', label: 'General' }, + { id: 'goals', label: 'Goals' }, + { id: 'visibility', label: 'Visibility' }, + { id: 'privacy', label: 'Privacy' }, + { id: 'bot-spam', label: 'Bot & Spam' }, + { id: 'reports', label: 'Reports' }, + { id: 'integrations', label: 'Integrations' }, +] + +const WORKSPACE_TABS: TabDef[] = [ + { id: 'general', label: 'General' }, + { id: 'members', label: 'Members' }, + { id: 'billing', label: 'Billing' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'audit', label: 'Audit Log' }, +] + +const ACCOUNT_TABS: TabDef[] = [ + { id: 'profile', label: 'Profile' }, + { id: 'security', label: 'Security' }, + { id: 'devices', label: 'Devices' }, +] + +// ─── Context Switcher ─────────────────────────────────────────── + +function ContextSwitcher({ + active, + onChange, + sites, + activeSiteId, + onSiteChange, +}: { + active: SettingsContext + onChange: (ctx: SettingsContext) => void + sites: Site[] + activeSiteId: string | null + onSiteChange: (id: string) => void +}) { + const [siteDropdownOpen, setSiteDropdownOpen] = useState(false) + const activeSite = sites.find(s => s.id === activeSiteId) + + return ( +
+ {/* Site button with dropdown */} +
+ + + + {siteDropdownOpen && active === 'site' && sites.length > 1 && ( + + {sites.map(site => ( + + ))} + + )} + +
+ + + + +
+ ) +} + +// ─── Tab Bar ──────────────────────────────────────────────────── + +function TabBar({ + tabs, + activeTab, + onChange, +}: { + tabs: TabDef[] + activeTab: string + onChange: (id: string) => void +}) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ) +} + +// ─── Tab Content ──────────────────────────────────────────────── + +function ComingSoon({ label }: { label: string }) { + return ( +
+
+ +
+

{label}

+

+ This section is being migrated. For now, use the existing settings page. +

+
+ ) +} + +function TabContent({ + context, + activeTab, + siteId, +}: { + context: SettingsContext + activeTab: string + siteId: string | null +}) { + // Site tabs + if (context === 'site' && siteId) { + switch (activeTab) { + case 'general': return + case 'goals': return + case 'visibility': return + case 'privacy': return + case 'bot-spam': return + case 'reports': return + case 'integrations': return + } + } + + // Workspace tabs + if (context === 'workspace') { + switch (activeTab) { + case 'general': return + case 'billing': return + case 'members': return + case 'notifications': return + case 'audit': return + } + } + + // Account tabs + if (context === 'account') { + switch (activeTab) { + case 'profile': return + case 'security': return + case 'devices': return + } + } + + return null +} + +// ─── Main Modal ───────────────────────────────────────────────── + +export default function UnifiedSettingsModal() { + const { isOpen, closeUnifiedSettings: closeSettings, initialTab: initTab } = useUnifiedSettings() + const { user } = useAuth() + + const [context, setContext] = useState('site') + const [siteTabs, setSiteTabs] = useState('general') + const [workspaceTabs, setWorkspaceTabs] = useState('general') + const [accountTabs, setAccountTabs] = useState('profile') + + const [sites, setSites] = useState([]) + const [activeSiteId, setActiveSiteId] = useState(null) + + // Apply initial tab when modal opens + useEffect(() => { + if (isOpen && initTab) { + if (initTab.context) setContext(initTab.context) + if (initTab.tab) { + if (initTab.context === 'site') setSiteTabs(initTab.tab) + else if (initTab.context === 'workspace') setWorkspaceTabs(initTab.tab) + else if (initTab.context === 'account') setAccountTabs(initTab.tab) + } + } + }, [isOpen, initTab]) + + // Load sites when modal opens + useEffect(() => { + if (isOpen && user?.org_id) { + listSites().then(data => { + const list = Array.isArray(data) ? data : [] + setSites(list) + if (!activeSiteId && list.length > 0) { + setActiveSiteId(list[0].id) + } + }).catch(() => {}) + } + }, [isOpen, user?.org_id]) + + // Try to pick up site from URL + useEffect(() => { + if (isOpen && typeof window !== 'undefined') { + const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/) + if (match) setActiveSiteId(match[1]) + } + }, [isOpen]) + + // Escape key closes + useEffect(() => { + if (!isOpen) return + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeSettings() + } + window.addEventListener('keydown', handleEsc) + return () => window.removeEventListener('keydown', handleEsc) + }, [isOpen, closeSettings]) + + const tabs = context === 'site' ? SITE_TABS : context === 'workspace' ? WORKSPACE_TABS : ACCOUNT_TABS + const activeTab = context === 'site' ? siteTabs : context === 'workspace' ? workspaceTabs : accountTabs + const setActiveTab = context === 'site' ? setSiteTabs : context === 'workspace' ? setWorkspaceTabs : setAccountTabs + + const handleContextChange = useCallback((ctx: SettingsContext) => { + setContext(ctx) + }, []) + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Modal */} + +
e.stopPropagation()} + > + {/* Header */} +
+
+

Settings

+ +
+ + {/* Context Switcher */} + + + {/* Tabs */} +
+ +
+
+ + {/* Content */} +
+ + + + + +
+
+
+ + )} +
+ ) +} diff --git a/components/settings/unified/tabs/AccountProfileTab.tsx b/components/settings/unified/tabs/AccountProfileTab.tsx new file mode 100644 index 0000000..cb81ee7 --- /dev/null +++ b/components/settings/unified/tabs/AccountProfileTab.tsx @@ -0,0 +1,17 @@ +'use client' + +import ProfileSettings from '@/components/settings/ProfileSettings' +import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard' + +export default function AccountProfileTab() { + return ( +
+
+

Profile

+

Manage your personal account settings.

+
+ + +
+ ) +} diff --git a/components/settings/unified/tabs/SiteGeneralTab.tsx b/components/settings/unified/tabs/SiteGeneralTab.tsx new file mode 100644 index 0000000..b6c0e9e --- /dev/null +++ b/components/settings/unified/tabs/SiteGeneralTab.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Input, Button, Select, toast, Spinner } from '@ciphera-net/ui' +import { Copy, CheckCircle } from '@phosphor-icons/react' +import { useSite } from '@/lib/swr/dashboard' +import { updateSite } from '@/lib/api/sites' + +const TIMEZONES = [ + { value: 'UTC', label: 'UTC' }, + { value: 'Europe/London', label: 'Europe/London (GMT)' }, + { value: 'Europe/Brussels', label: 'Europe/Brussels (CET)' }, + { value: 'Europe/Berlin', label: 'Europe/Berlin (CET)' }, + { value: 'Europe/Paris', label: 'Europe/Paris (CET)' }, + { value: 'Europe/Amsterdam', label: 'Europe/Amsterdam (CET)' }, + { value: 'America/New_York', label: 'America/New York (EST)' }, + { value: 'America/Chicago', label: 'America/Chicago (CST)' }, + { value: 'America/Denver', label: 'America/Denver (MST)' }, + { value: 'America/Los_Angeles', label: 'America/Los Angeles (PST)' }, + { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' }, + { value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' }, + { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' }, + { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, +] + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' + +export default function SiteGeneralTab({ siteId }: { siteId: string }) { + const { data: site, mutate } = useSite(siteId) + const [name, setName] = useState('') + const [timezone, setTimezone] = useState('UTC') + const [saving, setSaving] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (site) { + setName(site.name || '') + setTimezone(site.timezone || 'UTC') + } + }, [site]) + + const handleSave = async () => { + if (!site) return + setSaving(true) + try { + await updateSite(siteId, { name, timezone }) + await mutate() + toast.success('Site updated') + } catch { + toast.error('Failed to save') + } finally { + setSaving(false) + } + } + + const trackingScript = `` + const frustrationScript = `` + + const copyScript = () => { + navigator.clipboard.writeText(trackingScript + '\n' + frustrationScript) + setCopied(true) + toast.success('Copied to clipboard') + setTimeout(() => setCopied(false), 2000) + } + + if (!site) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Site details */} +
+
+

General Configuration

+

Update your site details and tracking script.

+
+ +
+
+ + setName(e.target.value)} placeholder="My Website" /> +
+ +
+ + +

Domain cannot be changed after creation.

+
+
+
+ + {/* Tracking Script */} +
+
+

Tracking Script

+

Add this to your website to start tracking visitors.

+
+ +
+
+            {trackingScript}{'\n'}{frustrationScript}
+          
+ +
+
+ + {/* Save */} +
+ +
+
+ ) +} diff --git a/components/settings/unified/tabs/SiteGoalsTab.tsx b/components/settings/unified/tabs/SiteGoalsTab.tsx new file mode 100644 index 0000000..4121b92 --- /dev/null +++ b/components/settings/unified/tabs/SiteGoalsTab.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState } from 'react' +import { Input, Button, toast } from '@ciphera-net/ui' +import { Plus, Pencil, Trash, X } from '@phosphor-icons/react' +import { Spinner } from '@ciphera-net/ui' +import { useGoals } from '@/lib/swr/dashboard' +import { createGoal, updateGoal, deleteGoal } from '@/lib/api/goals' +import { getAuthErrorMessage } from '@ciphera-net/ui' + +export default function SiteGoalsTab({ siteId }: { siteId: string }) { + const { data: goals = [], mutate, isLoading } = useGoals(siteId) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [name, setName] = useState('') + const [eventName, setEventName] = useState('') + const [saving, setSaving] = useState(false) + + const startCreate = () => { + setCreating(true) + setEditing(null) + setName('') + setEventName('') + } + + const startEdit = (goal: { id: string; name: string; event_name: string }) => { + setEditing(goal.id) + setCreating(false) + setName(goal.name) + setEventName(goal.event_name) + } + + const cancel = () => { + setCreating(false) + setEditing(null) + setName('') + setEventName('') + } + + const handleSave = async () => { + if (!name.trim() || !eventName.trim()) { + toast.error('Name and event name are required') + return + } + if (!/^[a-zA-Z0-9_]+$/.test(eventName)) { + toast.error('Event name can only contain letters, numbers, and underscores') + return + } + + setSaving(true) + try { + if (editing) { + await updateGoal(siteId, editing, { name, event_name: eventName }) + toast.success('Goal updated') + } else { + await createGoal(siteId, { name, event_name: eventName }) + toast.success('Goal created') + } + await mutate() + cancel() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to save goal') + } finally { + setSaving(false) + } + } + + const handleDelete = async (goalId: string) => { + try { + await deleteGoal(siteId, goalId) + toast.success('Goal deleted') + await mutate() + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete goal') + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+

Goals

+

Track custom events as conversion goals.

+
+ {!creating && !editing && ( + + )} +
+ + {/* Create/Edit form */} + {(creating || editing) && ( +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Sign Up" + /> +
+
+ + setEventName(e.target.value)} + placeholder="e.g. signup_click" + disabled={!!editing} + /> +
+
+
+ + +
+
+ )} + + {/* Goals list */} + {goals.length === 0 && !creating ? ( +
+

No goals yet. Add a goal to track custom events.

+ +
+ ) : ( +
+ {goals.map(goal => ( +
+
+

{goal.name}

+

{goal.event_name}

+
+
+ + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/settings/unified/tabs/WorkspaceBillingTab.tsx b/components/settings/unified/tabs/WorkspaceBillingTab.tsx new file mode 100644 index 0000000..480c281 --- /dev/null +++ b/components/settings/unified/tabs/WorkspaceBillingTab.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { Button, toast, Spinner } from '@ciphera-net/ui' +import { CreditCard, ArrowSquareOut } from '@phosphor-icons/react' +import { useSubscription } from '@/lib/swr/dashboard' +import { createPortalSession, cancelSubscription, resumeSubscription } from '@/lib/api/billing' +import { formatDateLong } from '@/lib/utils/formatDate' +import { getAuthErrorMessage } from '@ciphera-net/ui' + +export default function WorkspaceBillingTab() { + const { data: subscription, isLoading, mutate } = useSubscription() + const [cancelling, setCancelling] = useState(false) + + const handleManageBilling = async () => { + try { + const { url } = await createPortalSession() + if (url) window.open(url, '_blank') + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to open billing portal') + } + } + + const handleCancel = async () => { + if (!confirm('Are you sure you want to cancel your subscription?')) return + setCancelling(true) + try { + await cancelSubscription() + await mutate() + toast.success('Subscription cancelled') + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to cancel subscription') + } finally { + setCancelling(false) + } + } + + const handleResume = async () => { + try { + await resumeSubscription() + await mutate() + toast.success('Subscription resumed') + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to resume subscription') + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!subscription) { + return ( +
+ +

No subscription

+

You're on the free plan.

+ + + +
+ ) + } + + const planLabel = (() => { + const raw = subscription.plan_id?.startsWith('price_') ? 'Pro' + : subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' + : subscription.plan_id + return raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1) + })() + + const isActive = subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing' + + return ( +
+
+

Billing & Subscription

+

Manage your plan, usage, and payment details.

+
+ + {/* Plan card */} +
+
+
+

{planLabel} Plan

+ {isActive && ( + + {subscription.subscription_status === 'trialing' ? 'Trial' : 'Active'} + + )} + {subscription.cancel_at_period_end && ( + + Cancelling + + )} +
+ + + +
+ + {/* Usage stats */} +
+ {typeof subscription.sites_count === 'number' && ( +
+

Sites

+

{subscription.sites_count}

+
+ )} + {subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ( +
+

Pageviews

+

{subscription.pageview_usage.toLocaleString()} / {subscription.pageview_limit.toLocaleString()}

+
+ )} + {subscription.current_period_end && ( +
+

+ {subscription.cancel_at_period_end ? 'Ends' : 'Renews'} +

+

{formatDateLong(new Date(subscription.current_period_end))}

+
+ )} + {subscription.pageview_limit > 0 && ( +
+

Limit

+

{subscription.pageview_limit.toLocaleString()} / mo

+
+ )} +
+
+ + {/* Actions */} +
+ {subscription.has_payment_method && ( + + )} + + {isActive && !subscription.cancel_at_period_end && ( + + )} + + {subscription.cancel_at_period_end && ( + + )} +
+
+ ) +} diff --git a/components/settings/unified/tabs/WorkspaceGeneralTab.tsx b/components/settings/unified/tabs/WorkspaceGeneralTab.tsx new file mode 100644 index 0000000..531d5da --- /dev/null +++ b/components/settings/unified/tabs/WorkspaceGeneralTab.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Input, Button, toast } from '@ciphera-net/ui' +import { Spinner } from '@ciphera-net/ui' +import { useAuth } from '@/lib/auth/context' +import { getOrganization, updateOrganization } from '@/lib/api/organization' +import { getAuthErrorMessage } from '@ciphera-net/ui' + +export default function WorkspaceGeneralTab() { + const { user } = useAuth() + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!user?.org_id) return + setLoading(true) + getOrganization(user.org_id) + .then(org => { + setName(org.name || '') + setSlug(org.slug || '') + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [user?.org_id]) + + const handleSave = async () => { + if (!user?.org_id) return + setSaving(true) + try { + await updateOrganization(user.org_id, name, slug) + toast.success('Workspace updated') + } catch (err) { + toast.error(getAuthErrorMessage(err as Error) || 'Failed to update workspace') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+

General Information

+

Basic details about your workspace.

+
+ +
+ + setName(e.target.value)} placeholder="Acme Corp" /> +
+ +
+ +
+ pulse.ciphera.net/ + setSlug(e.target.value)} placeholder="acme-corp" /> +
+

Changing the slug will change your organization's URL.

+
+
+ +
+ +
+ + {/* Danger Zone */} +
+

Danger Zone

+
+
+

Delete Organization

+

Permanently delete this organization and all its data.

+
+ +
+
+
+ ) +} diff --git a/lib/unified-settings-context.tsx b/lib/unified-settings-context.tsx new file mode 100644 index 0000000..5711314 --- /dev/null +++ b/lib/unified-settings-context.tsx @@ -0,0 +1,44 @@ +'use client' + +import { createContext, useContext, useState, useCallback } from 'react' + +type InitialTab = { context?: 'site' | 'workspace' | 'account'; tab?: string } | null + +interface UnifiedSettingsContextType { + isOpen: boolean + openUnifiedSettings: (initialTab?: InitialTab) => void + closeUnifiedSettings: () => void + initialTab: InitialTab +} + +const UnifiedSettingsContext = createContext({ + isOpen: false, + openUnifiedSettings: () => {}, + closeUnifiedSettings: () => {}, + initialTab: null, +}) + +export function UnifiedSettingsProvider({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + const [initialTab, setInitialTab] = useState(null) + + const openUnifiedSettings = useCallback((init?: InitialTab) => { + setInitialTab(init || null) + setIsOpen(true) + }, []) + + const closeUnifiedSettings = useCallback(() => { + setIsOpen(false) + setInitialTab(null) + }, []) + + return ( + + {children} + + ) +} + +export function useUnifiedSettings() { + return useContext(UnifiedSettingsContext) +} -- 2.49.1 From d050d32d24c9accb33ccffd7b9832d7fe8201f33 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 21:04:31 +0100 Subject: [PATCH 002/107] fix(settings): remove flicker and scrollbar flash on context switch --- .../settings/unified/UnifiedSettingsModal.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx index dffc77b..cee3b2c 100644 --- a/components/settings/unified/UnifiedSettingsModal.tsx +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -158,7 +158,7 @@ function TabBar({ onChange: (id: string) => void }) { return ( -
+
{tabs.map(tab => (
- {/* Content */} -
+ {/* Content — fixed min-height prevents size jumping between contexts */} +
-- 2.49.1 From e55a3c4ce4dd9aed3b31389363bf3c6520f90a3d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 21:09:24 +0100 Subject: [PATCH 003/107] fix(settings): fixed modal height prevents bottom-edge twitch on context switch --- components/settings/unified/UnifiedSettingsModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx index cee3b2c..94e77a0 100644 --- a/components/settings/unified/UnifiedSettingsModal.tsx +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -332,7 +332,7 @@ export default function UnifiedSettingsModal() { className="fixed inset-0 z-[61] flex items-center justify-center p-4 pointer-events-none" >
e.stopPropagation()} > {/* Header */} @@ -362,8 +362,8 @@ export default function UnifiedSettingsModal() {
- {/* Content — fixed min-height prevents size jumping between contexts */} -
+ {/* Content — parent has fixed h-[85vh] so this fills remaining space without jumping */} +
Date: Mon, 23 Mar 2026 21:29:49 +0100 Subject: [PATCH 004/107] =?UTF-8?q?feat(settings):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20all=2015=20tabs=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../settings/unified/UnifiedSettingsModal.tsx | 34 ++-- .../unified/tabs/AccountDevicesTab.tsx | 18 ++ .../unified/tabs/AccountSecurityTab.tsx | 16 ++ .../settings/unified/tabs/SiteBotSpamTab.tsx | 85 ++++++++++ .../unified/tabs/SiteIntegrationsTab.tsx | 130 +++++++++++++++ .../settings/unified/tabs/SitePrivacyTab.tsx | 111 +++++++++++++ .../settings/unified/tabs/SiteReportsTab.tsx | 157 ++++++++++++++++++ .../unified/tabs/SiteVisibilityTab.tsx | 131 +++++++++++++++ .../unified/tabs/WorkspaceAuditTab.tsx | 80 +++++++++ .../unified/tabs/WorkspaceMembersTab.tsx | 150 +++++++++++++++++ .../tabs/WorkspaceNotificationsTab.tsx | 68 ++++++++ 11 files changed, 969 insertions(+), 11 deletions(-) create mode 100644 components/settings/unified/tabs/AccountDevicesTab.tsx create mode 100644 components/settings/unified/tabs/AccountSecurityTab.tsx create mode 100644 components/settings/unified/tabs/SiteBotSpamTab.tsx create mode 100644 components/settings/unified/tabs/SiteIntegrationsTab.tsx create mode 100644 components/settings/unified/tabs/SitePrivacyTab.tsx create mode 100644 components/settings/unified/tabs/SiteReportsTab.tsx create mode 100644 components/settings/unified/tabs/SiteVisibilityTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceAuditTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceMembersTab.tsx create mode 100644 components/settings/unified/tabs/WorkspaceNotificationsTab.tsx diff --git a/components/settings/unified/UnifiedSettingsModal.tsx b/components/settings/unified/UnifiedSettingsModal.tsx index 94e77a0..3ced645 100644 --- a/components/settings/unified/UnifiedSettingsModal.tsx +++ b/components/settings/unified/UnifiedSettingsModal.tsx @@ -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 case 'goals': return - case 'visibility': return - case 'privacy': return - case 'bot-spam': return - case 'reports': return - case 'integrations': return + case 'visibility': return + case 'privacy': return + case 'bot-spam': return + case 'reports': return + case 'integrations': return } } @@ -226,9 +238,9 @@ function TabContent({ switch (activeTab) { case 'general': return case 'billing': return - case 'members': return - case 'notifications': return - case 'audit': return + case 'members': return + case 'notifications': return + case 'audit': return } } @@ -236,8 +248,8 @@ function TabContent({ if (context === 'account') { switch (activeTab) { case 'profile': return - case 'security': return - case 'devices': return + case 'security': return + case 'devices': return } } diff --git a/components/settings/unified/tabs/AccountDevicesTab.tsx b/components/settings/unified/tabs/AccountDevicesTab.tsx new file mode 100644 index 0000000..695b8f6 --- /dev/null +++ b/components/settings/unified/tabs/AccountDevicesTab.tsx @@ -0,0 +1,18 @@ +'use client' + +import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard' +import SecurityActivityCard from '@/components/settings/SecurityActivityCard' + +export default function AccountDevicesTab() { + return ( +
+
+

Devices & Activity

+

Manage trusted devices and review security activity.

+
+ + + +
+ ) +} diff --git a/components/settings/unified/tabs/AccountSecurityTab.tsx b/components/settings/unified/tabs/AccountSecurityTab.tsx new file mode 100644 index 0000000..c3ef2dd --- /dev/null +++ b/components/settings/unified/tabs/AccountSecurityTab.tsx @@ -0,0 +1,16 @@ +'use client' + +import ProfileSettings from '@/components/settings/ProfileSettings' + +export default function AccountSecurityTab() { + return ( +
+
+

Security

+

Manage your password and two-factor authentication.

+
+ + +
+ ) +} diff --git a/components/settings/unified/tabs/SiteBotSpamTab.tsx b/components/settings/unified/tabs/SiteBotSpamTab.tsx new file mode 100644 index 0000000..98a452d --- /dev/null +++ b/components/settings/unified/tabs/SiteBotSpamTab.tsx @@ -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
+ + return ( +
+
+

Bot & Spam Filtering

+

Automatically filter bot traffic and referrer spam from your analytics.

+
+ + {/* Bot filtering toggle */} +
+
+ +
+

Enable bot filtering

+

Filter known bots, crawlers, referrer spam, and suspicious traffic.

+
+
+ setFilterBots(p => !p)} /> +
+ + {/* Stats */} + {botStats && ( +
+
+

{botStats.filtered_sessions ?? 0}

+

Sessions filtered

+
+
+

{botStats.filtered_events ?? 0}

+

Events filtered

+
+
+

{botStats.auto_blocked_this_month ?? 0}

+

Auto-blocked this month

+
+
+ )} + +

+ For detailed session review and manual blocking, use the full{' '} + + site settings page + . +

+ +
+ +
+
+ ) +} diff --git a/components/settings/unified/tabs/SiteIntegrationsTab.tsx b/components/settings/unified/tabs/SiteIntegrationsTab.tsx new file mode 100644 index 0000000..0165464 --- /dev/null +++ b/components/settings/unified/tabs/SiteIntegrationsTab.tsx @@ -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 ( +
+
+
{icon}
+
+
+

{name}

+ {connected && ( + + + Connected + + )} +
+

{detail || description}

+
+
+ {connected ? ( + + ) : ( + + )} +
+ ) +} + +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 ( +
+
+

Integrations

+

Connect third-party services to enrich your analytics.

+
+ +
+ } + 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" + /> + + { (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} + /> +
+
+ ) +} diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx new file mode 100644 index 0000000..1f1e2b2 --- /dev/null +++ b/components/settings/unified/tabs/SitePrivacyTab.tsx @@ -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
+ + return ( +
+
+

Data & Privacy

+

Control what data is collected from your visitors.

+
+ +
+ {[ + { 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 => ( +
+
+

{item.label}

+

{item.desc}

+
+ item.onChange((p: boolean) => !p)} /> +
+ ))} +
+ +
+ + + +
+
+ + {/* Password protection */} +
+
+ +
+

Password Protection

+

Require a password to view the public dashboard.

+
+
+ setPasswordEnabled(p => !p)} /> +
+ + + {passwordEnabled && ( + + setPassword(e.target.value)} + placeholder={site.has_password ? 'Leave empty to keep current password' : 'Set a password'} + /> + + )} + +
+ )} +
+ +
+ +
+
+ ) +} diff --git a/components/settings/unified/tabs/WorkspaceAuditTab.tsx b/components/settings/unified/tabs/WorkspaceAuditTab.tsx new file mode 100644 index 0000000..6f29469 --- /dev/null +++ b/components/settings/unified/tabs/WorkspaceAuditTab.tsx @@ -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 = { + 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([]) + 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
+ + return ( +
+
+

Audit Log

+

Track who made changes and when.

+
+ + {entries.length === 0 ? ( +

No activity recorded yet.

+ ) : ( +
+ {entries.map(entry => ( +
+
+

+ {entry.actor_email || 'System'} + {' '} + {ACTION_LABELS[entry.action] || entry.action} +

+ {entry.payload && Object.keys(entry.payload).length > 0 && ( +

{JSON.stringify(entry.payload)}

+ )} +
+

+ {formatDateTimeShort(new Date(entry.occurred_at))} +

+
+ ))} +
+ )} +
+ ) +} diff --git a/components/settings/unified/tabs/WorkspaceMembersTab.tsx b/components/settings/unified/tabs/WorkspaceMembersTab.tsx new file mode 100644 index 0000000..24e7729 --- /dev/null +++ b/components/settings/unified/tabs/WorkspaceMembersTab.tsx @@ -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 ( + + Owner + + ) + if (role === 'admin') return ( + + Admin + + ) + return ( + + Member + + ) +} + +export default function WorkspaceMembersTab() { + const { user } = useAuth() + const [members, setMembers] = useState([]) + 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
+ + return ( +
+
+
+

Members

+

{members.length} member{members.length !== 1 ? 's' : ''} in your workspace.

+
+ {canManage && !showInvite && ( + + )} +
+ + {/* Invite form */} + {showInvite && ( +
+
+
+ setInviteEmail(e.target.value)} + placeholder="email@example.com" + type="email" + /> +
+
@@ -220,7 +220,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${ site.id === siteId ? 'bg-brand-orange/10 text-brand-orange font-medium' - : 'text-neutral-300 hover:bg-neutral-800' + : 'text-neutral-300 hover:bg-white/[0.06]' }`} > No sites found

}
-
- setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-800 rounded-lg"> +
+ setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg"> Add new site @@ -270,7 +270,7 @@ function NavLink({ className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${ active ? 'bg-brand-orange/10 text-brand-orange' - : 'text-neutral-400 hover:text-white hover:bg-neutral-800 hover:translate-x-0.5' + : 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5' }`} > @@ -348,7 +348,7 @@ function SidebarContent({ {NAV_GROUPS.map((group) => (
{c ? ( -
+
) : (

@@ -369,7 +369,7 @@ function SidebarContent({ {/* Bottom — utility items */} -

+
{/* Notifications, Profile — same layout as nav items */}
@@ -411,7 +411,7 @@ function SidebarContent({
{open && ( -
+
Date: Tue, 24 Mar 2026 21:59:25 +0100 Subject: [PATCH 014/107] fix: site picker dropdown matches AppLauncher glassmorphism exactly --- components/dashboard/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 4a75b15..39179d3 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -194,7 +194,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps {open && ( -
+
Date: Tue, 24 Mar 2026 22:04:16 +0100 Subject: [PATCH 015/107] fix: portal site picker to document.body to avoid glass-on-glass Dropdown now uses createPortal + fixed positioning like AppLauncher, UserMenu and NotificationCenter. Renders over page content instead of over the glass sidebar, so /65 opacity looks correct. --- components/dashboard/Sidebar.tsx | 153 ++++++++++++++++++++----------- 1 file changed, 97 insertions(+), 56 deletions(-) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 39179d3..8da7fed 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -1,6 +1,8 @@ 'use client' import { useState, useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { listSites, type Site } from '@/lib/api/sites' @@ -123,17 +125,35 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps const [faviconFailed, setFaviconFailed] = useState(false) const [faviconLoaded, setFaviconLoaded] = useState(false) const ref = useRef(null) + const panelRef = useRef(null) + const buttonRef = useRef(null) + const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null) const pathname = usePathname() const router = useRouter() const currentSite = sites.find((s) => s.id === siteId) const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null + const updatePosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + let top = rect.bottom + 4 + if (panelRef.current) { + const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8 + top = Math.min(top, Math.max(8, maxTop)) + } + setFixedPos({ left: rect.left, top }) + } + }, []) + useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { + const target = e.target as Node + if ( + ref.current && !ref.current.contains(target) && + (!panelRef.current || !panelRef.current.contains(target)) + ) { if (open) { setOpen(false); setSearch('') - // Re-collapse if we auto-expanded if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } } } @@ -142,20 +162,91 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps return () => document.removeEventListener('mousedown', handler) }, [open, onCollapse, wasCollapsed]) + useEffect(() => { + if (open) { + updatePosition() + requestAnimationFrame(() => updatePosition()) + } + }, [open, updatePosition]) + + const closePicker = () => { + setOpen(false); setSearch('') + if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } + } + const switchSite = (id: string) => { router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) - setOpen(false); setSearch('') - // Re-collapse if we auto-expanded - if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } + closePicker() } const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) + const dropdown = ( + + {open && ( + +
+ setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') closePicker() + }} + className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" + autoFocus + /> +
+
+ {filtered.map((site) => ( + + ))} + {filtered.length === 0 &&

No sites found

} +
+
+ closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg"> + + Add new site + +
+
+ )} +
+ ) + return (
- {open && ( -
-
- setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setOpen(false) - setSearch('') - if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } - } - }} - className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" - autoFocus - /> -
-
- {filtered.map((site) => ( - - ))} - {filtered.length === 0 &&

No sites found

} -
-
- setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg"> - - Add new site - -
-
- )} + {typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
) } -- 2.49.1 From 441fd9afda04699aa4141910251962c910d78d5c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 22:05:09 +0100 Subject: [PATCH 016/107] fix: remove border-r from desktop sidebar --- components/dashboard/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 8da7fed..696b158 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -557,7 +557,7 @@ export default function Sidebar({ <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */}
) @@ -492,10 +467,7 @@ export default function Sidebar({ const [mobileClosing, setMobileClosing] = useState(false) const wasCollapsedRef = useRef(false) const pickerOpenCallbackRef = useRef<(() => void) | null>(null) - // Safe to read localStorage directly — this component is loaded with ssr:false - const [collapsed, setCollapsed] = useState(() => { - return localStorage.getItem(SIDEBAR_KEY) !== 'false' - }) + const { collapsed, toggle, expand, collapse } = useSidebar() useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { @@ -519,31 +491,6 @@ export default function Sidebar({ } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) - useEffect(() => { - const handler = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return - if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault(); toggle() - } - // `,` shortcut is handled globally by UnifiedSettingsModal — not here - } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [collapsed]) - - const toggle = useCallback(() => { - setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next }) - }, []) - - const expand = useCallback(() => { - setCollapsed(false); localStorage.setItem(SIDEBAR_KEY, 'false') - }, []) - - const collapse = useCallback(() => { - setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true') - }, []) - const handleMobileClose = useCallback(() => { setMobileClosing(true) setTimeout(() => { @@ -578,7 +525,7 @@ export default function Sidebar({ onMobileClose={onMobileClose} onExpand={expand} onCollapse={collapse} - onToggle={toggle} + wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} @@ -621,7 +568,7 @@ export default function Sidebar({ onMobileClose={handleMobileClose} onExpand={expand} onCollapse={collapse} - onToggle={toggle} + wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx new file mode 100644 index 0000000..6691ac9 --- /dev/null +++ b/lib/sidebar-context.tsx @@ -0,0 +1,68 @@ +'use client' + +import { createContext, useContext, useState, useCallback, useEffect } from 'react' + +const SIDEBAR_KEY = 'pulse_sidebar_collapsed' + +interface SidebarState { + collapsed: boolean + toggle: () => void + expand: () => void + collapse: () => void +} + +const SidebarContext = createContext({ + collapsed: true, + toggle: () => {}, + expand: () => {}, + collapse: () => {}, +}) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return true + return localStorage.getItem(SIDEBAR_KEY) !== 'false' + }) + + const toggle = useCallback(() => { + setCollapsed((prev) => { + const next = !prev + localStorage.setItem(SIDEBAR_KEY, String(next)) + return next + }) + }, []) + + const expand = useCallback(() => { + setCollapsed(false) + localStorage.setItem(SIDEBAR_KEY, 'false') + }, []) + + const collapse = useCallback(() => { + setCollapsed(true) + localStorage.setItem(SIDEBAR_KEY, 'true') + }, []) + + // Keyboard shortcut: [ to toggle + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + toggle() + } + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [toggle]) + + return ( + + {children} + + ) +} + +export function useSidebar() { + return useContext(SidebarContext) +} -- 2.49.1 From 0f462314e2389cc68271b020c8bbee99d45ddd3e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:02:52 +0100 Subject: [PATCH 023/107] fix: move collapse toggle + realtime to glass area above content panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlassTopBar in the margin strip — SidebarSimple icon (phosphor) on left, "Live · Xs ago" on right. ContentHeader reverted to mobile-only. --- components/dashboard/ContentHeader.tsx | 64 ++++--------------------- components/dashboard/DashboardShell.tsx | 63 +++++++++++++++++++++--- 2 files changed, 65 insertions(+), 62 deletions(-) diff --git a/components/dashboard/ContentHeader.tsx b/components/dashboard/ContentHeader.tsx index 91732f4..417fc13 100644 --- a/components/dashboard/ContentHeader.tsx +++ b/components/dashboard/ContentHeader.tsx @@ -1,67 +1,21 @@ 'use client' -import { useState, useEffect, useRef } from 'react' -import { MenuIcon, CollapseLeftIcon, formatUpdatedAgo } from '@ciphera-net/ui' -import { useSidebar } from '@/lib/sidebar-context' -import { useRealtime } from '@/lib/swr/dashboard' +import { MenuIcon } from '@ciphera-net/ui' export default function ContentHeader({ - siteId, onMobileMenuOpen, }: { - siteId: string onMobileMenuOpen: () => void }) { - const { collapsed, toggle } = useSidebar() - const { data: realtime } = useRealtime(siteId) - const lastUpdatedRef = useRef(null) - const [, setTick] = useState(0) - - // Track when realtime data last changed - useEffect(() => { - if (realtime) lastUpdatedRef.current = Date.now() - }, [realtime]) - - // Tick every second to keep "X seconds ago" fresh - useEffect(() => { - if (lastUpdatedRef.current == null) return - const timer = setInterval(() => setTick((t) => t + 1), 1000) - return () => clearInterval(timer) - }, [realtime]) - return ( -
- {/* Left — mobile hamburger or desktop collapse toggle */} -
- {/* Mobile hamburger */} - - - {/* Desktop collapse toggle */} - -
- - {/* Right — realtime indicator */} - {lastUpdatedRef.current != null && ( -
- - - - - Live · {formatUpdatedAgo(lastUpdatedRef.current)} -
- )} +
+
) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 78ebcc3..0b5de5c 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -1,8 +1,11 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import dynamic from 'next/dynamic' -import { SidebarProvider } from '@/lib/sidebar-context' +import { formatUpdatedAgo } from '@ciphera-net/ui' +import { SidebarSimple } from '@phosphor-icons/react' +import { SidebarProvider, useSidebar } from '@/lib/sidebar-context' +import { useRealtime } from '@/lib/swr/dashboard' import ContentHeader from './ContentHeader' // Load sidebar only on the client — prevents SSR flash @@ -18,6 +21,47 @@ const Sidebar = dynamic(() => import('./Sidebar'), { ), }) +function GlassTopBar({ siteId }: { siteId: string }) { + const { collapsed, toggle } = useSidebar() + const { data: realtime } = useRealtime(siteId) + const lastUpdatedRef = useRef(null) + const [, setTick] = useState(0) + + useEffect(() => { + if (realtime) lastUpdatedRef.current = Date.now() + }, [realtime]) + + useEffect(() => { + if (lastUpdatedRef.current == null) return + const timer = setInterval(() => setTick((t) => t + 1), 1000) + return () => clearInterval(timer) + }, [realtime]) + + return ( +
+ {/* Collapse toggle */} + + + {/* Realtime indicator */} + {lastUpdatedRef.current != null && ( +
+ + + + + Live · {formatUpdatedAgo(lastUpdatedRef.current)} +
+ )} +
+ ) +} + export default function DashboardShell({ siteId, children, @@ -38,11 +82,16 @@ export default function DashboardShell({ onMobileClose={closeMobile} onMobileOpen={openMobile} /> -
- -
- {children} -
+
+ {/* Glass top bar — collapse toggle + realtime, in the margin above the content panel */} + + {/* Content panel */} +
+ +
+ {children} +
+
-- 2.49.1 From bb55782dba3d4d706ce1c58aa8882ba9dd5d3b59 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:06:44 +0100 Subject: [PATCH 024/107] =?UTF-8?q?fix:=20restore=20scrolling=20=E2=80=94?= =?UTF-8?q?=20overflow-clip=20was=20blocking=20overflow-y-auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/dashboard/DashboardShell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 0b5de5c..1df8dd4 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -86,7 +86,7 @@ export default function DashboardShell({ {/* Glass top bar — collapse toggle + realtime, in the margin above the content panel */} {/* Content panel */} -
+
{children} -- 2.49.1 From d6627413b8440dc59e2ba06794e9c8abbf3cf8a7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:16:36 +0100 Subject: [PATCH 025/107] =?UTF-8?q?feat:=20full-width=20content=20?= =?UTF-8?q?=E2=80=94=20remove=20max-w-6xl=20from=20all=20site=20pages=20an?= =?UTF-8?q?d=20skeletons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sites/[id]/behavior/page.tsx | 2 +- app/sites/[id]/cdn/page.tsx | 6 +++--- app/sites/[id]/funnels/[funnelId]/page.tsx | 10 +++++----- app/sites/[id]/funnels/page.tsx | 2 +- app/sites/[id]/journeys/page.tsx | 2 +- app/sites/[id]/page.tsx | 4 ++-- app/sites/[id]/pagespeed/page.tsx | 6 +++--- app/sites/[id]/search/page.tsx | 6 +++--- app/sites/[id]/settings/page.tsx | 6 +++--- app/sites/[id]/uptime/page.tsx | 4 ++-- components/skeletons.tsx | 12 ++++++------ 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/app/sites/[id]/behavior/page.tsx b/app/sites/[id]/behavior/page.tsx index 050b635..11130fa 100644 --- a/app/sites/[id]/behavior/page.tsx +++ b/app/sites/[id]/behavior/page.tsx @@ -56,7 +56,7 @@ export default function BehaviorPage() { if (showSkeleton) return return ( -
+
{/* Header */}
diff --git a/app/sites/[id]/cdn/page.tsx b/app/sites/[id]/cdn/page.tsx index b8872d0..259b615 100644 --- a/app/sites/[id]/cdn/page.tsx +++ b/app/sites/[id]/cdn/page.tsx @@ -135,7 +135,7 @@ export default function CDNPage() { if (showSkeleton) { return ( -
+
@@ -172,7 +172,7 @@ export default function CDNPage() { if (bunnyStatus && !bunnyStatus.connected) { return ( -
+
@@ -208,7 +208,7 @@ export default function CDNPage() { const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0) return ( -
+
{/* Header */}
diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 0ce4fe1..2393948 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -84,7 +84,7 @@ export default function FunnelReportPage() { if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { return ( -
+

Funnel not found

) @@ -92,7 +92,7 @@ export default function FunnelReportPage() { if (loadError === 'forbidden') { return ( -
+

Access denied

{/* Realtime indicator */} -- 2.49.1 From 4702bb91b9e2257e25c32e2fd640eaaae9217a26 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:38:13 +0100 Subject: [PATCH 028/107] =?UTF-8?q?fix:=20top=20bar=20spans=20full=20width?= =?UTF-8?q?=20=E2=80=94=20collapse=20icon=20aligns=20above=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/dashboard/DashboardShell.tsx | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index f3834bb..6c1baa0 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -39,7 +39,7 @@ function GlassTopBar({ siteId }: { siteId: string }) { return (
- {/* Collapse toggle */} + {/* Collapse toggle — aligned above sidebar */} - {/* Realtime indicator */} + {/* Realtime indicator — aligned above content right edge */} {lastUpdatedRef.current != null && ( -
+
@@ -75,16 +75,17 @@ export default function DashboardShell({ return ( -
- -
- {/* Glass top bar — collapse toggle + realtime, in the margin above the content panel */} - +
+ {/* Full-width top bar in glass area */} + + {/* Sidebar + content side by side */} +
+ {/* Content panel */}
-- 2.49.1 From 4e5dd6e3f3ef7e54f94db024b8b3d49d6df2b89e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:40:16 +0100 Subject: [PATCH 029/107] fix: collapse icon uses negative margin to align with sidebar icons --- components/dashboard/DashboardShell.tsx | 31 ++++++++++++------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 6c1baa0..93b2bab 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -38,19 +38,19 @@ function GlassTopBar({ siteId }: { siteId: string }) { }, [realtime]) return ( -
- {/* Collapse toggle — aligned above sidebar */} +
+ {/* Collapse toggle — negative margin to align with sidebar icons */} - {/* Realtime indicator — aligned above content right edge */} + {/* Realtime indicator */} {lastUpdatedRef.current != null && ( -
+
@@ -75,17 +75,16 @@ export default function DashboardShell({ return ( -
- {/* Full-width top bar in glass area */} - - {/* Sidebar + content side by side */} -
- +
+ +
+ {/* Glass top bar — above content only, collapse icon reaches back into sidebar column */} + {/* Content panel */}
-- 2.49.1 From 132afa749c3e5577921cd69efe5638037b55016b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:44:49 +0100 Subject: [PATCH 030/107] fix: collapse toggle as first sidebar item, realtime stays in glass bar Collapse icon at top of sidebar (aligned with all icons). Glass top bar now only shows realtime indicator on the right. --- components/dashboard/DashboardShell.tsx | 11 +---------- components/dashboard/Sidebar.tsx | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 93b2bab..714baca 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -38,16 +38,7 @@ function GlassTopBar({ siteId }: { siteId: string }) { }, [realtime]) return ( -
- {/* Collapse toggle — negative margin to align with sidebar icons */} - - +
{/* Realtime indicator */} {lastUpdatedRef.current != null && (
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 8a265f0..99c49dd 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -9,6 +9,7 @@ import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' import { useSettingsModal } from '@/lib/settings-modal-context' import { useSidebar } from '@/lib/sidebar-context' +import { SidebarSimple } from '@phosphor-icons/react' // `,` shortcut handled globally by UnifiedSettingsModal import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' @@ -340,6 +341,7 @@ interface SidebarContentProps { onMobileClose: () => void onExpand: () => void onCollapse: () => void + onToggle: () => void wasCollapsed: React.MutableRefObject pickerOpenCallbackRef: React.MutableRefObject<(() => void) | null> auth: ReturnType @@ -350,7 +352,7 @@ interface SidebarContentProps { function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, - onNavigate, onMobileClose, onExpand, onCollapse, + onNavigate, onMobileClose, onExpand, onCollapse, onToggle, wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings, }: SidebarContentProps) { const router = useRouter() @@ -359,7 +361,20 @@ function SidebarContent({ return (
- {/* App Switcher — top of sidebar (scope-level switch) */} + {/* Collapse toggle — first item, aligned with all other sidebar icons */} + {!isMobile && ( +
+ +
+ )} + + {/* App Switcher — scope-level switch */}
@@ -525,7 +540,7 @@ export default function Sidebar({ onMobileClose={onMobileClose} onExpand={expand} onCollapse={collapse} - + onToggle={toggle} wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} @@ -568,7 +583,7 @@ export default function Sidebar({ onMobileClose={handleMobileClose} onExpand={expand} onCollapse={collapse} - + onToggle={toggle} wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} -- 2.49.1 From 9aacd63d1d2c7199faa701788381aed5dafb9e00 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:53:44 +0100 Subject: [PATCH 031/107] fix: collapse toggle back in glass top bar, removed from sidebar --- components/dashboard/DashboardShell.tsx | 11 ++++++++++- components/dashboard/Sidebar.tsx | 15 +-------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 714baca..8432d82 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -38,7 +38,16 @@ function GlassTopBar({ siteId }: { siteId: string }) { }, [realtime]) return ( -
+
+ {/* Collapse toggle */} + + {/* Realtime indicator */} {lastUpdatedRef.current != null && (
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 99c49dd..fe7e5ac 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -361,20 +361,7 @@ function SidebarContent({ return (
- {/* Collapse toggle — first item, aligned with all other sidebar icons */} - {!isMobile && ( -
- -
- )} - - {/* App Switcher — scope-level switch */} + {/* App Switcher — top of sidebar (scope-level switch) */}
-- 2.49.1 From 540c0b51ca7e5703a7e12c49ea847377b4cb4189 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 23:56:30 +0100 Subject: [PATCH 032/107] fix: remove duplicate realtime indicator from under chart --- components/dashboard/Chart.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 5a41b30..9eceb86 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -543,16 +543,6 @@ export default function Chart({ )}
- {/* Live indicator right */} - {lastUpdatedAt != null && ( -
- - - - - Live · {formatUpdatedAgo(lastUpdatedAt)} -
- )}
)} -- 2.49.1 From 953d828cd96cd4072d6094a8a1e16d57c9534ea3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 16:39:53 +0100 Subject: [PATCH 033/107] fix: align collapse toggle with sidebar AppLauncher row (Dokploy-style) --- components/dashboard/DashboardShell.tsx | 6 +++--- components/dashboard/Sidebar.tsx | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 8432d82..6d091bb 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -38,11 +38,11 @@ function GlassTopBar({ siteId }: { siteId: string }) { }, [realtime]) return ( -
- {/* Collapse toggle */} +
+ {/* Collapse toggle — mirrors sidebar AppLauncher row sizing for pixel alignment */} + {/* Left: collapse toggle + page title */} +
+ + {pageTitle} +
{/* Realtime indicator */} {lastUpdatedRef.current != null && ( -- 2.49.1 From b78f5d4b96d24df6c5757a9e267aff1be2064278 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 17:07:55 +0100 Subject: [PATCH 037/107] fix: add parens around nullish coalescing mixed with logical OR --- components/dashboard/DashboardShell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 5f301fb..20b36db 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -25,7 +25,7 @@ function usePageTitle() { const pathname = usePathname() // pathname is /sites/:id or /sites/:id/section/... const segment = pathname.replace(/^\/sites\/[^/]+\/?/, '').split('/')[0] - return PAGE_TITLES[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1) || 'Dashboard' + return PAGE_TITLES[segment] ?? (segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard') } // Load sidebar only on the client — prevents SSR flash -- 2.49.1 From 48b404eb3733c4792d8c2c23b50c8f457fe0e2b4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 17:11:48 +0100 Subject: [PATCH 038/107] =?UTF-8?q?fix:=20remove=20duplicate=20h1=20from?= =?UTF-8?q?=20uptime=20page=20=E2=80=94=20title=20now=20in=20top=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sites/[id]/uptime/page.tsx | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index ead4317..1b7c709 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -404,15 +404,10 @@ export default function UptimePage() { if (!uptimeEnabled) { return (
- {/* Header */} -
-

- Uptime -

-

- Monitor your site's availability and response time -

-
+ {/* Description */} +

+ Monitor your site's availability and response time +

{/* Empty state */}
@@ -443,16 +438,11 @@ export default function UptimePage() { // * Enabled state — show uptime dashboard return (
- {/* Header */} + {/* Description + action */}
-
-

- Uptime -

-

- Monitor your site's availability and response time -

-
+

+ Monitor your site's availability and response time +

{canEdit && (
+ + {/* Danger Zone */} + {canEdit && ( +
+
+

Danger Zone

+

Irreversible actions for your site.

+
+ +
+
+
+

Reset Data

+

Delete all stats and events. This cannot be undone.

+
+ +
+
+ +
+
+
+

Delete Site

+

Schedule this site for deletion with a 7-day grace period.

+
+ +
+
+
+ )} + + setShowDeleteModal(false)} + onDeleted={() => { router.push('/'); closeSettings(); }} + siteName={site?.name || ''} + siteDomain={site?.domain || ''} + siteId={siteId} + />
) } -- 2.49.1 From 4e6837a9ee30a047dac9b5e6a20ea6cd977be17a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 17:42:34 +0100 Subject: [PATCH 041/107] feat(settings): add framework picker and verification to unified general tab --- .../settings/unified/tabs/SiteGeneralTab.tsx | 80 ++++++++++++------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/components/settings/unified/tabs/SiteGeneralTab.tsx b/components/settings/unified/tabs/SiteGeneralTab.tsx index d88c245..eb91777 100644 --- a/components/settings/unified/tabs/SiteGeneralTab.tsx +++ b/components/settings/unified/tabs/SiteGeneralTab.tsx @@ -2,13 +2,14 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' -import { Input, Button, Select, toast, Spinner, getAuthErrorMessage } from '@ciphera-net/ui' -import { Copy, CheckCircle } from '@phosphor-icons/react' +import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui' import { useSite } from '@/lib/swr/dashboard' import { updateSite, resetSiteData } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' import { useUnifiedSettings } from '@/lib/unified-settings-context' import DeleteSiteModal from '@/components/sites/DeleteSiteModal' +import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' +import VerificationModal from '@/components/sites/VerificationModal' const TIMEZONES = [ { value: 'UTC', label: 'UTC' }, @@ -27,9 +28,6 @@ const TIMEZONES = [ { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, ] -const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' -const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' - export default function SiteGeneralTab({ siteId }: { siteId: string }) { const router = useRouter() const { user } = useAuth() @@ -38,8 +36,8 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) { const [name, setName] = useState('') const [timezone, setTimezone] = useState('UTC') const [saving, setSaving] = useState(false) - const [copied, setCopied] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [showVerificationModal, setShowVerificationModal] = useState(false) const canEdit = user?.role === 'owner' || user?.role === 'admin' @@ -74,16 +72,6 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) { } } - const trackingScript = `` - const frustrationScript = `` - - const copyScript = () => { - navigator.clipboard.writeText(trackingScript + '\n' + frustrationScript) - setCopied(true) - toast.success('Copied to clipboard') - setTimeout(() => setCopied(false), 2000) - } - if (!site) { return (
@@ -129,21 +117,48 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) {

Tracking Script

-

Add this to your website to start tracking visitors.

+

Add this to your website to start tracking visitors. Choose your framework for setup instructions.

-
-
-            {trackingScript}{'\n'}{frustrationScript}
-          
- -
+ { + try { + await updateSite(siteId, { name: site.name, timezone: site.timezone, script_features: features }) + await mutate() + } catch { + toast.error('Failed to update script features') + } + }} + /> + + {/* Verify Installation */} + +

+ {site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'} +

{/* Save */} @@ -203,6 +218,13 @@ export default function SiteGeneralTab({ siteId }: { siteId: string }) { siteDomain={site?.domain || ''} siteId={siteId} /> + + setShowVerificationModal(false)} + site={site} + onVerified={() => mutate()} + />
) } -- 2.49.1 From d0d7a9710226ebc54458371a22a875fbafd9fbb3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 25 Mar 2026 17:46:06 +0100 Subject: [PATCH 042/107] feat(settings): add retention, excluded paths, pagespeed to unified privacy tab --- .../settings/unified/tabs/SitePrivacyTab.tsx | 135 +++++++++++++++++- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/components/settings/unified/tabs/SitePrivacyTab.tsx b/components/settings/unified/tabs/SitePrivacyTab.tsx index 1f1e2b2..1834578 100644 --- a/components/settings/unified/tabs/SitePrivacyTab.tsx +++ b/components/settings/unified/tabs/SitePrivacyTab.tsx @@ -2,8 +2,13 @@ import { useState, useEffect } from 'react' import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui' -import { useSite } from '@/lib/swr/dashboard' +import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard' import { updateSite } from '@/lib/api/sites' +import { updatePageSpeedConfig } from '@/lib/api/pagespeed' +import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans' +import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' +import { Copy, CheckCircle } from '@phosphor-icons/react' +import Link from 'next/link' const GEO_OPTIONS = [ { value: 'full', label: 'Full (country, region, city)' }, @@ -13,6 +18,8 @@ const GEO_OPTIONS = [ export default function SitePrivacyTab({ siteId }: { siteId: string }) { const { data: site, mutate } = useSite(siteId) + const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription() + const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId) const [collectPagePaths, setCollectPagePaths] = useState(true) const [collectReferrers, setCollectReferrers] = useState(true) const [collectDeviceInfo, setCollectDeviceInfo] = useState(true) @@ -20,6 +27,8 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { const [collectGeoData, setCollectGeoData] = useState('full') const [hideUnknownLocations, setHideUnknownLocations] = useState(false) const [dataRetention, setDataRetention] = useState(6) + const [excludedPaths, setExcludedPaths] = useState('') + const [snippetCopied, setSnippetCopied] = useState(false) const [saving, setSaving] = useState(false) useEffect(() => { @@ -31,6 +40,7 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { setCollectGeoData(site.collect_geo_data ?? 'full') setHideUnknownLocations(site.hide_unknown_locations ?? false) setDataRetention(site.data_retention_months ?? 6) + setExcludedPaths((site.excluded_paths || []).join('\n')) } }, [site]) @@ -45,6 +55,8 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) { collect_screen_resolution: collectScreenRes, collect_geo_data: collectGeoData as 'full' | 'country' | 'none', hide_unknown_locations: hideUnknownLocations, + data_retention_months: dataRetention, + excluded_paths: excludedPaths.split('\n').map(p => p.trim()).filter(Boolean), }) await mutate() toast.success('Privacy settings updated') @@ -93,12 +105,121 @@ export default function SitePrivacyTab({ siteId }: { siteId: string }) {

Controls location granularity. "Disabled" collects no geographic data at all.

-
- -

- Currently retaining data for {dataRetention} months. - Manage retention in the full site settings. -

+ {/* Data Retention */} +
+

Data Retention

+ + {subscriptionError && ( +
+

Plan limits could not be loaded.

+ +
+ )} + +
+
+
+

Keep raw event data for

+

Events older than this are automatically deleted. Aggregated daily stats are kept permanently.

+
+