[PULSE-36] Funnels UI - builder and report #8

Merged
uz1mani merged 12 commits from staging into main 2026-02-04 23:16:04 +00:00
7 changed files with 816 additions and 5 deletions

View File

@@ -0,0 +1,331 @@
'use client'
greptile-apps[bot] commented 2026-02-04 22:07:42 +00:00 (Migrated from github.com)
Review

ReferenceError on mount

useEffect calls loadData() before loadData is defined (const loadData = async () => { ... }), which throws ReferenceError: Cannot access 'loadData' before initialization on first render. Move the loadData definition above the useEffect (or switch to a hoisted function loadData() {}) so the page doesn’t crash.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 46:52

Comment:
**ReferenceError on mount**

`useEffect` calls `loadData()` before `loadData` is defined (`const loadData = async () => { ... }`), which throws `ReferenceError: Cannot access 'loadData' before initialization` on first render. Move the `loadData` definition above the `useEffect` (or switch to a hoisted `function loadData() {}`) so the page doesn’t crash.

How can I resolve this? If you propose a fix, please make it concise.
**ReferenceError on mount** `useEffect` calls `loadData()` before `loadData` is defined (`const loadData = async () => { ... }`), which throws `ReferenceError: Cannot access 'loadData' before initialization` on first render. Move the `loadData` definition above the `useEffect` (or switch to a hoisted `function loadData() {}`) so the page doesn’t crash. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 46:52 Comment: **ReferenceError on mount** `useEffect` calls `loadData()` before `loadData` is defined (`const loadData = async () => { ... }`), which throws `ReferenceError: Cannot access 'loadData' before initialization` on first render. Move the `loadData` definition above the `useEffect` (or switch to a hoisted `function loadData() {}`) so the page doesn’t crash. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:17:09 +00:00 (Migrated from github.com)
Review

Change: The loadData definition was moved above the useEffect that calls it.
Why: const loadData = async () => { ... } is not hoisted, so when the effect ran on the first render it was still in the temporal dead zone and threw “Cannot access 'loadData' before initialization”. Defining loadData before the useEffect ensures it exists when the effect runs.

Change: The loadData definition was moved above the useEffect that calls it. Why: const loadData = async () => { ... } is not hoisted, so when the effect ran on the first render it was still in the temporal dead zone and threw “Cannot access 'loadData' before initialization”. Defining loadData before the useEffect ensures it exists when the effect runs.
greptile-apps[bot] commented 2026-02-04 22:21:40 +00:00 (Migrated from github.com)
Review

Missing loadData in dependency array causes React exhaustive-deps warning and stale closure issues. Either add loadData to the dependency array or use useCallback to memoize loadData.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 50:52

