diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx index 9891f21..7607733 100644 --- a/app/sites/[id]/funnels/new/page.tsx +++ b/app/sites/[id]/funnels/new/page.tsx @@ -3,90 +3,25 @@ import { useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { useSWRConfig } from 'swr' -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 - } -} +import { createFunnel, type CreateFunnelRequest } from '@/lib/api/funnels' +import { toast } from '@ciphera-net/ui' +import FunnelForm from '@/components/funnels/FunnelForm' export default function CreateFunnelPage() { const params = useParams() const router = useRouter() const { mutate } = useSWRConfig() 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[]>([ - { 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, value: string) => { - const newSteps = [...steps] - newSteps[index] = { ...newSteps[index], [field]: value } - setSteps(newSteps) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - 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 - } - - if (steps.some(s => !s.value.trim())) { - 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 - } - + const handleSubmit = async (data: CreateFunnelRequest) => { try { setSaving(true) - const funnelSteps = steps.map((s, i) => ({ - ...s, - order: i - })) - - await createFunnel(siteId, { - name, - description, - steps: funnelSteps - }) - + await createFunnel(siteId, data) await mutate(['funnels', siteId]) toast.success('Funnel created') router.push(`/sites/${siteId}/funnels`) - } catch (error) { + } catch { toast.error('Failed to create funnel. Please try again.') } finally { setSaving(false) @@ -94,149 +29,11 @@ export default function CreateFunnelPage() { } return ( -
-
- - - Back to Funnels - - -

- Create New Funnel -

-

- Define the steps users take to complete a goal. -

-
- -
-
-
-
- - 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" - /> -
-
-
- -
-
-

- Funnel Steps -

-
- - {steps.map((step, index) => ( -
-
-
-
- {index + 1} -
-
- -
-
- - handleUpdateStep(index, 'name', e.target.value)} - placeholder="e.g. Landing Page" - /> -
-
- -
- - handleUpdateStep(index, 'value', e.target.value)} - placeholder={step.type === 'exact' ? '/pricing' : 'pricing'} - className="flex-1" - /> -
-
-
- - -
-
- ))} - - -
- -
- - - - -
-
-
+ ) } diff --git a/components/funnels/FunnelForm.tsx b/components/funnels/FunnelForm.tsx new file mode 100644 index 0000000..0c3b0a1 --- /dev/null +++ b/components/funnels/FunnelForm.tsx @@ -0,0 +1,519 @@ +'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" + /> + +
+
+ +
+ + + + +
+
+
+ ) +}