Merge pull request #57 from ciphera-net/staging

Sidebar redesign, dropdown fixes, and soft-delete UI
This commit is contained in:
Usman
2026-03-19 01:08:16 +01:00
committed by GitHub
15 changed files with 1802 additions and 432 deletions

View File

@@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added
- **Funnels now track actions, not just pages.** When creating or editing a funnel, you can now choose between "Page Visit" and "Custom Event" for each step. Page Visit steps work as before — matching URLs. Custom Event steps let you track specific actions like signups, purchases, or button clicks. You can also add property filters to event steps (e.g., "purchase where plan is pro") to get even more specific about what you're measuring.
- **Edit your funnels.** You can now edit existing funnels — change the name, description, steps, or conversion window without having to delete and recreate them. Click the pencil icon on any funnel's detail page.
- **Conversion window.** Funnels now have a configurable time limit. Visitors must complete all steps within your chosen window (e.g., 7 days, 24 hours) to count as converted. Set it when creating or editing a funnel — quick presets for common windows, or type your own. Default is 7 days.
- **Filter your funnels.** Apply the same filters you use on the dashboard — by device, country, browser, UTM source, and more — directly on your funnel stats. See how your funnel performs for mobile visitors vs desktop, or for traffic from a specific campaign.
- **See where visitors go after dropping off.** Each funnel step now shows the top pages visitors navigated to after leaving the funnel. A quick preview appears inline, and you can expand to see the full list. Helps you understand why visitors aren't converting.
- **Conversion trends over time.** A new chart below your funnel shows how conversion rates change day by day. See at a glance whether your funnel is improving or degrading. Toggle individual steps on or off to pinpoint which step is changing.
- **Step-level breakdowns.** Click any step in your funnel stats to open a breakdown panel showing who converts at that step — split by device, country, browser, or traffic source. Useful for spotting segments that convert better or worse than average.
- **Up to 8 steps per funnel.** The step limit has been increased from 5 to 8, so you can track longer user journeys like multi-page onboarding flows or detailed checkout processes.
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time.
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.

View File