Comment:
Missing `loadData` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadData` to the dependency array or use `useCallback` to memoize `loadData`.

How can I resolve this? If you propose a fix, please make it concise.
Missing `loadData` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadData` to the dependency array or use `useCallback` to memoize `loadData`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 50:52 Comment: Missing `loadData` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadData` to the dependency array or use `useCallback` to memoize `loadData`. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-04 22:21:42 +00:00 (Migrated from github.com)
Review

Date comparison using string equality (dateRange.start === getDateRange(7).start) creates new Date objects on every render, causing unnecessary re-renders and always evaluating to false since objects are different references.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 132:143

Comment:
Date comparison using string equality (`dateRange.start === getDateRange(7).start`) creates new Date objects on every render, causing unnecessary re-renders and always evaluating to false since objects are different references.

How can I resolve this? If you propose a fix, please make it concise.
Date comparison using string equality (`dateRange.start === getDateRange(7).start`) creates new Date objects on every render, causing unnecessary re-renders and always evaluating to false since objects are different references. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 132:143 Comment: Date comparison using string equality (`dateRange.start === getDateRange(7).start`) creates new Date objects on every render, causing unnecessary re-renders and always evaluating to false since objects are different references. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-04 22:21:45 +00:00 (Migrated from github.com)
Review

Unused import useAuth - not referenced in the component.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 3:3

Comment:
Unused import `useAuth` - not referenced in the component.

How can I resolve this? If you propose a fix, please make it concise.
Unused import `useAuth` - not referenced in the component. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 3:3 Comment: Unused import `useAuth` - not referenced in the component. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-04 22:21:47 +00:00 (Migrated from github.com)
Review

Opacity calculation can go negative for funnels with more than 6 steps, causing render issues. Wrap in Math.max(0.1, ...) to prevent negative opacity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 218:218

Comment:
Opacity calculation can go negative for funnels with more than 6 steps, causing render issues. Wrap in `Math.max(0.1, ...)` to prevent negative opacity.

How can I resolve this? If you propose a fix, please make it concise.
Opacity calculation can go negative for funnels with more than 6 steps, causing render issues. Wrap in `Math.max(0.1, ...)` to prevent negative opacity. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 218:218 Comment: Opacity calculation can go negative for funnels with more than 6 steps, causing render issues. Wrap in `Math.max(0.1, ...)` to prevent negative opacity. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:28:23 +00:00 (Migrated from github.com)
Review

Change: Replaced fillOpacity={1 - (index * 0.15)} with fillOpacity={Math.max(0.1, 1 - index * 0.15)}.
Why: For index ≥ 7, 1 - index * 0.15 becomes negative; capping with Math.max(0.1, ...) keeps opacity in a valid range and avoids render issues.

Change: Replaced fillOpacity={1 - (index * 0.15)} with fillOpacity={Math.max(0.1, 1 - index * 0.15)}. Why: For index ≥ 7, 1 - index * 0.15 becomes negative; capping with Math.max(0.1, ...) keeps opacity in a valid range and avoids render issues.
uz1mani commented 2026-02-04 22:28:33 +00:00 (Migrated from github.com)
Review

Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/[funnelId]/page.tsx.
Why: The report page doesn’t use useAuth.

Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/[funnelId]/page.tsx. Why: The report page doesn’t use useAuth.
uz1mani commented 2026-02-04 22:29:04 +00:00 (Migrated from github.com)
Review

Change: Introduced datePreset state: '7' | '30' | 'custom', default '30'. The Select uses value={datePreset}. In onChange: for '7'/'30' we call setDateRange(getDateRange(7)) / setDateRange(getDateRange(30)) and setDatePreset('7') / setDatePreset('30'); for 'custom' we only open the picker. In the DatePicker onApply we call setDatePreset('custom') when applying a custom range.
Why: We no longer call getDateRange(7) / getDateRange(30) on every render to derive the Select value; the value comes from datePreset, so no extra Date allocations or reference issues.

Change: Introduced datePreset state: '7' | '30' | 'custom', default '30'. The Select uses value={datePreset}. In onChange: for '7'/'30' we call setDateRange(getDateRange(7)) / setDateRange(getDateRange(30)) and setDatePreset('7') / setDatePreset('30'); for 'custom' we only open the picker. In the DatePicker onApply we call setDatePreset('custom') when applying a custom range. Why: We no longer call getDateRange(7) / getDateRange(30) on every render to derive the Select value; the value comes from datePreset, so no extra Date allocations or reference issues.
uz1mani commented 2026-02-04 22:29:23 +00:00 (Migrated from github.com)
Review

Change: Wrapped the loader in useCallback with deps [siteId, funnelId, dateRange], and made the effect depend only on [loadData].
Why: loadData is now stable per (siteId, funnelId, dateRange), so the effect runs when any of those change and satisfies exhaustive-deps without stale closures.

Change: Wrapped the loader in useCallback with deps [siteId, funnelId, dateRange], and made the effect depend only on [loadData]. Why: loadData is now stable per (siteId, funnelId, dateRange), so the effect runs when any of those change and satisfies exhaustive-deps without stale closures.
import { useCallback, useEffect, useMemo, useState } from 'react'
greptile-apps[bot] commented 2026-02-04 22:35:37 +00:00 (Migrated from github.com)
Review

Unused auth import

useAuth is imported here but never referenced, which will fail lint/typecheck in projects enforcing no-unused-vars. Remove the import to avoid CI failures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 1:3

Comment:
**Unused auth import**

`useAuth` is imported here but never referenced, which will fail lint/typecheck in projects enforcing `no-unused-vars`. Remove the import to avoid CI failures.

How can I resolve this? If you propose a fix, please make it concise.
**Unused auth import** `useAuth` is imported here but never referenced, which will fail lint/typecheck in projects enforcing `no-unused-vars`. Remove the import to avoid CI failures. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 1:3 Comment: **Unused auth import** `useAuth` is imported here but never referenced, which will fail lint/typecheck in projects enforcing `no-unused-vars`. Remove the import to avoid CI failures. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:38:27 +00:00 (Migrated from github.com)
Review

Already fixed.

Already fixed.
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<Funnel | null>(null)
const [stats, setStats] = useState<FunnelStats | null>(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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
}
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
greptile-apps[bot] commented 2026-02-04 23:08:05 +00:00 (Migrated from github.com)
Review

Shows "Funnel not found" for all errors (404, 403, 500, network failures). For transient errors, this suggests the funnel doesn't exist when it might just be a temporary issue. Consider tracking error type separately to show appropriate messages (e.g., "Unable to load funnel" for 500/network, "Access denied" for 403).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 91:96

Comment:
Shows "Funnel not found" for all errors (404, 403, 500, network failures). For transient errors, this suggests the funnel doesn't exist when it might just be a temporary issue. Consider tracking error type separately to show appropriate messages (e.g., "Unable to load funnel" for 500/network, "Access denied" for 403).

How can I resolve this? If you propose a fix, please make it concise.
Shows "Funnel not found" for all errors (404, 403, 500, network failures). For transient errors, this suggests the funnel doesn't exist when it might just be a temporary issue. Consider tracking error type separately to show appropriate messages (e.g., "Unable to load funnel" for 500/network, "Access denied" for 403). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 91:96 Comment: Shows "Funnel not found" for all errors (404, 403, 500, network failures). For transient errors, this suggests the funnel doesn't exist when it might just be a temporary issue. Consider tracking error type separately to show appropriate messages (e.g., "Unable to load funnel" for 500/network, "Access denied" for 403). How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 23:09:39 +00:00 (Migrated from github.com)
Review

Change:
loadError is now 'not_found' | 'forbidden' | 'error' | null.
In loadData catch: status === 404 → setLoadError('not_found'); status === 403 → setLoadError('forbidden'); otherwise → setLoadError('error'). Toast only for non-404, non-403.
Render:
loadError === 'not_found' (or fallback) → "Funnel not found" (unchanged).
loadError === 'forbidden' → "Access denied" and a "Back to Funnels" link.
loadError === 'error' → "Unable to load funnel" and "Try again" button that calls loadData().
Why: 404 stays “Funnel not found”, 403 shows “Access denied” with a way back, and 500/network show “Unable to load funnel” with retry so transient errors are no longer treated as “not found”.

Change: loadError is now 'not_found' | 'forbidden' | 'error' | null. In loadData catch: status === 404 → setLoadError('not_found'); status === 403 → setLoadError('forbidden'); otherwise → setLoadError('error'). Toast only for non-404, non-403. Render: loadError === 'not_found' (or fallback) → "Funnel not found" (unchanged). loadError === 'forbidden' → "Access denied" and a "Back to Funnels" link. loadError === 'error' → "Unable to load funnel" and "Try again" button that calls loadData(). Why: 404 stays “Funnel not found”, 403 shows “Access denied” with a way back, and 500/network show “Unable to load funnel” with retry so transient errors are no longer treated as “not found”.
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
}
if (loadError === 'forbidden') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Access denied</p>
<Link href={`/sites/${siteId}/funnels`} className="btn-primary mt-4 inline-block">
Back to Funnels
</Link>
</div>
)
}
if (loadError === 'error') {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400 mb-4">Unable to load funnel</p>
<button type="button" onClick={() => loadData()} className="btn-primary">
Try again
</button>
</div>
)
}
if (!funnel || !stats) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
</div>
)
greptile-apps[bot] commented 2026-02-04 23:03:01 +00:00 (Migrated from github.com)
Review

Misleading error state

When loadData() fails (network/5xx/permission), funnel/stats remain null and the render falls through to if (!funnel || !stats) showing "Funnel not found". That message is only correct for a 404; for transient errors it incorrectly looks like a permanent missing resource and provides no retry path. Consider tracking an explicit error/notFound state and only rendering "not found" on an actual 404 response.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/[funnelId]/page.tsx
Line: 91:96

Comment:
**Misleading error state**

When `loadData()` fails (network/5xx/permission), `funnel`/`stats` remain `null` and the render falls through to `if (!funnel || !stats)` showing "Funnel not found". That message is only correct for a 404; for transient errors it incorrectly looks like a permanent missing resource and provides no retry path. Consider tracking an explicit `error`/`notFound` state and only rendering "not found" on an actual 404 response.

How can I resolve this? If you propose a fix, please make it concise.
**Misleading error state** When `loadData()` fails (network/5xx/permission), `funnel`/`stats` remain `null` and the render falls through to `if (!funnel || !stats)` showing "Funnel not found". That message is only correct for a 404; for transient errors it incorrectly looks like a permanent missing resource and provides no retry path. Consider tracking an explicit `error`/`notFound` state and only rendering "not found" on an actual 404 response. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/[funnelId]/page.tsx Line: 91:96 Comment: **Misleading error state** When `loadData()` fails (network/5xx/permission), `funnel`/`stats` remain `null` and the render falls through to `if (!funnel || !stats)` showing "Funnel not found". That message is only correct for a 404; for transient errors it incorrectly looks like a permanent missing resource and provides no retry path. Consider tracking an explicit `error`/`notFound` state and only rendering "not found" on an actual 404 response. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 23:05:05 +00:00 (Migrated from github.com)
Review

Change:
Added loadError state: 'not_found' | 'error' | null, default null.
In loadData: at the start, setLoadError(null); in the catch, if error instanceof ApiError && error.status === 404 then setLoadError('not_found'), else setLoadError('error') and toast.error('Failed to load funnel data') (toast only for non-404).
Render logic:
loadError === 'not_found' or (!funnel && !stats && !loadError) → show "Funnel not found" (no retry).
loadError === 'error' → show "Failed to load funnel data" and a "Try again" button that calls loadData().
Otherwise, if !funnel || !stats → fallback "Funnel not found".
Why: 404 is shown as “Funnel not found”; other failures (network/5xx/permission) are shown as “Failed to load funnel data” with a retry, so transient errors are no longer treated as a permanent missing resource.

Change: Added loadError state: 'not_found' | 'error' | null, default null. In loadData: at the start, setLoadError(null); in the catch, if error instanceof ApiError && error.status === 404 then setLoadError('not_found'), else setLoadError('error') and toast.error('Failed to load funnel data') (toast only for non-404). Render logic: loadError === 'not_found' or (!funnel && !stats && !loadError) → show "Funnel not found" (no retry). loadError === 'error' → show "Failed to load funnel data" and a "Try again" button that calls loadData(). Otherwise, if !funnel || !stats → fallback "Funnel not found". Why: 404 is shown as “Funnel not found”; other failures (network/5xx/permission) are shown as “Failed to load funnel data” with a retry, so transient errors are no longer treated as a permanent missing resource.
}
const chartData = stats.steps.map(s => ({
name: s.step.name,
visitors: s.visitors,
dropoff: s.dropoff,
conversion: s.conversion
}))
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href={`/sites/${siteId}/funnels`}
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<ChevronLeftIcon className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
{funnel.name}
</h1>
{funnel.description && (
<p className="text-neutral-600 dark:text-neutral-400">
{funnel.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={datePreset}
onChange={(value) => {
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' },
{ value: '30', label: 'Last 30 days' },
{ value: 'custom', label: 'Custom' },
]}
/>
<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"
aria-label="Delete funnel"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</div>
{/* Chart */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
Funnel Visualization
</h3>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartColors.border} />
<XAxis
dataKey="name"
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke={chartColors.axis}
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div
className="p-3 rounded-xl shadow-lg border"
style={{
backgroundColor: chartColors.tooltipBg,
borderColor: chartColors.tooltipBorder,
}}
>
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
<p className="text-brand-orange font-bold text-lg">
{data.visitors.toLocaleString()} visitors
</p>
{data.dropoff > 0 && (
<p className="text-red-500 text-sm">
{Math.round(data.dropoff)}% drop-off
</p>
)}
{data.conversion > 0 && (
<p className="text-green-500 text-sm">
{Math.round(data.conversion)}% conversion (overall)
</p>
)}
</div>
);
}
return null;
}}
/>
<Bar dataKey="visitors" radius={[4, 4, 0, 0]} barSize={60}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={BRAND_ORANGE} fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Detailed Stats Table */}
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
<tr>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Conversion</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{stats.steps.map((step, i) => (
<tr key={i} 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 text-xs font-mono mt-0.5">{step.step.value}</p>
</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)}%
</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>
))}
</tbody>
</table>
</div>
</div>
</div>
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
setDatePreset('custom')
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
</div>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
greptile-apps[bot] commented 2026-02-04 22:21:44 +00:00 (Migrated from github.com)
Review

Unused import useAuth - not referenced in the component.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 3:3

Comment:
Unused import `useAuth` - not referenced in the component.

How can I resolve this? If you propose a fix, please make it concise.
Unused import `useAuth` - not referenced in the component. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 3:3 Comment: Unused import `useAuth` - not referenced in the component. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:28:56 +00:00 (Migrated from github.com)
Review

Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/new/page.tsx.
Why: The component doesn’t use useAuth.

Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/new/page.tsx. Why: The component doesn’t use useAuth.
import { useState } from 'react'
greptile-apps[bot] commented 2026-02-04 22:35:38 +00:00 (Migrated from github.com)
Review

Unused auth import

useAuth is imported but not used in this page, which will trip no-unused-vars/typecheck in many setups. Remove the import.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 1:3

Comment:
**Unused auth import**

`useAuth` is imported but not used in this page, which will trip `no-unused-vars`/typecheck in many setups. Remove the import.

How can I resolve this? If you propose a fix, please make it concise.
**Unused auth import** `useAuth` is imported but not used in this page, which will trip `no-unused-vars`/typecheck in many setups. Remove the import. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 1:3 Comment: **Unused auth import** `useAuth` is imported but not used in this page, which will trip `no-unused-vars`/typecheck in many setups. Remove the import. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:38:31 +00:00 (Migrated from github.com)
Review

Already fixed.

Already fixed.
import { useParams, useRouter } from 'next/navigation'
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
}
}
export default function CreateFunnelPage() {
const params = useParams()
const router = useRouter()
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
greptile-apps[bot] commented 2026-02-04 22:45:42 +00:00 (Migrated from github.com)
Review

Minimum step validation (≥1) only happens in UI. Backend may require at least 2 steps for a funnel to be meaningful. Verify backend requirements.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 26:27

Comment:
Minimum step validation (≥1) only happens in UI. Backend may require at least 2 steps for a funnel to be meaningful. Verify backend requirements.

How can I resolve this? If you propose a fix, please make it concise.
Minimum step validation (≥1) only happens in UI. Backend may require at least 2 steps for a funnel to be meaningful. Verify backend requirements. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 26:27 Comment: Minimum step validation (≥1) only happens in UI. Backend may require at least 2 steps for a funnel to be meaningful. Verify backend requirements. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:47:44 +00:00 (Migrated from github.com)
Review

Change: Added a comment above the steps state: // * Backend requires at least one step (API binding min=1, DB rejects empty steps).
No change to the minimum step count: the backend uses min=1 (API binding:"required,min=1" and DB len(steps) < 1), and the UI already enforces that (e.g. “remove step” disabled when only one step).
Why: The comment records that the backend requirement is “at least 1 step” and that the UI matches it; no change to require 2 steps.

Change: Added a comment above the steps state: // * Backend requires at least one step (API binding min=1, DB rejects empty steps). No change to the minimum step count: the backend uses min=1 (API binding:"required,min=1" and DB len(steps) < 1), and the UI already enforces that (e.g. “remove step” disabled when only one step). Why: The comment records that the backend requirement is “at least 1 step” and that the UI matches it; no change to require 2 steps.
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()
greptile-apps[bot] commented 2026-02-04 22:51:45 +00:00 (Migrated from github.com)
Review

Regex validation missing despite previous thread mentioning it was added. When step.type === 'regex', invalid regex patterns will cause backend errors. The previous thread indicated isValidRegex helper and validation were added, but they're not present in the code.

    if (steps.some(s => !s.value.trim())) {
      toast.error('Please enter a path for all steps')
      return
    }

    // Validate regex patterns
    const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value))
    if (invalidRegexStep) {
      toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`)
      return
    }

