From 3c17895d647b49ce6957fe85f083f7420f2de41f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 23 Mar 2026 20:57:20 +0100 Subject: [PATCH] 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) +}