Unified settings modal + dashboard shell redesign #69

Merged
uz1mani merged 107 commits from staging into main 2026-03-26 09:15:33 +00:00
46 changed files with 4527 additions and 381 deletions

View File

@@ -1,4 +1,5 @@
# * Runs unit tests on push/PR to main and staging.
# * Uses self-hosted runner for push events, GitHub-hosted for PRs (public repo security).
name: Test
on:
@@ -7,6 +8,10 @@ on:
pull_request:
branches: [main, staging]
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: read
@@ -14,7 +19,7 @@ permissions:
jobs:
test:
name: unit-tests
runs-on: ubuntu-latest
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }}
steps:
- uses: actions/checkout@v4

View File

@@ -15,8 +15,9 @@ import { getUserOrganizations, switchContext, type OrganizationMember } from '@/
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 SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
import { SettingsModalProvider } from '@/lib/settings-modal-context'
import { UnifiedSettingsProvider, useUnifiedSettings } from '@/lib/unified-settings-context'
import UnifiedSettingsModal from '@/components/settings/unified/UnifiedSettingsModal'
const ORG_SWITCH_KEY = 'pulse_switching_org'
@@ -52,7 +53,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const isOnline = useOnlineStatus()
const { openSettings } = useSettingsModal()
const { openUnifiedSettings } = useUnifiedSettings()
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
@@ -108,7 +109,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
<>
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
{children}
<SettingsModalWrapper />
<UnifiedSettingsModal />
</>
)
}
@@ -135,12 +136,12 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
rightSideActions={<NotificationCenter />}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
onOpenSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
/>
<main className="flex-1 pb-8">
{children}
</main>
<SettingsModalWrapper />
<UnifiedSettingsModal />
</div>
)
}
@@ -157,7 +158,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
appName="Pulse"
isAuthenticated={false}
/>
<SettingsModalWrapper />
</div>
)
}
@@ -165,7 +165,9 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
export default function LayoutContent({ children }: { children: React.ReactNode }) {
return (
<SettingsModalProvider>
<LayoutInner>{children}</LayoutInner>
<UnifiedSettingsProvider>
<LayoutInner>{children}</LayoutInner>
</UnifiedSettingsProvider>
</SettingsModalProvider>
)
}

View File

