diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx new file mode 100644 index 0000000..61d07c5 --- /dev/null +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -0,0 +1,331 @@ +'use client' + +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' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell +} from 'recharts' +import { getDateRange } from '@/lib/utils/format' + +const CHART_COLORS_LIGHT = { + border: '#E5E5E5', + axis: '#A3A3A3', + tooltipBg: '#ffffff', + tooltipBorder: '#E5E5E5', +} + +const CHART_COLORS_DARK = { + border: '#404040', + axis: '#737373', + tooltipBg: '#262626', + tooltipBorder: '#404040', +} + +const BRAND_ORANGE = '#FD5E0F' + +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 [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30') + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) + const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null) + + const loadData = useCallback(async () => { + setLoadError(null) + 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) { + 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) + } + }, [siteId, funnelId, dateRange]) + + useEffect(() => { + loadData() + }, [loadData]) + + const { resolvedTheme } = useTheme() + const chartColors = useMemo( + () => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT), + [resolvedTheme] + ) + + 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 (loadError === 'not_found' || (!funnel && !stats && !loadError)) { + return ( +
+

Funnel not found

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

Access denied

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

Unable to load funnel

+ +
+ ) + } + + 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..b799d94 --- /dev/null +++ b/app/sites/[id]/funnels/page.tsx @@ -0,0 +1,149 @@ +'use client' + +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 params = useParams() + const router = useRouter() + const siteId = params.id as string + + const [funnels, setFunnels] = useState([]) + const [loading, setLoading] = useState(true) + + const loadFunnels = useCallback(async () => { + try { + setLoading(true) + const data = await listFunnels(siteId) + setFunnels(data) + } catch (error) { + toast.error('Failed to load funnels') + } finally { + setLoading(false) + } + }, [siteId]) + + useEffect(() => { + loadFunnels() + }, [loadFunnels]) + + 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 && (