Unified settings modal + dashboard shell redesign #69
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
})()}
|
||||
|
||||
60
components/settings/unified/DangerZone.tsx
Normal file
60
components/settings/unified/DangerZone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
491
components/settings/unified/UnifiedSettingsModal.tsx
Normal file
491
components/settings/unified/UnifiedSettingsModal.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
components/settings/unified/tabs/AccountDevicesTab.tsx
Normal file
18
components/settings/unified/tabs/AccountDevicesTab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard'
|
||||
import SecurityActivityCard from '@/components/settings/SecurityActivityCard'
|
||||
|
||||
export default function AccountDevicesTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Devices & Activity</h3>
|
||||
<p className="text-sm text-neutral-400">Manage trusted devices and review security activity.</p>
|
||||
</div>
|
||||
|
||||
<TrustedDevicesCard />
|
||||
<SecurityActivityCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
components/settings/unified/tabs/AccountProfileTab.tsx
Normal file
144
components/settings/unified/tabs/AccountProfileTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
components/settings/unified/tabs/AccountSecurityTab.tsx
Normal file
16
components/settings/unified/tabs/AccountSecurityTab.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||
|
||||
export default function AccountSecurityTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-white mb-1">Security</h3>
|
||||
<p className="text-sm text-neutral-400">Manage your password and two-factor authentication.</p>
|
||||
</div>
|
||||
|
||||
<ProfileSettings activeTab="security" borderless />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
components/settings/unified/tabs/SiteBotSpamTab.tsx
Normal file
223
components/settings/unified/tabs/SiteBotSpamTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
components/settings/unified/tabs/SiteGeneralTab.tsx
Normal file
212
components/settings/unified/tabs/SiteGeneralTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
components/settings/unified/tabs/SiteGoalsTab.tsx
Normal file
170
components/settings/unified/tabs/SiteGoalsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
383
components/settings/unified/tabs/SiteIntegrationsTab.tsx
Normal file
383
components/settings/unified/tabs/SiteIntegrationsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
components/settings/unified/tabs/SitePrivacyTab.tsx
Normal file
271
components/settings/unified/tabs/SitePrivacyTab.tsx
Normal 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. "Disabled" 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>
|
||||
)
|
||||
}
|
||||
697
components/settings/unified/tabs/SiteReportsTab.tsx
Normal file
697
components/settings/unified/tabs/SiteReportsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
components/settings/unified/tabs/SiteVisibilityTab.tsx
Normal file
148
components/settings/unified/tabs/SiteVisibilityTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
components/settings/unified/tabs/WorkspaceAuditTab.tsx
Normal file
158
components/settings/unified/tabs/WorkspaceAuditTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
components/settings/unified/tabs/WorkspaceBillingTab.tsx
Normal file
198
components/settings/unified/tabs/WorkspaceBillingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
components/settings/unified/tabs/WorkspaceGeneralTab.tsx
Normal file
153
components/settings/unified/tabs/WorkspaceGeneralTab.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
205
components/settings/unified/tabs/WorkspaceMembersTab.tsx
Normal file
205
components/settings/unified/tabs/WorkspaceMembersTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
128
lib/__tests__/filters.test.ts
Normal file
128
lib/__tests__/filters.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
60
lib/api/__tests__/client.test.ts
Normal file
60
lib/api/__tests__/client.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
68
lib/sidebar-context.tsx
Normal 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)
|
||||
}
|
||||
44
lib/unified-settings-context.tsx
Normal file
44
lib/unified-settings-context.tsx
Normal 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)
|
||||
}
|
||||
149
lib/utils/__tests__/formatDate.test.ts
Normal file
149
lib/utils/__tests__/formatDate.test.ts
Normal 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
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user