@@ -56,11 +56,11 @@ export default function BehaviorPage() {
if (showSkeleton) return <BehaviorSkeleton />
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Behavior
</h1>
<p className="text-sm text-neutral-400">

View File

@@ -135,7 +135,7 @@ export default function CDNPage() {
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
@@ -172,7 +172,7 @@ export default function CDNPage() {
if (bunnyStatus && !bunnyStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<CloudArrowUp size={40} className="text-neutral-400 dark:text-neutral-500" />
@@ -208,11 +208,11 @@ export default function CDNPage() {
const totalBandwidth = countries.reduce((sum, row) => sum + row.bandwidth, 0)
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
CDN Analytics
</h1>
<p className="text-sm text-neutral-400">

View File

@@ -84,7 +84,7 @@ export default function FunnelReportPage() {
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -92,7 +92,7 @@ export default function FunnelReportPage() {
if (loadError === 'forbidden') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="primary" className="mt-4">
@@ -105,7 +105,7 @@ export default function FunnelReportPage() {
if (loadError === 'error') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
<Button type="button" onClick={() => loadData()} variant="primary">
Try again
@@ -116,7 +116,7 @@ export default function FunnelReportPage() {
if (!funnel || !stats) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
@@ -143,7 +143,7 @@ export default function FunnelReportPage() {
}) : []
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
@@ -154,7 +154,7 @@ export default function FunnelReportPage() {
<ChevronLeftIcon className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">
<h1 className="text-lg font-semibold text-neutral-200">
{funnel.name}
</h1>
{funnel.description && (

View File

@@ -36,11 +36,11 @@ export default function FunnelsPage() {
}
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">
<h1 className="text-lg font-semibold text-neutral-200">
Funnels
</h1>
<p className="text-neutral-600 dark:text-neutral-400">

View File

@@ -73,11 +73,11 @@ export default function JourneysPage() {
const totalSessions = transitionsData?.total_sessions ?? 0
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Journeys
</h1>
<p className="text-sm text-neutral-400">

View File

@@ -417,19 +417,19 @@ export default function SiteDashboardPage() {
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
{site.name}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">

View File

@@ -235,10 +235,10 @@ export default function PageSpeedPage() {
// * Disabled state — show empty state with enable toggle
if (!enabled) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
PageSpeed
</h1>
<p className="text-sm text-neutral-400">
@@ -357,11 +357,11 @@ export default function PageSpeedPage() {
// * Enabled state — show full PageSpeed dashboard
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
PageSpeed
</h1>
<p className="text-sm text-neutral-400">
@@ -868,35 +868,67 @@ function AuditItem({ item }: { item: Record<string, any> }) {
// * Skeleton loading state
function PageSpeedSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 space-y-6">
<div className="animate-pulse space-y-2 mb-8">
<div className="h-8 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-72 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
{/* Hero skeleton */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
<div className="flex items-center gap-8">
<div className="w-40 h-40 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0" />
<div className="flex-1 space-y-3">
<div className="h-5 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-5 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 space-y-6 animate-pulse">
{/* Header — title + subtitle + toggle buttons */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-2">
<div className="h-8 w-36 bg-neutral-700 rounded" />
<div className="h-4 w-72 bg-neutral-700 rounded" />
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1">
<div className="h-8 w-16 bg-neutral-700 rounded" />
<div className="h-8 w-20 bg-neutral-700 rounded" />
</div>
<div className="w-48 h-36 bg-neutral-200 dark:bg-neutral-700 rounded-lg flex-shrink-0 hidden md:block" />
<div className="h-9 w-24 bg-neutral-700 rounded-lg" />
</div>
</div>
{/* Metrics skeleton */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 sm:p-8 animate-pulse">
<div className="h-3 w-16 bg-neutral-200 dark:bg-neutral-700 rounded mb-5" />
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
{/* Score overview — 4 gauge circles + screenshot */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
<div className="flex flex-col lg:flex-row items-center gap-8">
<div className="flex-1 flex items-center justify-center gap-6 sm:gap-8 flex-wrap">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex flex-col items-center gap-2">
<div className="w-[90px] h-[90px] rounded-full border-[6px] border-neutral-700 bg-transparent" />
<div className="h-3 w-16 bg-neutral-700 rounded" />
</div>
))}
</div>
<div className="w-48 h-44 bg-neutral-700 rounded-lg flex-shrink-0 hidden md:block" />
</div>
{/* Legend bar */}
<div className="flex items-center gap-4 mt-6 pt-4 border-t border-neutral-800">
<div className="h-3 w-32 bg-neutral-700 rounded" />
<div className="ml-auto flex items-center gap-3">
<div className="h-2 w-10 bg-neutral-700 rounded" />
<div className="h-2 w-10 bg-neutral-700 rounded" />
<div className="h-2 w-10 bg-neutral-700 rounded" />
</div>
</div>
</div>
{/* Metrics card — 6 metrics in 3-col grid */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
<div className="h-3 w-16 bg-neutral-700 rounded mb-5" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-7 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div key={i} className="flex items-start gap-3">
<div className="mt-1.5 w-2.5 h-2.5 rounded-full bg-neutral-700 flex-shrink-0" />
<div className="space-y-2">
<div className="h-3 w-32 bg-neutral-700 rounded" />
<div className="h-7 w-20 bg-neutral-700 rounded" />
</div>
</div>
))}
</div>
</div>
{/* Score trend chart placeholder */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 sm:p-8">
<div className="h-3 w-40 bg-neutral-700 rounded mb-5" />
<div className="h-48 w-full bg-neutral-800 rounded-lg" />
</div>
</div>
)
}

View File

@@ -125,7 +125,7 @@ export default function SearchConsolePage() {
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
@@ -161,7 +161,7 @@ export default function SearchConsolePage() {
if (gscStatus && !gscStatus.connected) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-5 mb-6">
<MagnifyingGlass size={40} className="text-neutral-400 dark:text-neutral-500" />
@@ -198,11 +198,11 @@ export default function SearchConsolePage() {
const pagesTotal = topPages?.total ?? 0
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Search Console
</h1>
<p className="text-sm text-neutral-400">

View File

@@ -704,7 +704,7 @@ export default function SiteSettingsPage() {
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
@@ -727,18 +727,18 @@ export default function SiteSettingsPage() {
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-white">Site Settings</h1>
<h1 className="text-lg font-semibold text-neutral-200">Site Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
Manage settings for <span className="font-medium text-white">{site.domain}</span>
</p>

View File

@@ -403,10 +403,10 @@ export default function UptimePage() {
// * Disabled state — show empty state with enable toggle
if (!uptimeEnabled) {
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-400">
@@ -442,11 +442,11 @@ export default function UptimePage() {
// * Enabled state — show uptime dashboard
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header */}
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
{/* Header + action */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">
<h1 className="text-lg font-semibold text-neutral-200 mb-1">
Uptime
</h1>
<p className="text-sm text-neutral-400">

View File

@@ -3,30 +3,13 @@
import { formatNumber } from '@ciphera-net/ui'
import { Files } from '@phosphor-icons/react'
import type { FrustrationByPage } from '@/lib/api/stats'
import { TableSkeleton } from '@/components/skeletons'
interface FrustrationByPageTableProps {
pages: FrustrationByPage[]
loading: boolean
}
function SkeletonRows() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="h-4 w-40 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="flex gap-6">
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-10 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
))}
</div>
)
}
export default function FrustrationByPageTable({ pages, loading }: FrustrationByPageTableProps) {
const hasData = pages.length > 0
const maxTotal = Math.max(...pages.map(p => p.total), 1)
@@ -43,7 +26,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy
</p>
{loading ? (
<SkeletonRows />
<TableSkeleton rows={5} cols={5} />
) : hasData ? (
<div className="overflow-x-auto -mx-6 px-6">
{/* Header */}

View File

@@ -1,6 +1,7 @@
'use client'
import type { FrustrationSummary } from '@/lib/api/stats'
import { StatCardSkeleton } from '@/components/skeletons'
interface FrustrationSummaryCardsProps {
data: FrustrationSummary | null
@@ -39,25 +40,13 @@ function ChangeIndicator({ change }: { change: ReturnType<typeof pctChange> }) {
)
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="animate-pulse space-y-3">
<div className="h-4 w-24 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-8 w-16 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-3 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
</div>
)
}
export default function FrustrationSummaryCards({ data, loading }: FrustrationSummaryCardsProps) {
if (loading || !data) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
)
}

View File

@@ -8,26 +8,13 @@ import {
type ChartConfig,
} from '@/components/charts'
import type { FrustrationSummary } from '@/lib/api/stats'
import { WidgetSkeleton } from '@/components/skeletons'
interface FrustrationTrendProps {
summary: FrustrationSummary | null
loading: boolean
}
function SkeletonCard() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="animate-pulse space-y-3 mb-4">
<div className="h-5 w-36 bg-neutral-200 dark:bg-neutral-700 rounded" />
<div className="h-4 w-48 bg-neutral-200 dark:bg-neutral-700 rounded" />
</div>
<div className="flex-1 min-h-[270px] animate-pulse flex items-center justify-center">
<div className="w-[200px] h-[200px] rounded-full bg-neutral-200 dark:bg-neutral-700" />
</div>
</div>
)
}
const LABELS: Record<string, string> = {
rage_clicks: 'Rage Clicks',
dead_clicks: 'Dead Clicks',
@@ -70,7 +57,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<
}
export default function FrustrationTrend({ summary, loading }: FrustrationTrendProps) {
if (loading || !summary) return <SkeletonCard />
if (loading || !summary) return <WidgetSkeleton />
const hasData = summary.rage_clicks > 0 || summary.dead_clicks > 0 ||
summary.prev_rage_clicks > 0 || summary.prev_dead_clicks > 0

View File

@@ -543,16 +543,6 @@ export default function Chart({
</>
)}
</div>
{/* Live indicator right */}
{lastUpdatedAt != null && (
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
)}
</div>
)}
</Card>

View File

@@ -1,17 +1,39 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { usePathname } from 'next/navigation'
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'
const PAGE_TITLES: Record<string, string> = {
'': 'Dashboard',
journeys: 'Journeys',
funnels: 'Funnels',
behavior: 'Behavior',
search: 'Search',
cdn: 'CDN',
uptime: 'Uptime',
pagespeed: 'PageSpeed',
settings: 'Site Settings',
}
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 ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard')
}
// Load sidebar only on the client — prevents SSR flash
const Sidebar = dynamic(() => import('./Sidebar'), {
ssr: false,
// Placeholder reserves the sidebar's space in the server HTML
// so page content never occupies the sidebar zone
loading: () => (
<div
className="hidden md:block shrink-0 bg-neutral-900 overflow-hidden relative"
className="hidden md:block shrink-0 bg-transparent overflow-hidden relative"
style={{ width: 64 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-neutral-800/10 to-transparent animate-shimmer" />
@@ -19,6 +41,52 @@ const Sidebar = dynamic(() => import('./Sidebar'), {
),
})
function GlassTopBar({ siteId }: { siteId: string }) {
const { collapsed, toggle } = useSidebar()
const { data: realtime } = useRealtime(siteId)
const lastUpdatedRef = useRef<number | null>(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])
const pageTitle = usePageTitle()
return (
<div className="hidden md:flex items-center justify-between shrink-0 px-3 pt-1.5 pb-1">
{/* Left: collapse toggle + page title */}
<div className="flex items-center gap-1.5">
<button
onClick={toggle}
className="w-9 h-9 flex items-center justify-center text-neutral-400 hover:text-white rounded-lg hover:bg-white/[0.06] transition-colors"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<SidebarSimple className="w-[18px] h-[18px]" weight={collapsed ? 'regular' : 'fill'} />
</button>
<span className="text-sm text-neutral-400 font-medium">{pageTitle}</span>
</div>
{/* Realtime indicator */}
{lastUpdatedRef.current != null && (
<div className="flex items-center gap-1.5 text-xs text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedRef.current)}
</div>
)}
</div>
)
}
export default function DashboardShell({
siteId,
children,
@@ -31,20 +99,26 @@ export default function DashboardShell({
const openMobile = useCallback(() => setMobileOpen(true), [])
return (
<div className="flex h-screen overflow-hidden bg-neutral-900">
<Sidebar
siteId={siteId}
mobileOpen={mobileOpen}
onMobileClose={closeMobile}
onMobileOpen={openMobile}
/>
{/* Content panel — rounded corners, inset from edges. The left border doubles as the sidebar's right edge. */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden mt-2 mr-2 mb-2 rounded-2xl bg-neutral-950 border border-neutral-800/60">
<ContentHeader onMobileMenuOpen={openMobile} />
<main className="flex-1 overflow-y-auto pt-4">
{children}
</main>
<SidebarProvider>
<div className="flex h-screen overflow-hidden bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60">
<Sidebar
siteId={siteId}
mobileOpen={mobileOpen}
onMobileClose={closeMobile}
onMobileOpen={openMobile}
/>
<div className="flex-1 flex flex-col min-w-0">
{/* Glass top bar — above content only, collapse icon reaches back into sidebar column */}
<GlassTopBar siteId={siteId} />
{/* Content panel */}
<div className="flex-1 flex flex-col min-w-0 mr-2 mb-2 rounded-2xl bg-neutral-950 border border-neutral-800/60 overflow-hidden">
<ContentHeader onMobileMenuOpen={openMobile} />
<main className="flex-1 overflow-y-auto pt-4">
{children}
</main>
</div>
</div>
</div>
</div>
</SidebarProvider>
)
}

View File

@@ -1,11 +1,15 @@
'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'
import { useAuth } from '@/lib/auth/context'
import { useSettingsModal } from '@/lib/settings-modal-context'
import { useUnifiedSettings } from '@/lib/unified-settings-context'
import { useSidebar } from '@/lib/sidebar-context'
// `,` shortcut handled globally by UnifiedSettingsModal
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
@@ -20,8 +24,6 @@ import {
CloudUploadIcon,
HeartbeatIcon,
SettingsIcon,
CollapseLeftIcon,
CollapseRightIcon,
ChevronUpDownIcon,
PlusIcon,
XIcon,
@@ -58,7 +60,6 @@ const CIPHERA_APPS: CipheraApp[] = [
},
]
const SIDEBAR_KEY = 'pulse_sidebar_collapsed'
const EXPANDED = 256
const COLLAPSED = 64
@@ -122,18 +123,46 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
const [faviconFailed, setFaviconFailed] = useState(false)
const [faviconLoaded, setFaviconLoaded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(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()
if (collapsed) {
// Collapsed: open to the right, like AppLauncher/UserMenu/Notifications
let top = rect.top
if (panelRef.current) {
const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8
top = Math.min(top, Math.max(8, maxTop))
}
setFixedPos({ left: rect.right + 8, top })
} else {
// Expanded: open below the button
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 })
}
}
}, [collapsed])
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 }
}
}
}
@@ -141,30 +170,92 @@ 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('')
}
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 = (
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed z-50 w-[240px] bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-xl shadow-xl shadow-black/20 overflow-hidden origin-top-left"
style={fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
>
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => 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
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filtered.map((site) => (
<button
key={site.id}
onClick={() => switchSite(site.id)}
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-white/[0.06]'
}`}
>
<img
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt=""
className="w-5 h-5 rounded object-contain shrink-0"
/>
<span className="flex flex-col min-w-0">
<span className="truncate">{site.name}</span>
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
</span>
</button>
))}
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
</div>
<div className="border-t border-white/[0.06] p-2">
<Link href="/sites/new" onClick={() => closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
)
return (
<div className="relative mb-4 px-2" ref={ref}>
<button
onClick={() => {
if (collapsed) {
wasCollapsed.current = true
pickerOpenCallback.current = () => setOpen(true)
onExpand()
} else {
setOpen(!open)
}
}}
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-200 hover:bg-neutral-800 overflow-hidden"
ref={buttonRef}
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-200 hover:bg-white/[0.06] overflow-hidden"
>
<span className="w-7 h-7 rounded-md bg-brand-orange/10 flex items-center justify-center shrink-0 overflow-hidden">
{faviconUrl && !faviconFailed ? (
@@ -192,57 +283,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
</Label>
</button>
{open && (
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => 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-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filtered.map((site) => (
<button
key={site.id}
onClick={() => switchSite(site.id)}
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'
}`}
>
<img
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt=""
className="w-5 h-5 rounded object-contain shrink-0"
/>
<span className="flex flex-col min-w-0">
<span className="truncate">{site.name}</span>
<span className="text-xs text-neutral-400 truncate">{site.domain}</span>
</span>
</button>
))}
{filtered.length === 0 && <p className="px-4 py-3 text-sm text-neutral-400">No sites found</p>}
</div>
<div className="border-t border-neutral-700 p-2">
<Link href="/sites/new" onClick={() => setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-800 rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
</div>
</div>
)}
{typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown}
</div>
)
}
@@ -269,7 +310,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'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
@@ -320,7 +361,7 @@ function SidebarContent({
return (
<div className="flex flex-col h-full overflow-hidden">
{/* App Switcher — top of sidebar (scope-level switch) */}
<div className="flex items-center gap-2.5 px-[14px] pt-3 pb-1 shrink-0 overflow-hidden">
<div className="flex items-center gap-2.5 px-[14px] pt-1.5 pb-1 shrink-0 overflow-hidden">
<span className="w-9 h-9 flex items-center justify-center shrink-0">
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
</span>
@@ -347,7 +388,7 @@ function SidebarContent({
{NAV_GROUPS.map((group) => (
<div key={group.label}>
{c ? (
<div className="mx-3 my-2 border-t border-neutral-800/40" />
<div className="mx-3 my-2 border-t border-white/[0.04]" />
) : (
<div className="h-5 flex items-center overflow-hidden">
<p className="px-2.5 text-[11px] font-semibold text-neutral-400 dark:text-neutral-500 uppercase tracking-wider whitespace-nowrap">
@@ -368,7 +409,7 @@ function SidebarContent({
</nav>
{/* Bottom — utility items */}
<div className="border-t border-neutral-800/60 px-2 py-3 shrink-0">
<div className="border-t border-white/[0.06] px-2 py-3 shrink-0">
{/* Notifications, Profile — same layout as nav items */}
<div className="space-y-0.5 mb-1">
<div className="relative group/notif">
@@ -403,28 +444,6 @@ function SidebarContent({
)}
</div>
</div>
{/* Settings + Collapse */}
<div className="space-y-0.5">
{!isMobile && (
<div className="relative group/collapse">
<button
onClick={onToggle}
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden"
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} />
</span>
<Label collapsed={c}>{c ? 'Expand' : 'Collapse'}</Label>
</button>
{c && (
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/collapse:opacity-100 transition-opacity duration-150 delay-150 z-50">
Expand (press [)
</span>
)}
</div>
)}
</div>
</div>
</div>
)
@@ -442,17 +461,14 @@ export default function Sidebar({
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const pathname = usePathname()
const router = useRouter()
const { openSettings } = useSettingsModal()
const { openUnifiedSettings } = useUnifiedSettings()
const [sites, setSites] = useState<Site[]>([])
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [pendingHref, setPendingHref] = useState<string | null>(null)
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(() => {
@@ -476,30 +492,6 @@ export default function Sidebar({
}
useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
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()
}
}
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(() => {
@@ -514,7 +506,7 @@ export default function Sidebar({
<>
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
<aside
className="hidden md:flex flex-col shrink-0 bg-neutral-900 overflow-hidden relative z-10"
className="hidden md:flex flex-col shrink-0 bg-transparent overflow-hidden relative z-10"
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
onTransitionEnd={(e) => {
if (e.propertyName === 'width' && pickerOpenCallbackRef.current) {
@@ -540,7 +532,7 @@ export default function Sidebar({
auth={auth}
orgs={orgs}
onSwitchOrganization={handleSwitchOrganization}
openSettings={openSettings}
openSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
/>
</aside>
@@ -554,13 +546,13 @@ export default function Sidebar({
onClick={handleMobileClose}
/>
<aside
className={`fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden ${
className={`fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border-r border-white/[0.08] shadow-xl shadow-black/20 md:hidden ${
mobileClosing
? 'animate-out slide-out-to-left duration-200 fill-mode-forwards'
: 'animate-in slide-in-from-left duration-200'
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<span className="text-sm font-semibold text-white">Navigation</span>
<button onClick={handleMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
<XIcon className="w-5 h-5" />
@@ -583,7 +575,7 @@ export default function Sidebar({
auth={auth}
orgs={orgs}
onSwitchOrganization={handleSwitchOrganization}
openSettings={openSettings}
openSettings={() => openUnifiedSettings({ context: 'account', tab: 'profile' })}
/>
</aside>
</>

View File

@@ -6,6 +6,7 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -173,7 +174,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
aria-controls={open ? 'notification-dropdown' : undefined}
className={isSidebar
? 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden transition-colors'
: 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors'
: 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-white/[0.06] transition-colors'
}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
@@ -198,20 +199,26 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
</button>
{(() => {
const panel = open ? (
<div
const panel = (
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className={`bg-white dark:bg-neutral-900/65 border border-neutral-200 dark:border-white/[0.08] rounded-xl shadow-xl dark:shadow-black/20 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:dark:bg-neutral-900/60 overflow-hidden z-[100] ${
anchor === 'right'
? `fixed w-96 ${fixedPos?.bottom !== undefined ? 'origin-bottom-left' : 'origin-top-left'}`
: 'fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96'
}`}
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top, bottom: fixedPos.bottom } : undefined}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-white/[0.06]">
<h3 className="font-semibold text-white">Notifications</h3>
{unreadCount > 0 && (
<button
@@ -248,14 +255,14 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
<ul className="divide-y divide-neutral-200 dark:divide-white/[0.06]">
{(notifications ?? []).map((n) => (
<li key={n.id}>
{n.link_url ? (
<Link
href={n.link_url}
onClick={() => handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-white/[0.06] transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
@@ -278,7 +285,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
className={`w-full text-left block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-white/[0.06] cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
@@ -304,7 +311,7 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<div className="border-t border-neutral-200 dark:border-white/[0.06] px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
@@ -321,10 +328,12 @@ export default function NotificationCenter({ anchor = 'bottom', variant = 'defau
Manage settings
</Link>
</div>
</div>
) : null
</motion.div>
)}
</AnimatePresence>
)
return anchor === 'right' && panel && typeof document !== 'undefined'
return anchor === 'right' && typeof document !== 'undefined'
? createPortal(panel, document.body)
: panel
})()}

View File

@@ -0,0 +1,60 @@
'use client'
import { Button } from '@ciphera-net/ui'
interface DangerZoneItem {
title: string
description: string
buttonLabel: string
/** 'outline' = bordered button (Reset Data style), 'solid' = red filled button (Delete style) */
variant: 'outline' | 'solid'
onClick: () => void
disabled?: boolean
}
interface DangerZoneProps {
items: DangerZoneItem[]
children?: React.ReactNode
}
export function DangerZone({ items, children }: DangerZoneProps) {
return (
<div className="space-y-4 pt-6 border-t border-neutral-800">
<div>
<h3 className="text-base font-semibold text-red-500 mb-1">Danger Zone</h3>
<p className="text-xs text-neutral-500">Irreversible actions.</p>
</div>
{items.map((item) => (
<div key={item.title} className="rounded-xl border border-red-900/30 bg-red-900/10 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">{item.title}</p>
<p className="text-xs text-neutral-400">{item.description}</p>
</div>
{item.variant === 'outline' ? (
<Button
variant="secondary"
className="text-sm text-red-400 border-red-900 hover:bg-red-900/20"
onClick={item.onClick}
disabled={item.disabled}
>
{item.buttonLabel}
</Button>
) : (
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
onClick={item.onClick}
disabled={item.disabled}
>
{item.buttonLabel}
</button>
)}
</div>
</div>
))}
{children}
</div>
)
}

View File

@@ -0,0 +1,491 @@
'use client'
import { useState, useCallback, useEffect, useRef } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { X, GearSix, Buildings, User } from '@phosphor-icons/react'
import { Button } from '@ciphera-net/ui'
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 — 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 ──────────────────────────────────────────────────────
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,
activeSiteDomain,
}: {
active: SettingsContext
onChange: (ctx: SettingsContext) => void
activeSiteDomain: string | null
}) {
return (
<div className="flex items-center gap-1 p-1 bg-neutral-800/50 rounded-xl">
{/* Site button — locked to current site, no dropdown */}
{activeSiteDomain && (
<button
onClick={() => onChange('site')}
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
active === 'site'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-400 hover:text-white'
}`}
>
<GearSix weight="bold" className="w-4 h-4" />
<span className="hidden sm:inline">{activeSiteDomain}</span>
</button>
)}
<button
onClick={() => onChange('workspace')}
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
active === 'workspace'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-400 hover:text-white'
}`}
>
<Buildings weight="bold" className="w-4 h-4" />
<span className="hidden sm:inline">Organization</span>
</button>
<button
onClick={() => onChange('account')}
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
active === 'account'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-400 hover:text-white'
}`}
>
<User weight="bold" className="w-4 h-4" />
<span className="hidden sm:inline">Account</span>
</button>
</div>
)
}
// ─── Tab Bar ────────────────────────────────────────────────────
function TabBar({
tabs,
activeTab,
onChange,
}: {
tabs: TabDef[]
activeTab: string
onChange: (id: string) => void
}) {
return (
<div className="flex gap-1 overflow-x-auto overflow-y-hidden scrollbar-hide pb-px">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`relative px-3 py-2 text-sm font-medium whitespace-nowrap rounded-lg transition-all duration-200 ${
activeTab === tab.id
? 'text-brand-orange'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="settings-tab-indicator"
className="absolute bottom-0 left-2 right-2 h-0.5 bg-brand-orange rounded-full"
transition={{ type: 'spring', bounce: 0.2, duration: 0.4 }}
/>
)}
</button>
))}
</div>
)
}
// ─── Tab Content ────────────────────────────────────────────────
function ComingSoon({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-neutral-800 p-4 mb-4">
<GearSix className="w-8 h-8 text-neutral-500" />
</div>
<h3 className="text-lg font-semibold text-white mb-1">{label}</h3>
<p className="text-sm text-neutral-400 max-w-sm">
This section is being migrated. For now, use the existing settings page.
</p>
</div>
)
}
function TabContent({
context,
activeTab,
siteId,
onDirtyChange,
onRegisterSave,
}: {
context: SettingsContext
activeTab: string
siteId: string | null
onDirtyChange: (dirty: boolean) => void
onRegisterSave: (fn: () => Promise<void>) => void
}) {
const dirtyProps = { onDirtyChange, onRegisterSave }
// Site tabs
if (context === 'site' && siteId) {
switch (activeTab) {
case 'general': return <SiteGeneralTab siteId={siteId} {...dirtyProps} />
case 'goals': return <SiteGoalsTab siteId={siteId} />
case 'visibility': return <SiteVisibilityTab siteId={siteId} {...dirtyProps} />
case 'privacy': return <SitePrivacyTab siteId={siteId} {...dirtyProps} />
case 'bot-spam': return <SiteBotSpamTab siteId={siteId} {...dirtyProps} />
case 'reports': return <SiteReportsTab siteId={siteId} />
case 'integrations': return <SiteIntegrationsTab siteId={siteId} />
}
}
// Workspace tabs
if (context === 'workspace') {
switch (activeTab) {
case 'general': return <WorkspaceGeneralTab {...dirtyProps} />
case 'billing': return <WorkspaceBillingTab />
case 'members': return <WorkspaceMembersTab />
case 'notifications': return <WorkspaceNotificationsTab {...dirtyProps} />
case 'audit': return <WorkspaceAuditTab />
}
}
// Account tabs
if (context === 'account') {
switch (activeTab) {
case 'profile': return <AccountProfileTab {...dirtyProps} />
case 'security': return <AccountSecurityTab />
case 'devices': return <AccountDevicesTab />
}
}
return null
}
// ─── Main Modal ─────────────────────────────────────────────────
export default function UnifiedSettingsModal() {
const { isOpen, openUnifiedSettings, closeUnifiedSettings: closeSettings, initialTab: initTab } = useUnifiedSettings()
const { user } = useAuth()
const [context, setContext] = useState<SettingsContext>('site')
const [siteTabs, setSiteTabs] = useState('general')
const [workspaceTabs, setWorkspaceTabs] = useState('general')
const [accountTabs, setAccountTabs] = useState('profile')
const [sites, setSites] = useState<Site[]>([])
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
// ─── Dirty state & pending navigation ────────────────────────
const isDirtyRef = useRef(false)
const [isDirtyVisible, setIsDirtyVisible] = useState(false)
const pendingActionRef = useRef<(() => void) | null>(null)
const [hasPendingAction, setHasPendingAction] = useState(false)
const saveHandlerRef = useRef<(() => Promise<void>) | null>(null)
const [saving, setSaving] = useState(false)
const [showGlass, setShowGlass] = useState(false)
const handleDirtyChange = useCallback((dirty: boolean) => {
isDirtyRef.current = dirty
setIsDirtyVisible(dirty)
// If user saved and there was a pending action, execute it
if (!dirty && pendingActionRef.current) {
const action = pendingActionRef.current
pendingActionRef.current = null
setHasPendingAction(false)
action()
}
}, [])
const handleRegisterSave = useCallback((fn: () => Promise<void>) => {
saveHandlerRef.current = fn
}, [])
const handleSaveFromBar = useCallback(async () => {
if (!saveHandlerRef.current) return
setSaving(true)
try {
await saveHandlerRef.current()
} finally {
setSaving(false)
}
}, [])
/** Run action if clean, or store as pending if dirty */
const guardedAction = useCallback((action: () => void) => {
if (isDirtyRef.current) {
pendingActionRef.current = action
setHasPendingAction(true)
} else {
action()
}
}, [])
const handleDiscard = useCallback(() => {
isDirtyRef.current = false
setIsDirtyVisible(false)
setHasPendingAction(false)
saveHandlerRef.current = null
const action = pendingActionRef.current
pendingActionRef.current = null
action?.()
}, [])
// 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])
// Reset dirty state when modal opens
useEffect(() => {
if (isOpen) {
isDirtyRef.current = false
pendingActionRef.current = null
setHasPendingAction(false)
setShowGlass(true)
}
}, [isOpen])
// Detect site from URL and load sites list when modal opens
useEffect(() => {
if (!isOpen || !user?.org_id) return
if (typeof window !== 'undefined') {
const match = window.location.pathname.match(/\/sites\/([a-f0-9-]+)/)
if (match) {
setActiveSiteId(match[1])
setContext('site')
} else {
setActiveSiteId(null)
if (!initTab?.context) setContext('workspace')
}
}
listSites().then(data => {
setSites(Array.isArray(data) ? data : [])
}).catch(() => {})
}, [isOpen, user?.org_id])
// Global keyboard shortcuts: `,` toggles settings, Escape closes
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()
if (isOpen) guardedAction(closeSettings)
else openUnifiedSettings()
}
if (e.key === 'Escape' && isOpen) {
guardedAction(closeSettings)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, openUnifiedSettings, closeSettings, guardedAction])
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) => {
guardedAction(() => {
setContext(ctx)
if (ctx === 'site') setSiteTabs('general')
else if (ctx === 'workspace') setWorkspaceTabs('general')
else if (ctx === 'account') setAccountTabs('profile')
})
}, [guardedAction])
const handleTabChange = useCallback((tabId: string) => {
guardedAction(() => setActiveTab(tabId))
}, [guardedAction, setActiveTab])
const handleClose = useCallback(() => {
guardedAction(closeSettings)
}, [guardedAction, closeSettings])
const handleBackdropClick = useCallback(() => {
guardedAction(closeSettings)
}, [guardedAction, closeSettings])
return (
<>
{/* Backdrop — fades in/out */}
<div
className={`fixed inset-0 z-[60] bg-black/50 transition-opacity duration-200 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={handleBackdropClick}
/>
{/* Glass panel — always mounted, fades out on close */}
<div
className={`fixed inset-0 z-[61] flex items-center justify-center p-4 ${
isOpen
? 'opacity-100 pointer-events-auto transition-opacity duration-150'
: showGlass
? 'opacity-0 pointer-events-none transition-opacity duration-150'
: 'opacity-0 pointer-events-none invisible'
}`}
>
<div
className="relative w-full max-w-3xl h-[85vh] bg-neutral-900/65 backdrop-blur-3xl backdrop-saturate-150 supports-[backdrop-filter]:bg-neutral-900/60 border border-white/[0.08] rounded-2xl shadow-xl shadow-black/20 flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Content animates in/out */}
<AnimatePresence onExitComplete={() => setShowGlass(false)}>
{isOpen && (
<motion.div
className="flex flex-col h-full"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
>
{/* Header */}
<div className="shrink-0 px-6 pt-5 pb-4 border-b border-white/[0.06]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Settings</h2>
<button
onClick={handleClose}
className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
>
<X weight="bold" className="w-4 h-4" />
</button>
</div>
{/* Context Switcher */}
<ContextSwitcher
active={context}
onChange={handleContextChange}
activeSiteDomain={sites.find(s => s.id === activeSiteId)?.domain ?? null}
/>
{/* Tabs */}
<div className="mt-4">
<TabBar tabs={tabs} activeTab={activeTab} onChange={handleTabChange} />
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<AnimatePresence mode="wait">
<motion.div
key={`${context}-${activeTab}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="p-6"
>
<TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} onRegisterSave={handleRegisterSave} />
</motion.div>
</AnimatePresence>
</div>
{/* Save bar */}
<AnimatePresence>
{isDirtyVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="shrink-0 overflow-hidden"
>
<div className={`px-6 py-3 border-t flex items-center justify-between ${
hasPendingAction
? 'bg-red-900/10 border-red-900/30'
: 'bg-neutral-950/80 border-white/[0.06]'
}`}>
<span className="text-sm font-medium text-neutral-400">
{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<Button onClick={handleDiscard} variant="secondary" className="text-sm">
Discard
</Button>
)}
<Button onClick={handleSaveFromBar} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
export default function AccountDevicesTab() {
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Devices & Activity</h3>
<p className="text-sm text-neutral-400">Manage trusted devices and review security activity.</p>
</div>
<TrustedDevicesCard />
<SecurityActivityCard />
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Input, toast, Spinner } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { updateDisplayName } from '@/lib/api/user'
import { deleteAccount } from '@/lib/api/user'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { DangerZone } from '@/components/settings/unified/DangerZone'
export default function AccountProfileTab({ onDirtyChange, onRegisterSave }: { onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { user, refresh, logout } = useAuth()
const [displayName, setDisplayName] = useState('')
const initialRef = useRef('')
const hasInitialized = useRef(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteText, setDeleteText] = useState('')
const [deletePassword, setDeletePassword] = useState('')
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (!user || hasInitialized.current) return
setDisplayName(user.display_name || '')
initialRef.current = user.display_name || ''
hasInitialized.current = true
}, [user])
// Track dirty state
useEffect(() => {
if (!hasInitialized.current) return
onDirtyChange?.(displayName !== initialRef.current)
}, [displayName, onDirtyChange])
const handleSave = useCallback(async () => {
try {
await updateDisplayName(displayName.trim())
await refresh()
initialRef.current = displayName.trim()
onDirtyChange?.(false)
toast.success('Profile updated')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to update profile')
}
}, [displayName, refresh, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleDelete = async () => {
if (deleteText !== 'DELETE' || !deletePassword) return
setDeleting(true)
try {
await deleteAccount(deletePassword)
logout()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete account')
setDeleting(false)
}
}
if (!user) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Profile</h3>
<p className="text-sm text-neutral-400">Manage your personal account settings.</p>
</div>
{/* Display Name */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Display Name</label>
<Input
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Your name"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Email Address</label>
<Input value={user.email} disabled className="opacity-60" />
<p className="text-xs text-neutral-500 mt-1">Email changes require password verification. Use <a href="https://auth.ciphera.net/settings" target="_blank" rel="noopener noreferrer" className="text-brand-orange hover:underline">Ciphera Auth</a> to change your email.</p>
</div>
</div>
{/* Danger Zone */}
<DangerZone
items={[
{
title: 'Delete Account',
description: 'Permanently delete your account and all associated data.',
buttonLabel: 'Delete',
variant: 'solid',
onClick: () => setShowDeleteConfirm(prev => !prev),
},
]}
/>
{showDeleteConfirm && (
<div className="p-4 border border-red-900/50 bg-red-900/10 rounded-xl space-y-3">
<p className="text-sm text-red-300">This will permanently delete:</p>
<ul className="text-xs text-neutral-400 list-disc list-inside space-y-1">
<li>Your account and all personal data</li>
<li>All sessions and trusted devices</li>
<li>You will be removed from all organizations</li>
</ul>
<div>
<label className="block text-xs text-neutral-400 mb-1">Your password</label>
<Input
type="password"
value={deletePassword}
onChange={e => setDeletePassword(e.target.value)}
placeholder="Enter your password"
/>
</div>
<div>
<label className="block text-xs text-neutral-400 mb-1">Type DELETE to confirm</label>
<Input
type="text"
value={deleteText}
onChange={e => setDeleteText(e.target.value)}
placeholder="DELETE"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={deleteText !== 'DELETE' || !deletePassword || deleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete Account'}
</button>
<button onClick={() => { setShowDeleteConfirm(false); setDeleteText(''); setDeletePassword('') }} className="px-4 py-2 text-neutral-400 hover:text-white text-sm">
Cancel
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use client'
import ProfileSettings from '@/components/settings/ProfileSettings'
export default function AccountSecurityTab() {
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Security</h3>
<p className="text-sm text-neutral-400">Manage your password and two-factor authentication.</p>
</div>
<ProfileSettings activeTab="security" borderless />
</div>
)
}

View File

@@ -0,0 +1,223 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
import { ShieldCheck } from '@phosphor-icons/react'
import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard'
import { updateSite } from '@/lib/api/sites'
import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter'
export default function SiteBotSpamTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { data: site, mutate } = useSite(siteId)
const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId)
const [filterBots, setFilterBots] = useState(false)
const initialFilterRef = useRef<boolean | null>(null)
const [botView, setBotView] = useState<'review' | 'blocked'>('review')
const [suspiciousOnly, setSuspiciousOnly] = useState(true)
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [botDateRange] = useState(() => getDateRange(7))
const { data: sessionsData, mutate: mutateSessions } = useSessions(siteId, botDateRange.start, botDateRange.end, botView === 'review' ? suspiciousOnly : false)
const sessions = sessionsData?.sessions
const hasInitialized = useRef(false)
useEffect(() => {
if (!site || hasInitialized.current) return
setFilterBots(site.filter_bots ?? false)
initialFilterRef.current = site.filter_bots ?? false
hasInitialized.current = true
}, [site])
// Track dirty state
useEffect(() => {
if (initialFilterRef.current === null) return
const dirty = filterBots !== initialFilterRef.current
onDirtyChange?.(dirty)
}, [filterBots, onDirtyChange])
const handleSave = useCallback(async () => {
try {
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
await mutate()
initialFilterRef.current = filterBots
onDirtyChange?.(false)
toast.success('Bot filtering updated')
} catch {
toast.error('Failed to save')
}
}, [siteId, site?.name, filterBots, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleBotFilter = async (sessionIds: string[]) => {
try {
await botFilterSessions(siteId, sessionIds)
toast.success(`${sessionIds.length} session(s) flagged as bot`)
setSelectedSessions(new Set())
mutateSessions()
mutateBotStats()
} catch {
toast.error('Failed to flag sessions')
}
}
const handleBotUnfilter = async (sessionIds: string[]) => {
try {
await botUnfilterSessions(siteId, sessionIds)
toast.success(`${sessionIds.length} session(s) unblocked`)
setSelectedSessions(new Set())
mutateSessions()
mutateBotStats()
} catch {
toast.error('Failed to unblock sessions')
}
}
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Bot & Spam Filtering</h3>
<p className="text-sm text-neutral-400">Automatically filter bot traffic and referrer spam from your analytics.</p>
</div>
{/* Bot filtering toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div className="flex items-center gap-3">
<ShieldCheck weight="bold" className="w-5 h-5 text-brand-orange" />
<div>
<p className="text-sm font-medium text-white">Enable bot filtering</p>
<p className="text-xs text-neutral-500">Filter known bots, crawlers, referrer spam, and suspicious traffic.</p>
</div>
</div>
<Toggle checked={filterBots} onChange={() => setFilterBots(p => !p)} />
</div>
{/* Stats */}
{botStats && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.filtered_sessions ?? 0}</p>
<p className="text-xs text-neutral-500 mt-1">Sessions filtered</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.filtered_events ?? 0}</p>
<p className="text-xs text-neutral-500 mt-1">Events filtered</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 text-center">
<p className="text-2xl font-bold text-white">{botStats.auto_blocked_this_month ?? 0}</p>
<p className="text-xs text-neutral-500 mt-1">Auto-blocked this month</p>
</div>
</div>
)}
{/* Session Review */}
<div className="space-y-3 pt-6 border-t border-neutral-800">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-neutral-300">Session Review</h4>
{/* Review/Blocked toggle */}
<div className="flex items-center rounded-lg border border-neutral-700 overflow-hidden text-sm">
<button
onClick={() => { setBotView('review'); setSelectedSessions(new Set()) }}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${botView === 'review' ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
>
Review
</button>
<button
onClick={() => { setBotView('blocked'); setSelectedSessions(new Set()) }}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${botView === 'blocked' ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
>
Blocked
</button>
</div>
</div>
{/* Suspicious only filter (review mode only) */}
{botView === 'review' && (
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div>
<p className="text-sm font-medium text-white">Suspicious only</p>
<p className="text-xs text-neutral-500">Show only sessions flagged as suspicious.</p>
</div>
<Toggle checked={suspiciousOnly} onChange={() => setSuspiciousOnly(v => !v)} />
</div>
)}
{/* Bulk actions bar */}
{selectedSessions.size > 0 && (
<div className="flex items-center gap-3 p-2 bg-brand-orange/10 border border-brand-orange/20 rounded-lg text-sm">
<span className="text-neutral-300">{selectedSessions.size} selected</span>
{botView === 'review' ? (
<button onClick={() => handleBotFilter(Array.from(selectedSessions))} className="text-red-400 hover:text-red-300 font-medium">Flag as bot</button>
) : (
<button onClick={() => handleBotUnfilter(Array.from(selectedSessions))} className="text-green-400 hover:text-green-300 font-medium">Unblock</button>
)}
<button onClick={() => setSelectedSessions(new Set())} className="text-neutral-500 hover:text-neutral-300 ml-auto">Clear</button>
</div>
)}
{/* Session cards */}
<div className="space-y-2 max-h-96 overflow-y-auto">
{(sessions || [])
.filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered)
.map(session => (
<div key={session.session_id} className="flex items-center gap-3 p-3 rounded-xl border border-neutral-800 hover:bg-neutral-800/40 hover:border-neutral-700 transition-colors">
<input
type="checkbox"
checked={selectedSessions.has(session.session_id)}
onChange={e => {
const next = new Set(selectedSessions)
e.target.checked ? next.add(session.session_id) : next.delete(session.session_id)
setSelectedSessions(next)
}}
className="w-4 h-4 shrink-0 cursor-pointer"
style={{ accentColor: '#FD5E0F' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate">{session.first_page || '/'}</span>
{session.suspicion_score != null && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
session.suspicion_score >= 5 ? 'bg-red-900/30 text-red-400' :
session.suspicion_score >= 3 ? 'bg-yellow-900/30 text-yellow-400' :
'bg-neutral-800 text-neutral-400'
}`}>
{session.suspicion_score >= 5 ? 'High risk' : session.suspicion_score >= 3 ? 'Suspicious' : 'Low risk'}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-neutral-500 mt-0.5">
<span>{session.pageviews} page(s)</span>
<span>{session.duration ? `${Math.round(session.duration)}s` : 'No duration'}</span>
<span>{[session.city, session.country].filter(Boolean).join(', ') || 'Unknown location'}</span>
<span>{session.browser || 'Unknown browser'}</span>
<span>{session.referrer || 'Direct'}</span>
</div>
</div>
<button
onClick={() => botView === 'review' ? handleBotFilter([session.session_id]) : handleBotUnfilter([session.session_id])}
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
botView === 'review'
? 'text-red-400 border-red-500/20 hover:bg-red-900/20'
: 'text-green-400 border-green-500/20 hover:bg-green-900/20'
}`}
>
{botView === 'review' ? 'Flag as bot' : 'Unblock'}
</button>
</div>
))}
{(!sessions || sessions.filter(s => botView === 'blocked' ? s.bot_filtered : !s.bot_filtered).length === 0) && (
<p className="text-sm text-neutral-500 text-center py-4">
{botView === 'blocked' ? 'No blocked sessions' : 'No suspicious sessions found'}
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
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 { DangerZone } from '@/components/settings/unified/DangerZone'
import DeleteSiteModal from '@/components/sites/DeleteSiteModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import VerificationModal from '@/components/sites/VerificationModal'
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)' },
]
export default function SiteGeneralTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const router = useRouter()
const { user } = useAuth()
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
const { data: site, mutate } = useSite(siteId)
const [name, setName] = useState('')
const [timezone, setTimezone] = useState('UTC')
const [scriptFeatures, setScriptFeatures] = useState<Record<string, unknown>>({})
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false)
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const initialRef = useRef('')
const hasInitialized = useRef(false)
useEffect(() => {
if (!site || hasInitialized.current) return
setName(site.name || '')
setTimezone(site.timezone || 'UTC')
setScriptFeatures(site.script_features || {})
initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC', scriptFeatures: JSON.stringify(site.script_features || {}) })
hasInitialized.current = true
}, [site])
// Track dirty state
useEffect(() => {
if (!initialRef.current) return
const current = JSON.stringify({ name, timezone, scriptFeatures: JSON.stringify(scriptFeatures) })
onDirtyChange?.(current !== initialRef.current)
}, [name, timezone, scriptFeatures, onDirtyChange])
const handleSave = useCallback(async () => {
if (!site) return
try {
await updateSite(siteId, { name, timezone, script_features: scriptFeatures })
await mutate()
initialRef.current = JSON.stringify({ name, timezone, scriptFeatures: JSON.stringify(scriptFeatures) })
onDirtyChange?.(false)
toast.success('Site updated')
} catch {
toast.error('Failed to save')
}
}, [site, siteId, name, timezone, scriptFeatures, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleResetData = async () => {
if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) return
try {
await resetSiteData(siteId)
toast.success('All site data has been reset')
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to reset site data')
}
}
if (!site || !hasInitialized.current) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="w-6 h-6 text-neutral-500" />
</div>
)
}
return (
<div className="space-y-6">
{/* Site details */}
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold text-white mb-1">General Configuration</h3>
<p className="text-sm text-neutral-400">Update your site details and tracking script.</p>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Site Name</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="My Website" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Domain</label>
<Input value={site.domain} disabled className="opacity-60" />
<p className="text-xs text-neutral-500 mt-1">Cannot be changed.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Timezone</label>
<Select
value={timezone}
onChange={setTimezone}
variant="input"
options={TIMEZONES.map(tz => ({ value: tz.value, label: tz.label }))}
/>
</div>
</div>
</div>
{/* Tracking Script */}
<div className="space-y-3">
<div>
<h3 className="text-base font-semibold text-white mb-1">Tracking Script</h3>
<p className="text-sm text-neutral-400">Add this to your website to start tracking visitors. Choose your framework for setup instructions.</p>
</div>
<ScriptSetupBlock
site={{ domain: site.domain, name: site.name, script_features: scriptFeatures }}
showFrameworkPicker
className="mb-4"
onFeaturesChange={(features) => setScriptFeatures(features)}
/>
{/* Verify Installation */}
<button
type="button"
onClick={() => setShowVerificationModal(true)}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-xl border transition-colors ${
site.is_verified
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-900/30 text-green-700 dark:text-green-400'
: 'bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
{site.is_verified ? (
<>
<CheckIcon className="w-4 h-4" />
Verified
</>
) : (
<>
<ZapIcon className="w-4 h-4" />
Verify Installation
</>
)}
</button>
<p className="text-xs text-neutral-500">
{site.is_verified ? 'Your site is sending data correctly.' : 'Check if your site is sending data correctly.'}
</p>
</div>
{/* Danger Zone */}
{canEdit && (
<DangerZone
items={[
{
title: 'Reset Data',
description: 'Delete all stats and events. This cannot be undone.',
buttonLabel: 'Reset Data',
variant: 'outline',
onClick: handleResetData,
},
{
title: 'Delete Site',
description: 'Schedule this site for deletion with a 7-day grace period.',
buttonLabel: 'Delete Site...',
variant: 'solid',
onClick: () => setShowDeleteModal(true),
},
]}
/>
)}
<DeleteSiteModal
open={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onDeleted={() => { router.push('/'); closeSettings(); }}
siteName={site?.name || ''}
siteDomain={site?.domain || ''}
siteId={siteId}
/>
<VerificationModal
isOpen={showVerificationModal}
onClose={() => setShowVerificationModal(false)}
site={site}
onVerified={() => mutate()}
/>
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="flex items-center justify-center py-12">
<Spinner className="w-6 h-6 text-neutral-500" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Goals</h3>
<p className="text-sm text-neutral-400">Track custom events as conversion goals.</p>
</div>
{!creating && !editing && (
<Button onClick={startCreate} variant="primary" className="text-sm gap-1.5">
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Goal
</Button>
)}
</div>
{/* Create/Edit form */}
{(creating || editing) && (
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1">Display Name</label>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Sign Up"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1">Event Name</label>
<Input
value={eventName}
onChange={e => setEventName(e.target.value)}
placeholder="e.g. signup_click"
disabled={!!editing}
/>
</div>
</div>
<div className="flex items-center gap-2 justify-end">
<Button onClick={cancel} variant="secondary" className="text-sm">Cancel</Button>
<Button onClick={handleSave} variant="primary" className="text-sm" disabled={saving}>
{saving ? 'Saving...' : editing ? 'Update' : 'Create'}
</Button>
</div>
</div>
)}
{/* Goals list */}
{goals.length === 0 && !creating ? (
<div className="text-center py-8">
<p className="text-sm text-neutral-500 mb-3">No goals yet. Add a goal to track custom events.</p>
<Button onClick={startCreate} variant="primary" className="text-sm gap-1.5">
<Plus weight="bold" className="w-3.5 h-3.5" /> Add your first goal
</Button>
</div>
) : (
<div className="space-y-1">
{goals.map(goal => (
<div
key={goal.id}
className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group"
>
<div>
<p className="text-sm font-medium text-white">{goal.name}</p>
<p className="text-xs text-neutral-500 font-mono">{goal.event_name}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => startEdit(goal)}
className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
>
<Pencil weight="bold" className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(goal.id)}
className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors"
>
<Trash weight="bold" className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,383 @@
'use client'
import { useState } from 'react'
import { Button, Input, Select, toast, Spinner } from '@ciphera-net/ui'
import { Plugs, LinkBreak, ShieldCheck } from '@phosphor-icons/react'
import { useGSCStatus, useBunnyStatus } from '@/lib/swr/dashboard'
import { disconnectGSC, getGSCAuthURL } from '@/lib/api/gsc'
import { disconnectBunny, getBunnyPullZones, connectBunny, type BunnyPullZone } from '@/lib/api/bunny'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatDateTime } from '@/lib/utils/formatDate'
function GoogleIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
)
}
function BunnyIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 23 26" fill="none">
<path fillRule="evenodd" clipRule="evenodd" d="M9.94 7.77l5.106.883c-3.83-.663-4.065-3.85-9.218-6.653-.562 1.859.603 5.21 4.112 5.77z" fill="url(#b1)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M5.828 2c5.153 2.803 5.388 5.99 9.218 6.653 1.922.332.186 3.612-1.864 3.266 3.684 1.252 7.044-2.085 5.122-3.132L5.828 2z" fill="url(#b2)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M13.186 11.92c-.241-.041-.486-.131-.731-.284-1.542-.959-3.093-1.269-4.496-1.118 2.93.359 5.716 4.196 5.37 7.036.06.97-.281 1.958-1.021 2.699l-1.69 1.69c1.303.858 3.284-.037 3.889-1.281l3.41-7.014c.836-.198 6.176-1.583 3.767-3.024l-3.37-1.833c1.907 1.05-1.449 4.378-5.125 3.129z" fill="url(#b3)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.953 10.518c-4.585.499-7.589 5.94-3.506 9.873l3.42 3.42c-2.243-2.243-2.458-5.525-1.073-7.806.149-.255.333-.495.551-.713 1.37-1.37 3.59-1.37 4.96 0 .629.628.969 1.436 1.02 2.26.346-2.84-2.439-6.675-5.367-7.035h-.005z" fill="url(#b4)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M7.868 23.812l1.925 1.925c.643-.511 1.028-2.01.031-3.006l-2.48-2.48c-1.151-1.151-1.334-2.903-.55-4.246-1.385 2.281-1.17 5.563 1.074 7.807z" fill="url(#b5)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M12.504 4.54l5.739 3.122L12.925.6c-.728.829-1.08 2.472-.421 3.94z" fill="url(#b6)"/>
<circle cx="9.825" cy="17.772" r="1.306" fill="url(#b7)"/>
<circle cx="1.507" cy="11.458" r="1.306" fill="url(#b8)"/>
<defs>
<linearGradient id="b1" x1="5.69" y1="8.5" x2="15.04" y2="8.5" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b2" x1="5.83" y1="12.65" x2="18.87" y2="12.65" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b3" x1="7.95" y1="22.04" x2="22.3" y2="22.04" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b4" x1="2.51" y1="22.59" x2="13.35" y2="22.59" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b5" x1="11.35" y1="20.74" x2="7.98" y2="17.71" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset=".69" stopColor="#FF7300"/><stop offset="1" stopColor="#F52900"/></linearGradient>
<linearGradient id="b6" x1="12.16" y1="7.48" x2="18.24" y2="7.48" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b7" x1="8.52" y1="19.08" x2="11.13" y2="19.08" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
<linearGradient id="b8" x1=".2" y1="12.76" x2="2.81" y2="12.76" gradientUnits="userSpaceOnUse"><stop stopColor="#FFA600"/><stop offset=".34" stopColor="#FF9F00"/><stop offset="1" stopColor="#FF6200"/></linearGradient>
</defs>
</svg>
)
}
function IntegrationCard({
icon,
name,
description,
connected,
detail,
onConnect,
onDisconnect,
connectLabel = 'Connect',
children,
}: {
icon: React.ReactNode
name: string
description: string
connected: boolean
detail?: string
onConnect: () => void
onDisconnect: () => void
connectLabel?: string
children?: React.ReactNode
}) {
return (
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30">
<div className="flex items-center justify-between py-4 px-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-neutral-800">{icon}</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">{name}</p>
{connected && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/30 text-green-400 border border-green-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
Connected
</span>
)}
</div>
<p className="text-xs text-neutral-400">{detail || description}</p>
</div>
</div>
{connected ? (
<Button onClick={onDisconnect} variant="secondary" className="text-sm text-red-400 border-red-900/50 hover:bg-red-900/20 gap-1.5">
<LinkBreak weight="bold" className="w-3.5 h-3.5" /> Disconnect
</Button>
) : (
<Button onClick={onConnect} variant="primary" className="text-sm gap-1.5">
<Plugs weight="bold" className="w-3.5 h-3.5" /> {connectLabel}
</Button>
)}
</div>
{children}
</div>
)
}
function SecurityNote({ text }: { text: string }) {
return (
<div className="flex items-start gap-2 px-4 py-3 mx-4 mb-4 rounded-lg bg-neutral-800/40 border border-neutral-700/50">
<ShieldCheck weight="bold" className="w-4 h-4 text-neutral-400 mt-0.5 shrink-0" />
<p className="text-xs text-neutral-400 leading-relaxed">{text}</p>
</div>
)
}
function StatusDot({ status }: { status?: string }) {
const color =
status === 'active' ? 'bg-green-400' :
status === 'syncing' ? 'bg-yellow-400 animate-pulse' :
status === 'error' ? 'bg-red-400' :
'bg-neutral-500'
const label =
status === 'active' ? 'Connected' :
status === 'syncing' ? 'Syncing' :
status === 'error' ? 'Error' :
'Unknown'
return (
<span className="inline-flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-sm text-white">{label}</span>
</span>
)
}
function GSCDetails({ gscStatus }: { gscStatus: { connected: boolean; google_email?: string; gsc_property?: string; status?: string; last_synced_at?: string | null; error_message?: string | null } }) {
if (!gscStatus.connected) return null
const rows = [
{ label: 'Google Account', value: gscStatus.google_email || 'Unknown' },
{ label: 'GSC Property', value: gscStatus.gsc_property || 'Unknown' },
{ label: 'Last Synced', value: gscStatus.last_synced_at ? formatDateTime(new Date(gscStatus.last_synced_at)) : 'Never' },
]
return (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-4 py-3 rounded-lg bg-neutral-800/40 border border-neutral-700/50">
{rows.map(row => (
<div key={row.label} className="flex flex-col gap-0.5">
<span className="text-xs text-neutral-500">{row.label}</span>
<span className="text-sm text-white">{row.value}</span>
</div>
))}
</div>
{gscStatus.error_message && (
<div className="px-4 py-3 rounded-lg bg-red-900/20 border border-red-900/50">
<p className="text-xs text-red-400">{gscStatus.error_message}</p>
</div>
)}
</div>
)
}
function BunnySetupForm({ siteId, onConnected }: { siteId: string; onConnected: () => void }) {
const [apiKey, setApiKey] = useState('')
const [pullZones, setPullZones] = useState<BunnyPullZone[]>([])
const [selectedZone, setSelectedZone] = useState<BunnyPullZone | null>(null)
const [loadingZones, setLoadingZones] = useState(false)
const [connecting, setConnecting] = useState(false)
const [zonesLoaded, setZonesLoaded] = useState(false)
const handleLoadZones = async () => {
if (!apiKey.trim()) {
toast.error('Please enter your BunnyCDN API key')
return
}
setLoadingZones(true)
try {
const data = await getBunnyPullZones(siteId, apiKey.trim())
setPullZones(data.pull_zones || [])
setSelectedZone(null)
setZonesLoaded(true)
if (!data.pull_zones?.length) {
toast.error('No pull zones found for this API key')
}
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to load pull zones')
} finally {
setLoadingZones(false)
}
}
const handleConnect = async () => {
if (!selectedZone) {
toast.error('Please select a pull zone')
return
}
setConnecting(true)
try {
await connectBunny(siteId, apiKey.trim(), selectedZone.id, selectedZone.name)
toast.success('BunnyCDN connected successfully')
onConnected()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to connect BunnyCDN')
} finally {
setConnecting(false)
}
}
return (
<div className="px-4 pb-4 space-y-3">
<div className="space-y-3 px-4 py-3 rounded-lg bg-neutral-800/40 border border-neutral-700/50">
<div className="space-y-1.5">
<label className="text-xs font-medium text-neutral-400">API Key</label>
<div className="flex gap-2">
<Input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="Enter your BunnyCDN API key"
className="flex-1"
/>
<Button
onClick={handleLoadZones}
variant="secondary"
className="text-sm shrink-0"
disabled={loadingZones || !apiKey.trim()}
>
{loadingZones ? <Spinner className="w-4 h-4" /> : 'Load Zones'}
</Button>
</div>
</div>
{zonesLoaded && pullZones.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-neutral-400">Pull Zone</label>
<Select
value={String(selectedZone?.id ?? '')}
onChange={(v) => {
const zone = pullZones.find(z => z.id === Number(v))
setSelectedZone(zone || null)
}}
variant="input"
fullWidth
options={[
{ value: '', label: 'Select a pull zone' },
...pullZones.map(zone => ({ value: String(zone.id), label: zone.name })),
]}
/>
</div>
)}
{zonesLoaded && pullZones.length > 0 && (
<Button
onClick={handleConnect}
variant="primary"
className="text-sm w-full"
disabled={connecting || !selectedZone}
>
{connecting ? <Spinner className="w-4 h-4" /> : 'Connect BunnyCDN'}
</Button>
)}
</div>
</div>
)
}
export default function SiteIntegrationsTab({ siteId }: { siteId: string }) {
const { data: gscStatus, isLoading: gscLoading, mutate: mutateGSC } = useGSCStatus(siteId)
const { data: bunnyStatus, isLoading: bunnyLoading, mutate: mutateBunny } = useBunnyStatus(siteId)
const [showBunnySetup, setShowBunnySetup] = useState(false)
if (gscLoading || bunnyLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="w-6 h-6 text-neutral-500" />
</div>
)
}
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 = () => {
setShowBunnySetup(true)
}
const handleDisconnectBunny = async () => {
if (!confirm('Disconnect BunnyCDN? This will remove all synced CDN data.')) return
try {
await disconnectBunny(siteId)
await mutateBunny()
setShowBunnySetup(false)
toast.success('BunnyCDN disconnected')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to disconnect')
}
}
const bunnyConnected = bunnyStatus?.connected ?? false
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Integrations</h3>
<p className="text-sm text-neutral-400">Connect third-party services to enrich your analytics.</p>
</div>
<div className="space-y-3">
<IntegrationCard
icon={<GoogleIcon />}
name="Google Search Console"
description="View search queries, clicks, impressions, and ranking data."
connected={gscStatus?.connected ?? false}
detail={undefined}
onConnect={handleConnectGSC}
onDisconnect={handleDisconnectGSC}
connectLabel="Connect with Google"
>
{gscStatus?.connected && <GSCDetails gscStatus={gscStatus} />}
<SecurityNote text="Pulse only requests read-only access. Your tokens are encrypted at rest." />
</IntegrationCard>
<IntegrationCard
icon={<BunnyIcon />}
name="BunnyCDN"
description="Monitor bandwidth, cache hit rates, and CDN performance."
connected={bunnyConnected}
detail={undefined}
onConnect={handleConnectBunny}
onDisconnect={handleDisconnectBunny}
>
{bunnyConnected && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-4 py-3 rounded-lg bg-neutral-800/40 border border-neutral-700/50">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-neutral-500">Pull Zone</span>
<span className="text-sm text-white">{bunnyStatus?.pull_zone_name || 'Unknown'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-neutral-500">Last Synced</span>
<span className="text-sm text-white">{bunnyStatus?.last_synced_at ? formatDateTime(new Date(bunnyStatus.last_synced_at)) : 'Never'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-neutral-500">Connected Since</span>
<span className="text-sm text-white">{bunnyStatus?.created_at ? formatDateTime(new Date(bunnyStatus.created_at)) : 'Unknown'}</span>
</div>
</div>
{bunnyStatus?.error_message && (
<div className="px-4 py-3 rounded-lg bg-red-900/20 border border-red-900/50">
<p className="text-xs text-red-400">{bunnyStatus.error_message}</p>
</div>
)}
</div>
)}
{!bunnyConnected && showBunnySetup && (
<BunnySetupForm
siteId={siteId}
onConnected={() => {
mutateBunny()
setShowBunnySetup(false)
}}
/>
)}
<SecurityNote text="Your API key is encrypted at rest and only used to fetch read-only statistics." />
</IntegrationCard>
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
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)' },
{ value: 'country', label: 'Country only' },
{ value: 'none', label: 'Disabled' },
]
function PrivacyToggle({ label, desc, checked, onToggle }: { label: string; desc: string; checked: boolean; onToggle: () => void }) {
return (
<div className="flex items-center justify-between p-4 rounded-xl border border-neutral-800 bg-neutral-800/30">
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-neutral-500">{desc}</p>
</div>
<Toggle checked={checked} onChange={onToggle} />
</div>
)
}
export default function SitePrivacyTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
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)
const [collectScreenRes, setCollectScreenRes] = useState(true)
const [collectGeoData, setCollectGeoData] = useState('full')
const [hideUnknownLocations, setHideUnknownLocations] = useState(false)
const [dataRetention, setDataRetention] = useState(6)
const [excludedPaths, setExcludedPaths] = useState('')
const [psiFrequency, setPsiFrequency] = useState('weekly')
const [snippetCopied, setSnippetCopied] = useState(false)
const initialRef = useRef('')
// Sync form state — only on first load, skip dirty tracking until ready
const hasInitialized = useRef(false)
useEffect(() => {
if (!site || hasInitialized.current) return
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)
setExcludedPaths((site.excluded_paths || []).join('\n'))
initialRef.current = JSON.stringify({
collectPagePaths: site.collect_page_paths ?? true,
collectReferrers: site.collect_referrers ?? true,
collectDeviceInfo: site.collect_device_info ?? true,
collectScreenRes: site.collect_screen_resolution ?? true,
collectGeoData: site.collect_geo_data ?? 'full',
hideUnknownLocations: site.hide_unknown_locations ?? false,
dataRetention: site.data_retention_months ?? 6,
excludedPaths: (site.excluded_paths || []).join('\n'),
psiFrequency: 'weekly',
})
hasInitialized.current = true
}, [site])
// Sync PSI frequency separately — update both state AND snapshot when it first loads
const psiInitialized = useRef(false)
useEffect(() => {
if (!psiConfig || psiInitialized.current) return
const freq = psiConfig.frequency || 'weekly'
setPsiFrequency(freq)
// Update the snapshot to include the real PSI frequency so it doesn't show as dirty
if (initialRef.current) {
const snap = JSON.parse(initialRef.current)
snap.psiFrequency = freq
initialRef.current = JSON.stringify(snap)
}
psiInitialized.current = true
}, [psiConfig])
// Track dirty state
useEffect(() => {
if (!initialRef.current) return
const current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, psiFrequency })
onDirtyChange?.(current !== initialRef.current)
}, [collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, psiFrequency, onDirtyChange])
const handleSave = useCallback(async () => {
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,
data_retention_months: dataRetention,
excluded_paths: excludedPaths.split('\n').map(p => p.trim()).filter(Boolean),
})
// Save PSI frequency separately if it changed
if (psiConfig?.enabled && psiFrequency !== (psiConfig.frequency || 'weekly')) {
await updatePageSpeedConfig(siteId, { enabled: psiConfig.enabled, frequency: psiFrequency })
mutatePSIConfig()
}
await mutate()
initialRef.current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, psiFrequency })
onDirtyChange?.(false)
toast.success('Privacy settings updated')
} catch {
toast.error('Failed to save')
}
}, [siteId, site?.name, collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, psiFrequency, psiConfig, mutatePSIConfig, mutate, onDirtyChange])
// Register save handler with modal
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Data & Privacy</h3>
<p className="text-sm text-neutral-400">Control what data is collected from your visitors.</p>
</div>
<div className="space-y-3">
<PrivacyToggle label="Page paths" desc="Track which pages visitors view." checked={collectPagePaths} onToggle={() => setCollectPagePaths(v => !v)} />
<PrivacyToggle label="Referrers" desc="Track where visitors come from." checked={collectReferrers} onToggle={() => setCollectReferrers(v => !v)} />
<PrivacyToggle label="Device info" desc="Track browser, OS, and device type." checked={collectDeviceInfo} onToggle={() => setCollectDeviceInfo(v => !v)} />
<PrivacyToggle label="Screen resolution" desc="Track visitor screen dimensions." checked={collectScreenRes} onToggle={() => setCollectScreenRes(v => !v)} />
<PrivacyToggle label="Hide unknown locations" desc='Exclude "Unknown" from location stats.' checked={hideUnknownLocations} onToggle={() => setHideUnknownLocations(v => !v)} />
</div>
<div className="p-4 rounded-xl border border-neutral-800 bg-neutral-800/30">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">Geographic data</p>
<p className="text-xs text-neutral-500 mt-0.5">Controls location granularity. &quot;Disabled&quot; collects no geographic data at all.</p>
</div>
<Select
value={collectGeoData}
onChange={setCollectGeoData}
variant="input"
options={GEO_OPTIONS}
className="min-w-[200px]"
/>
</div>
</div>
{/* Data Retention */}
<div className="space-y-3 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">Data Retention</h4>
{subscriptionError && (
<div className="p-3 rounded-xl border border-amber-800 bg-amber-900/20 flex items-center justify-between">
<p className="text-xs text-amber-200">Plan limits could not be loaded.</p>
<button onClick={() => mutateSubscription()} className="text-xs font-medium text-amber-400 hover:text-amber-300">Retry</button>
</div>
)}
<div className="p-4 bg-neutral-800/30 rounded-xl border border-neutral-800">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white text-sm">Keep raw event data for</p>
<p className="text-xs text-neutral-500 mt-0.5">Events older than this are automatically deleted. Aggregated daily stats are kept permanently.</p>
</div>
<Select
value={String(dataRetention)}
onChange={(v) => setDataRetention(Number(v))}
options={getRetentionOptionsForPlan(subscription?.plan_id).map(o => ({ value: String(o.value), label: o.label }))}
variant="input"
className="min-w-[160px]"
/>
</div>
{subscription && (
<p className="text-xs text-neutral-500 mt-2">
Your {subscription.plan_id?.includes('pro') ? 'Pro' : 'Free'} plan supports up to {formatRetentionMonths(Math.max(...getRetentionOptionsForPlan(subscription.plan_id).map(o => o.value)))} of data retention.
</p>
)}
{(!subscription || subscription.plan_id?.includes('free')) && (
<p className="text-xs text-neutral-500 mt-2">
<Link href="/pricing" className="text-brand-orange hover:underline">Upgrade</Link> for longer retention.
</p>
)}
</div>
</div>
{/* Path Filtering */}
<div className="space-y-3 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">Path Filtering</h4>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Excluded Paths</label>
<textarea
value={excludedPaths}
onChange={e => setExcludedPaths(e.target.value)}
rows={4}
placeholder={"/admin/*\n/staging/*"}
className="w-full px-4 py-3 border border-neutral-800 rounded-lg bg-neutral-800/30 text-white font-mono text-sm focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all"
/>
<p className="text-xs text-neutral-500 mt-1">Enter paths to exclude from tracking (one per line). Supports wildcards (e.g., /admin/*).</p>
</div>
</div>
{/* PageSpeed Monitoring */}
<div className="space-y-3 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">PageSpeed Monitoring</h4>
<div className="p-4 bg-neutral-800/30 rounded-xl border border-neutral-800">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white text-sm">Check frequency</p>
<p className="text-xs text-neutral-500 mt-0.5">How often PageSpeed Insights runs automated checks.</p>
</div>
{psiConfig?.enabled ? (
<Select
value={psiFrequency}
onChange={(v) => setPsiFrequency(v)}
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
]}
variant="input"
className="min-w-[140px]"
/>
) : (
<span className="text-sm text-neutral-400">Not enabled</span>
)}
</div>
</div>
</div>
{/* Privacy Policy */}
<div className="space-y-3 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">For your privacy policy</h4>
<p className="text-xs text-neutral-500">Copy the text below into your Privacy Policy. It updates automatically based on your saved settings.</p>
<p className="text-xs text-amber-600 dark:text-amber-500">This is provided for convenience and is not legal advice. Consult a lawyer for compliance requirements.</p>
<div className="relative">
<textarea
readOnly
rows={6}
value={generatePrivacySnippet(site)}
className="w-full px-4 py-3 pr-12 border border-neutral-800 rounded-xl bg-neutral-800/30 text-neutral-300 text-xs font-mono"
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(generatePrivacySnippet(site))
setSnippetCopied(true)
toast.success('Privacy snippet copied')
setTimeout(() => setSnippetCopied(false), 2000)
}}
className="absolute top-3 right-3 p-2 rounded-lg bg-neutral-700 hover:bg-neutral-600 text-neutral-300 transition-colors"
>
{snippetCopied ? <CheckCircle weight="bold" className="w-4 h-4" /> : <Copy weight="bold" className="w-4 h-4" />}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,697 @@
'use client'
import { useState } from 'react'
import { Button, toast, Spinner, Modal, Select } from '@ciphera-net/ui'
import { Plus, Pencil, Trash, EnvelopeSimple, WebhooksLogo, PaperPlaneTilt } from '@phosphor-icons/react'
import { SiDiscord } from '@icons-pack/react-simple-icons'
import { useReportSchedules, useAlertSchedules } from '@/lib/swr/dashboard'
import { useSite } from '@/lib/swr/dashboard'
import {
createReportSchedule,
updateReportSchedule,
deleteReportSchedule,
testReportSchedule,
type ReportSchedule,
type CreateReportScheduleRequest,
type EmailConfig,
type WebhookConfig,
} from '@/lib/api/report-schedules'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatDateTime } from '@/lib/utils/formatDate'
// ── Helpers ──────────────────────────────────────────────────────────────────
const TIMEZONES = [
'UTC', 'America/New_York', 'America/Los_Angeles', 'America/Chicago',
'America/Toronto', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
'Europe/Amsterdam', 'Asia/Tokyo', 'Asia/Singapore', 'Asia/Dubai',
'Australia/Sydney', 'Pacific/Auckland',
]
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const formatHour = (hour: number) => {
if (hour === 0) return '12:00 AM'
if (hour === 12) return '12:00 PM'
return hour < 12 ? `${hour}:00 AM` : `${hour - 12}:00 PM`
}
const ordinalSuffix = (n: number) => {
const s = ['th', 'st', 'nd', 'rd']
const v = n % 100
return n + (s[(v - 20) % 10] || s[v] || s[0])
}
// ── Icons ────────────────────────────────────────────────────────────────────
function SlackIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ fill: 'none' }}>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" style={{ fill: '#E01E5A' }}/>
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" style={{ fill: '#36C5F0' }}/>
<path d="M18.958 8.834a2.528 2.528 0 0 1 2.52-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.52V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.166 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" style={{ fill: '#2EB67D' }}/>
<path d="M15.166 18.958a2.528 2.528 0 0 1 2.521 2.52A2.528 2.528 0 0 1 15.166 24a2.528 2.528 0 0 1-2.521-2.522v-2.52h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.312A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.312z" style={{ fill: '#ECB22E' }}/>
</svg>
)
}
const CHANNEL_ICONS: Record<string, React.ReactNode> = {
email: <EnvelopeSimple weight="bold" className="w-4 h-4" />,
slack: <SlackIcon size={16} />,
discord: <SiDiscord size={16} color="#5865F2" />,
webhook: <WebhooksLogo weight="bold" className="w-4 h-4" />,
}
function ChannelIcon({ channel }: { channel: string }) {
return <>{CHANNEL_ICONS[channel] ?? <PaperPlaneTilt weight="bold" className="w-4 h-4" />}</>
}
// ── Schedule Row ─────────────────────────────────────────────────────────────
function ScheduleRow({
schedule,
siteId,
onMutate,
onEdit,
}: {
schedule: ReportSchedule
siteId: string
onMutate: () => void
onEdit: (schedule: ReportSchedule) => void
}) {
const [testing, setTesting] = useState(false)
const handleTest = async () => {
setTesting(true)
try {
await testReportSchedule(siteId, schedule.id)
toast.success('Test report sent')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to send test')
} finally {
setTesting(false)
}
}
const handleToggle = async () => {
try {
await updateReportSchedule(siteId, schedule.id, {
channel: schedule.channel,
channel_config: schedule.channel_config,
frequency: schedule.frequency,
report_type: schedule.report_type,
enabled: !schedule.enabled,
send_hour: schedule.send_hour,
send_day: schedule.send_day ?? undefined,
timezone: schedule.timezone,
purpose: schedule.purpose,
})
toast.success(schedule.enabled ? 'Report paused' : 'Report enabled')
onMutate()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to update')
}
}
const handleDelete = async () => {
try {
await deleteReportSchedule(siteId, schedule.id)
toast.success('Report deleted')
onMutate()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete')
}
}
return (
<div className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors group">
<div className="flex items-center gap-3 min-w-0">
<div className={`p-1.5 rounded-lg flex-shrink-0 ${schedule.enabled ? 'bg-brand-orange/10 text-brand-orange' : 'bg-neutral-800 text-neutral-500'}`}>
<ChannelIcon channel={schedule.channel} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">
{schedule.channel === 'email' && 'recipients' in schedule.channel_config
? (schedule.channel_config as EmailConfig).recipients?.[0]
: schedule.channel}
{!schedule.enabled && <span className="ml-2 text-xs text-neutral-500">(paused)</span>}
</p>
<p className="text-xs text-neutral-500">
{schedule.frequency} · {schedule.report_type} report
{schedule.last_sent_at && (
<span className="ml-1">· sent {formatDateTime(new Date(schedule.last_sent_at))}</span>
)}
</p>
{schedule.last_error && (
<p className="text-xs text-red-400 truncate mt-0.5">{schedule.last_error}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => onEdit(schedule)} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors" title="Edit">
<Pencil weight="bold" className="w-3.5 h-3.5" />
</button>
<button onClick={handleTest} disabled={testing} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors" title="Send test">
<PaperPlaneTilt weight="bold" className="w-3.5 h-3.5" />
</button>
<button onClick={handleToggle} className="p-1.5 rounded-lg text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors">
{schedule.enabled ? 'Pause' : 'Enable'}
</button>
<button onClick={handleDelete} className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors" title="Delete">
<Trash weight="bold" className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
// ── Channel Grid Picker ──────────────────────────────────────────────────────
const CHANNELS = ['email', 'slack', 'discord', 'webhook'] as const
function ChannelPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<div className="grid grid-cols-4 gap-2">
{CHANNELS.map((ch) => (
<button
key={ch}
type="button"
onClick={() => onChange(ch)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-colors ${
value === ch
? 'border-brand-orange bg-brand-orange/10 text-white'
: 'border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-white'
}`}
>
{CHANNEL_ICONS[ch]}
<span className="text-xs capitalize">{ch}</span>
</button>
))}
</div>
)
}
// ── Shared form label ────────────────────────────────────────────────────────
function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
return <label htmlFor={htmlFor} className="block text-sm font-medium text-neutral-300 mb-1.5">{children}</label>
}
function FormInput({ id, type = 'text', value, onChange, placeholder }: { id?: string; type?: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-10 px-4 bg-transparent border border-neutral-800 rounded-lg text-sm text-white placeholder:text-neutral-600 focus:outline-none focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 transition-colors"
/>
)
}
// ── Report Schedule Modal ────────────────────────────────────────────────────
function ReportScheduleModal({
isOpen,
onClose,
siteId,
siteTimezone,
editing,
onSaved,
}: {
isOpen: boolean
onClose: () => void
siteId: string
siteTimezone: string
editing: ReportSchedule | null
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState(() => formFromSchedule(editing, siteTimezone))
// Reset form when editing target changes
function formFromSchedule(schedule: ReportSchedule | null, fallbackTz: string) {
if (schedule) {
return {
channel: schedule.channel,
recipients: schedule.channel === 'email' && 'recipients' in schedule.channel_config
? (schedule.channel_config as EmailConfig).recipients.join(', ')
: '',
webhookUrl: schedule.channel !== 'email' && 'url' in schedule.channel_config
? (schedule.channel_config as WebhookConfig).url
: '',
frequency: schedule.frequency,
reportType: schedule.report_type,
timezone: schedule.timezone || fallbackTz,
sendHour: schedule.send_hour,
sendDay: schedule.send_day ?? 1,
}
}
return {
channel: 'email',
recipients: '',
webhookUrl: '',
frequency: 'weekly',
reportType: 'summary',
timezone: fallbackTz,
sendHour: 9,
sendDay: 1,
}
}
// Re-init when modal opens with different editing target
const [prevEditing, setPrevEditing] = useState<ReportSchedule | null>(editing)
if (editing !== prevEditing) {
setPrevEditing(editing)
setForm(formFromSchedule(editing, siteTimezone))
}
const updateField = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = async () => {
// Validation
if (form.channel === 'email') {
const emails = form.recipients.split(',').map((r) => r.trim()).filter(Boolean)
if (emails.length === 0) { toast.error('Enter at least one email address'); return }
} else {
if (!form.webhookUrl.trim()) { toast.error('Enter a webhook URL'); return }
}
setSaving(true)
try {
const channelConfig: EmailConfig | WebhookConfig =
form.channel === 'email'
? { recipients: form.recipients.split(',').map((r) => r.trim()).filter(Boolean) }
: { url: form.webhookUrl.trim() }
const payload: CreateReportScheduleRequest = {
channel: form.channel,
channel_config: channelConfig,
frequency: form.frequency,
report_type: form.reportType,
timezone: form.timezone,
send_hour: form.sendHour,
send_day: form.frequency === 'weekly' || form.frequency === 'monthly' ? form.sendDay : undefined,
purpose: 'report',
}
if (editing) {
await updateReportSchedule(siteId, editing.id, payload)
toast.success('Report schedule updated')
} else {
await createReportSchedule(siteId, payload)
toast.success('Report schedule created')
}
onSaved()
onClose()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save schedule')
} finally {
setSaving(false)
}
}
const webhookPlaceholder =
form.channel === 'slack' ? 'https://hooks.slack.com/services/...'
: form.channel === 'discord' ? 'https://discord.com/api/webhooks/...'
: 'https://example.com/webhook'
return (
<Modal isOpen={isOpen} onClose={onClose} title={editing ? 'Edit Report Schedule' : 'New Report Schedule'}>
<div className="space-y-5">
{/* Channel */}
<div>
<FormLabel>Channel</FormLabel>
<ChannelPicker value={form.channel} onChange={(v) => updateField('channel', v)} />
</div>
{/* Recipients / URL */}
{form.channel === 'email' ? (
<div>
<FormLabel htmlFor="report-recipients">Recipients</FormLabel>
<FormInput
id="report-recipients"
value={form.recipients}
onChange={(v) => updateField('recipients', v)}
placeholder="email@example.com, another@example.com"
/>
<p className="text-xs text-neutral-500 mt-1">Comma-separated email addresses</p>
</div>
) : (
<div>
<FormLabel htmlFor="report-webhook">Webhook URL</FormLabel>
<FormInput
id="report-webhook"
type="url"
value={form.webhookUrl}
onChange={(v) => updateField('webhookUrl', v)}
placeholder={webhookPlaceholder}
/>
</div>
)}
{/* Frequency */}
<div>
<FormLabel>Frequency</FormLabel>
<Select
value={form.frequency}
onChange={(v) => updateField('frequency', v)}
variant="input"
fullWidth
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
]}
/>
</div>
{/* Day of week (weekly) */}
{form.frequency === 'weekly' && (
<div>
<FormLabel>Day of Week</FormLabel>
<Select
value={String(form.sendDay)}
onChange={(v) => updateField('sendDay', Number(v))}
variant="input"
fullWidth
options={WEEKDAY_NAMES.map((name, i) => ({ value: String(i + 1), label: name }))}
/>
</div>
)}
{/* Day of month (monthly) */}
{form.frequency === 'monthly' && (
<div>
<FormLabel>Day of Month</FormLabel>
<Select
value={String(form.sendDay)}
onChange={(v) => updateField('sendDay', Number(v))}
variant="input"
fullWidth
options={Array.from({ length: 28 }, (_, i) => ({
value: String(i + 1),
label: ordinalSuffix(i + 1),
}))}
/>
</div>
)}
{/* Time */}
<div>
<FormLabel>Time</FormLabel>
<Select
value={String(form.sendHour)}
onChange={(v) => updateField('sendHour', Number(v))}
variant="input"
fullWidth
options={Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: formatHour(i),
}))}
/>
</div>
{/* Timezone */}
<div>
<FormLabel>Timezone</FormLabel>
<Select
value={form.timezone}
onChange={(v) => updateField('timezone', v)}
variant="input"
fullWidth
options={TIMEZONES.map((tz) => ({ value: tz, label: tz.replace(/_/g, ' ') }))}
/>
</div>
{/* Report Type */}
<div>
<FormLabel>Report Type</FormLabel>
<Select
value={form.reportType}
onChange={(v) => updateField('reportType', v)}
variant="input"
fullWidth
options={[
{ value: 'summary', label: 'Summary' },
{ value: 'pages', label: 'Pages' },
{ value: 'sources', label: 'Sources' },
{ value: 'goals', label: 'Goals' },
]}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit} disabled={saving}>
{saving ? <Spinner className="w-4 h-4" /> : editing ? 'Save Changes' : 'Create Schedule'}
</Button>
</div>
</div>
</Modal>
)
}
// ── Alert Channel Modal ──────────────────────────────────────────────────────
function AlertChannelModal({
isOpen,
onClose,
siteId,
siteTimezone,
editing,
onSaved,
}: {
isOpen: boolean
onClose: () => void
siteId: string
siteTimezone: string
editing: ReportSchedule | null
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [form, setForm] = useState(() => formFromAlert(editing))
function formFromAlert(schedule: ReportSchedule | null) {
if (schedule) {
return {
channel: schedule.channel,
recipients: schedule.channel === 'email' && 'recipients' in schedule.channel_config
? (schedule.channel_config as EmailConfig).recipients.join(', ')
: '',
webhookUrl: schedule.channel !== 'email' && 'url' in schedule.channel_config
? (schedule.channel_config as WebhookConfig).url
: '',
}
}
return { channel: 'email', recipients: '', webhookUrl: '' }
}
const [prevEditing, setPrevEditing] = useState<ReportSchedule | null>(editing)
if (editing !== prevEditing) {
setPrevEditing(editing)
setForm(formFromAlert(editing))
}
const updateField = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = async () => {
if (form.channel === 'email') {
const emails = form.recipients.split(',').map((r) => r.trim()).filter(Boolean)
if (emails.length === 0) { toast.error('Enter at least one email address'); return }
} else {
if (!form.webhookUrl.trim()) { toast.error('Enter a webhook URL'); return }
}
setSaving(true)
try {
const channelConfig: EmailConfig | WebhookConfig =
form.channel === 'email'
? { recipients: form.recipients.split(',').map((r) => r.trim()).filter(Boolean) }
: { url: form.webhookUrl.trim() }
const payload: CreateReportScheduleRequest = {
channel: form.channel,
channel_config: channelConfig,
frequency: 'daily', // Alerts don't have a user-chosen frequency
timezone: siteTimezone,
purpose: 'alert',
}
if (editing) {
await updateReportSchedule(siteId, editing.id, payload)
toast.success('Alert channel updated')
} else {
await createReportSchedule(siteId, payload)
toast.success('Alert channel created')
}
onSaved()
onClose()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to save alert channel')
} finally {
setSaving(false)
}
}
const webhookPlaceholder =
form.channel === 'slack' ? 'https://hooks.slack.com/services/...'
: form.channel === 'discord' ? 'https://discord.com/api/webhooks/...'
: 'https://example.com/webhook'
return (
<Modal isOpen={isOpen} onClose={onClose} title={editing ? 'Edit Alert Channel' : 'New Alert Channel'}>
<div className="space-y-5">
{/* Channel */}
<div>
<FormLabel>Channel</FormLabel>
<ChannelPicker value={form.channel} onChange={(v) => updateField('channel', v)} />
</div>
{/* Recipients / URL */}
{form.channel === 'email' ? (
<div>
<FormLabel htmlFor="alert-recipients">Recipients</FormLabel>
<FormInput
id="alert-recipients"
value={form.recipients}
onChange={(v) => updateField('recipients', v)}
placeholder="email@example.com, another@example.com"
/>
<p className="text-xs text-neutral-500 mt-1">Comma-separated email addresses</p>
</div>
) : (
<div>
<FormLabel htmlFor="alert-webhook">Webhook URL</FormLabel>
<FormInput
id="alert-webhook"
type="url"
value={form.webhookUrl}
onChange={(v) => updateField('webhookUrl', v)}
placeholder={webhookPlaceholder}
/>
</div>
)}
{/* Info box */}
<div className="rounded-lg border border-neutral-800 bg-neutral-800/30 p-3">
<p className="text-xs text-neutral-400">
Alerts are sent automatically when your site goes down or recovers.
</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit} disabled={saving}>
{saving ? <Spinner className="w-4 h-4" /> : editing ? 'Save Changes' : 'Add Channel'}
</Button>
</div>
</div>
</Modal>
)
}
// ── Main Tab ─────────────────────────────────────────────────────────────────
export default function SiteReportsTab({ siteId }: { siteId: string }) {
const { data: site } = useSite(siteId)
const { data: reports = [], isLoading: reportsLoading, mutate: mutateReports } = useReportSchedules(siteId)
const { data: alerts = [], isLoading: alertsLoading, mutate: mutateAlerts } = useAlertSchedules(siteId)
// Report modal state
const [reportModalOpen, setReportModalOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<ReportSchedule | null>(null)
// Alert modal state
const [alertModalOpen, setAlertModalOpen] = useState(false)
const [editingAlert, setEditingAlert] = useState<ReportSchedule | null>(null)
const siteTimezone = site?.timezone || 'UTC'
const loading = reportsLoading || alertsLoading
const openNewReport = () => { setEditingSchedule(null); setReportModalOpen(true) }
const openEditReport = (schedule: ReportSchedule) => { setEditingSchedule(schedule); setReportModalOpen(true) }
const openNewAlert = () => { setEditingAlert(null); setAlertModalOpen(true) }
const openEditAlert = (schedule: ReportSchedule) => { setEditingAlert(schedule); setAlertModalOpen(true) }
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
{/* Scheduled Reports */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Scheduled Reports</h3>
<p className="text-sm text-neutral-400">Automated analytics summaries via email or webhook.</p>
</div>
<Button variant="primary" className="text-sm gap-1.5" onClick={openNewReport}>
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Report
</Button>
</div>
{reports.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-8">No scheduled reports yet.</p>
) : (
<div className="space-y-1">
{reports.map((r) => (
<ScheduleRow key={r.id} schedule={r} siteId={siteId} onMutate={() => mutateReports()} onEdit={openEditReport} />
))}
</div>
)}
</div>
{/* Alert Channels */}
<div className="pt-6 border-t border-neutral-800 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Alert Channels</h3>
<p className="text-sm text-neutral-400">Get notified when uptime monitors go down.</p>
</div>
<Button variant="primary" className="text-sm gap-1.5" onClick={openNewAlert}>
<Plus weight="bold" className="w-3.5 h-3.5" /> Add Channel
</Button>
</div>
{alerts.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-8">No alert channels configured.</p>
) : (
<div className="space-y-1">
{alerts.map((a) => (
<ScheduleRow key={a.id} schedule={a} siteId={siteId} onMutate={() => mutateAlerts()} onEdit={openEditAlert} />
))}
</div>
)}
</div>
{/* Report Schedule Modal */}
{reportModalOpen && (
<ReportScheduleModal
isOpen={reportModalOpen}
onClose={() => setReportModalOpen(false)}
siteId={siteId}
siteTimezone={siteTimezone}
editing={editingSchedule}
onSaved={() => mutateReports()}
/>
)}
{/* Alert Channel Modal */}
{alertModalOpen && (
<AlertChannelModal
isOpen={alertModalOpen}
onClose={() => setAlertModalOpen(false)}
siteId={siteId}
siteTimezone={siteTimezone}
editing={editingAlert}
onSaved={() => mutateAlerts()}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui'
import { Copy, CheckCircle, Lock } from '@phosphor-icons/react'
import { AnimatePresence, motion } from 'framer-motion'
import { useSite } from '@/lib/swr/dashboard'
import { updateSite } from '@/lib/api/sites'
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export default function SiteVisibilityTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { data: site, mutate } = useSite(siteId)
const [isPublic, setIsPublic] = useState(false)
const [password, setPassword] = useState('')
const [passwordEnabled, setPasswordEnabled] = useState(false)
const [linkCopied, setLinkCopied] = useState(false)
const initialRef = useRef('')
const hasInitialized = useRef(false)
useEffect(() => {
if (!site || hasInitialized.current) return
setIsPublic(site.is_public ?? false)
setPasswordEnabled(site.has_password ?? false)
initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false })
hasInitialized.current = true
}, [site])
// Track dirty state
useEffect(() => {
if (!initialRef.current) return
const current = JSON.stringify({ isPublic, passwordEnabled })
const dirty = current !== initialRef.current || password.length > 0
onDirtyChange?.(dirty)
}, [isPublic, passwordEnabled, password, onDirtyChange])
const handleSave = useCallback(async () => {
try {
await updateSite(siteId, {
name: site?.name || '',
is_public: isPublic,
password: passwordEnabled ? password : undefined,
clear_password: !passwordEnabled,
})
setPassword('')
await mutate()
initialRef.current = JSON.stringify({ isPublic, passwordEnabled })
onDirtyChange?.(false)
toast.success('Visibility updated')
} catch {
toast.error('Failed to save')
}
}, [siteId, site?.name, isPublic, passwordEnabled, password, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const copyLink = () => {
navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`)
setLinkCopied(true)
toast.success('Link copied')
setTimeout(() => setLinkCopied(false), 2000)
}
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Visibility</h3>
<p className="text-sm text-neutral-400">Control who can see your analytics dashboard.</p>
</div>
{/* Public toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div>
<p className="text-sm font-medium text-white">Public Dashboard</p>
<p className="text-xs text-neutral-500">Allow anyone with the link to view this dashboard.</p>
</div>
<Toggle checked={isPublic} onChange={() => setIsPublic(p => !p)} />
</div>
<AnimatePresence>
{isPublic && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4 overflow-hidden"
>
{/* Share link */}
<div className="p-4 rounded-xl border border-neutral-800 bg-neutral-800/30">
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Public Link</label>
<div className="flex gap-2">
<Input value={`${APP_URL}/share/${siteId}`} readOnly className="font-mono text-xs" />
<Button onClick={copyLink} variant="secondary" className="shrink-0 text-sm gap-1.5">
{linkCopied ? <CheckCircle weight="bold" className="w-3.5 h-3.5" /> : <Copy weight="bold" className="w-3.5 h-3.5" />}
{linkCopied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
{/* Password protection */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-neutral-800/30 border border-neutral-800">
<div className="flex items-center gap-2">
<Lock weight="bold" className="w-4 h-4 text-neutral-500" />
<div>
<p className="text-sm font-medium text-white">Password Protection</p>
<p className="text-xs text-neutral-500">Require a password to view the public dashboard.</p>
</div>
</div>
<Toggle checked={passwordEnabled} onChange={() => setPasswordEnabled(p => !p)} />
</div>
<AnimatePresence>
{passwordEnabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<Input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={site.has_password ? 'Leave empty to keep current password' : 'Set a password'}
/>
{site.has_password && (
<button
type="button"
onClick={() => { setPasswordEnabled(false); setPassword('') }}
className="mt-2 text-xs font-medium text-red-400 hover:text-red-300 transition-colors"
>
Remove password protection
</button>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import { Spinner, Input, Button } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { getAuditLog, type AuditLogEntry } from '@/lib/api/audit'
import { formatDateTimeShort } from '@/lib/utils/formatDate'
const ACTION_LABELS: Record<string, string> = {
site_created: 'Created site',
site_updated: 'Updated site',
site_deleted: 'Deleted site',
site_restored: 'Restored site',
goal_created: 'Created goal',
goal_updated: 'Updated goal',
goal_deleted: 'Deleted goal',
funnel_created: 'Created funnel',
funnel_updated: 'Updated funnel',
funnel_deleted: 'Deleted funnel',
gsc_connected: 'Connected Google Search Console',
gsc_disconnected: 'Disconnected Google Search Console',
bunny_connected: 'Connected BunnyCDN',
bunny_disconnected: 'Disconnected BunnyCDN',
member_invited: 'Invited member',
member_removed: 'Removed member',
member_role_changed: 'Changed member role',
org_updated: 'Updated organization',
plan_changed: 'Changed plan',
subscription_cancelled: 'Cancelled subscription',
subscription_resumed: 'Resumed subscription',
}
const PAGE_SIZE = 20
export default function WorkspaceAuditTab() {
const { user } = useAuth()
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [actionFilter, setActionFilter] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
useEffect(() => {
if (!user?.org_id) return
setLoading(true)
getAuditLog({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
...(actionFilter && { action: actionFilter }),
...(startDate && { start_date: startDate }),
...(endDate && { end_date: endDate }),
})
.then(data => {
setEntries(data.entries)
setTotal(data.total)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [user?.org_id, page, actionFilter, startDate, endDate])
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Audit Log</h3>
<p className="text-sm text-neutral-400">Track who made changes and when.</p>
</div>
<div className="flex flex-wrap gap-2 items-end">
<div>
<label className="block text-xs text-neutral-500 mb-1">Action</label>
<Input
value={actionFilter}
onChange={e => { setActionFilter(e.target.value); setPage(1) }}
placeholder="e.g. site_created"
className="w-40"
/>
</div>
<div>
<label className="block text-xs text-neutral-500 mb-1">From</label>
<Input
type="date"
value={startDate}
onChange={e => { setStartDate(e.target.value); setPage(1) }}
/>
</div>
<div>
<label className="block text-xs text-neutral-500 mb-1">To</label>
<Input
type="date"
value={endDate}
onChange={e => { setEndDate(e.target.value); setPage(1) }}
/>
</div>
{(actionFilter || startDate || endDate) && (
<Button
variant="secondary"
className="text-sm"
onClick={() => { setActionFilter(''); setStartDate(''); setEndDate(''); setPage(1) }}
>
Clear
</Button>
)}
</div>
{entries.length === 0 ? (
<p className="text-sm text-neutral-500 text-center py-8">No activity recorded yet.</p>
) : (
<div className="space-y-1">
{entries.map(entry => (
<div key={entry.id} className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-neutral-800/40 transition-colors">
<div>
<p className="text-sm text-white">
<span className="font-medium">{entry.actor_email || 'System'}</span>
{' '}
<span className="text-neutral-400">{ACTION_LABELS[entry.action] || entry.action}</span>
</p>
{entry.payload && Object.keys(entry.payload).length > 0 && (
<p className="text-xs text-neutral-500 mt-0.5">{JSON.stringify(entry.payload)}</p>
)}
</div>
<p className="text-xs text-neutral-500 shrink-0 ml-4">
{formatDateTimeShort(new Date(entry.occurred_at))}
</p>
</div>
))}
</div>
)}
<div className="flex items-center justify-between pt-6 border-t border-neutral-800">
<span className="text-xs text-neutral-500">
{total > 0 ? `${(page - 1) * PAGE_SIZE + 1}${Math.min(page * PAGE_SIZE, total)} of ${total}` : 'No entries'}
</span>
<div className="flex gap-2">
<Button
variant="secondary"
className="text-sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="secondary"
className="text-sm"
onClick={() => setPage(p => p + 1)}
disabled={page * PAGE_SIZE >= total}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useState, useEffect } 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, getOrders, type Order } from '@/lib/api/billing'
import { formatDateLong, formatDate } 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 [orders, setOrders] = useState<Order[]>([])
useEffect(() => {
getOrders().then(setOrders).catch(() => {})
}, [])
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'USD' }).format(amount / 100)
}
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 (
<div className="flex items-center justify-center py-12">
<Spinner className="w-6 h-6 text-neutral-500" />
</div>
)
}
if (!subscription) {
return (
<div className="text-center py-12">
<CreditCard className="w-10 h-10 text-neutral-500 mx-auto mb-3" />
<h3 className="text-base font-semibold text-white mb-1">No subscription</h3>
<p className="text-sm text-neutral-400 mb-4">You're on the free plan.</p>
<Link href="/pricing">
<Button variant="primary" className="text-sm">View Plans</Button>
</Link>
</div>
)
}
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 (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Billing & Subscription</h3>
<p className="text-sm text-neutral-400">Manage your plan, usage, and payment details.</p>
</div>
{/* Plan card */}
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h4 className="text-lg font-bold text-white">{planLabel} Plan</h4>
{isActive && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/30 text-green-400 border border-green-900/50">
{subscription.subscription_status === 'trialing' ? 'Trial' : 'Active'}
</span>
)}
{subscription.cancel_at_period_end && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-900/30 text-yellow-400 border border-yellow-900/50">
Cancelling
</span>
)}
</div>
<Link href="/pricing">
<Button variant="primary" className="text-sm">Change Plan</Button>
</Link>
</div>
{/* Usage stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{typeof subscription.sites_count === 'number' && (
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">Sites</p>
<p className="text-lg font-semibold text-white">{subscription.sites_count}</p>
</div>
)}
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">Pageviews</p>
<p className="text-lg font-semibold text-white">{subscription.pageview_usage.toLocaleString()} / {subscription.pageview_limit.toLocaleString()}</p>
</div>
)}
{subscription.current_period_end && (
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">
{subscription.cancel_at_period_end ? 'Ends' : 'Renews'}
</p>
<p className="text-lg font-semibold text-white">{formatDateLong(new Date(subscription.current_period_end))}</p>
</div>
)}
{subscription.pageview_limit > 0 && (
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wider">Limit</p>
<p className="text-lg font-semibold text-white">{subscription.pageview_limit.toLocaleString()} / mo</p>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{subscription.has_payment_method && (
<Button onClick={handleManageBilling} variant="secondary" className="text-sm gap-1.5">
<ArrowSquareOut weight="bold" className="w-3.5 h-3.5" />
Payment method & invoices
</Button>
)}
{isActive && !subscription.cancel_at_period_end && (
<Button
onClick={handleCancel}
variant="secondary"
className="text-sm text-neutral-400 hover:text-red-400"
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Cancel subscription'}
</Button>
)}
{subscription.cancel_at_period_end && (
<Button onClick={handleResume} variant="secondary" className="text-sm text-brand-orange">
Resume subscription
</Button>
)}
</div>
{/* Recent Invoices */}
{orders.length > 0 && (
<div className="space-y-2 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">Recent Invoices</h4>
<div className="space-y-1">
{orders.map(order => (
<div key={order.id} className="flex items-center justify-between p-3 rounded-lg border border-neutral-800 text-sm">
<div className="flex items-center gap-3">
<span className="text-neutral-300">{formatDate(new Date(order.created_at))}</span>
<span className="text-white font-medium">{formatAmount(order.total_amount, order.currency)}</span>
{order.invoice_number && (
<span className="text-neutral-500 text-xs">{order.invoice_number}</span>
)}
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${order.paid ? 'bg-green-900/30 text-green-400' : 'bg-neutral-800 text-neutral-400'}`}>
{order.paid ? 'Paid' : order.status}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Input, Button, toast } from '@ciphera-net/ui'
import { Spinner } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { getOrganization, updateOrganization, deleteOrganization } from '@/lib/api/organization'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { useUnifiedSettings } from '@/lib/unified-settings-context'
import { DangerZone } from '@/components/settings/unified/DangerZone'
export default function WorkspaceGeneralTab({ onDirtyChange, onRegisterSave }: { onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { user } = useAuth()
const router = useRouter()
const { closeUnifiedSettings } = useUnifiedSettings()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [loading, setLoading] = useState(true)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteText, setDeleteText] = useState('')
const [deleting, setDeleting] = useState(false)
const initialRef = useRef('')
const hasInitialized = useRef(false)
useEffect(() => {
if (!user?.org_id) return
setLoading(true)
getOrganization(user.org_id)
.then(org => {
setName(org.name || '')
setSlug(org.slug || '')
if (!hasInitialized.current) {
initialRef.current = JSON.stringify({ name: org.name || '', slug: org.slug || '' })
hasInitialized.current = true
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [user?.org_id])
// Track dirty state
useEffect(() => {
if (!initialRef.current) return
onDirtyChange?.(JSON.stringify({ name, slug }) !== initialRef.current)
}, [name, slug, onDirtyChange])
const handleSave = useCallback(async () => {
if (!user?.org_id) return
try {
await updateOrganization(user.org_id, name, slug)
initialRef.current = JSON.stringify({ name, slug })
onDirtyChange?.(false)
toast.success('Organization updated')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to update organization')
}
}, [user?.org_id, name, slug, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleDelete = async () => {
if (!user?.org_id || deleteText !== 'DELETE') return
setDeleting(true)
try {
await deleteOrganization(user.org_id)
localStorage.clear()
closeUnifiedSettings()
router.push('/')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to delete organization')
setDeleting(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="w-6 h-6 text-neutral-500" />
</div>
)
}
return (
<div className="space-y-6">
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold text-white mb-1">General Information</h3>
<p className="text-sm text-neutral-400">Basic details about your organization.</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Organization Name</label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="Acme Corp" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Organization Slug</label>
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-500">pulse.ciphera.net/</span>
<Input value={slug} onChange={e => setSlug(e.target.value)} placeholder="acme-corp" />
</div>
<p className="text-xs text-neutral-500 mt-1">Changing the slug will change your organization&apos;s URL.</p>
</div>
</div>
{/* Danger Zone */}
<DangerZone
items={[{
title: 'Delete Organization',
description: 'Permanently delete this organization and all its data.',
buttonLabel: 'Delete',
variant: 'solid',
onClick: () => setShowDeleteConfirm(prev => !prev),
}]}
>
{showDeleteConfirm && (
<div className="p-4 border border-red-900/50 bg-red-900/10 rounded-xl space-y-3">
<p className="text-sm text-red-300">This will permanently delete:</p>
<ul className="text-xs text-neutral-400 list-disc list-inside space-y-1">
<li>All sites and their analytics data</li>
<li>All team members and pending invitations</li>
<li>Active subscription will be cancelled</li>
<li>All notifications and settings</li>
</ul>
<div>
<label className="block text-xs text-neutral-400 mb-1">Type DELETE to confirm</label>
<Input
value={deleteText}
onChange={e => setDeleteText(e.target.value)}
placeholder="DELETE"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={deleteText !== 'DELETE' || deleting}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete Organization'}
</button>
<button onClick={() => { setShowDeleteConfirm(false); setDeleteText('') }} className="px-4 py-2 text-neutral-400 hover:text-white text-sm">
Cancel
</button>
</div>
</div>
)}
</DangerZone>
</div>
)
}

View File

@@ -0,0 +1,205 @@
'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, removeOrganizationMember, sendInvitation, getInvitations, revokeInvitation, type OrganizationMember, type OrganizationInvitation } from '@/lib/api/organization'
import { getAuthErrorMessage } from '@ciphera-net/ui'
const ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'member', label: 'Member' },
]
function RoleBadge({ role }: { role: string }) {
if (role === 'owner') return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-brand-orange/10 text-brand-orange">
<Crown weight="bold" className="w-3 h-3" /> Owner
</span>
)
if (role === 'admin') return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-900/30 text-blue-400">
Admin
</span>
)
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-neutral-800 text-neutral-400">
Member
</span>
)
}
export default function WorkspaceMembersTab() {
const { user } = useAuth()
const [members, setMembers] = useState<OrganizationMember[]>([])
const [invitations, setInvitations] = useState<OrganizationInvitation[]>([])
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 [membersData, invitationsData] = await Promise.all([
getOrganizationMembers(user.org_id),
getInvitations(user.org_id).catch(() => [] as OrganizationInvitation[]),
])
setMembers(membersData)
setInvitations(invitationsData)
} 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) => {
if (!user?.org_id) return
if (!confirm(`Remove ${email} from the organization?`)) return
try {
await removeOrganizationMember(user.org_id, memberId)
toast.success(`${email} has been removed`)
loadMembers()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to remove member')
}
}
const handleRevokeInvitation = async (inviteId: string) => {
if (!user?.org_id) return
if (!confirm('Revoke this invitation?')) return
try {
await revokeInvitation(user.org_id, inviteId)
toast.success('Invitation revoked')
loadMembers()
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to revoke invitation')
}
}
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold text-white mb-1">Members</h3>
<p className="text-sm text-neutral-400">{members.length} member{members.length !== 1 ? 's' : ''} in your organization.</p>
</div>
{canManage && !showInvite && (
<Button onClick={() => setShowInvite(true)} variant="primary" className="text-sm gap-1.5">
<Plus weight="bold" className="w-3.5 h-3.5" /> Invite
</Button>
)}
</div>
{/* Invite form */}
{showInvite && (
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 p-4 space-y-3">
<div className="flex gap-3">
<div className="flex-1">
<Input
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
placeholder="email@example.com"
type="email"
/>
</div>
<Select
value={inviteRole}
onChange={setInviteRole}
variant="input"
className="w-32"
options={ROLE_OPTIONS}
/>
</div>
<div className="flex gap-2 justify-end">
<Button onClick={() => setShowInvite(false)} variant="secondary" className="text-sm">Cancel</Button>
<Button onClick={handleInvite} variant="primary" className="text-sm gap-1.5" disabled={inviting}>
<EnvelopeSimple weight="bold" className="w-3.5 h-3.5" />
{inviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</div>
)}
{/* Members list */}
<div className="rounded-xl border border-neutral-800 bg-neutral-800/30 divide-y divide-neutral-800">
{members.map(member => (
<div key={member.user_id} className="flex items-center justify-between px-4 py-3 group">
<div className="flex items-center gap-3">
<UserCircle weight="fill" className="w-8 h-8 text-neutral-600" />
<div>
<p className="text-sm font-medium text-white">{member.user_email || member.user_id}</p>
</div>
</div>
<div className="flex items-center gap-2">
<RoleBadge role={member.role} />
{canManage && member.role !== 'owner' && member.user_id !== user?.id && (
<button
onClick={() => handleRemove(member.user_id, member.user_email || member.user_id)}
className="p-1.5 rounded-lg text-neutral-500 hover:text-red-400 hover:bg-red-900/20 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash weight="bold" className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
{members.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-8">No members found.</p>
)}
</div>
{/* Pending Invitations */}
{invitations.length > 0 && (
<div className="space-y-2 pt-6 border-t border-neutral-800">
<h4 className="text-sm font-medium text-neutral-300">Pending Invitations</h4>
{invitations.map(inv => (
<div key={inv.id} className="flex items-center justify-between p-3 rounded-xl border border-neutral-800">
<div className="flex items-center gap-3">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400" />
</span>
<div>
<span className="text-sm text-white">{inv.email}</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400">{inv.role}</span>
<span className="ml-2 text-xs text-neutral-500">expires {new Date(inv.expires_at).toLocaleDateString('en-GB')}</span>
</div>
</div>
{canManage && (
<button
onClick={() => handleRevokeInvitation(inv.id)}
className="text-xs text-red-400 hover:text-red-300 font-medium"
>
Revoke
</button>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Toggle, toast, Spinner } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { getNotificationSettings, updateNotificationSettings, type NotificationSettingsResponse } from '@/lib/api/notification-settings'
export default function WorkspaceNotificationsTab({ onDirtyChange, onRegisterSave }: { onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { user } = useAuth()
const [data, setData] = useState<NotificationSettingsResponse | null>(null)
const [settings, setSettings] = useState<Record<string, boolean>>({})
const [loading, setLoading] = useState(true)
const initialRef = useRef('')
const hasInitialized = useRef(false)
useEffect(() => {
if (!user?.org_id) return
getNotificationSettings()
.then(resp => {
setData(resp)
setSettings(resp.settings || {})
if (!hasInitialized.current) {
initialRef.current = JSON.stringify(resp.settings || {})
hasInitialized.current = true
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [user?.org_id])
// Track dirty state
useEffect(() => {
if (!initialRef.current) return
onDirtyChange?.(JSON.stringify(settings) !== initialRef.current)
}, [settings, onDirtyChange])
const handleToggle = (key: string) => {
setSettings(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSave = useCallback(async () => {
try {
await updateNotificationSettings(settings)
initialRef.current = JSON.stringify(settings)
onDirtyChange?.(false)
toast.success('Notification preferences updated')
} catch {
toast.error('Failed to update notification preferences')
}
}, [settings, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
if (loading) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
return (
<div className="space-y-6">
<div>
<h3 className="text-base font-semibold text-white mb-1">Notifications</h3>
<p className="text-sm text-neutral-400">Choose what notifications you receive.</p>
</div>
<div className="space-y-3">
{(data?.categories || []).map(cat => (
<div key={cat.id} className="flex items-center justify-between p-4 rounded-xl border border-neutral-800 bg-neutral-800/30">
<div>
<p className="text-sm font-medium text-white">{cat.label}</p>
<p className="text-xs text-neutral-400">{cat.description}</p>
</div>
<Toggle checked={settings[cat.id] ?? false} onChange={() => handleToggle(cat.id)} />
</div>
))}
{(!data?.categories || data.categories.length === 0) && (
<p className="text-sm text-neutral-500 text-center py-8">No notification preferences available.</p>
)}
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
/**
* Reusable skeleton loading primitives and composites for Pulse.
* All skeletons follow the design-system pattern:
* animate-pulse + bg-neutral-100 dark:bg-neutral-800 + rounded
* animate-pulse + bg-neutral-800 + rounded
*/
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
const SK = 'animate-pulse bg-neutral-800'
export { useMinimumLoading, useSkeletonFade } from './useMinimumLoading'
@@ -71,7 +71,7 @@ export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: nu
export function WidgetSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<SkeletonLine className="h-6 w-32" />
<div className="flex gap-1">
@@ -90,7 +90,7 @@ export function WidgetSkeleton() {
export function StatCardSkeleton() {
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<div className="p-4 rounded-xl border border-neutral-800 bg-white dark:bg-neutral-900">
<SkeletonLine className="h-4 w-20 mb-2" />
<SkeletonLine className="h-8 w-28" />
</div>
@@ -101,7 +101,7 @@ export function StatCardSkeleton() {
export function ChartSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex gap-4">
{Array.from({ length: 4 }).map((_, i) => (
@@ -122,7 +122,7 @@ export function ChartSkeleton() {
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
@@ -157,7 +157,7 @@ export function DashboardSkeleton() {
{/* Campaigns table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<TableSkeleton rows={7} cols={5} />
</div>
@@ -170,7 +170,7 @@ export function DashboardSkeleton() {
export function JourneysSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
@@ -187,7 +187,7 @@ export function JourneysSkeleton() {
{/* Sankey area */}
<SkeletonCard className="h-[400px] mb-6" />
{/* Top paths table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-24 mb-4" />
<TableSkeleton rows={5} cols={4} />
</div>
@@ -199,29 +199,50 @@ export function JourneysSkeleton() {
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<SkeletonLine className="h-8 w-24 mb-2" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonLine className="h-9 w-36 rounded-lg" />
</div>
{/* Overall status */}
<SkeletonCard className="h-20 mb-6" />
{/* Monitor cards */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3 h-3" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-48 hidden sm:block" />
</div>
<SkeletonLine className="h-4 w-28" />
</div>
<SkeletonLine className="h-8 w-full rounded-sm" />
{/* Overall status card */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3.5 h-3.5" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-20" />
</div>
))}
<div className="space-y-1 text-right">
<SkeletonLine className="h-4 w-24 ml-auto" />
<SkeletonLine className="h-3 w-32 ml-auto" />
</div>
</div>
</div>
{/* 90-day uptime bar */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-5 mb-6">
<SkeletonLine className="h-3 w-28 mb-3" />
<SkeletonLine className="h-6 w-full rounded-sm" />
<div className="flex justify-between mt-1.5">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-10" />
</div>
</div>
{/* Monitor details + chart + checks */}
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-5">
{/* 4-col detail grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<SkeletonLine className="h-3 w-20" />
<SkeletonLine className="h-4 w-16" />
</div>
))}
</div>
<ChecksSkeleton />
</div>
</div>
)
@@ -252,7 +273,7 @@ export function ChecksSkeleton() {
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
@@ -263,7 +284,7 @@ export function FunnelsListSkeleton() {
</div>
<div className="grid gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div key={i} className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<div className="flex items-center gap-2">
@@ -286,7 +307,7 @@ export function FunnelsListSkeleton() {
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />
@@ -308,7 +329,7 @@ export function NotificationsListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-800">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-2">
<SkeletonLine className="h-4 w-3/4" />
@@ -343,7 +364,7 @@ export function GoalsListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-800">
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
@@ -374,7 +395,7 @@ export function PricingCardsSkeleton() {
export function BehaviorSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8">
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
@@ -387,7 +408,7 @@ export function BehaviorSkeleton() {
{/* Summary cards (3 cols) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 space-y-3">
<div key={i} className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 space-y-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-8 w-16" />
<SkeletonLine className="h-3 w-32" />
@@ -403,7 +424,7 @@ export function BehaviorSkeleton() {
{/* By-page table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<TableSkeleton rows={5} cols={4} />

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import {
serializeFilters,
parseFiltersFromURL,
filterLabel,
DIMENSIONS,
OPERATORS,
type DimensionFilter,
} from '../filters'
describe('serializeFilters', () => {
it('returns empty string for empty array', () => {
expect(serializeFilters([])).toBe('')
})
it('serializes a single filter', () => {
const filters: DimensionFilter[] = [
{ dimension: 'browser', operator: 'is', values: ['Chrome'] },
]
expect(serializeFilters(filters)).toBe('browser|is|Chrome')
})
it('serializes multiple values with semicolons', () => {
const filters: DimensionFilter[] = [
{ dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] },
]
expect(serializeFilters(filters)).toBe('country|is|US;GB;DE')
})
it('serializes multiple filters with commas', () => {
const filters: DimensionFilter[] = [
{ dimension: 'browser', operator: 'is', values: ['Chrome'] },
{ dimension: 'country', operator: 'is_not', values: ['CN'] },
]
expect(serializeFilters(filters)).toBe('browser|is|Chrome,country|is_not|CN')
})
})
describe('parseFiltersFromURL', () => {
it('returns empty array for empty string', () => {
expect(parseFiltersFromURL('')).toEqual([])
})
it('parses a single filter', () => {
const result = parseFiltersFromURL('browser|is|Chrome')
expect(result).toEqual([
{ dimension: 'browser', operator: 'is', values: ['Chrome'] },
])
})
it('parses multiple values', () => {
const result = parseFiltersFromURL('country|is|US;GB;DE')
expect(result).toEqual([
{ dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] },
])
})
it('parses multiple filters', () => {
const result = parseFiltersFromURL('browser|is|Chrome,country|is_not|CN')
expect(result).toHaveLength(2)
expect(result[0].dimension).toBe('browser')
expect(result[1].dimension).toBe('country')
})
it('drops filters with missing values', () => {
const result = parseFiltersFromURL('browser|is')
expect(result).toEqual([])
})
it('handles completely invalid input', () => {
const result = parseFiltersFromURL('|||')
expect(result).toEqual([])
})
it('drops malformed entries but keeps valid ones', () => {
const result = parseFiltersFromURL('browser|is|Chrome,bad|input,country|is|US')
expect(result).toHaveLength(2)
expect(result[0].dimension).toBe('browser')
expect(result[1].dimension).toBe('country')
})
})
describe('serialize/parse roundtrip', () => {
it('roundtrips a complex filter set', () => {
const filters: DimensionFilter[] = [
{ dimension: 'page', operator: 'contains', values: ['/blog'] },
{ dimension: 'country', operator: 'is', values: ['US', 'GB'] },
{ dimension: 'browser', operator: 'is_not', values: ['IE'] },
]
const serialized = serializeFilters(filters)
const parsed = parseFiltersFromURL(serialized)
expect(parsed).toEqual(filters)
})
})
describe('filterLabel', () => {
it('returns human-readable label for known dimension', () => {
const f: DimensionFilter = { dimension: 'browser', operator: 'is', values: ['Chrome'] }
expect(filterLabel(f)).toBe('Browser is Chrome')
})
it('shows count for multiple values', () => {
const f: DimensionFilter = { dimension: 'country', operator: 'is', values: ['US', 'GB', 'DE'] }
expect(filterLabel(f)).toBe('Country is US +2')
})
it('falls back to raw dimension name if unknown', () => {
const f: DimensionFilter = { dimension: 'custom_dim', operator: 'contains', values: ['foo'] }
expect(filterLabel(f)).toBe('custom_dim contains foo')
})
it('uses readable operator labels', () => {
const f: DimensionFilter = { dimension: 'page', operator: 'not_contains', values: ['/admin'] }
expect(filterLabel(f)).toBe('Page does not contain /admin')
})
})
describe('constants', () => {
it('DIMENSIONS includes expected entries', () => {
expect(DIMENSIONS).toContain('page')
expect(DIMENSIONS).toContain('browser')
expect(DIMENSIONS).toContain('utm_source')
})
it('OPERATORS includes all four types', () => {
expect(OPERATORS).toEqual(['is', 'is_not', 'contains', 'not_contains'])
})
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@ciphera-net/ui', () => ({
authMessageFromStatus: (status: number) => `Error ${status}`,
AUTH_ERROR_MESSAGES: { NETWORK: 'Network error, please try again.' },
}))
const { getLoginUrl, getSignupUrl, ApiError } = await import('../client')
describe('getLoginUrl', () => {
it('builds login URL with default redirect', () => {
const url = getLoginUrl()
expect(url).toContain('/login')
expect(url).toContain('client_id=pulse-app')
expect(url).toContain('response_type=code')
expect(url).toContain(encodeURIComponent('/auth/callback'))
})
it('builds login URL with custom redirect', () => {
const url = getLoginUrl('/custom/path')
expect(url).toContain(encodeURIComponent('/custom/path'))
})
})
describe('getSignupUrl', () => {
it('builds signup URL with default redirect', () => {
const url = getSignupUrl()
expect(url).toContain('/signup')
expect(url).toContain('client_id=pulse-app')
expect(url).toContain('response_type=code')
})
it('builds signup URL with custom redirect', () => {
const url = getSignupUrl('/onboarding')
expect(url).toContain(encodeURIComponent('/onboarding'))
})
})
describe('ApiError', () => {
it('creates error with message and status', () => {
const err = new ApiError('Not found', 404)
expect(err.message).toBe('Not found')
expect(err.status).toBe(404)
expect(err.data).toBeUndefined()
expect(err).toBeInstanceOf(Error)
})
it('creates error with data payload', () => {
const data = { retryAfter: 30 }
const err = new ApiError('Rate limited', 429, data)
expect(err.status).toBe(429)
expect(err.data).toEqual({ retryAfter: 30 })
})
it('is catchable as a standard Error', () => {
const fn = () => { throw new ApiError('fail', 500) }
expect(fn).toThrow(Error)
expect(fn).toThrow('fail')
})
})

View File

@@ -79,6 +79,13 @@ export async function getOrganizationMembers(organizationId: string): Promise<Or
return data.members || []
}
// Remove a member from the organization
export async function removeOrganizationMember(organizationId: string, userId: string): Promise<void> {
await authFetch(`/auth/organizations/${organizationId}/members/${userId}`, {
method: 'DELETE',
})
}
// Send an invitation
export async function sendInvitation(
organizationId: string,

68
lib/sidebar-context.tsx Normal file
View File

@@ -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<SidebarState>({
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 (
<SidebarContext.Provider value={{ collapsed, toggle, expand, collapse }}>
{children}
</SidebarContext.Provider>
)
}
export function useSidebar() {
return useContext(SidebarContext)
}

View File

@@ -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<UnifiedSettingsContextType>({
isOpen: false,
openUnifiedSettings: () => {},
closeUnifiedSettings: () => {},
initialTab: null,
})
export function UnifiedSettingsProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [initialTab, setInitialTab] = useState<InitialTab>(null)
const openUnifiedSettings = useCallback((init?: InitialTab) => {
setInitialTab(init || null)
setIsOpen(true)
}, [])
const closeUnifiedSettings = useCallback(() => {
setIsOpen(false)
setInitialTab(null)
}, [])
return (
<UnifiedSettingsContext.Provider value={{ isOpen, openUnifiedSettings, closeUnifiedSettings, initialTab }}>
{children}
</UnifiedSettingsContext.Provider>
)
}
export function useUnifiedSettings() {
return useContext(UnifiedSettingsContext)
}

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
formatDate,
formatDateShort,
formatDateTime,
formatTime,
formatMonth,
formatDateISO,
formatDateFull,
formatDateTimeFull,
formatDateLong,
formatRelativeTime,
formatDateTimeShort,
} from '../formatDate'
// Fixed date: Friday 14 March 2025, 14:30:00 UTC
const date = new Date('2025-03-14T14:30:00Z')
describe('formatDate', () => {
it('returns day-first format with short month', () => {
const result = formatDate(date)
expect(result).toContain('14')
expect(result).toContain('Mar')
expect(result).toContain('2025')
})
})
describe('formatDateShort', () => {
it('omits year when same as current year', () => {
const now = new Date()
const sameYear = new Date(`${now.getFullYear()}-06-15T10:00:00Z`)
const result = formatDateShort(sameYear)
expect(result).toContain('15')
expect(result).toContain('Jun')
expect(result).not.toContain(String(now.getFullYear()))
})
it('includes year when different from current year', () => {
const oldDate = new Date('2020-06-15T10:00:00Z')
const result = formatDateShort(oldDate)
expect(result).toContain('2020')
})
})
describe('formatDateTime', () => {
it('includes date and 24-hour time', () => {
const result = formatDateTime(date)
expect(result).toContain('14')
expect(result).toContain('Mar')
expect(result).toContain('2025')
// 24-hour format check: should contain 14:30 (UTC) or local equivalent
expect(result).toMatch(/\d{2}:\d{2}/)
})
})
describe('formatTime', () => {
it('returns HH:MM in 24-hour format', () => {
const result = formatTime(date)
expect(result).toMatch(/^\d{2}:\d{2}$/)
})
})
describe('formatMonth', () => {
it('returns full month name and year', () => {
const result = formatMonth(date)
expect(result).toContain('March')
expect(result).toContain('2025')
})
})
describe('formatDateISO', () => {
it('returns YYYY-MM-DD format', () => {
expect(formatDateISO(date)).toBe('2025-03-14')
})
})
describe('formatDateFull', () => {
it('includes weekday', () => {
const result = formatDateFull(date)
expect(result).toContain('Fri')
expect(result).toContain('14')
expect(result).toContain('Mar')
expect(result).toContain('2025')
})
})
describe('formatDateTimeFull', () => {
it('includes weekday and time', () => {
const result = formatDateTimeFull(date)
expect(result).toContain('Fri')
expect(result).toMatch(/\d{2}:\d{2}/)
})
})
describe('formatDateLong', () => {
it('uses full month name', () => {
const result = formatDateLong(date)
expect(result).toContain('March')
expect(result).toContain('2025')
expect(result).toContain('14')
})
})
describe('formatRelativeTime', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns "Just now" for times less than a minute ago', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-14T14:30:30Z'))
expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('Just now')
})
it('returns minutes ago', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-14T14:35:00Z'))
expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('5m ago')
})
it('returns hours ago', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-14T16:30:00Z'))
expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('2h ago')
})
it('returns days ago', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-17T14:30:00Z'))
expect(formatRelativeTime('2025-03-14T14:30:00Z')).toBe('3d ago')
})
it('falls back to short date after 7 days', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-25T14:30:00Z'))
const result = formatRelativeTime('2025-03-14T14:30:00Z')
expect(result).toContain('14')
expect(result).toContain('Mar')
})
})
describe('formatDateTimeShort', () => {
it('includes date and time', () => {
const result = formatDateTimeShort(date)
expect(result).toContain('14')
expect(result).toContain('Mar')
expect(result).toMatch(/\d{2}:\d{2}/)
})
})

60
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pulse-frontend",
"version": "0.15.0-alpha",
"dependencies": {
"@ciphera-net/ui": "^0.3.1",
"@ciphera-net/ui": "^0.3.2",
"@ducanh2912/next-pwa": "^10.2.9",
"@icons-pack/react-simple-icons": "^13.13.0",
"@phosphor-icons/react": "^2.1.10",
@@ -16,8 +16,6 @@
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-slot": "^1.2.4",
"@simplewebauthn/browser": "^13.2.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-virtual": "^3.13.21",
"@types/d3": "^7.4.3",
"@visx/curve": "^3.12.0",
@@ -40,7 +38,6 @@
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.577.0",
"motion": "^12.35.2",
"next": "^16.1.1",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
@@ -1685,9 +1682,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.3.1",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.3.1/5696ea330397dfdedfb12cff5dc20ed073ede0d2",
"integrity": "sha512-NJgpcKERXbsMLABAdUsLq1V76O3lDFikAlf8xL4yfk19Jsg11llGEqtiW5CxmuyHWJXUiZjfT0M4sbwui0FAdA==",
"version": "0.3.2",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.3.2/f2271906ba6b3827dfc9598f6aed95bff20ae2a0",
"integrity": "sha512-bi8PANCpl27bCfIiHBmgyPFOKkTYgLHw/wOeUe9COAFHJj8MJGEj0rrXMa8L9Bf9IChw+MlITAojrklc1P8Kkw==",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1",
@@ -5540,29 +5537,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
"integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz",
"integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -12219,32 +12193,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "12.35.2",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.35.2.tgz",
"integrity": "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.35.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.35.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.2.tgz",

View File

@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.3.1",
"@ciphera-net/ui": "^0.3.2",
"@ducanh2912/next-pwa": "^10.2.9",
"@icons-pack/react-simple-icons": "^13.13.0",
"@phosphor-icons/react": "^2.1.10",

View File

@@ -69,6 +69,26 @@
transform-style: preserve-3d;
}
/* * Thin subtle scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* * Scrollbar hide - for horizontal scroll navs */
.scrollbar-hide {
-ms-overflow-style: none;
@@ -87,4 +107,13 @@
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fadeIn 150ms ease-out;
}
}