Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+590
View File
@@ -0,0 +1,590 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Contact, Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {
ArrowLeft,
Calendar,
Database,
Filter,
MailCheck,
MailX,
Plus,
RefreshCw,
Save,
Trash2,
Users,
} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import type {SegmentFilter} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export default function SegmentDetailPage() {
const router = useRouter();
const {id} = router.query;
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
const [contactsPage, setContactsPage] = useState(1);
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedContacts>(
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize form when segment loads
useEffect(() => {
if (segment) {
setName(segment.name);
setDescription(segment.description || '');
setTrackMembership(segment.trackMembership);
setFilters((segment.filters as unknown as SegmentFilter[]) || []);
}
}, [segment]);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
name,
description: description || undefined,
filters,
trackMembership,
});
toast.success('Segment updated successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update segment');
} finally {
setIsSubmitting(false);
}
};
const handleComputeMembership = async () => {
if (!trackMembership) {
toast.error('Membership tracking must be enabled to compute membership');
return;
}
setIsComputing(true);
try {
const result = await network.fetch<{added: number; removed: number; total: number}>(
'POST',
`/segments/${id}/compute`,
);
toast.success(`Membership updated: ${result.added} added, ${result.removed} removed, ${result.total} total`);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to compute membership');
} finally {
setIsComputing(false);
}
};
const handleDelete = async () => {
try {
await network.fetch('DELETE', `/segments/${id}`);
toast.success('Segment deleted successfully');
void router.push('/segments');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segment...</p>
</div>
</div>
</DashboardLayout>
);
}
if (!segment) {
return (
<DashboardLayout>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-neutral-900 mb-2">Segment not found</h3>
<p className="text-neutral-500 mb-6">
The segment you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
<Link href="/segments">
<Button>
<ArrowLeft className="h-4 w-4" />
Back to Segments
</Button>
</Link>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
<p className="text-neutral-500 mt-1">{segment.description}</p>
</div>
</div>
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
<Trash2 className="h-4 w-4" />
Delete Segment
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit Form */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Update segment name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end">
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
{/* Contacts */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Matching Contacts</CardTitle>
<CardDescription>Contacts that match this segment&apos;s filters</CardDescription>
</div>
{trackMembership && (
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
{isComputing ? 'Computing...' : 'Recompute'}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoadingContacts ? (
<div className="text-center py-8">
<p className="text-sm text-neutral-500">Loading contacts...</p>
</div>
) : contactsData?.contacts.length === 0 ? (
<div className="text-center py-8">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500">No contacts match this segment</p>
</div>
) : (
<>
<div className="space-y-2">
{contactsData?.contacts.map(contact => (
<div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2">
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600" />
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<span className="text-sm font-medium">{contact.email}</span>
</div>
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</div>
))}
</div>
{/* Pagination */}
{contactsData && contactsData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-neutral-500">
Page {contactsPage} of {contactsData.totalPages} ({contactsData.total} total)
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setContactsPage(p => p - 1)}
disabled={contactsPage === 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setContactsPage(p => p + 1)}
disabled={contactsPage === contactsData.totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Metadata Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Created</p>
<p className="text-sm text-neutral-500">{new Date(segment.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<p className="text-sm text-neutral-500">{new Date(segment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Segment ID</p>
<p className="text-xs text-neutral-500 font-mono break-all">{segment.id}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Segment"
description="Are you sure you want to delete this segment? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
);
}
+199
View File
@@ -0,0 +1,199 @@
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {AlertTriangle, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
export default function SegmentsPage() {
// Limit to 50 segments to avoid loading thousands into the browser
const {
data: segments,
mutate,
isLoading,
} = useSWR<Segment[]>('/segments', {
revalidateOnFocus: false,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<string | null>(null);
// Show warning if there are many segments
const showLimitWarning = segments && segments.length >= 50;
const handleDelete = async () => {
if (!segmentToDelete) return;
try {
await network.fetch('DELETE', `/segments/${segmentToDelete}`);
toast.success('Segment deleted successfully');
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete segment');
} finally {
setSegmentToDelete(null);
}
};
return (
<>
<NextSeo title="Segments" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-neutral-900">Segments</h1>
<p className="text-neutral-500 mt-2">
Create dynamic audience groups based on contact attributes and behaviors
</p>
</div>
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
</div>
{/* Warning if too many segments */}
{showLimitWarning && (
<Alert variant="default">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Showing first {segments?.length} segments. Consider archiving old segments to improve performance.
</AlertDescription>
</Alert>
)}
{/* Segments Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segments...</p>
</div>
</div>
) : segments?.length === 0 ? (
<Card>
<CardContent className="py-12">
<div className="text-center">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No segments yet</h3>
<p className="text-neutral-500 mb-6">
Create your first segment to group contacts based on attributes and behaviors
</p>
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{segments?.map(segment => (
<Card key={segment.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{segment.name}</CardTitle>
{segment.description && <CardDescription className="mt-1">{segment.description}</CardDescription>}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{Array.isArray(segment.filters) ? segment.filters.length : 0}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
<Link href={`/segments/${segment.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Edit className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Metadata */}
<div className="text-xs text-neutral-500 pt-2 border-t border-neutral-200">
Created {new Date(segment.createdAt).toLocaleDateString()}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Segment"
description="Are you sure you want to delete this segment? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
);
}
+335
View File
@@ -0,0 +1,335 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Filter, Plus, Save, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import type {SegmentFilter} from '@plunk/types';
import type {Segment} from '@plunk/db';
import {SegmentSchemas} from '@plunk/shared';
const FILTER_OPERATORS = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
] as const;
const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'},
{value: 'days', label: 'Days'},
] as const;
const FIELD_PRESETS = [
{value: 'email', label: 'Email', type: 'string'},
{value: 'subscribed', label: 'Subscribed', type: 'boolean'},
{value: 'createdAt', label: 'Created At', type: 'date'},
{value: 'updatedAt', label: 'Updated At', type: 'date'},
{value: 'data.firstName', label: 'First Name (custom)', type: 'string'},
{value: 'data.lastName', label: 'Last Name (custom)', type: 'string'},
{value: 'data.plan', label: 'Plan (custom)', type: 'string'},
] as const;
export default function NewSegmentPage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [trackMembership, setTrackMembership] = useState(false);
const [filters, setFilters] = useState<SegmentFilter[]>([{field: 'subscribed', operator: 'equals', value: true}]);
const [isSubmitting, setIsSubmitting] = useState(false);
const addFilter = () => {
setFilters([...filters, {field: 'email', operator: 'contains', value: ''}]);
};
const removeFilter = (index: number) => {
setFilters(filters.filter((_, i) => i !== index));
};
const updateFilter = (index: number, updates: Partial<SegmentFilter>) => {
setFilters(filters.map((filter, i) => (i === index ? {...filter, ...updates} : filter)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
name,
description: description || undefined,
filters,
trackMembership,
});
toast.success('Segment created successfully');
void router.push('/segments');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
} finally {
setIsSubmitting(false);
}
};
const needsValue = (operator: string) => {
return !['exists', 'notExists'].includes(operator);
};
const needsUnit = (operator: string) => {
return operator === 'within';
};
return (
<>
<NextSeo title="Create Segment" />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">Define filters to automatically group contacts</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Give your segment a name and description</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Segment Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g., Active Pro Users"
maxLength={100}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="e.g., Users on pro plan who have been active in the last 30 days"
maxLength={500}
/>
</div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="trackMembership"
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
Track membership changes
</Label>
<p className="text-xs text-neutral-500 mt-1">
When enabled, segment entry and exit events will be tracked for use in workflows and analytics
</p>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Filters</CardTitle>
<CardDescription>Define conditions to match contacts (all filters must match)</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add Filter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{filters.length === 0 ? (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500 mb-4">No filters defined. Add at least one filter.</p>
<Button type="button" variant="outline" onClick={addFilter}>
<Plus className="h-4 w-4" />
Add First Filter
</Button>
</div>
) : (
filters.map((filter, index) => (
<div key={index} className="flex items-start gap-2 p-4 border border-neutral-200 rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
{/* Field */}
<div>
<Label className="text-xs">Field</Label>
<Select value={filter.field} onValueChange={value => updateFilter(index, {field: value})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_PRESETS.map(preset => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
<SelectItem value="custom">Custom field...</SelectItem>
</SelectContent>
</Select>
{filter.field === 'custom' && (
<Input
type="text"
placeholder="e.g., data.customField"
className="mt-2"
onChange={e => updateFilter(index, {field: e.target.value})}
/>
)}
</div>
{/* Operator */}
<div>
<Label className="text-xs">Operator</Label>
<Select
value={filter.operator}
onValueChange={value => updateFilter(index, {operator: value as SegmentFilter['operator']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATORS.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
{needsValue(filter.operator) && (
<div>
<Label className="text-xs">Value</Label>
{filter.field === 'subscribed' ? (
<Select
value={filter.value?.toString()}
onValueChange={value => updateFilter(index, {value: value === 'true'})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type="text"
value={filter.value ?? ''}
onChange={e => updateFilter(index, {value: e.target.value})}
placeholder="Enter value"
/>
)}
</div>
)}
{/* Unit (for within operator) */}
{needsUnit(filter.operator) && (
<div>
<Label className="text-xs">Unit</Label>
<Select
value={filter.unit ?? 'days'}
onValueChange={value => updateFilter(index, {unit: value as SegmentFilter['unit']})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map(unit => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Remove button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 mt-6"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting || filters.length === 0}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
</Button>
</div>
</form>
</div>
</DashboardLayout>
</>
);
}