function isValidRegex(pattern: string): boolean {
  try { 
    new RegExp(pattern)
    return true
  } catch {
    return false
  }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 46:49

Comment:
Regex validation missing despite previous thread mentioning it was added. When `step.type === 'regex'`, invalid regex patterns will cause backend errors. The previous thread indicated `isValidRegex` helper and validation were added, but they're not present in the code.

```suggestion
    if (steps.some(s => !s.value.trim())) {
      toast.error('Please enter a path for all steps')
      return
    }

    // Validate regex patterns
    const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value))
    if (invalidRegexStep) {
      toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`)
      return
    }

function isValidRegex(pattern: string): boolean {
  try { 
    new RegExp(pattern)
    return true
  } catch {
    return false
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.
Regex validation missing despite previous thread mentioning it was added. When `step.type === 'regex'`, invalid regex patterns will cause backend errors. The previous thread indicated `isValidRegex` helper and validation were added, but they're not present in the code. ```suggestion if (steps.some(s => !s.value.trim())) { toast.error('Please enter a path for all steps') return } // Validate regex patterns const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value)) if (invalidRegexStep) { toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`) return } function isValidRegex(pattern: string): boolean { try { new RegExp(pattern) return true } catch { return false } } ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 46:49 Comment: Regex validation missing despite previous thread mentioning it was added. When `step.type === 'regex'`, invalid regex patterns will cause backend errors. The previous thread indicated `isValidRegex` helper and validation were added, but they're not present in the code. ```suggestion if (steps.some(s => !s.value.trim())) { toast.error('Please enter a path for all steps') return } // Validate regex patterns const invalidRegexStep = steps.find(s => s.type === 'regex' && !isValidRegex(s.value)) if (invalidRegexStep) { toast.error(`Invalid regex pattern in step: ${invalidRegexStep.name}`) return } function isValidRegex(pattern: string): boolean { try { new RegExp(pattern) return true } catch { return false } } ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:54:36 +00:00 (Migrated from github.com)
Review

Already in place: The isValidRegex helper (lines 9–16) and regex validation for steps with type === 'regex' were already there.
Change made: The validation was updated to match the suggested style:
Use steps.find(s => s.type === 'regex' && !isValidRegex(s.value)) instead of steps.some(...).
If an invalid regex step is found, show toast.error(\Invalid regex pattern in step: ${invalidRegexStep.name}) so the user sees which step failed.
Why: Behavior is the same (invalid regex still blocks submit), but the error message now names the step, as in the review suggestion.

Already in place: The isValidRegex helper (lines 9–16) and regex validation for steps with type === 'regex' were already there. Change made: The validation was updated to match the suggested style: Use steps.find(s => s.type === 'regex' && !isValidRegex(s.value)) instead of steps.some(...). If an invalid regex step is found, show toast.error(\Invalid regex pattern in step: ${invalidRegexStep.name}\) so the user sees which step failed. Why: Behavior is the same (invalid regex still blocks submit), but the error message now names the step, as in the review suggestion.
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
}
greptile-apps[bot] commented 2026-02-04 23:08:04 +00:00 (Migrated from github.com)
Review

Validates step.value but not step.name. Empty/whitespace step names will render as blank labels throughout the UI (list page line 121, report page line 246).

    if (steps.some(s => !s.value.trim())) {
      toast.error('Please enter a path for all steps')
      return
    }
    
    if (steps.some(s => !s.name.trim())) {
      toast.error('Please enter a name for all steps')
      return
    }
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 56:59

Comment:
Validates `step.value` but not `step.name`. Empty/whitespace step names will render as blank labels throughout the UI (list page line 121, report page line 246).

```suggestion
    if (steps.some(s => !s.value.trim())) {
      toast.error('Please enter a path for all steps')
      return
    }
    
    if (steps.some(s => !s.name.trim())) {
      toast.error('Please enter a name for all steps')
      return
    }
```

How can I resolve this? If you propose a fix, please make it concise.
Validates `step.value` but not `step.name`. Empty/whitespace step names will render as blank labels throughout the UI (list page line 121, report page line 246). ```suggestion if (steps.some(s => !s.value.trim())) { toast.error('Please enter a path for all steps') return } if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps') return } ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 56:59 Comment: Validates `step.value` but not `step.name`. Empty/whitespace step names will render as blank labels throughout the UI (list page line 121, report page line 246). ```suggestion if (steps.some(s => !s.value.trim())) { toast.error('Please enter a path for all steps') return } if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps') return } ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 23:09:55 +00:00 (Migrated from github.com)
Review

