'use client' import { useState } from 'react' import Link from 'next/link' import { Input, Button, ChevronLeftIcon, ChevronDownIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui' import { CaretUp } from '@phosphor-icons/react' import type { FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels' type StepWithoutOrder = Omit interface FunnelFormProps { siteId: string initialData?: { name: string description: string steps: StepWithoutOrder[] conversion_window_value: number conversion_window_unit: 'hours' | 'days' } onSubmit: (data: CreateFunnelRequest) => Promise submitLabel: string cancelHref: string } function isValidRegex(pattern: string): boolean { try { new RegExp(pattern) return true } catch { return false } } const WINDOW_PRESETS = [ { label: '1h', value: 1, unit: 'hours' as const }, { label: '24h', value: 24, unit: 'hours' as const }, { label: '7d', value: 7, unit: 'days' as const }, { label: '14d', value: 14, unit: 'days' as const }, { label: '30d', value: 30, unit: 'days' as const }, ] const OPERATOR_OPTIONS: { value: StepPropertyFilter['operator']; label: string }[] = [ { value: 'is', label: 'is' }, { value: 'is_not', label: 'is not' }, { value: 'contains', label: 'contains' }, { value: 'not_contains', label: 'does not contain' }, ] const MAX_STEPS = 8 const MAX_FILTERS = 10 export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel, cancelHref }: FunnelFormProps) { const [name, setName] = useState(initialData?.name ?? '') const [description, setDescription] = useState(initialData?.description ?? '') const [steps, setSteps] = useState( initialData?.steps ?? [ { name: 'Step 1', value: '/', type: 'exact' }, { name: 'Step 2', value: '', type: 'exact' }, ] ) const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7) const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days') const handleAddStep = () => { if (steps.length >= MAX_STEPS) return setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }]) } const handleRemoveStep = (index: number) => { if (steps.length <= 1) return setSteps(steps.filter((_, i) => i !== index)) } const handleUpdateStep = (index: number, field: string, value: string) => { const newSteps = [...steps] const step = { ...newSteps[index] } if (field === 'category') { step.category = value as 'page' | 'event' // Reset fields when switching category if (value === 'event') { step.type = 'exact' step.value = '' } else { step.value = '' step.property_filters = undefined } } else { ;(step as Record)[field] = value } newSteps[index] = step setSteps(newSteps) } const moveStep = (index: number, direction: -1 | 1) => { const targetIndex = index + direction if (targetIndex < 0 || targetIndex >= steps.length) return const newSteps = [...steps] const temp = newSteps[index] newSteps[index] = newSteps[targetIndex] newSteps[targetIndex] = temp setSteps(newSteps) } // Property filter handlers const addPropertyFilter = (stepIndex: number) => { const newSteps = [...steps] const step = { ...newSteps[stepIndex] } const filters = [...(step.property_filters || [])] if (filters.length >= MAX_FILTERS) return filters.push({ key: '', operator: 'is', value: '' }) step.property_filters = filters newSteps[stepIndex] = step setSteps(newSteps) } const updatePropertyFilter = (stepIndex: number, filterIndex: number, field: keyof StepPropertyFilter, value: string) => { const newSteps = [...steps] const step = { ...newSteps[stepIndex] } const filters = [...(step.property_filters || [])] filters[filterIndex] = { ...filters[filterIndex], [field]: value } step.property_filters = filters newSteps[stepIndex] = step setSteps(newSteps) } const removePropertyFilter = (stepIndex: number, filterIndex: number) => { const newSteps = [...steps] const step = { ...newSteps[stepIndex] } const filters = [...(step.property_filters || [])] filters.splice(filterIndex, 1) step.property_filters = filters.length > 0 ? filters : undefined newSteps[stepIndex] = step setSteps(newSteps) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!name.trim()) { const { toast } = await import('@ciphera-net/ui') toast.error('Please enter a funnel name') return } if (steps.some(s => !s.name.trim())) { const { toast } = await import('@ciphera-net/ui') toast.error('Please enter a name for all steps') return } // Validate based on category for (const step of steps) { const category = step.category || 'page' if (!step.value.trim()) { const { toast } = await import('@ciphera-net/ui') toast.error(category === 'event' ? `Please enter an event name for step: ${step.name}` : `Please enter a path for step: ${step.name}`) return } if (category === 'page' && step.type === 'regex' && !isValidRegex(step.value)) { const { toast } = await import('@ciphera-net/ui') toast.error(`Invalid regex pattern in step: ${step.name}`) return } if (category === 'event' && step.property_filters) { for (const filter of step.property_filters) { if (!filter.key.trim()) { const { toast } = await import('@ciphera-net/ui') toast.error(`Property filter key is required in step: ${step.name}`) return } } } } const funnelSteps = steps.map((s, i) => ({ ...s, order: i, })) await onSubmit({ name, description, steps: funnelSteps, conversion_window_value: windowValue, conversion_window_unit: windowUnit, }) } const selectClass = 'px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none' return (
Back to Funnels

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

Define the steps users take to complete a goal.

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

Funnel Steps

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

Maximum 8 steps

)}
{/* Conversion Window */}

Conversion Window

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

{/* Quick presets */}
{WINDOW_PRESETS.map(preset => ( ))}
{/* Custom input */}
setWindowValue(Math.max(1, parseInt(e.target.value) || 1))} className="w-20" />
) }