From d5b594d6f954d987e1e50846cd2c4932492528b7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:20:15 +0100 Subject: [PATCH 01/49] feat(funnels): update frontend types and API client for funnels v2 --- lib/api/funnels.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/api/funnels.ts b/lib/api/funnels.ts index f5e2d51..cc1faf8 100644 --- a/lib/api/funnels.ts +++ b/lib/api/funnels.ts @@ -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[] + conversion_window_value?: number + conversion_window_unit?: 'hours' | 'days' +} + +export interface FunnelTrends { + dates: string[] + overall: number[] + steps: Record +} + +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 { @@ -64,10 +100,41 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise { +export async function getFunnelStats(siteId: string, funnelId: string, startDate?: string, endDate?: string, filters?: string): Promise { 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(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`) } + +export async function getFunnelTrends( + siteId: string, funnelId: string, + startDate?: string, endDate?: string, + interval: string = 'day', filters?: string +): Promise { + 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(`/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 { + 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(`/sites/${siteId}/funnels/${funnelId}/breakdown${queryString}`) +} From 18e66917d3983e72e4ad8e3146992716d2793ae0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:23:25 +0100 Subject: [PATCH 02/49] feat(funnels): extract reusable FunnelForm with category toggle, property filters, and conversion window --- app/sites/[id]/funnels/new/page.tsx | 227 +----------- components/funnels/FunnelForm.tsx | 519 ++++++++++++++++++++++++++++ 2 files changed, 531 insertions(+), 215 deletions(-) create mode 100644 components/funnels/FunnelForm.tsx diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 9891f21..7607733 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -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[]>([ - { 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, 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 ( -
-
- - - Back to Funnels - - -

- Create New Funnel -

-

- Define the steps users take to complete a goal. -

-
- -
-
-
-
- - setName(e.target.value)} - placeholder="e.g. Signup Flow" - autoFocus - required - maxLength={100} - /> - {name.length > 80 && ( - 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100 - )} -
-
- - setDescription(e.target.value)} - placeholder="Tracks users from landing page to signup" - /> -
-
-
- -
-
-

- Funnel Steps -

-
- - {steps.map((step, index) => ( -
-
-
-
- {index + 1} -
-
- -
-
- - handleUpdateStep(index, 'name', e.target.value)} - placeholder="e.g. Landing Page" - /> -
-
- -
- - handleUpdateStep(index, 'value', e.target.value)} - placeholder={step.type === 'exact' ? '/pricing' : 'pricing'} - className="flex-1" - /> -
-
-
- - -
-
- ))} - - -
- -
- - - - -
-
-
+ ) } diff --git a/components/funnels/FunnelForm.tsx b/components/funnels/FunnelForm.tsx new file mode 100644 index 0000000..0c3b0a1 --- /dev/null +++ b/components/funnels/FunnelForm.tsx @@ -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 + +interface FunnelFormProps { + siteId: string + initialData?: { + name: string + description: string + steps: StepWithoutOrder[] + conversion_window_value: number + conversion_window_unit: 'hours' | 'days' + } + onSubmit: (data: CreateFunnelRequest) => Promise + 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( + 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)[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 ( +
+
+ + + Back to Funnels + + +

+ {initialData ? 'Edit Funnel' : 'Create New Funnel'} +

+

+ Define the steps users take to complete a goal. +

+
+ +
+ {/* Name & Description */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Signup Flow" + autoFocus + required + maxLength={100} + /> + {name.length > 80 && ( + 90 ? 'text-amber-500' : 'text-neutral-400'}`}> + {name.length}/100 + + )} +
+
+ + setDescription(e.target.value)} + placeholder="Tracks users from landing page to signup" + /> +
+
+
+ + {/* Steps */} +
+
+

+ Funnel Steps +

+
+ + {steps.map((step, index) => { + const category = step.category || 'page' + + return ( +
+
+ {/* Step number + reorder */} +
+
+ {index + 1} +
+
+ + +
+
+ +
+ {/* Category toggle */} +
+ + +
+ +
+
+ + handleUpdateStep(index, 'name', e.target.value)} + placeholder="e.g. Landing Page" + /> +
+ + {category === 'page' ? ( +
+ +
+ + handleUpdateStep(index, 'value', e.target.value)} + placeholder={step.type === 'exact' ? '/pricing' : 'pricing'} + className="flex-1" + /> +
+
+ ) : ( +
+ + handleUpdateStep(index, 'value', e.target.value)} + placeholder="e.g. signup, purchase" + /> +
+ )} +
+ + {/* Property filters (event steps only) */} + {category === 'event' && ( +
+ {step.property_filters && step.property_filters.length > 0 && ( +
+ {step.property_filters.map((filter, filterIndex) => ( +
+ updatePropertyFilter(index, filterIndex, 'key', e.target.value)} + placeholder="key" + className="flex-1" + /> + + updatePropertyFilter(index, filterIndex, 'value', e.target.value)} + placeholder="value" + className="flex-1" + /> + +
+ ))} +
+ )} + {(!step.property_filters || step.property_filters.length < MAX_FILTERS) && ( + + )} +
+ )} +
+ + +
+
+ ) + })} + + {steps.length < MAX_STEPS ? ( + + ) : ( +

Maximum 8 steps

+ )} +
+ + {/* Conversion Window */} +
+

