From 3f99bc81cca37c8825bdfe063c0daa577486f659 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 4 Feb 2026 21:49:23 +0100 Subject: [PATCH 01/12] feat: add button to navigate to Funnels page in SiteDashboardPage for improved user navigation --- app/sites/[id]/funnels/[funnelId]/page.tsx | 265 +++++++++++++++++++++ app/sites/[id]/funnels/new/page.tsx | 216 +++++++++++++++++ app/sites/[id]/funnels/page.tsx | 151 ++++++++++++ app/sites/[id]/page.tsx | 6 + lib/api/funnels.ts | 74 ++++++ 5 files changed, 712 insertions(+) create mode 100644 app/sites/[id]/funnels/[funnelId]/page.tsx create mode 100644 app/sites/[id]/funnels/new/page.tsx create mode 100644 app/sites/[id]/funnels/page.tsx create mode 100644 lib/api/funnels.ts diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx new file mode 100644 index 0000000..ba8da73 --- /dev/null +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -0,0 +1,265 @@ +'use client' + +import { useAuth } from '@/lib/auth/context' +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' +import { toast, LoadingOverlay, Card, Select, DatePicker } from '@ciphera-net/ui' +import Link from 'next/link' +import { LuChevronLeft as ChevronLeftIcon, LuTrash as TrashIcon, LuEdit as EditIcon, LuArrowRight as ArrowRightIcon } from 'react-icons/lu' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell +} from 'recharts' +import { getDateRange } from '@/lib/utils/format' + +export default function FunnelReportPage() { + const params = useParams() + const router = useRouter() + const siteId = params.id as string + const funnelId = params.funnelId as string + + const [funnel, setFunnel] = useState(null) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [dateRange, setDateRange] = useState(getDateRange(30)) + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + + useEffect(() => { + loadData() + }, [siteId, funnelId, dateRange]) + + const loadData = async () => { + try { + setLoading(true) + const [funnelData, statsData] = await Promise.all([ + getFunnel(siteId, funnelId), + getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end) + ]) + setFunnel(funnelData) + setStats(statsData) + } catch (error) { + toast.error('Failed to load funnel data') + } finally { + setLoading(false) + } + } + + const handleDelete = async () => { + if (!confirm('Are you sure you want to delete this funnel?')) return + + try { + await deleteFunnel(siteId, funnelId) + toast.success('Funnel deleted') + router.push(`/sites/${siteId}/funnels`) + } catch (error) { + toast.error('Failed to delete funnel') + } + } + + if (loading && !funnel) { + return + } + + if (!funnel || !stats) { + return ( +
+

Funnel not found

+
+ ) + } + + const chartData = stats.steps.map(s => ({ + name: s.step.name, + visitors: s.visitors, + dropoff: s.dropoff, + conversion: s.conversion + })) + + return ( +
+
+
+
+ + + +
+

+ {funnel.name} +

+ {funnel.description && ( +

+ {funnel.description} +

+ )} +
+
+ +
+ setName(e.target.value)} + placeholder="e.g. Signup Flow" + required + /> +
+
+ + 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" + /> +
+
+
+ + +
+
+ ))} + + +
+ +
+ + Cancel + + +
+ +
+ ) +} diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx new file mode 100644 index 0000000..d47dd50 --- /dev/null +++ b/app/sites/[id]/funnels/page.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useAuth } from '@/lib/auth/context' +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels' +import { toast, LoadingOverlay, Card } from '@ciphera-net/ui' +import Link from 'next/link' +import { LuPlus as PlusIcon, LuTrash as TrashIcon, LuArrowRight as ArrowRightIcon, LuChevronLeft as ChevronLeftIcon } from 'react-icons/lu' + +export default function FunnelsPage() { + const { user } = useAuth() + const params = useParams() + const router = useRouter() + const siteId = params.id as string + + const [funnels, setFunnels] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadFunnels() + }, [siteId]) + + const loadFunnels = async () => { + try { + setLoading(true) + const data = await listFunnels(siteId) + setFunnels(data) + } catch (error) { + toast.error('Failed to load funnels') + } finally { + setLoading(false) + } + } + + const handleDelete = async (e: React.MouseEvent, funnelId: string) => { + e.preventDefault() // Prevent navigation + if (!confirm('Are you sure you want to delete this funnel?')) return + + try { + await deleteFunnel(siteId, funnelId) + toast.success('Funnel deleted') + loadFunnels() + } catch (error) { + toast.error('Failed to delete funnel') + } + } + + if (loading) { + return + } + + return ( +
+
+
+ + + +
+

+ Funnels +

+

+ Track user journeys and identify drop-off points +

