import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, ConfirmDialog, EmptyState, IconSpinner, Input, Label, } from '@plunk/ui'; import type {Contact, Segment} from '@plunk/db'; import type {PaginatedResponse} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import {ArrowLeft, Database, Filter, Layers, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, 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 {NextSeo} from 'next-seo'; import type {FilterCondition} from '@plunk/types'; import {SegmentSchemas} from '@plunk/shared'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; import {ContactPicker} from '../../components/ContactPicker'; import dayjs from 'dayjs'; type SegmentType = 'DYNAMIC' | 'STATIC'; type SegmentWithType = Segment & {type: SegmentType}; // Count total filters in a condition (recursive) function countFilters(condition: FilterCondition): number { let count = 0; for (const group of condition.groups) { count += group.filters.length; if (group.conditions) { count += countFilters(group.conditions); } } return count; } export default function SegmentDetailPage() { const router = useRouter(); const {id} = router.query; const {data: segment, mutate, isLoading} = useSWR(id ? `/segments/${id}` : null); const [contactsPage, setContactsPage] = useState(1); const { data: contactsData, isLoading: isLoadingContacts, mutate: mutateContacts, } = useSWR>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [trackMembership, setTrackMembership] = useState(false); const [condition, setCondition] = useState({ logic: 'AND', groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}], }); const [isSubmitting, setIsSubmitting] = useState(false); const [isComputing, setIsComputing] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); // Static segment member management const [pickedEmails, setPickedEmails] = useState([]); const [isAddingMembers, setIsAddingMembers] = useState(false); const [removingEmail, setRemovingEmail] = useState(null); useEffect(() => { if (segment) { setName(segment.name); setDescription(segment.description || ''); setTrackMembership(segment.trackMembership); setCondition( (segment.condition as unknown as FilterCondition) || { logic: 'AND', groups: [{filters: [{field: 'subscribed', operator: 'equals', value: true}]}], }, ); } }, [segment]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { await network.fetch('PATCH', `/segments/${id}`, { name, description: description || undefined, ...(segment?.type !== 'STATIC' && {condition}), 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 handleAddMembers = async (emails: string[], subscribed = true) => { setIsAddingMembers(true); try { const result = await network.fetch<{added: number; created: number; notFound: string[]}, typeof SegmentSchemas.members>( 'POST', `/segments/${id}/members`, {emails, createMissing: true, subscribed}, ); const msg = result.created > 0 ? `Added ${result.added} contact${result.added !== 1 ? 's' : ''} (${result.created} new)` : `Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`; toast.success(msg); setPickedEmails([]); void mutate(); void mutateContacts(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to add contacts'); } finally { setIsAddingMembers(false); } }; const handleRemoveMember = async (email: string) => { setRemovingEmail(email); try { await network.fetch<{removed: number}, typeof SegmentSchemas.members>('DELETE', `/segments/${id}/members`, { emails: [email], }); toast.success(`Removed ${email} from segment`); void mutate(); void mutateContacts(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to remove contact'); } finally { setRemovingEmail(null); } }; 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'); } }; if (isLoading) { return (
); } if (!segment) { return (

Segment not found

The segment you're looking for doesn't exist or has been deleted.

); } const isStatic = segment.type === 'STATIC'; return (
{/* Header */}

{segment.name}

{isStatic ? 'Static' : 'Dynamic'}
{segment.description &&

{segment.description}

}
{/* Edit Form */}
{/* Basic Info */} Segment Details Update segment name and description
setName(e.target.value)} required placeholder="e.g., Active Pro Users" maxLength={100} />
setDescription(e.target.value)} placeholder="e.g., Users on pro plan who have been active in the last 30 days" maxLength={500} />
setTrackMembership(e.target.checked)} className="mt-1 h-4 w-4 text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border-neutral-300 rounded" />

When enabled, segment entry and exit events will be tracked for use in workflows and analytics

{/* Filter Builder (DYNAMIC only) */} {!isStatic && ( Filter Conditions Build complex audience filters with AND/OR logic )} {/* Actions */}
{/* Static member management */} {isStatic && ( Add Members Search and select contacts, or paste a list of emails c.email) ?? []} placeholder="Search contacts to add..." /> {pickedEmails.length > 0 && ( )} )} {/* Contacts */}
{isStatic ? 'Members' : 'Matching Contacts'} {isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
{!isStatic && trackMembership && ( )}
{isLoadingContacts ? (

Loading contacts...

) : contactsData?.data.length === 0 ? ( ) : ( <>
{contactsData?.data.map(contact => (
{contact.subscribed ? ( ) : ( )} {contact.email}
{isStatic && ( )}
))}
{/* Pagination */} {contactsData && contactsData.totalPages > 1 && (

Page {contactsPage} of {contactsData.totalPages} ({contactsData.total} total)

)} )}
{/* Metadata Sidebar */}
Statistics
Members
{segment.memberCount}
{!isStatic && ( <>
Filters
{countFilters(segment.condition as unknown as FilterCondition)}
Groups
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
)}
Metadata

Segment ID

{segment.id}

Created

{dayjs(segment.createdAt).fromNow()}

{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}

Last Updated

{dayjs(segment.updatedAt).fromNow()}

{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
); }