+ Conversion Window +

+

+ Visitors must complete all steps within this time to count as converted. +

+ + {/* Quick presets */} +
+ {WINDOW_PRESETS.map(preset => ( + + ))} +
+ + {/* Custom input */} +
+ setWindowValue(Math.max(1, parseInt(e.target.value) || 1))} + className="w-20" + /> + +
+
+ +
+ + + + +
+
+
+ ) +} From 2811945d3e0fc996321ec42d8a6e86c0a2297b66 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:26:26 +0100 Subject: [PATCH 03/49] feat(funnels): add filter bar and exit path display to funnel detail --- app/sites/[id]/funnels/[funnelId]/page.tsx | 121 ++++++++++++++------- 1 file changed, 84 insertions(+), 37 deletions(-) diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 655abe3..4936335 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -1,9 +1,12 @@ '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 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 { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' @@ -23,6 +26,8 @@ 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([]) + const [expandedExitStep, setExpandedExitStep] = useState(null) const loadData = useCallback(async () => { setLoadError(null) @@ -30,7 +35,7 @@ export default function FunnelReportPage() { setLoading(true) const [funnelData, statsData] = await Promise.all([ getFunnel(siteId, funnelId), - getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end) + getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, serializeFilters(filters) || undefined) ]) setFunnel(funnelData) setStats(statsData) @@ -43,7 +48,7 @@ export default function FunnelReportPage() { } finally { setLoading(false) } - }, [siteId, funnelId, dateRange]) + }, [siteId, funnelId, dateRange, filters]) useEffect(() => { loadData() @@ -167,6 +172,18 @@ export default function FunnelReportPage() { + {/* Filters */} +
+ setFilters(prev => [...prev, f])} + /> + setFilters(prev => prev.filter((_, idx) => idx !== i))} + onClear={() => setFilters([])} + /> +
+ {/* Chart */}

@@ -195,42 +212,72 @@ export default function FunnelReportPage() { {stats.steps.map((step, i) => ( - - -
- - {i + 1} - -
-

{step.step.name}

-

{step.step.value}

+ + + +
+ + {i + 1} + +
+

{step.step.name}

+

{step.step.value}

+
-
- - - - {step.visitors.toLocaleString()} - - - - {i > 0 ? ( - 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)}% + + + + {step.visitors.toLocaleString()} - ) : ( - - - )} - - - - {Math.round(step.conversion)}% - - - + + + {i > 0 ? ( + 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)}% + + ) : ( + - + )} + + + + {Math.round(step.conversion)}% + + + + {step.exit_pages && step.exit_pages.length > 0 && ( + + +
+

+ Where visitors went after dropping off: +

+
+ {(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => ( + + {ep.path} + {ep.visitors} + + ))} +
+ {step.exit_pages.length > 3 && ( + + )} +
+ + + )} + ))} From 585cb4fd8837d1e3fdc6418343ff5b984087e87e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:27:45 +0100 Subject: [PATCH 04/49] feat(funnels): add edit funnel page with pre-populated form --- .../[id]/funnels/[funnelId]/edit/page.tsx | 58 +++++++++++++++++++ app/sites/[id]/funnels/[funnelId]/page.tsx | 8 +++ 2 files changed, 66 insertions(+) create mode 100644 app/sites/[id]/funnels/[funnelId]/edit/page.tsx diff --git a/app/sites/[id]/funnels/[funnelId]/edit/page.tsx b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx new file mode 100644 index 0000000..cea7f0f --- /dev/null +++ b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx @@ -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(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 + + return ( + 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}`} + /> + ) +} diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 4936335..5e688d9 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -8,6 +8,7 @@ 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' @@ -162,6 +163,13 @@ export default function FunnelReportPage() { ]} /> + + + + ))} +
+

+ +
+ + + + + `${v}%`} + tick={{ fontSize: 12 }} + className="text-neutral-500" + /> + [`${value}%`]} + contentStyle={{ + backgroundColor: 'var(--color-neutral-900, #171717)', + border: '1px solid var(--color-neutral-700, #404040)', + borderRadius: '8px', + color: '#fff', + fontSize: '12px', + }} + /> + + {Array.from(visibleSteps).map((stepKey) => ( + + ))} + + +
+ + )} + {/* Detailed Stats Table */}
From 4c7ed858f71e17936c41449b7f4169d7e13780be Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:34:07 +0100 Subject: [PATCH 06/49] feat(funnels): add step-level breakdown drawer with dimension tabs --- app/sites/[id]/funnels/[funnelId]/page.tsx | 17 +++- components/funnels/BreakdownDrawer.tsx | 111 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 components/funnels/BreakdownDrawer.tsx diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 7050a1d..9fd7a5c 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -13,6 +13,7 @@ import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/comp 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() { @@ -32,6 +33,7 @@ export default function FunnelReportPage() { const [expandedExitStep, setExpandedExitStep] = useState(null) const [trends, setTrends] = useState(null) const [visibleSteps, setVisibleSteps] = useState>(new Set()) + const [breakdownStep, setBreakdownStep] = useState(null) const loadData = useCallback(async () => { setLoadError(null) @@ -326,7 +328,7 @@ export default function FunnelReportPage() { {stats.steps.map((step, i) => ( - + setBreakdownStep(i)}>
@@ -398,6 +400,19 @@ export default function FunnelReportPage() {
+ {breakdownStep !== null && stats && ( + setBreakdownStep(null)} + /> + )} + setIsDatePickerOpen(false)} diff --git a/components/funnels/BreakdownDrawer.tsx b/components/funnels/BreakdownDrawer.tsx new file mode 100644 index 0000000..fc605ab --- /dev/null +++ b/components/funnels/BreakdownDrawer.tsx @@ -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(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 */} +
+ +
+ {/* Header */} +
+
+

Step Breakdown

+

{stepName}

+
+ +
+ + {/* Dimension tabs */} +
+ {BREAKDOWN_DIMENSIONS.map(dim => ( + + ))} +
+ + {/* Content */} +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : !breakdown || breakdown.entries.length === 0 ? ( +

No data for this dimension

+ ) : ( +
+ {breakdown.entries.map(entry => ( +
+ + {entry.value || '(unknown)'} + +
+ {entry.visitors} + + {Math.round(entry.conversion)}% + +
+
+ ))} +
+ )} +
+
+ + ) +} From 94112161f07a5adbb580650445f898285821c95b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:38:01 +0100 Subject: [PATCH 07/49] docs: update changelog for funnels v2 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd8f94..1a68ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. From 80ae8311dc7b60af1dc79799eb7163ba5f7546ef Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 15:30:17 +0100 Subject: [PATCH 08/49] feat: static header + collapsible sidebar navigation Replace floating pill header with static variant for authenticated views. Add collapsible sidebar with site picker, grouped navigation (Analytics/Infrastructure), and mobile overlay drawer. Remove horizontal SiteNav tab bar. --- app/layout-content.tsx | 68 +++-- app/sites/[id]/SiteLayoutShell.tsx | 9 +- components/dashboard/DashboardShell.tsx | 23 ++ components/dashboard/Sidebar.tsx | 364 ++++++++++++++++++++++++ lib/sidebar-context.tsx | 31 ++ package-lock.json | 8 +- package.json | 2 +- 7 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 components/dashboard/DashboardShell.tsx create mode 100644 components/dashboard/Sidebar.tsx create mode 100644 lib/sidebar-context.tsx diff --git a/app/layout-content.tsx b/app/layout-content.tsx index e5f2caa..ccf5075 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -2,7 +2,7 @@ import { OfflineBanner } from '@/components/OfflineBanner' import { Footer } from '@/components/Footer' -import { Header, type CipheraApp } from '@ciphera-net/ui' +import { Header, type CipheraApp, MenuIcon } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' @@ -15,6 +15,7 @@ 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 { SidebarProvider, useSidebar } from '@/lib/sidebar-context' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -46,6 +47,19 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] +function MobileSidebarToggle() { + const { openMobile } = useSidebar() + return ( + + ) +} + function LayoutInner({ children }: { children: React.ReactNode }) { const auth = useAuth() const router = useRouter() @@ -91,23 +105,22 @@ function LayoutInner({ children }: { children: React.ReactNode }) { 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) if (isSwitchingOrg) { return } return ( - <> +
{auth.user && }
: null} apps={CIPHERA_APPS} currentAppId="pulse" onOpenSettings={openSettings} + leftActions={isAuthenticated ? : undefined} customNavItems={ <> {!auth.user && ( @@ -134,26 +148,40 @@ function LayoutInner({ children }: { children: React.ReactNode }) { } /> -
- {children} -
-
+ {isAuthenticated ? ( + // Authenticated: sidebar layout — children include DashboardShell + <>{children} + ) : ( + // Public: standard content with footer + <> +
+ {children} +
+
+ + )} + {isAuthenticated && ( +
+ )} - +
) } export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/app/sites/[id]/SiteLayoutShell.tsx b/app/sites/[id]/SiteLayoutShell.tsx index 1bc2a07..8879945 100644 --- a/app/sites/[id]/SiteLayoutShell.tsx +++ b/app/sites/[id]/SiteLayoutShell.tsx @@ -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 ( - <> -
- -
+ {children} - + ) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx new file mode 100644 index 0000000..0488ba5 --- /dev/null +++ b/components/dashboard/DashboardShell.tsx @@ -0,0 +1,23 @@ +'use client' + +import Sidebar from './Sidebar' +import { useSidebar } from '@/lib/sidebar-context' + +export default function DashboardShell({ + siteId, + children, +}: { + siteId: string + children: React.ReactNode +}) { + const { mobileOpen, closeMobile } = useSidebar() + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx new file mode 100644 index 0000000..34e9f9c --- /dev/null +++ b/components/dashboard/Sidebar.tsx @@ -0,0 +1,364 @@ +'use client' + +import { useState, useEffect, useRef } 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 { + LayoutDashboardIcon, + PathIcon, + FunnelIcon, + CursorClickIcon, + SearchIcon, + CloudUploadIcon, + HeartbeatIcon, + SettingsIcon, + CollapseLeftIcon, + CollapseRightIcon, + ChevronUpDownIcon, + PlusIcon, + XIcon, + MenuIcon, +} from '@ciphera-net/ui' + +const SIDEBAR_COLLAPSED_KEY = 'pulse_sidebar_collapsed' + +interface NavItem { + label: string + href: (siteId: string) => string + icon: React.ComponentType<{ className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }> + 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: 'Settings', + href: (id) => `/sites/${id}/settings`, + icon: SettingsIcon, + matchPrefix: true, +} + +function SitePicker({ + sites, + currentSiteId, + collapsed, +}: { + sites: Site[] + currentSiteId: string + collapsed: boolean +}) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const ref = useRef(null) + const pathname = usePathname() + const router = useRouter() + + const currentSite = sites.find((s) => s.id === currentSiteId) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + setSearch('') + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const filtered = sites.filter( + (s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.domain.toLowerCase().includes(search.toLowerCase()) + ) + + const switchSite = (siteId: string) => { + // Preserve current page type + const currentPageType = pathname.replace(/^\/sites\/[^/]+/, '') + router.push(`/sites/${siteId}${currentPageType}`) + setOpen(false) + setSearch('') + } + + const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' + + return ( +
+ + + {open && ( +
+
+ 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 + /> +
+
+ {filtered.map((site) => ( + + ))} + {filtered.length === 0 && ( +

No sites found

+ )} +
+
+ setOpen(false)} + className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg transition-colors" + > + + Add new site + +
+
+ )} +
+ ) +} + +function NavItemLink({ + item, + siteId, + collapsed, + onClick, +}: { + item: NavItem + siteId: string + collapsed: boolean + onClick?: () => void +}) { + const pathname = usePathname() + const href = item.href(siteId) + const isActive = item.matchPrefix ? pathname.startsWith(href) : pathname === href + + return ( + + + {!collapsed && {item.label}} + + ) +} + +export default function Sidebar({ + siteId, + mobileOpen, + onMobileClose, +}: { + siteId: string + mobileOpen: boolean + onMobileClose: () => void +}) { + const { user } = useAuth() + const canEdit = user?.role === 'owner' || user?.role === 'admin' + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' + }) + const [sites, setSites] = useState([]) + const pathname = usePathname() + + // Close mobile drawer on navigation + useEffect(() => { + onMobileClose() + }, [pathname, onMobileClose]) + + useEffect(() => { + listSites() + .then(setSites) + .catch(() => {}) + }, []) + + const toggleCollapsed = () => { + const next = !collapsed + setCollapsed(next) + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)) + } + + const sidebarContent = (isMobile: boolean) => { + const isCollapsed = isMobile ? false : collapsed + + return ( +
+ {/* Site Picker */} +
+ +
+ + {/* Nav Groups */} + + + {/* Bottom: Settings + Collapse toggle */} +
+ {canEdit && ( + onMobileClose() : undefined} + /> + )} + {!isMobile && ( + + )} +
+
+ ) + } + + return ( + <> + {/* Mobile hamburger trigger — rendered in the header via leftActions */} + + {/* Desktop sidebar */} + + + {/* Mobile overlay drawer */} + {mobileOpen && ( + <> +
onMobileClose()} + /> + + + )} + + ) +} + +export function SidebarMobileToggle({ onClick }: { onClick: () => void }) { + return ( + + ) +} diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx new file mode 100644 index 0000000..482c3ba --- /dev/null +++ b/lib/sidebar-context.tsx @@ -0,0 +1,31 @@ +'use client' + +import { createContext, useCallback, useContext, useState } from 'react' + +interface SidebarContextValue { + mobileOpen: boolean + openMobile: () => void + closeMobile: () => void +} + +const SidebarContext = createContext({ + mobileOpen: false, + openMobile: () => {}, + closeMobile: () => {}, +}) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [mobileOpen, setMobileOpen] = useState(false) + const openMobile = useCallback(() => setMobileOpen(true), []) + const closeMobile = useCallback(() => setMobileOpen(false), []) + + return ( + + {children} + + ) +} + +export function useSidebar() { + return useContext(SidebarContext) +} diff --git a/package-lock.json b/package-lock.json index c4243a9..2385d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1668,9 +1668,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.10", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.10/aeae8c3cb25cc9b5193bfba47ce2e444ac82f1d7", + "integrity": "sha512-yWHitk43epGjtwUxGVrKwGYZb+VtJhauy7fgmqYfDC8tq33eVlH+yOdi44J/OiWDl8ONSlt8i5Xptz3k79UuXQ==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index bf3e35d..635fb5f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", From 61ce505ee53309b86928e92d50815c975619f29a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 15:34:48 +0100 Subject: [PATCH 09/49] fix: pin sidebar to viewport with sticky positioning Sidebar was scrolling with page content. Fix by adding sticky top-0 h-screen. Widen collapsed width to 68px to prevent icon clipping. --- components/dashboard/DashboardShell.tsx | 4 ++-- components/dashboard/Sidebar.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 0488ba5..a2aeb3c 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -13,9 +13,9 @@ export default function DashboardShell({ const { mobileOpen, closeMobile } = useSidebar() return ( -
+
-
+
{children}
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 34e9f9c..ad08a1d 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -319,8 +319,8 @@ export default function Sidebar({ {/* Desktop sidebar */}