@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
@@ -18,7 +19,6 @@ import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper'
const ORG_SWITCH_KEY = 'pulse_switching_org'
// * Available Ciphera apps for the app switcher
const CIPHERA_APPS: CipheraApp[] = [
{
id: 'pulse',
@@ -26,7 +26,7 @@ const CIPHERA_APPS: CipheraApp[] = [
description: 'Your current app — Privacy-first analytics',
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
href: 'https://pulse.ciphera.net',
isAvailable: false, // * Current app
isAvailable: false,
},
{
id: 'drop',
@@ -49,6 +49,7 @@ const CIPHERA_APPS: CipheraApp[] = [
function LayoutInner({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const pathname = usePathname()
const isOnline = useOnlineStatus()
const { openSettings } = useSettingsModal()
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
@@ -57,7 +58,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
// * Clear the switching flag once the page has settled after reload
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
@@ -66,7 +66,6 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher
useEffect(() => {
if (auth.user) {
getUserOrganizations()
@@ -76,7 +75,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
}, [auth.user])
const handleSwitchOrganization = async (orgId: string | null) => {
if (!orgId) return // Pulse doesn't support personal organization context
if (!orgId) return
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
@@ -87,66 +86,98 @@ function LayoutInner({ children }: { children: React.ReactNode }) {
}
}
const handleCreateOrganization = () => {
router.push('/onboarding')
}
const showOfflineBar = Boolean(auth.user && !isOnline);
const barHeightRem = 2.5;
const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem;
const isAuthenticated = !!auth.user
const showOfflineBar = Boolean(auth.user && !isOnline)
// Site pages use DashboardShell with full sidebar — no Header needed
const isSitePage = pathname.startsWith('/sites/') && pathname !== '/sites/new'
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
// While auth is loading on a site page, render nothing to prevent flash of public header
if (auth.loading && isSitePage) {
return null
}
// Authenticated site pages: full sidebar layout
// DashboardShell inside children handles everything
if (isAuthenticated && isSitePage) {
return (
<>
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
{children}
<SettingsModalWrapper />
</>
)
}
// Authenticated non-site pages (sites list, onboarding, etc.): static header
if (isAuthenticated) {
return (
<div className="flex flex-col min-h-screen">
{showOfflineBar && <OfflineBanner isOnline={isOnline} />}
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
variant="static"
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={() => router.push('/onboarding')}
allowPersonalOrganization={false}
showFaq={false}
showSecurity={false}
showPricing={false}
rightSideActions={<NotificationCenter />}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
/>
<main className="flex-1 pb-8">
{children}
</main>
<SettingsModalWrapper />
</div>
)
}
// Public/marketing: floating header + footer
return (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}
<div className="flex flex-col min-h-screen">
<Header
auth={auth}
LinkComponent={Link}
logoSrc="/pulse_icon_no_margins.png"
appName="Pulse"
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={handleCreateOrganization}
allowPersonalOrganization={false}
variant="floating"
showFaq={false}
showSecurity={false}
showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
topOffset={showOfflineBar ? '2.5rem' : undefined}
apps={CIPHERA_APPS}
currentAppId="pulse"
onOpenSettings={openSettings}
customNavItems={
<>
{!auth.user && (
<Link
href="/features"
className="px-4 py-2 text-sm font-medium 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-all duration-200"
>
Features
</Link>
)}
</>
<Link
href="/features"
className="px-4 py-2 text-sm font-medium 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-all duration-200"
>
Features
</Link>
}
/>
<main
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
style={showOfflineBar ? { paddingTop: `${mainTopPaddingRem}rem` } : undefined}
>
<main className="flex-1 pb-8 pt-24">
{children}
</main>
<Footer
LinkComponent={Link}
appName="Pulse"
isAuthenticated={!!auth.user}
isAuthenticated={false}
/>
<SettingsModalWrapper />
</>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import SiteNav from '@/components/dashboard/SiteNav'
import DashboardShell from '@/components/dashboard/DashboardShell'
export default function SiteLayoutShell({
siteId,
@@ -10,11 +10,8 @@ export default function SiteLayoutShell({
children: React.ReactNode
}) {
return (
<>
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pt-8">
<SiteNav siteId={siteId} />
</div>
<DashboardShell siteId={siteId}>
{children}
</>
</DashboardShell>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useSWRConfig } from 'swr'
import { getFunnel, updateFunnel, type Funnel, type CreateFunnelRequest } from '@/lib/api/funnels'
import { toast } from '@ciphera-net/ui'
import FunnelForm from '@/components/funnels/FunnelForm'
import { FunnelDetailSkeleton } from '@/components/skeletons'
export default function EditFunnelPage() {
const params = useParams()
const router = useRouter()
const { mutate } = useSWRConfig()
const siteId = params.id as string
const funnelId = params.funnelId as string
const [funnel, setFunnel] = useState<Funnel | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
getFunnel(siteId, funnelId).then(setFunnel).catch(() => {
toast.error('Failed to load funnel')
router.push(`/sites/${siteId}/funnels`)
})
}, [siteId, funnelId, router])
const handleSubmit = async (data: CreateFunnelRequest) => {
try {
setSaving(true)
await updateFunnel(siteId, funnelId, data)
await mutate(['funnels', siteId])
toast.success('Funnel updated')
router.push(`/sites/${siteId}/funnels/${funnelId}`)
} catch {
toast.error('Failed to update funnel. Please try again.')
} finally {
setSaving(false)
}
}
if (!funnel) return <FunnelDetailSkeleton />
return (
<FunnelForm
siteId={siteId}
initialData={{
name: funnel.name,
description: funnel.description,
steps: funnel.steps.map(({ order, ...rest }) => rest),
conversion_window_value: funnel.conversion_window_value,
conversion_window_unit: funnel.conversion_window_unit,
}}
onSubmit={handleSubmit}
submitLabel={saving ? 'Saving...' : 'Save Changes'}
cancelHref={`/sites/${siteId}/funnels/${funnelId}`}
/>
)
}

View File

@@ -1,14 +1,20 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
import { getFunnel, getFunnelStats, getFunnelTrends, deleteFunnel, type Funnel, type FunnelStats, type FunnelTrends } from '@/lib/api/funnels'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown'
import { type DimensionFilter, serializeFilters } from '@/lib/filters'
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { PencilSimple } from '@phosphor-icons/react'
import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import Link from 'next/link'
import { FunnelChart } from '@/components/ui/funnel-chart'
import { getDateRange } from '@ciphera-net/ui'
import BreakdownDrawer from '@/components/funnels/BreakdownDrawer'
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
export default function FunnelReportPage() {
const params = useParams()
@@ -23,17 +29,25 @@ export default function FunnelReportPage() {
const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30')
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null)
const [filters, setFilters] = useState<DimensionFilter[]>([])
const [expandedExitStep, setExpandedExitStep] = useState<number | null>(null)
const [trends, setTrends] = useState<FunnelTrends | null>(null)
const [visibleSteps, setVisibleSteps] = useState<Set<string>>(new Set())
const [breakdownStep, setBreakdownStep] = useState<number | null>(null)
const loadData = useCallback(async () => {
setLoadError(null)
try {
setLoading(true)
const [funnelData, statsData] = await Promise.all([
const filterStr = serializeFilters(filters) || undefined
const [funnelData, statsData, trendsData] = await Promise.all([
getFunnel(siteId, funnelId),
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end)
getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, filterStr),
getFunnelTrends(siteId, funnelId, dateRange.start, dateRange.end, 'day', filterStr)
])
setFunnel(funnelData)
setStats(statsData)
setTrends(trendsData)
} catch (error) {
const status = error instanceof ApiError ? error.status : 0
if (status === 404) setLoadError('not_found')
@@ -43,7 +57,7 @@ export default function FunnelReportPage() {
} finally {
setLoading(false)
}
}, [siteId, funnelId, dateRange])
}, [siteId, funnelId, dateRange, filters])
useEffect(() => {
loadData()
@@ -113,6 +127,21 @@ export default function FunnelReportPage() {
value: s.visitors,
}))
const STEP_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
const trendsChartData = trends ? trends.dates.map((date, idx) => {
const point: Record<string, any> = {
date: new Date(date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }),
overall: Math.round(trends.overall[idx] * 10) / 10,
}
for (const [stepKey, values] of Object.entries(trends.steps)) {
if (visibleSteps.has(stepKey)) {
point[`step_${stepKey}`] = Math.round(values[idx] * 10) / 10
}
}
return point
}) : []
return (
<div className={`w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
<div className="mb-8">
@@ -157,6 +186,13 @@ export default function FunnelReportPage() {
]}
/>
<Link
href={`/sites/${siteId}/funnels/${funnelId}/edit`}
className="p-2 text-neutral-400 hover:text-brand-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-xl transition-colors"
aria-label="Edit funnel"
>
<PencilSimple className="w-5 h-5" />
</Link>
<button
onClick={handleDelete}
className="p-2 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
@@ -167,6 +203,18 @@ export default function FunnelReportPage() {
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 mb-6">
<AddFilterDropdown
onAdd={(f) => setFilters(prev => [...prev, f])}
/>
<FilterBar
filters={filters}
onRemove={(i) => setFilters(prev => prev.filter((_, idx) => idx !== i))}
onClear={() => setFilters([])}
/>
</div>
{/* Chart */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
@@ -181,6 +229,90 @@ export default function FunnelReportPage() {
/>
</div>
{/* Conversion Trends */}
{trends && trends.dates.length > 1 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Conversion Trends
</h3>
<div className="flex flex-wrap gap-2">
{stats?.steps.map((s, i) => (
<button
key={i}
type="button"
onClick={() => {
setVisibleSteps(prev => {
const next = new Set(prev)
if (next.has(String(i))) next.delete(String(i))
else next.add(String(i))
return next
})
}}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
visibleSteps.has(String(i))
? 'bg-brand-orange/10 text-brand-orange border border-brand-orange/30'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 border border-transparent'
}`}
>
{s.step.name}
</button>
))}
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendsChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-neutral-200 dark:text-neutral-700" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-neutral-500"
/>
<YAxis
domain={[0, 100]}
tickFormatter={(v) => `${v}%`}
tick={{ fontSize: 12 }}
className="text-neutral-500"
/>
<Tooltip
formatter={(value: number) => [`${value}%`]}
contentStyle={{
backgroundColor: 'var(--color-neutral-900, #171717)',
border: '1px solid var(--color-neutral-700, #404040)',
borderRadius: '8px',
color: '#fff',
fontSize: '12px',
}}
/>
<Line
type="monotone"
dataKey="overall"
name="Overall"
stroke="#F97316"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
{Array.from(visibleSteps).map((stepKey) => (
<Line
key={stepKey}
type="monotone"
dataKey={`step_${stepKey}`}
name={stats?.steps[Number(stepKey)]?.step.name || `Step ${stepKey}`}
stroke={STEP_COLORS[Number(stepKey) % STEP_COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Detailed Stats Table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
@@ -195,42 +327,72 @@ export default function FunnelReportPage() {
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{stats.steps.map((step, i) => (
<tr key={step.step.name} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
{i + 1}
</span>
<div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
<React.Fragment key={step.step.name}>
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/30 transition-colors cursor-pointer" onClick={() => setBreakdownStep(i)}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-xs font-medium text-neutral-600 dark:text-neutral-400">
{i + 1}
</span>
<div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
<p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-neutral-900 dark:text-white">
{step.visitors.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 text-right">
{i > 0 ? (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
step.dropoff > 50
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{Math.round(step.dropoff)}%
</td>
<td className="px-6 py-4 text-right">
<span className="font-medium text-neutral-900 dark:text-white">
{step.visitors.toLocaleString()}
</span>
) : (
<span className="text-neutral-400">-</span>
)}
</td>
<td className="px-6 py-4 text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{Math.round(step.conversion)}%
</span>
</td>
</tr>
</td>
<td className="px-6 py-4 text-right">
{i > 0 ? (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
step.dropoff > 50
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{Math.round(step.dropoff)}%
</span>
) : (
<span className="text-neutral-400">-</span>
)}
</td>
<td className="px-6 py-4 text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{Math.round(step.conversion)}%
</span>
</td>
</tr>
{step.exit_pages && step.exit_pages.length > 0 && (
<tr className="bg-neutral-50/50 dark:bg-neutral-800/20">
<td colSpan={4} className="px-6 py-3">
<div className="ml-9">
<p className="text-xs font-medium text-neutral-500 mb-2">
Where visitors went after dropping off:
</p>
<div className="flex flex-wrap gap-2">
{(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => (
<span key={ep.path} className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-xs">
<span className="font-mono text-neutral-600 dark:text-neutral-300">{ep.path}</span>
<span className="text-neutral-400">{ep.visitors}</span>
</span>
))}
</div>
{step.exit_pages.length > 3 && (
<button
type="button"
onClick={() => setExpandedExitStep(expandedExitStep === i ? null : i)}
className="mt-2 text-xs text-brand-orange hover:underline"
>
{expandedExitStep === i ? 'Show less' : `See all ${step.exit_pages.length} exit pages`}
</button>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
@@ -238,6 +400,19 @@ export default function FunnelReportPage() {
</div>
</div>
{breakdownStep !== null && stats && (
<BreakdownDrawer
siteId={siteId}
funnelId={funnelId}
stepIndex={breakdownStep}
stepName={stats.steps[breakdownStep].step.name}
startDate={dateRange.start}
endDate={dateRange.end}
filters={serializeFilters(filters) || undefined}
onClose={() => setBreakdownStep(null)}
/>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}

View File

@@ -3,90 +3,25 @@
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useSWRConfig } from 'swr'
import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels'
import { toast, Input, Button, ChevronLeftIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
import Link from 'next/link'
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern)
return true
} catch {
return false
}
}
import { createFunnel, type CreateFunnelRequest } from '@/lib/api/funnels'
import { toast } from '@ciphera-net/ui'
import FunnelForm from '@/components/funnels/FunnelForm'
export default function CreateFunnelPage() {
const params = useParams()
const router = useRouter()
const { mutate } = useSWRConfig()
const siteId = params.id as string
const [name, setName] = useState('')
const [description, setDescription] = useState('')
// * Backend requires at least one step (API binding min=1, DB rejects empty steps)
const [steps, setSteps] = useState<Omit<FunnelStep, 'order'>[]>([
{ name: 'Step 1', value: '/', type: 'exact' },
{ name: 'Step 2', value: '', type: 'exact' }
])
const [saving, setSaving] = useState(false)
const handleAddStep = () => {
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
}
const handleRemoveStep = (index: number) => {
if (steps.length <= 1) return
const newSteps = steps.filter((_, i) => i !== index)
setSteps(newSteps)
}
const handleUpdateStep = (index: number, field: keyof Omit<FunnelStep, 'order'>, value: string) => {
const newSteps = [...steps]
newSteps[index] = { ...newSteps[index], [field]: value }
setSteps(newSteps)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast.error('Please enter a funnel name')
return
}
if (steps.some(s => !s.name.trim())) {
toast.error('Please enter a name for all steps')
return
}
if (steps.some(s => !s.value.trim())) {
toast.error('Please enter a path for all steps')
return
}
const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value))
if (invalidRegexStep) {
toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`)
return
}
const handleSubmit = async (data: CreateFunnelRequest) => {
try {
setSaving(true)
const funnelSteps = steps.map((s, i) => ({
...s,
order: i
}))
await createFunnel(siteId, {
name,
description,
steps: funnelSteps
})
await createFunnel(siteId, data)
await mutate(['funnels', siteId])
toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`)
} catch (error) {
} catch {
toast.error('Failed to create funnel. Please try again.')
} finally {
setSaving(false)
@@ -94,149 +29,11 @@ export default function CreateFunnelPage() {
}
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<Link
href={`/sites/${siteId}/funnels`}
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
>
<ChevronLeftIcon className="w-4 h-4" />
Back to Funnels
</Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Create New Funnel
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Define the steps users take to complete a goal.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Funnel Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
autoFocus
required
maxLength={100}
/>
{name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Description (Optional)
</label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Tracks users from landing page to signup"
/>
</div>
</div>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Funnel Steps
</h3>
</div>
{steps.map((step, index) => (
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div className="flex items-start gap-4">
<div className="mt-3 text-neutral-400">
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
{index + 1}
</div>
</div>
<div className="flex-1 grid gap-4 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Step Name
</label>
<Input
value={step.name}
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
placeholder="e.g. Landing Page"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Path / URL
</label>
<div className="flex gap-2">
<select
value={step.type}
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
className="w-24 px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
>
<option value="exact">Exact</option>
<option value="contains">Contains</option>
<option value="regex">Regex</option>
</select>
<Input
value={step.value}
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
className="flex-1"
/>
</div>
</div>
</div>
<button
type="button"
onClick={() => handleRemoveStep(index)}
disabled={steps.length <= 1}
aria-label="Remove step"
className={`mt-3 p-2 rounded-xl transition-colors ${
steps.length <= 1
? 'text-neutral-300 cursor-not-allowed'
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
}`}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</div>
))}
<button
type="button"
onClick={handleAddStep}
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
>
<PlusIcon className="w-4 h-4" />
Add Step
</button>
</div>
<div className="flex justify-end gap-4">
<Link href={`/sites/${siteId}/funnels`}>
<Button variant="secondary">
Cancel
</Button>
</Link>
<Button
type="submit"
disabled={saving}
variant="primary"
>
{saving ? 'Creating...' : 'Create Funnel'}
</Button>
</div>
</form>
</div>
<FunnelForm
siteId={siteId}
onSubmit={handleSubmit}
submitLabel={saving ? 'Creating...' : 'Create Funnel'}
cancelHref={`/sites/${siteId}/funnels`}
/>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { MenuIcon } from '@ciphera-net/ui'
export default function ContentHeader({
onMobileMenuOpen,
}: {
onMobileMenuOpen: () => void
}) {
return (
<div className="shrink-0 flex items-center border-b border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl px-4 py-3.5 md:hidden">
<button
onClick={onMobileMenuOpen}
className="p-2 -ml-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
aria-label="Open navigation"
>
<MenuIcon className="w-5 h-5" />
</button>
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import { useState, useCallback } from 'react'
import dynamic from 'next/dynamic'
import ContentHeader from './ContentHeader'
// 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 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl"
style={{ width: 64 }}
/>
),
})
export default function DashboardShell({
siteId,
children,
}: {
siteId: string
children: React.ReactNode
}) {
const [mobileOpen, setMobileOpen] = useState(false)
const closeMobile = useCallback(() => setMobileOpen(false), [])
const openMobile = useCallback(() => setMobileOpen(true), [])
return (
<div className="flex h-screen overflow-hidden">
<Sidebar
siteId={siteId}
mobileOpen={mobileOpen}
onMobileClose={closeMobile}
onMobileOpen={openMobile}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<ContentHeader onMobileMenuOpen={openMobile} />
<main className="flex-1 overflow-y-auto pt-6">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,463 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
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 { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
import {
LayoutDashboardIcon,
PathIcon,
FunnelIcon,
CursorClickIcon,
SearchIcon,
CloudUploadIcon,
HeartbeatIcon,
SettingsIcon,
CollapseLeftIcon,
CollapseRightIcon,
ChevronUpDownIcon,
PlusIcon,
XIcon,
AppLauncher,
UserMenu,
type CipheraApp,
} from '@ciphera-net/ui'
import NotificationCenter from '@/components/notifications/NotificationCenter'
const CIPHERA_APPS: CipheraApp[] = [
{
id: 'pulse',
name: 'Pulse',
description: 'Your current app — Privacy-first analytics',
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
href: 'https://pulse.ciphera.net',
isAvailable: false,
},
{
id: 'drop',
name: 'Drop',
description: 'Secure file sharing',
icon: 'https://ciphera.net/drop_icon_no_margins.png',
href: 'https://drop.ciphera.net',
isAvailable: true,
},
{
id: 'auth',
name: 'Auth',
description: 'Your Ciphera account settings',
icon: 'https://ciphera.net/auth_icon_no_margins.png',
href: 'https://auth.ciphera.net',
isAvailable: true,
},
]
const SIDEBAR_KEY = 'pulse_sidebar_collapsed'
const EXPANDED = 256
const COLLAPSED = 64
type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'
interface NavItem {
label: string
href: (siteId: string) => string
icon: React.ComponentType<{ className?: string; weight?: IconWeight }>
matchPrefix?: boolean
}
interface NavGroup { label: string; items: NavItem[] }
const NAV_GROUPS: NavGroup[] = [
{
label: 'Analytics',
items: [
{ label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon },
{ label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true },
{ label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true },
{ label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true },
{ label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true },
],
},
{
label: 'Infrastructure',
items: [
{ label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true },
{ label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true },
],
},
]
const SETTINGS_ITEM: NavItem = {
label: 'Site Settings', href: (id) => `/sites/${id}/settings`, icon: SettingsIcon, matchPrefix: true,
}
// Label that fades with the sidebar — always in the DOM, never removed
function Label({ children, collapsed }: { children: React.ReactNode; collapsed: boolean }) {
return (
<span
className="whitespace-nowrap overflow-hidden transition-opacity duration-150"
style={{ opacity: collapsed ? 0 : 1 }}
>
{children}
</span>
)
}
// ─── Site Picker ────────────────────────────────────────────
function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed }: {
sites: Site[]; siteId: string; collapsed: boolean
onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject<boolean>
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [faviconFailed, setFaviconFailed] = useState(false)
const [faviconLoaded, setFaviconLoaded] = useState(false)
const ref = useRef<HTMLDivElement>(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
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
if (open) {
setOpen(false); setSearch('')
// Re-collapse if we auto-expanded
if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false }
}
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, onCollapse, wasCollapsed])
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 }
}
const filtered = sites.filter(
(s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="relative mb-4 px-2" ref={ref}>
<button
onClick={() => {
if (collapsed) {
wasCollapsed.current = true
onExpand()
// Open picker after sidebar expands
setTimeout(() => setOpen(true), 220)
} 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-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 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 ? (
<>
{!faviconLoaded && <span className="w-5 h-5 rounded animate-pulse bg-neutral-100 dark:bg-neutral-800" />}
<img
src={faviconUrl}
alt=""
className={`w-5 h-5 object-contain ${faviconLoaded ? '' : 'hidden'}`}
onLoad={() => setFaviconLoaded(true)}
onError={() => setFaviconFailed(true)}
/>
</>
) : null}
</span>
<Label collapsed={collapsed}>
<span className="flex items-center gap-1">
<span className="truncate">{currentSite?.name || ''}</span>
<ChevronUpDownIcon className="w-4 h-4 text-neutral-400 shrink-0" />
</span>
</Label>
</button>
{open && (
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden">
<div className="p-2">
<input
type="text"
placeholder="Search sites..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark: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-700 dark:text-neutral-300 hover:bg-neutral-50 dark: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-200 dark: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-50 dark:hover:bg-neutral-800 rounded-lg">
<PlusIcon className="w-4 h-4" />
Add new site
</Link>
</div>
</div>
)}
</div>
)
}
// ─── Nav Item ───────────────────────────────────────────────
function NavLink({
item, siteId, collapsed, onClick, pendingHref, onNavigate,
}: {
item: NavItem; siteId: string; collapsed: boolean; onClick?: () => void
pendingHref: string | null; onNavigate: (href: string) => void
}) {
const pathname = usePathname()
const href = item.href(siteId)
const matchesPathname = item.matchPrefix ? pathname.startsWith(href) : pathname === href
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
const active = matchesPathname || matchesPending
return (
<Link
href={href}
onClick={() => { onNavigate(href); onClick?.() }}
title={collapsed ? item.label : undefined}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<span className="w-7 h-7 flex items-center justify-center shrink-0">
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
</span>
<Label collapsed={collapsed}>{item.label}</Label>
</Link>
)
}
// ─── Main Sidebar ───────────────────────────────────────────
export default function Sidebar({
siteId, mobileOpen, onMobileClose, onMobileOpen,
}: {
siteId: string; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void
}) {
const auth = useAuth()
const { user } = auth
const canEdit = user?.role === 'owner' || user?.role === 'admin'
const pathname = usePathname()
const router = useRouter()
const { openSettings } = useSettingsModal()
const [sites, setSites] = useState<Site[]>([])
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [pendingHref, setPendingHref] = useState<string | null>(null)
const wasCollapsedRef = useRef(false)
// Safe to read localStorage directly — this component is loaded with ssr:false
const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem(SIDEBAR_KEY) !== 'false'
})
useEffect(() => { listSites().then(setSites).catch(() => {}) }, [])
useEffect(() => {
if (user) {
getUserOrganizations()
.then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
.catch(err => logger.error('Failed to fetch orgs', err))
}
}, [user])
const handleSwitchOrganization = async (orgId: string | null) => {
if (!orgId) return
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload()
} catch (err) {
logger.error('Failed to switch organization', err)
}
}
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 handleNavigate = useCallback((href: string) => { setPendingHref(href) }, [])
const sidebarContent = (isMobile: boolean) => {
const c = isMobile ? false : collapsed
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">
<span className="w-9 h-9 flex items-center justify-center shrink-0">
<AppLauncher apps={CIPHERA_APPS} currentAppId="pulse" anchor="right" />
</span>
<Label collapsed={c}>
<span className="text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">Ciphera</span>
</Label>
</div>
{/* Logo — fixed layout, text fades */}
<Link href="/" className="flex items-center gap-3 px-[14px] py-4 shrink-0 group overflow-hidden">
<span className="w-9 h-9 flex items-center justify-center shrink-0">
<img src="/pulse_icon_no_margins.png" alt="Pulse" className="w-9 h-9 shrink-0 object-contain group-hover:scale-105 transition-transform duration-200" />
</span>
<span className={`text-xl font-bold text-neutral-900 dark:text-white tracking-tight group-hover:text-brand-orange whitespace-nowrap transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
Pulse
</span>
</Link>
{/* Site Picker */}
<SitePicker sites={sites} siteId={siteId} collapsed={c} onExpand={expand} onCollapse={collapse} wasCollapsed={wasCollapsedRef} />
{/* Nav Groups */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-2 space-y-4">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<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 transition-opacity duration-150 ${c ? 'opacity-0' : 'opacity-100'}`}>
{group.label}
</p>
</div>
<div className="space-y-0.5">
{group.items.map((item) => (
<NavLink key={item.label} item={item} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
))}
{group.label === 'Infrastructure' && canEdit && (
<NavLink item={SETTINGS_ITEM} siteId={siteId} collapsed={c} onClick={isMobile ? onMobileClose : undefined} pendingHref={pendingHref} onNavigate={handleNavigate} />
)}
</div>
</div>
))}
</nav>
{/* Bottom — utility items */}
<div className="border-t border-neutral-200/60 dark:border-neutral-800/60 px-2 py-3 shrink-0">
{/* Notifications, Profile — same layout as nav items */}
<div className="space-y-0.5 mb-1">
<span title={c ? 'Notifications' : undefined}>
<NotificationCenter anchor="right" variant="sidebar">
<Label collapsed={c}>Notifications</Label>
</NotificationCenter>
</span>
<span title={c ? (user?.display_name?.trim() || 'Profile') : undefined}>
<UserMenu
auth={auth}
LinkComponent={Link}
orgs={orgs}
activeOrgId={auth.user?.org_id}
onSwitchOrganization={handleSwitchOrganization}
onCreateOrganization={() => router.push('/onboarding')}
allowPersonalOrganization={false}
onOpenSettings={openSettings}
compact
anchor="right"
>
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
</UserMenu>
</span>
</div>
{/* Settings + Collapse */}
<div className="space-y-0.5">
{!isMobile && (
<button
onClick={toggle}
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-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden"
title={collapsed ? 'Expand sidebar (press [)' : 'Collapse sidebar (press [)'}
>
<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}>Collapse</Label>
</button>
)}
</div>
</div>
</div>
)
}
return (
<>
{/* Desktop — ssr:false means this only renders on client, no hydration flash */}
<aside
className="hidden md:flex flex-col shrink-0 border-r border-neutral-200/60 dark:border-neutral-800/60 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl overflow-hidden relative z-10"
style={{ width: collapsed ? COLLAPSED : EXPANDED, transition: 'width 200ms cubic-bezier(0.4, 0, 0.2, 1)' }}
>
{sidebarContent(false)}
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<>
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={onMobileClose} />
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200">
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">Navigation</span>
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
<XIcon className="w-5 h-5" />
</button>
</div>
{sidebarContent(true)}
</aside>
</>
)}
</>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { getFunnelBreakdown, type FunnelBreakdown } from '@/lib/api/funnels'
import { DIMENSION_LABELS } from '@/lib/filters'
const BREAKDOWN_DIMENSIONS = [
'device', 'country', 'browser', 'os',
'utm_source', 'utm_medium', 'utm_campaign'
]
interface BreakdownDrawerProps {
siteId: string
funnelId: string
stepIndex: number
stepName: string
startDate: string
endDate: string
filters?: string
onClose: () => void
}
export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName, startDate, endDate, filters, onClose }: BreakdownDrawerProps) {
const [activeDimension, setActiveDimension] = useState('device')
const [breakdown, setBreakdown] = useState<FunnelBreakdown | null>(null)
const [loading, setLoading] = useState(true)
const loadBreakdown = useCallback(async () => {
setLoading(true)
try {
const data = await getFunnelBreakdown(siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters)
setBreakdown(data)
} catch {
setBreakdown(null)
} finally {
setLoading(false)
}
}, [siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters])
useEffect(() => {
loadBreakdown()
}, [loadBreakdown])
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 bg-black/20" onClick={onClose} />
<div className="fixed inset-y-0 right-0 z-50 w-96 max-w-full bg-white dark:bg-neutral-900 border-l border-neutral-200 dark:border-neutral-800 shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">Step Breakdown</h3>
<p className="text-sm text-neutral-500">{stepName}</p>
</div>
<button onClick={onClose} className="p-2 text-neutral-400 hover:text-neutral-600 rounded-lg">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Dimension tabs */}
<div className="flex overflow-x-auto gap-1 px-6 py-3 border-b border-neutral-200 dark:border-neutral-800">
{BREAKDOWN_DIMENSIONS.map(dim => (
<button
key={dim}
onClick={() => setActiveDimension(dim)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg whitespace-nowrap transition-colors ${
activeDimension === dim
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{DIMENSION_LABELS[dim] || dim}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
))}
</div>
) : !breakdown || breakdown.entries.length === 0 ? (
<p className="text-sm text-neutral-500">No data for this dimension</p>
) : (
<div className="space-y-2">
{breakdown.entries.map(entry => (
<div key={entry.value} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<span className="text-sm text-neutral-900 dark:text-white truncate mr-4">
{entry.value || '(unknown)'}
</span>
<div className="flex items-center gap-4 text-sm shrink-0">
<span className="text-neutral-500">{entry.visitors}</span>
<span className="text-green-600 dark:text-green-400 font-medium w-16 text-right">
{Math.round(entry.conversion)}%
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,519 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Input, Button, ChevronLeftIcon, ChevronDownIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui'
import { CaretUp } from '@phosphor-icons/react'
import type { FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels'
type StepWithoutOrder = Omit<FunnelStep, 'order'>
interface FunnelFormProps {
siteId: string
initialData?: {
name: string
description: string
steps: StepWithoutOrder[]
conversion_window_value: number
conversion_window_unit: 'hours' | 'days'
}
onSubmit: (data: CreateFunnelRequest) => Promise<void>
submitLabel: string
cancelHref: string
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern)
return true
} catch {
return false
}
}
const WINDOW_PRESETS = [
{ label: '1h', value: 1, unit: 'hours' as const },
{ label: '24h', value: 24, unit: 'hours' as const },
{ label: '7d', value: 7, unit: 'days' as const },
{ label: '14d', value: 14, unit: 'days' as const },
{ label: '30d', value: 30, unit: 'days' as const },
]
const OPERATOR_OPTIONS: { value: StepPropertyFilter['operator']; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
]
const MAX_STEPS = 8
const MAX_FILTERS = 10
export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel, cancelHref }: FunnelFormProps) {
const [name, setName] = useState(initialData?.name ?? '')
const [description, setDescription] = useState(initialData?.description ?? '')
const [steps, setSteps] = useState<StepWithoutOrder[]>(
initialData?.steps ?? [
{ name: 'Step 1', value: '/', type: 'exact' },
{ name: 'Step 2', value: '', type: 'exact' },
]
)
const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7)
const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days')
const handleAddStep = () => {
if (steps.length >= MAX_STEPS) return
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
}
const handleRemoveStep = (index: number) => {
if (steps.length <= 1) return
setSteps(steps.filter((_, i) => i !== index))
}
const handleUpdateStep = (index: number, field: string, value: string) => {
const newSteps = [...steps]
const step = { ...newSteps[index] }
if (field === 'category') {
step.category = value as 'page' | 'event'
// Reset fields when switching category
if (value === 'event') {
step.type = 'exact'
step.value = ''
} else {
step.value = ''
step.property_filters = undefined
}
} else {
;(step as Record<string, unknown>)[field] = value
}
newSteps[index] = step
setSteps(newSteps)
}
const moveStep = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= steps.length) return
const newSteps = [...steps]
const temp = newSteps[index]
newSteps[index] = newSteps[targetIndex]
newSteps[targetIndex] = temp
setSteps(newSteps)
}
// Property filter handlers
const addPropertyFilter = (stepIndex: number) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
if (filters.length >= MAX_FILTERS) return
filters.push({ key: '', operator: 'is', value: '' })
step.property_filters = filters
newSteps[stepIndex] = step
setSteps(newSteps)
}
const updatePropertyFilter = (stepIndex: number, filterIndex: number, field: keyof StepPropertyFilter, value: string) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
filters[filterIndex] = { ...filters[filterIndex], [field]: value }
step.property_filters = filters
newSteps[stepIndex] = step
setSteps(newSteps)
}
const removePropertyFilter = (stepIndex: number, filterIndex: number) => {
const newSteps = [...steps]
const step = { ...newSteps[stepIndex] }
const filters = [...(step.property_filters || [])]
filters.splice(filterIndex, 1)
step.property_filters = filters.length > 0 ? filters : undefined
newSteps[stepIndex] = step
setSteps(newSteps)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error('Please enter a funnel name')
return
}
if (steps.some(s => !s.name.trim())) {
const { toast } = await import('@ciphera-net/ui')
toast.error('Please enter a name for all steps')
return
}
// Validate based on category
for (const step of steps) {
const category = step.category || 'page'
if (!step.value.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error(category === 'event'
? `Please enter an event name for step: ${step.name}`
: `Please enter a path for step: ${step.name}`)
return
}
if (category === 'page' && step.type === 'regex' && !isValidRegex(step.value)) {
const { toast } = await import('@ciphera-net/ui')
toast.error(`Invalid regex pattern in step: ${step.name}`)
return
}
if (category === 'event' && step.property_filters) {
for (const filter of step.property_filters) {
if (!filter.key.trim()) {
const { toast } = await import('@ciphera-net/ui')
toast.error(`Property filter key is required in step: ${step.name}`)
return
}
}
}
}
const funnelSteps = steps.map((s, i) => ({
...s,
order: i,
}))
await onSubmit({
name,
description,
steps: funnelSteps,
conversion_window_value: windowValue,
conversion_window_unit: windowUnit,
})
}
const selectClass = 'px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none'
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<Link
href={cancelHref}
className="inline-flex items-center gap-2 text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white mb-6 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 px-2 py-1.5 -ml-2 transition-colors"
>
<ChevronLeftIcon className="w-4 h-4" />
Back to Funnels
</Link>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
{initialData ? 'Edit Funnel' : 'Create New Funnel'}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Define the steps users take to complete a goal.
</p>
</div>
<form onSubmit={handleSubmit}>
{/* Name & Description */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Funnel Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
autoFocus
required
maxLength={100}
/>
{name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>
{name.length}/100
</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Description (Optional)
</label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Tracks users from landing page to signup"
/>
</div>
</div>
</div>
{/* Steps */}
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Funnel Steps
</h3>
</div>
{steps.map((step, index) => {
const category = step.category || 'page'
return (
<div key={`step-${index}`} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4">
<div className="flex items-start gap-4">
{/* Step number + reorder */}
<div className="mt-3 text-neutral-400 flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
{index + 1}
</div>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveStep(index, -1)}
disabled={index === 0}
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
>
<CaretUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => moveStep(index, 1)}
disabled={index === steps.length - 1}
className="p-0.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-30 transition-colors"
>
<ChevronDownIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex-1">
{/* Category toggle */}
<div className="flex gap-1 mb-3">
<button
type="button"
onClick={() => handleUpdateStep(index, 'category', 'page')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'page'
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
Page Visit
</button>
<button
type="button"
onClick={() => handleUpdateStep(index, 'category', 'event')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
category === 'event'
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
Custom Event
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Step Name
</label>
<Input
value={step.name}
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
placeholder="e.g. Landing Page"
/>
</div>
{category === 'page' ? (
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Path / URL
</label>
<div className="flex gap-2">
<select
value={step.type}
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
className={`w-24 ${selectClass}`}
>
<option value="exact">Exact</option>
<option value="contains">Contains</option>
<option value="regex">Regex</option>
</select>
<Input
value={step.value}
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
className="flex-1"
/>
</div>
</div>
) : (
<div>
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
Event Name
</label>
<Input
value={step.value}
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
placeholder="e.g. signup, purchase"
/>
</div>
)}
</div>
{/* Property filters (event steps only) */}
{category === 'event' && (
<div className="mt-3">
{step.property_filters && step.property_filters.length > 0 && (
<div className="space-y-2 mb-2">
{step.property_filters.map((filter, filterIndex) => (
<div key={filterIndex} className="flex gap-2 items-center">
<Input
value={filter.key}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'key', e.target.value)}
placeholder="key"
className="flex-1"
/>
<select
value={filter.operator}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'operator', e.target.value)}
className={selectClass}
>
{OPERATOR_OPTIONS.map(op => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
<Input
value={filter.value}
onChange={(e) => updatePropertyFilter(index, filterIndex, 'value', e.target.value)}
placeholder="value"
className="flex-1"
/>
<button
type="button"
onClick={() => removePropertyFilter(index, filterIndex)}
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{(!step.property_filters || step.property_filters.length < MAX_FILTERS) && (
<button
type="button"
onClick={() => addPropertyFilter(index)}
className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white flex items-center gap-1 transition-colors"
>
<PlusIcon className="w-3.5 h-3.5" />
Add property filter
</button>
)}
</div>
)}
</div>
<button
type="button"
onClick={() => handleRemoveStep(index)}
disabled={steps.length <= 1}
aria-label="Remove step"
className={`mt-3 p-2 rounded-xl transition-colors ${
steps.length <= 1
? 'text-neutral-300 cursor-not-allowed'
: 'text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
}`}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</div>
)
})}
{steps.length < MAX_STEPS ? (
<button
type="button"
onClick={handleAddStep}
className="w-full py-3 border-2 border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors flex items-center justify-center gap-2 font-medium"
>
<PlusIcon className="w-4 h-4" />
Add Step
</button>
) : (
<p className="text-center text-sm text-neutral-400">Maximum 8 steps</p>
)}
</div>
{/* Conversion Window */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 mb-6">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
Conversion Window
</h3>
<p className="text-xs text-neutral-500 mb-4">
Visitors must complete all steps within this time to count as converted.
</p>
{/* Quick presets */}
<div className="flex flex-wrap gap-2 mb-4">
{WINDOW_PRESETS.map(preset => (
<button
key={preset.label}
type="button"
onClick={() => {
setWindowValue(preset.value)
setWindowUnit(preset.unit)
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
windowValue === preset.value && windowUnit === preset.unit
? 'bg-brand-orange text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700'
}`}
>
{preset.label}
</button>
))}
</div>
{/* Custom input */}
<div className="flex gap-2 items-center">
<Input
type="number"
min={1}
max={2160}
value={windowValue}
onChange={(e) => setWindowValue(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20"
/>
<select
value={windowUnit}
onChange={(e) => setWindowUnit(e.target.value as 'hours' | 'days')}
className={selectClass}
>
<option value="hours">hours</option>
<option value="days">days</option>
</select>
</div>
</div>
<div className="flex justify-end gap-4">
<Link href={cancelHref}>
<Button variant="secondary">
Cancel
</Button>
</Link>
<Button
type="submit"
variant="primary"
>
{submitLabel}
</Button>
</div>
</form>
</div>
)
}

View File

@@ -4,7 +4,8 @@
* @file Notification center: bell icon with dropdown of recent notifications.
*/
import { useEffect, useState, useRef } from 'react'
import { useEffect, useState, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Link from 'next/link'
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -37,13 +38,37 @@ function BellIcon({ className }: { className?: string }) {
const LOADING_DELAY_MS = 250
const POLL_INTERVAL_MS = 90_000
export default function NotificationCenter() {
interface NotificationCenterProps {
/** Where the dropdown opens. 'right' uses fixed positioning to escape overflow:hidden containers. */
anchor?: 'bottom' | 'right'
/** Render variant. 'sidebar' matches NavLink styling. */
variant?: 'default' | 'sidebar'
/** Optional label content rendered after the icon (useful for sidebar variant with fading labels). */
children?: React.ReactNode
}
export default function NotificationCenter({ anchor = 'bottom', variant = 'default', children }: NotificationCenterProps) {
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null)
const updatePosition = useCallback(() => {
if (anchor === 'right' && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
const left = rect.right + 8
if (rect.top > window.innerHeight / 2) {
setFixedPos({ left, bottom: window.innerHeight - rect.bottom })
} else {
setFixedPos({ left, top: rect.top })
}
}
}, [anchor])
const fetchUnreadCount = async () => {
try {
@@ -74,8 +99,9 @@ export default function NotificationCenter() {
useEffect(() => {
if (open) {
fetchNotifications()
updatePosition()
}
}, [open])
}, [open, updatePosition])
// * Poll unread count in background (when authenticated)
useEffect(() => {
@@ -88,7 +114,11 @@ export default function NotificationCenter() {
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
const target = e.target as Node
if (
dropdownRef.current && !dropdownRef.current.contains(target) &&
(!panelRef.current || !panelRef.current.contains(target))
) {
setOpen(false)
}
}
@@ -130,142 +160,174 @@ export default function NotificationCenter() {
setOpen(false)
}
const isSidebar = variant === 'sidebar'
return (
<div className="relative" ref={dropdownRef}>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
aria-controls={open ? 'notification-dropdown' : undefined}
className="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"
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'
}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
{isSidebar ? (
<>
<span className="w-7 h-7 flex items-center justify-center shrink-0 relative">
<BellIcon className="h-[18px] w-[18px]" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</span>
{children}
</>
) : (
<>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</>
)}
</button>
{open && (
<div
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
className="fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
{(() => {
const panel = open ? (
<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] ${
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">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
aria-label="Mark all notifications as read"
className="text-sm text-brand-orange hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
)}
{!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No notifications yet
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(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' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<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' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</button>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="text-sm text-brand-orange hover:underline"
>
Mark all read
</button>
)}
View all
</Link>
<Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>
</div>
) : null
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{error && (
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
)}
{!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No notifications yet
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(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' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<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' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</button>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="text-sm text-brand-orange hover:underline"
>
View all
</Link>
<Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" aria-hidden="true" />
Manage settings
</Link>
</div>
</div>
)}
return anchor === 'right' && panel && typeof document !== 'undefined'
? createPortal(panel, document.body)
: panel
})()}
</div>
)
}

View File

@@ -1,10 +1,18 @@
import apiRequest from './client'
export interface StepPropertyFilter {
key: string
operator: 'is' | 'is_not' | 'contains' | 'not_contains'
value: string
}
export interface FunnelStep {
order: number
name: string
value: string
type: string // "exact", "contains", "regex"
category?: 'page' | 'event'
property_filters?: StepPropertyFilter[]
}
export interface Funnel {
@@ -13,15 +21,23 @@ export interface Funnel {
name: string
description: string
steps: FunnelStep[]
conversion_window_value: number
conversion_window_unit: 'hours' | 'days'
created_at: string
updated_at: string
}
export interface ExitPage {
path: string
visitors: number
}
export interface FunnelStepStats {
step: FunnelStep
visitors: number
dropoff: number
conversion: number
exit_pages: ExitPage[]
}
export interface FunnelStats {
@@ -32,7 +48,27 @@ export interface FunnelStats {
export interface CreateFunnelRequest {
name: string
description: string
steps: FunnelStep[]
steps: Omit<FunnelStep, 'order'>[]
conversion_window_value?: number
conversion_window_unit?: 'hours' | 'days'
}
export interface FunnelTrends {
dates: string[]
overall: number[]
steps: Record<string, number[]>
}
export interface FunnelBreakdownEntry {
value: string
visitors: number
conversion: number
}
export interface FunnelBreakdown {
step: number
dimension: string
entries: FunnelBreakdownEntry[]
}
export async function listFunnels(siteId: string): Promise<Funnel[]> {
@@ -64,10 +100,41 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise<vo
})
}
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string): Promise<FunnelStats> {
export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string, filters?: string): Promise<FunnelStats> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (filters) params.append('filters', filters)
const queryString = params.toString() ? `?${params.toString()}` : ''
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
}
export async function getFunnelTrends(
siteId: string, funnelId: string,
startDate?: string, endDate?: string,
interval: string = 'day', filters?: string
): Promise<FunnelTrends> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
params.append('interval', interval)
if (filters) params.append('filters', filters)
const queryString = params.toString() ? `?${params.toString()}` : ''
return apiRequest<FunnelTrends>(`/sites/${siteId}/funnels/${funnelId}/trends${queryString}`)
}
export async function getFunnelBreakdown(
siteId: string, funnelId: string,
step: number, dimension: string,
startDate?: string, endDate?: string,
filters?: string
): Promise<FunnelBreakdown> {
const params = new URLSearchParams()
params.append('step', step.toString())
params.append('dimension', dimension)
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
if (filters) params.append('filters', filters)
const queryString = params.toString() ? `?${params.toString()}` : ''
return apiRequest<FunnelBreakdown>(`/sites/${siteId}/funnels/${funnelId}/breakdown${queryString}`)
}

26
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pulse-frontend",
"version": "0.15.0-alpha",
"dependencies": {
"@ciphera-net/ui": "^0.2.8",
"@ciphera-net/ui": "^0.2.15",
"@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2",
@@ -17,6 +17,7 @@
"@tanstack/react-virtual": "^3.13.21",
"@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3": "^7.9.0",
@@ -36,6 +37,7 @@
"sonner": "^2.0.7",
"svg-dotted-map": "^2.0.1",
"swr": "^2.3.3",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
@@ -1668,9 +1670,9 @@
}
},
"node_modules/@ciphera-net/ui": {
"version": "0.2.8",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.8/3a78342207ee2351625b9469ec6030033df183cc",
"integrity": "sha512-I6B7fE2YXjJaipmcVS60q2pzhsy/NKM4sfvHIv4awi6mcrXjGag8FznW0sI1SbsplFWpoT5iSMtWIi/lZdFhbA==",
"version": "0.2.15",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.15/ec35ffe3be80cb5deca6a05bd2d36d636333a4a9",
"integrity": "sha512-Y2snU21OFbcarVq6QbSkW/pbL3BL9SePf8dBzC36zUvDp5TuhIU7E/21ydVGxGH6Ye6wKw2G1Qsv3xsnsumyPA==",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1",
@@ -1684,6 +1686,16 @@
"react-dom": ">=18"
}
},
"node_modules/@ciphera-net/ui/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -14352,9 +14364,9 @@
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",

View File

@@ -12,7 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ciphera-net/ui": "^0.2.8",
"@ciphera-net/ui": "^0.2.15",
"@ducanh2912/next-pwa": "^10.2.9",
"@phosphor-icons/react": "^2.1.10",
"@simplewebauthn/browser": "^13.2.2",
@@ -21,6 +21,7 @@
"@tanstack/react-virtual": "^3.13.21",
"@types/d3": "^7.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.5",
"country-flag-icons": "^1.6.4",
"d3": "^7.9.0",
@@ -40,6 +41,7 @@
"sonner": "^2.0.7",
"svg-dotted-map": "^2.0.1",
"swr": "^2.3.3",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"overrides": {