+
+
+ + + Create Funnel + +
+
+ + {funnels.length === 0 ? ( + +
+ +
+

+ No funnels yet +

+

+ Create a funnel to track how users move through your site and where they drop off. +

+ + + Create Funnel + +
+ ) : ( +
+ {funnels.map((funnel) => ( + + +
+
+

+ {funnel.name} +

+ {funnel.description && ( +

+ {funnel.description} +

+ )} +
+ {funnel.steps.map((step, i) => ( +
+ + {step.name} + + {i < funnel.steps.length - 1 && ( + + )} +
+ ))} +
+
+
+ + +
+
+
+ + ))} +
+ )} +
+
+ ) +} diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index d3ffc9e..154a206 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -297,6 +297,12 @@ export default function SiteDashboardPage() { { value: 'custom', label: 'Custom' }, ]} /> + {canEdit && (
{/* Chart */} - +

Funnel Visualization

@@ -192,10 +191,10 @@ export default function FunnelReportPage() {
-
+ {/* Detailed Stats Table */} - +
@@ -248,7 +247,7 @@ export default function FunnelReportPage() {
- +
- +
- +
@@ -126,7 +125,7 @@ export default function CreateFunnelPage() {
{steps.map((step, index) => ( - +
@@ -182,7 +181,7 @@ export default function CreateFunnelPage() {
- +
))} @@ -136,7 +137,7 @@ export default function FunnelReportPage() { {/* Chart */}
-

+

Funnel Visualization

@@ -162,7 +163,7 @@ export default function FunnelReportPage() { if (active && payload && payload.length) { const data = payload[0].payload; return ( -
+

{label}

{data.visitors.toLocaleString()} visitors diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 6b4991d..6f90183 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -76,7 +76,7 @@ export default function CreateFunnelPage() {

Back to Funnels @@ -119,7 +119,7 @@ export default function CreateFunnelPage() {
-

+

Funnel Steps

@@ -152,7 +152,7 @@ export default function CreateFunnelPage() { { - if (value === '7') setDateRange(getDateRange(7)) - else if (value === '30') setDateRange(getDateRange(30)) - else if (value === 'custom') setIsDatePickerOpen(true) + if (value === '7') { + setDateRange(getDateRange(7)) + setDatePreset('7') + } else if (value === '30') { + setDateRange(getDateRange(30)) + setDatePreset('30') + } else if (value === 'custom') { + setIsDatePickerOpen(true) + } }} options={[ { value: '7', label: 'Last 7 days' }, @@ -215,7 +214,7 @@ export default function FunnelReportPage() { /> {chartData.map((entry, index) => ( - + ))} @@ -285,6 +284,7 @@ export default function FunnelReportPage() { onClose={() => setIsDatePickerOpen(false)} onApply={(range) => { setDateRange(range) + setDatePreset('custom') setIsDatePickerOpen(false) }} initialRange={dateRange} diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 6f90183..a0d1ec5 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -1,6 +1,5 @@ 'use client' -import { useAuth } from '@/lib/auth/context' import { useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels' diff --git a/app/sites/[id]/funnels/page.tsx b/app/sites/[id]/funnels/page.tsx index 448a826..b799d94 100644 --- a/app/sites/[id]/funnels/page.tsx +++ b/app/sites/[id]/funnels/page.tsx @@ -1,14 +1,12 @@ 'use client' -import { useAuth } from '@/lib/auth/context' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels' import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon } from '@ciphera-net/ui' import Link from 'next/link' export default function FunnelsPage() { - const { user } = useAuth() const params = useParams() const router = useRouter() const siteId = params.id as string @@ -16,7 +14,7 @@ export default function FunnelsPage() { const [funnels, setFunnels] = useState([]) const [loading, setLoading] = useState(true) - const loadFunnels = async () => { + const loadFunnels = useCallback(async () => { try { setLoading(true) const data = await listFunnels(siteId) @@ -26,11 +24,11 @@ export default function FunnelsPage() { } finally { setLoading(false) } - } + }, [siteId]) useEffect(() => { loadFunnels() - }, [siteId]) + }, [loadFunnels]) const handleDelete = async (e: React.MouseEvent, funnelId: string) => { e.preventDefault() // Prevent navigation From c37c66cbf3af54d2133df01e58b30f268e65c011 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 4 Feb 2026 23:41:31 +0100 Subject: [PATCH 08/12] feat: enhance funnel stats API by normalizing date inputs to RFC3339 format, improving query consistency and accuracy --- lib/api/funnels.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/api/funnels.ts b/lib/api/funnels.ts index de0e62e..ad5fde7 100644 --- a/lib/api/funnels.ts +++ b/lib/api/funnels.ts @@ -64,11 +64,27 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise { const params = new URLSearchParams() - if (from) params.append('from', from) - if (to) params.append('to', to) - + if (from && to) { + const { from: fromRfc, to: toRfc } = toRFC3339Range(from, to) + params.append('from', fromRfc) + params.append('to', toRfc) + } else if (from) { + params.append('from', DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from) + } else if (to) { + params.append('to', DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to) + } const queryString = params.toString() ? `?${params.toString()}` : '' return apiRequest(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`) } From 002fe50d9afb4319758dcc6f473a4f161cd5224e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 4 Feb 2026 23:48:06 +0100 Subject: [PATCH 09/12] feat: add regex validation for funnel steps and improve error handling in CreateFunnelPage --- app/sites/[id]/funnels/new/page.tsx | 14 ++++++++++++++ lib/api/funnels.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index a0d1ec5..c4e9990 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -6,6 +6,15 @@ import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/a 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 + } +} + export default function CreateFunnelPage() { const params = useParams() const router = useRouter() @@ -13,6 +22,7 @@ export default function CreateFunnelPage() { 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' } @@ -47,6 +57,10 @@ export default function CreateFunnelPage() { toast.error('Please enter a path for all steps') return } + if (steps.some(s => s.type === 'regex' && !isValidRegex(s.value))) { + toast.error('Invalid regex in one or more steps. Check the pattern for steps with type "regex".') + return + } try { setSaving(true) diff --git a/lib/api/funnels.ts b/lib/api/funnels.ts index ad5fde7..d28eb6b 100644 --- a/lib/api/funnels.ts +++ b/lib/api/funnels.ts @@ -66,7 +66,7 @@ export async function deleteFunnel(siteId: string, funnelId: string): Promise Date: Wed, 4 Feb 2026 23:54:44 +0100 Subject: [PATCH 10/12] fix: improve regex error handling in CreateFunnelPage by providing specific step names in error messages --- app/sites/[id]/funnels/new/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index c4e9990..30aa77b 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -57,8 +57,9 @@ export default function CreateFunnelPage() { toast.error('Please enter a path for all steps') return } - if (steps.some(s => s.type === 'regex' && !isValidRegex(s.value))) { - toast.error('Invalid regex in one or more steps. Check the pattern for steps with type "regex".') + const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value)) + if (invalidRegexStep) { + toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`) return } From ceb668890b370b555d224970c2afda1adf4ac4ba Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 00:04:44 +0100 Subject: [PATCH 11/12] feat: enhance error handling in FunnelReportPage by adding specific messages for not found and general load errors, improving user feedback and experience --- app/sites/[id]/funnels/[funnelId]/page.tsx | 26 +++++++++++++++++++++- app/sites/[id]/funnels/new/page.tsx | 5 +++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index ac1b729..ddbec35 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, 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 { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme } from '@ciphera-net/ui' import Link from 'next/link' @@ -45,8 +46,10 @@ export default function FunnelReportPage() { const [dateRange, setDateRange] = useState(getDateRange(30)) const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30') const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + const [loadError, setLoadError] = useState<'not_found' | 'error' | null>(null) const loadData = useCallback(async () => { + setLoadError(null) try { setLoading(true) const [funnelData, statsData] = await Promise.all([ @@ -56,7 +59,9 @@ export default function FunnelReportPage() { setFunnel(funnelData) setStats(statsData) } catch (error) { - toast.error('Failed to load funnel data') + const is404 = error instanceof ApiError && error.status === 404 + setLoadError(is404 ? 'not_found' : 'error') + if (!is404) toast.error('Failed to load funnel data') } finally { setLoading(false) } @@ -88,6 +93,25 @@ export default function FunnelReportPage() { return } + if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { + return ( +
+

Funnel not found

+
+ ) + } + + if (loadError === 'error') { + return ( +
+

Failed to load funnel data

+ +
+ ) + } + if (!funnel || !stats) { return (
diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 30aa77b..fedc126 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -53,6 +53,11 @@ export default function CreateFunnelPage() { 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 From ffe6f464e673fd5deab74a7df866388d1989c8a6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 00:10:03 +0100 Subject: [PATCH 12/12] feat: enhance error handling in FunnelReportPage by adding support for forbidden access and updating error messages for better user clarity --- app/sites/[id]/funnels/[funnelId]/page.tsx | 23 +++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index ddbec35..61d07c5 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -46,7 +46,7 @@ export default function FunnelReportPage() { const [dateRange, setDateRange] = useState(getDateRange(30)) const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30') const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) - const [loadError, setLoadError] = useState<'not_found' | 'error' | null>(null) + const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null) const loadData = useCallback(async () => { setLoadError(null) @@ -59,9 +59,11 @@ export default function FunnelReportPage() { setFunnel(funnelData) setStats(statsData) } catch (error) { - const is404 = error instanceof ApiError && error.status === 404 - setLoadError(is404 ? 'not_found' : 'error') - if (!is404) toast.error('Failed to load funnel data') + const status = error instanceof ApiError ? error.status : 0 + if (status === 404) setLoadError('not_found') + else if (status === 403) setLoadError('forbidden') + else setLoadError('error') + if (status !== 404 && status !== 403) toast.error('Failed to load funnel data') } finally { setLoading(false) } @@ -101,10 +103,21 @@ export default function FunnelReportPage() { ) } + if (loadError === 'forbidden') { + return ( +
+

Access denied

+ + Back to Funnels + +
+ ) + } + if (loadError === 'error') { return (
-

Failed to load funnel data

+

Unable to load funnel