[PULSE-36] Funnels UI - builder and report #8
265
app/sites/[id]/funnels/[funnelId]/page.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
|
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
Unused auth import
Prompt To Fix With AI**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>
Already fixed. Already fixed.
|
||||
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<Funnel | null>(null)
|
||||
const [stats, setStats] = useState<FunnelStats | null>(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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
||||
}
|
||||
|
||||
if (!funnel || !stats) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Funnel not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-lg 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}
|
||||
|
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 AIShows "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>
Change: 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”.
|
||||
</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={
|
||||
dateRange.start === getDateRange(7).start
|
||||
? '7'
|
||||
: dateRange.start === getDateRange(30).start
|
||||
? '30'
|
||||
: 'custom'
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (value === '7') setDateRange(getDateRange(7))
|
||||
else if (value === '30') setDateRange(getDateRange(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-lg transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
|
Misleading error state When Prompt To Fix With AI**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>
Change: 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.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<Card className="p-6 mb-8">
|
||||
<h3 className="text-lg font-medium 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="#E5E5E5" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#A3A3A3"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#A3A3A3"
|
||||
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="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 p-3 rounded-lg shadow-lg">
|
||||
<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="#FD5E0F" fillOpacity={1 - (index * 0.15)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Stats Table */}
|
||||
<Card className="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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
isOpen={isDatePickerOpen}
|
||||
onClose={() => setIsDatePickerOpen(false)}
|
||||
onApply={(range) => {
|
||||
setDateRange(range)
|
||||
setIsDatePickerOpen(false)
|
||||
}}
|
||||
initialRange={dateRange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
app/sites/[id]/funnels/new/page.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
Unused import Prompt To Fix With AIUnused 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>
Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/new/page.tsx. Change: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/new/page.tsx.
Why: The component doesn’t use useAuth.
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
|
Unused auth import
Prompt To Fix With AI**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>
Already fixed. Already fixed.
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createFunnel, type CreateFunnelRequest, type FunnelStep } from '@/lib/api/funnels'
|
||||
import { toast, Card, Input, Button } from '@ciphera-net/ui'
|
||||
import Link from 'next/link'
|
||||
import { LuChevronLeft as ChevronLeftIcon, LuPlus as PlusIcon, LuTrash as TrashIcon, LuGripVertical as GripVerticalIcon } from 'react-icons/lu'
|
||||
|
||||
export default function CreateFunnelPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const siteId = params.id as string
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [steps, setSteps] = useState<Omit<FunnelStep, 'order'>[]>([
|
||||
{ name: 'Step 1', value: '/', type: 'exact' },
|
||||
{ name: 'Step 2', value: '', type: 'exact' }
|
||||
])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }])
|
||||
}
|
||||
|
||||
const handleRemoveStep = (index: number) => {
|
||||
if (steps.length <= 1) return
|
||||
const newSteps = steps.filter((_, i) => i !== index)
|
||||
setSteps(newSteps)
|
||||
}
|
||||
|
||||
const handleUpdateStep = (index: number, field: keyof Omit<FunnelStep, 'order'>, value: string) => {
|
||||
const newSteps = [...steps]
|
||||
newSteps[index] = { ...newSteps[index], [field]: value }
|
||||
setSteps(newSteps)
|
||||
|
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 AIMinimum 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>
Change: Added a comment above the steps state: // * Backend requires at least one step (API binding min=1, DB rejects empty 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 handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error('Please enter a funnel name')
|
||||
return
|
||||
}
|
||||
|
||||
if (steps.some(s => !s.value.trim())) {
|
||||
toast.error('Please enter a path for all steps')
|
||||
|
Regex validation missing despite previous thread mentioning it was added. When Prompt To Fix With AIRegex 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>
Already in place: The isValidRegex helper (lines 9–16) and regex validation for steps with type === 'regex' were already there. 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.
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const funnelSteps = steps.map((s, i) => ({
|
||||
...s,
|
||||
order: i + 1
|
||||
}))
|
||||
|
||||
|
Validates Prompt To Fix With AIValidates `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>
Status: Already implemented. 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.
|
||||
await createFunnel(siteId, {
|
||||
name,
|
||||
|
Step validation only checks if Add a helper: Prompt To Fix With AIStep 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>
Change: 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.
|
||||
description,
|
||||
steps: funnelSteps
|
||||
})
|
||||
|
||||
toast.success('Funnel created')
|
||||
router.push(`/sites/${siteId}/funnels`)
|
||||
} catch (error) {
|
||||
|
Missing step name validation
Prompt To Fix With AI**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>
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 }. 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.
|
||||
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 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}>
|
||||
<Card className="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>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-white">
|
||||
Funnel Steps
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-3 text-neutral-400">
|
||||
<div className="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||
Step Name
|
||||
</label>
|
||||
<Input
|
||||
value={step.name}
|
||||
onChange={(e) => handleUpdateStep(index, 'name', e.target.value)}
|
||||
placeholder="e.g. Landing Page"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase mb-1">
|
||||
Path / URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={step.type}
|
||||
onChange={(e) => handleUpdateStep(index, 'type', e.target.value)}
|
||||
className="w-24 px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none"
|
||||
>
|
||||
<option value="exact">Exact</option>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="regex">Regex</option>
|
||||
</select>
|
||||
<Input
|
||||
value={step.value}
|
||||
onChange={(e) => handleUpdateStep(index, 'value', e.target.value)}
|
||||
placeholder={step.type === 'exact' ? '/pricing' : 'pricing'}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveStep(index)}
|
||||
disabled={steps.length <= 1}
|
||||
className={`mt-3 p-2 rounded-lg 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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<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="px-4 py-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-brand-orange hover:bg-brand-orange/90 text-white"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Funnel'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
app/sites/[id]/funnels/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
ReferenceError on mount Same pattern as the report page: Prompt To Fix With AI**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>
Change: The loadFunnels definition was moved above the useEffect that calls it. 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.
Missing Prompt To Fix With AIMissing `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>
Unused variable Prompt To Fix With AIUnused 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>
Change: Removed useAuth() and its import from app/sites/[id]/funnels/page.tsx (the list page). 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: Wrapped loadFunnels in useCallback with deps [siteId], and set the effect dependency array to [loadFunnels]. 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 { 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<Funnel[]>([])
|
||||
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 <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-lg 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="flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors font-medium"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{funnels.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center">
|
||||
<ArrowRightIcon className="w-8 h-8 text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium 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="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors font-medium"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>Create Funnel</span>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{funnels.map((funnel) => (
|
||||
<Link
|
||||
key={funnel.id}
|
||||
href={`/sites/${siteId}/funnels/${funnel.id}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="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 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-lg transition-colors"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<ChevronLeftIcon className="w-5 h-5 text-neutral-300 rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -297,6 +297,12 @@ export default function SiteDashboardPage() {
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/funnels`)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Funnels
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => router.push(`/sites/${siteId}/settings`)}
|
||||
|
||||
74
lib/api/funnels.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFunnelStats(siteId: string, funnelId: string, from?: string, to?: string): Promise<FunnelStats> {
|
||||
const params = new URLSearchParams()
|
||||
if (from) params.append('from', from)
|
||||
if (to) params.append('to', to)
|
||||
|
||||
const queryString = params.toString() ? `?${params.toString()}` : ''
|
||||
return apiRequest<FunnelStats>(`/sites/${siteId}/funnels/${funnelId}/stats${queryString}`)
|
||||
}
|
||||
|
Date normalization hardcodes Prompt To Fix With AIDate 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>
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). 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.
|
||||
ReferenceError on mount
useEffectcallsloadData()beforeloadDatais defined (const loadData = async () => { ... }), which throwsReferenceError: Cannot access 'loadData' before initializationon first render. Move theloadDatadefinition above theuseEffect(or switch to a hoistedfunction loadData() {}) so the page doesn’t crash.Prompt To Fix With AI
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.
Missing
loadDatain dependency array causes React exhaustive-deps warning and stale closure issues. Either addloadDatato the dependency array or useuseCallbackto memoizeloadData.Prompt To Fix With AI
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
Unused import
useAuth- not referenced in the component.Prompt To Fix With AI
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
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: Removed import { useAuth } from '@/lib/auth/context' from app/sites/[id]/funnels/[funnelId]/page.tsx.
Why: The report page doesn’t use useAuth.
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: 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.