feat: Static segments
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import type {Contact} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
||||
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ContactPickerProps {
|
||||
/** Currently selected emails */
|
||||
selected: string[];
|
||||
/** Called when selection changes */
|
||||
onChange: (emails: string[]) => void;
|
||||
/** Emails already in the segment (shown as disabled) */
|
||||
existing?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-select contact picker backed by the /contacts API.
|
||||
* Only fetches when the user types (safe for large contact lists).
|
||||
*/
|
||||
export function ContactPicker({
|
||||
selected,
|
||||
onChange,
|
||||
existing = [],
|
||||
placeholder = 'Search contacts...',
|
||||
}: ContactPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
// Only fetch when there's a search term — avoids loading all contacts on open
|
||||
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const contacts = data?.data ?? [];
|
||||
|
||||
const toggle = (email: string) => {
|
||||
if (selected.includes(email)) {
|
||||
onChange(selected.filter(e => e !== email));
|
||||
} else {
|
||||
onChange([...selected, email]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
align="start"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Type an email to search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||
{debouncedSearch.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||
) : contacts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||
) : (
|
||||
contacts.map(contact => {
|
||||
const isSelected = selected.includes(contact.email);
|
||||
const isExisting = existing.includes(contact.email);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
disabled={isExisting}
|
||||
onClick={() => toggle(contact.email)}
|
||||
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||
>
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||
{isExisting && (
|
||||
<span className="text-xs text-neutral-400 shrink-0">already member</span>
|
||||
)}
|
||||
{isSelected && !isExisting && (
|
||||
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ 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, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||
import {ArrowLeft, Database, Filter, 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';
|
||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
||||
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;
|
||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
|
||||
const {data: segment, mutate, isLoading} = useSWR<SegmentWithType>(id ? `/segments/${id}` : null);
|
||||
const [contactsPage, setContactsPage] = useState(1);
|
||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
||||
);
|
||||
const {
|
||||
data: contactsData,
|
||||
isLoading: isLoadingContacts,
|
||||
mutate: mutateContacts,
|
||||
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -57,7 +63,12 @@ export default function SegmentDetailPage() {
|
||||
const [isComputing, setIsComputing] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Static segment member management
|
||||
const [pickedEmails, setPickedEmails] = useState<string[]>([]);
|
||||
const [isAddingMembers, setIsAddingMembers] = useState(false);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (segment) {
|
||||
setName(segment.name);
|
||||
setDescription(segment.description || '');
|
||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
...(segment?.type !== 'STATIC' && {condition}),
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment updated successfully');
|
||||
@@ -112,6 +123,47 @@ export default function SegmentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMembers = async () => {
|
||||
if (pickedEmails.length === 0) {
|
||||
toast.error('Select at least one contact');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingMembers(true);
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${id}/members`,
|
||||
{emails: pickedEmails},
|
||||
);
|
||||
|
||||
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
|
||||
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}`);
|
||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isStatic = segment.type === 'STATIC';
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
||||
</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 className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isStatic ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && <p className="text-neutral-500 mt-1">{segment.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Filter Builder (DYNAMIC only) */}
|
||||
{!isStatic && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Static member management */}
|
||||
{isStatic && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Members</CardTitle>
|
||||
<CardDescription>Search and select contacts to add to this segment</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ContactPicker
|
||||
selected={pickedEmails}
|
||||
onChange={setPickedEmails}
|
||||
existing={contactsData?.data.map(c => c.email) ?? []}
|
||||
placeholder="Search contacts to add..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMembers}
|
||||
disabled={isAddingMembers || pickedEmails.length === 0}
|
||||
>
|
||||
{isAddingMembers
|
||||
? 'Adding...'
|
||||
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Matching Contacts</CardTitle>
|
||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
||||
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{trackMembership && (
|
||||
{!isStatic && trackMembership && (
|
||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||
{isComputing ? 'Computing...' : 'Recompute'}
|
||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
||||
) : contactsData?.data.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>
|
||||
<p className="text-neutral-500">
|
||||
{isStatic ? 'No members in this segment yet' : 'No contacts match this segment'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
||||
)}
|
||||
<span className="text-sm font-medium">{contact.email}</span>
|
||||
</div>
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
{isStatic && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMember(contact.email)}
|
||||
disabled={removingEmail === contact.email}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,24 +464,28 @@ export default function SegmentDetailPage() {
|
||||
<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">
|
||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||
</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">Groups</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{!isStatic && (
|
||||
<>
|
||||
<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">
|
||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||
</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">Groups</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && (
|
||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||
)}
|
||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{countFiltersInCondition(segment.condition)}
|
||||
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? '—'
|
||||
: countFiltersInCondition(segment.condition)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import {ContactPicker} from '../../components/ContactPicker';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Save} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
|
||||
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
|
||||
export default function NewSegmentPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||
const [trackMembership, setTrackMembership] = useState(false);
|
||||
const [condition, setCondition] = useState<FilterCondition>({
|
||||
logic: 'AND',
|
||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
||||
},
|
||||
],
|
||||
});
|
||||
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment created successfully');
|
||||
|
||||
// For static segments with pre-selected contacts, add them now
|
||||
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${segment.id}/members`,
|
||||
{emails: selectedContacts},
|
||||
);
|
||||
toast.success(
|
||||
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
|
||||
);
|
||||
} catch {
|
||||
// Segment was created; just warn about members
|
||||
toast.warning('Segment created, but some contacts could not be added');
|
||||
}
|
||||
} else {
|
||||
toast.success('Segment created successfully');
|
||||
}
|
||||
|
||||
void router.push('/segments');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
||||
<p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
|
||||
<p className="text-neutral-500 mt-1">
|
||||
{segmentType === 'DYNAMIC'
|
||||
? 'Build complex audience filters with AND/OR logic'
|
||||
: 'Manually curate a list of contacts'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Toggle */}
|
||||
<div className="flex gap-2 p-1 bg-neutral-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('DYNAMIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'DYNAMIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Dynamic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('STATIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'STATIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Static
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Filter Builder or Contact Picker */}
|
||||
{segmentType === 'DYNAMIC' ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Initial Members</CardTitle>
|
||||
<CardDescription>
|
||||
Optionally add contacts now — you can always add or remove members later
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContactPicker
|
||||
selected={selectedContacts}
|
||||
onChange={setSelectedContacts}
|
||||
placeholder="Search and select contacts..."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
Reference in New Issue
Block a user