Status: Already implemented.
handleSubmit already has if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps'); return } (lines 56–59), before the path check.

Status: Already implemented. handleSubmit already has if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps'); return } (lines 56–59), before the path check.
if (steps.some(s => !s.value.trim())) {
greptile-apps[bot] commented 2026-02-04 22:45:41 +00:00 (Migrated from github.com)
Review

Step validation only checks if value is empty but doesn't validate regex syntax when type === 'regex'. Invalid regex will cause backend errors or runtime issues when matching. Consider adding client-side regex validation:

    if (steps.some(s => !s.value.trim() || (s.type === 'regex' && !isValidRegex(s.value)))) {

Add a helper:

function isValidRegex(pattern: string): boolean {
  try { new RegExp(pattern); return true } catch { return false }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 46:46

Comment:
Step validation only checks if `value` is empty but doesn't validate regex syntax when `type === 'regex'`. Invalid regex will cause backend errors or runtime issues when matching. Consider adding client-side regex validation:

```suggestion
    if (steps.some(s => !s.value.trim() || (s.type === 'regex' && !isValidRegex(s.value)))) {
```

Add a helper:
```typescript
function isValidRegex(pattern: string): boolean {
  try { new RegExp(pattern); return true } catch { return false }
}
```

How can I resolve this? If you propose a fix, please make it concise.
Step validation only checks if `value` is empty but doesn't validate regex syntax when `type === 'regex'`. Invalid regex will cause backend errors or runtime issues when matching. Consider adding client-side regex validation: ```suggestion if (steps.some(s => !s.value.trim() || (s.type === 'regex' && !isValidRegex(s.value)))) { ``` Add a helper: ```typescript function isValidRegex(pattern: string): boolean { try { new RegExp(pattern); return true } catch { return false } } ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 46:46 Comment: Step validation only checks if `value` is empty but doesn't validate regex syntax when `type === 'regex'`. Invalid regex will cause backend errors or runtime issues when matching. Consider adding client-side regex validation: ```suggestion if (steps.some(s => !s.value.trim() || (s.type === 'regex' && !isValidRegex(s.value)))) { ``` Add a helper: ```typescript function isValidRegex(pattern: string): boolean { try { new RegExp(pattern); return true } catch { return false } } ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:47:56 +00:00 (Migrated from github.com)
Review

Change:
Added isValidRegex(pattern: string): boolean that does try { new RegExp(pattern); return true } catch { return false }.
After the “path for all steps” check, added a check: steps.some(s => s.type === 'regex' && !isValidRegex(s.value)). If true, show toast.error('Invalid regex in one or more steps. Check the pattern for steps with type "regex".') and return without submitting.
Why: Invalid regex for type === 'regex' is caught on the client so the user gets a clear error instead of a backend/runtime failure.

Change: Added isValidRegex(pattern: string): boolean that does try { new RegExp(pattern); return true } catch { return false }. After the “path for all steps” check, added a check: steps.some(s => s.type === 'regex' && !isValidRegex(s.value)). If true, show toast.error('Invalid regex in one or more steps. Check the pattern for steps with type "regex".') and return without submitting. Why: Invalid regex for type === 'regex' is caught on the client so the user gets a clear error instead of a backend/runtime failure.
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
greptile-apps[bot] commented 2026-02-04 23:03:02 +00:00 (Migrated from github.com)
Review

Missing step name validation

handleSubmit validates step.value (and regex syntax) but never validates step.name. This allows creating funnels with empty/whitespace step names, which then render as blank labels in the funnels list/report (step.name is displayed in multiple places). Add a check like steps.some(s => !s.name.trim()) and block submit with a toast so funnels stay usable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/new/page.tsx
Line: 56:63

Comment:
**Missing step name validation**

`handleSubmit` validates `step.value` (and regex syntax) but never validates `step.name`. This allows creating funnels with empty/whitespace step names, which then render as blank labels in the funnels list/report (`step.name` is displayed in multiple places). Add a check like `steps.some(s => !s.name.trim())` and block submit with a toast so funnels stay usable.

How can I resolve this? If you propose a fix, please make it concise.
**Missing step name validation** `handleSubmit` validates `step.value` (and regex syntax) but never validates `step.name`. This allows creating funnels with empty/whitespace step names, which then render as blank labels in the funnels list/report (`step.name` is displayed in multiple places). Add a check like `steps.some(s => !s.name.trim())` and block submit with a toast so funnels stay usable. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/new/page.tsx Line: 56:63 Comment: **Missing step name validation** `handleSubmit` validates `step.value` (and regex syntax) but never validates `step.name`. This allows creating funnels with empty/whitespace step names, which then render as blank labels in the funnels list/report (`step.name` is displayed in multiple places). Add a check like `steps.some(s => !s.name.trim())` and block submit with a toast so funnels stay usable. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 23:04:53 +00:00 (Migrated from github.com)
Review

Change: In handleSubmit, after the funnel name check and before the path check, added: if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps'); return }.
Why: Steps with empty or whitespace-only names are rejected so funnel list/report labels stay usable.

Change: In handleSubmit, after the funnel name check and before the path check, added: if (steps.some(s => !s.name.trim())) { toast.error('Please enter a name for all steps'); return }. Why: Steps with empty or whitespace-only names are rejected so funnel list/report labels stay usable.
}
try {
setSaving(true)
const funnelSteps = steps.map((s, i) => ({
...s,
order: i + 1
}))
await createFunnel(siteId, {
name,
description,
steps: funnelSteps
})
toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`)
} catch (error) {
toast.error('Failed to create funnel')
} finally {
setSaving(false)
}
}
return (
<div className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-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-xl 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"
required
/>
</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={index} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl 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-xl 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`}
className="btn-secondary"
>
Cancel
</Link>
<Button
type="submit"
disabled={saving}
className="btn-primary"
>
{saving ? 'Creating...' : 'Create Funnel'}
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
greptile-apps[bot] commented 2026-02-04 22:07:43 +00:00 (Migrated from github.com)
Review

ReferenceError on mount

Same pattern as the report page: useEffect calls loadFunnels() before const loadFunnels = async () => { ... } is initialized, so this page will throw ReferenceError: Cannot access 'loadFunnels' before initialization on mount. Define loadFunnels above the effect (or use a hoisted function declaration).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/page.tsx
Line: 15:21

Comment:
**ReferenceError on mount**

Same pattern as the report page: `useEffect` calls `loadFunnels()` before `const loadFunnels = async () => { ... }` is initialized, so this page will throw `ReferenceError: Cannot access 'loadFunnels' before initialization` on mount. Define `loadFunnels` above the effect (or use a hoisted function declaration).

How can I resolve this? If you propose a fix, please make it concise.
**ReferenceError on mount** Same pattern as the report page: `useEffect` calls `loadFunnels()` before `const loadFunnels = async () => { ... }` is initialized, so this page will throw `ReferenceError: Cannot access 'loadFunnels' before initialization` on mount. Define `loadFunnels` above the effect (or use a hoisted function declaration). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/page.tsx Line: 15:21 Comment: **ReferenceError on mount** Same pattern as the report page: `useEffect` calls `loadFunnels()` before `const loadFunnels = async () => { ... }` is initialized, so this page will throw `ReferenceError: Cannot access 'loadFunnels' before initialization` on mount. Define `loadFunnels` above the effect (or use a hoisted function declaration). How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:17:00 +00:00 (Migrated from github.com)
Review

Change: The loadFunnels definition was moved above the useEffect that calls it.
Why: Same pattern as the report page: the effect was calling loadFunnels() before the const loadFunnels = async () => { ... } was initialized. Defining loadFunnels before the useEffect fixes the ReferenceError on mount.

Change: The loadFunnels definition was moved above the useEffect that calls it. Why: Same pattern as the report page: the effect was calling loadFunnels() before the const loadFunnels = async () => { ... } was initialized. Defining loadFunnels before the useEffect fixes the ReferenceError on mount.
greptile-apps[bot] commented 2026-02-04 22:21:41 +00:00 (Migrated from github.com)
Review

Missing loadFunnels in dependency array causes React exhaustive-deps warning and stale closure issues. Either add loadFunnels to the dependency array or use useCallback to memoize loadFunnels.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/page.tsx
Line: 19:21

Comment:
Missing `loadFunnels` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadFunnels` to the dependency array or use `useCallback` to memoize `loadFunnels`.

How can I resolve this? If you propose a fix, please make it concise.
Missing `loadFunnels` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadFunnels` to the dependency array or use `useCallback` to memoize `loadFunnels`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/page.tsx Line: 19:21 Comment: Missing `loadFunnels` in dependency array causes React exhaustive-deps warning and stale closure issues. Either add `loadFunnels` to the dependency array or use `useCallback` to memoize `loadFunnels`. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-04 22:21:44 +00:00 (Migrated from github.com)
Review

Unused variable user destructured from useAuth() - not referenced in the component.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/funnels/page.tsx
Line: 11:11

Comment:
Unused variable `user` destructured from `useAuth()` - not referenced in the component.

How can I resolve this? If you propose a fix, please make it concise.
Unused variable `user` destructured from `useAuth()` - not referenced in the component. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/funnels/page.tsx Line: 11:11 Comment: Unused variable `user` destructured from `useAuth()` - not referenced in the component. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:28:45 +00:00 (Migrated from github.com)
Review

Change: Removed useAuth() and its import from app/sites/[id]/funnels/page.tsx (the list page).
Why: Only user was destructured and it wasn’t used, so the whole hook and import were removed.

Change: Removed useAuth() and its import from app/sites/[id]/funnels/page.tsx (the list page). Why: Only user was destructured and it wasn’t used, so the whole hook and import were removed.
uz1mani commented 2026-02-04 22:29:13 +00:00 (Migrated from github.com)
Review

Change: Wrapped loadFunnels in useCallback with deps [siteId], and set the effect dependency array to [loadFunnels].
Why: Same as above: correct deps and no stale closure.

Change: Wrapped loadFunnels in useCallback with deps [siteId], and set the effect dependency array to [loadFunnels]. Why: Same as above: correct deps and no stale closure.
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<Funnel[]>([])
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<Link
href={`/sites/${siteId}`}
className="p-2 -ml-2 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<ChevronLeftIcon className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Funnels
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
Track user journeys and identify drop-off points
</p>
</div>
<div className="ml-auto">
<Link
href={`/sites/${siteId}/funnels/new`}
className="btn-primary inline-flex items-center gap-2"
>
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Link>
</div>
</div>
{funnels.length === 0 ? (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 mx-auto mb-4 w-fit">
<ArrowRightIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
</div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-2">
No funnels yet
</h3>
<p className="text-neutral-600 dark:text-neutral-400 mb-6 max-w-md mx-auto">
Create a funnel to track how users move through your site and where they drop off.
</p>
<Link
href={`/sites/${siteId}/funnels/new`}
className="btn-primary inline-flex items-center gap-2"
>
<PlusIcon className="w-4 h-4" />
<span>Create Funnel</span>
</Link>
</div>
) : (
<div className="grid gap-4">
{funnels.map((funnel) => (
<Link
key={funnel.id}
href={`/sites/${siteId}/funnels/${funnel.id}`}
className="block group"
>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl p-6 hover:border-brand-orange/50 transition-colors">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange transition-colors">
{funnel.name}
</h3>
{funnel.description && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{funnel.description}
</p>
)}
<div className="flex items-center gap-2 mt-4">
{funnel.steps.map((step, i) => (
<div key={i} className="flex items-center text-sm text-neutral-500">
<span className="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-700 dark:text-neutral-300">
{step.name}
</span>
{i < funnel.steps.length - 1 && (
<ArrowRightIcon className="w-4 h-4 mx-2 text-neutral-300" />
)}
</div>
))}
</div>
</div>
<div className="flex items-center gap-4">
<button
onClick={(e) => handleDelete(e, funnel.id)}
className="p-2 text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors"
aria-label="Delete funnel"
>
<TrashIcon className="w-5 h-5" />
</button>
<ChevronLeftIcon className="w-5 h-5 text-neutral-300 rotate-180" />
</div>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -297,6 +297,12 @@ export default function SiteDashboardPage() {
{ value: 'custom', label: 'Custom' }, { value: 'custom', label: 'Custom' },
]} ]}
/> />
<button
onClick={() => router.push(`/sites/${siteId}/funnels`)}
className="btn-secondary text-sm"
>
Funnels
</button>
{canEdit && ( {canEdit && (
<button <button
onClick={() => router.push(`/sites/${siteId}/settings`)} onClick={() => router.push(`/sites/${siteId}/settings`)}

90
lib/api/funnels.ts Normal file
View File

@@ -0,0 +1,90 @@
import apiRequest from './client'
export interface FunnelStep {
order: number
name: string
value: string
type: string // "exact", "contains", "regex"
}
export interface Funnel {
id: string
site_id: string
name: string
description: string
steps: FunnelStep[]
created_at: string
updated_at: string
}
export interface FunnelStepStats {
step: FunnelStep
visitors: number
dropoff: number
conversion: number
}
export interface FunnelStats {
funnel_id: string
steps: FunnelStepStats[]
}
export interface CreateFunnelRequest {
name: string
description: string
steps: FunnelStep[]
}
export async function listFunnels(siteId: string): Promise<Funnel[]> {
const response = await apiRequest<{ funnels: Funnel[] }>(`/sites/${siteId}/funnels`)
return response?.funnels || []
}
export async function getFunnel(siteId: string, funnelId: string): Promise<Funnel> {
return apiRequest<Funnel>(`/sites/${siteId}/funnels/${funnelId}`)
}
export async function createFunnel(siteId: string, data: CreateFunnelRequest): Promise<Funnel> {
return apiRequest<Funnel>(`/sites/${siteId}/funnels`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateFunnel(siteId: string, funnelId: string, data: CreateFunnelRequest): Promise<Funnel> {
return apiRequest<Funnel>(`/sites/${siteId}/funnels/${funnelId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteFunnel(siteId: string, funnelId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/funnels/${funnelId}`, {
method: 'DELETE',
})
}
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
/** Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC). */
function toRFC3339Range(from: string, to: string): { from: string; to: string } {
return {
from: DATE_ONLY_REGEX.test(from) ? `${from}T00:00:00.000Z` : from,
to: DATE_ONLY_REGEX.test(to) ? `${to}T23:59:59.999Z` : to,
}
greptile-apps[bot] commented 2026-02-04 22:45:43 +00:00 (Migrated from github.com)
Review

Date normalization hardcodes T23:59:59.999Z for end dates, but this may not account for user's timezone. If to is "2024-02-04", this sets end to "2024-02-04T23:59:59.999Z" (UTC), which might exclude data from users in timezones ahead of UTC who accessed the site on Feb 4th local time. Consider if timezone handling is needed or document the UTC assumption.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/api/funnels.ts
Line: 72:74

Comment:
Date normalization hardcodes `T23:59:59.999Z` for end dates, but this may not account for user's timezone. If `to` is "2024-02-04", this sets end to "2024-02-04T23:59:59.999Z" (UTC), which might exclude data from users in timezones ahead of UTC who accessed the site on Feb 4th local time. Consider if timezone handling is needed or document the UTC assumption.

How can I resolve this? If you propose a fix, please make it concise.
Date normalization hardcodes `T23:59:59.999Z` for end dates, but this may not account for user's timezone. If `to` is "2024-02-04", this sets end to "2024-02-04T23:59:59.999Z" (UTC), which might exclude data from users in timezones ahead of UTC who accessed the site on Feb 4th local time. Consider if timezone handling is needed or document the UTC assumption. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: lib/api/funnels.ts Line: 72:74 Comment: Date normalization hardcodes `T23:59:59.999Z` for end dates, but this may not account for user's timezone. If `to` is "2024-02-04", this sets end to "2024-02-04T23:59:59.999Z" (UTC), which might exclude data from users in timezones ahead of UTC who accessed the site on Feb 4th local time. Consider if timezone handling is needed or document the UTC assumption. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 22:47:34 +00:00 (Migrated from github.com)
Review

Change: Updated the JSDoc for toRFC3339Range to: Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC).
Why: It documents that the T23:59:59.999Z end-of-day is intentional UTC and that the API/server use UTC; no code change for timezone handling.

Change: Updated the JSDoc for toRFC3339Range to: Normalize date-only (YYYY-MM-DD) to RFC3339 for backend funnel stats API. Uses UTC for boundaries (API/server timestamps are UTC). Why: It documents that the T23:59:59.999Z end-of-day is intentional UTC and that the API/server use UTC; no code change for timezone handling.
}
export async function getFunnelStats(siteId: string, funnelId: string, from?: string, to?: string): Promise<FunnelStats> {
const params = new URLSearchParams()
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<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
}

8
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.2", "version": "0.1.2",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.44", "@ciphera-net/ui": "^0.0.45",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
@@ -1467,9 +1467,9 @@
} }
}, },
"node_modules/@ciphera-net/ui": { "node_modules/@ciphera-net/ui": {
"version": "0.0.44", "version": "0.0.45",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.44/a36bb829498560c7dc49e105e048fdc02e6735d9", "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.45/de60e0da8e1c78ea906d49fdc85cd7d7dd163348",
"integrity": "sha512-3dgHoVwnYqbKVKC7Dzjzm3sbPHoL+t3J58TC0XvH6S9OYBW1vC+nkF3Jxqq6pVoHOblpZ1/ZokL7hA6xZFeSIQ==", "integrity": "sha512-KvrNKb9NzLMztB75h94opaaUp9RG43QW7GCRcVX+xGT8EFmrXi/N2h2kpjHZV652H/Cz1EXfcDA0hzoq/+wJXA==",
"dependencies": { "dependencies": {
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",

View File

@@ -10,7 +10,7 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.44", "@ciphera-net/ui": "^0.0.45",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",