feat: Static segments
This commit is contained in:
@@ -72,20 +72,23 @@ export class Segments {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {name, description, condition, trackMembership} = req.body;
|
const {name, description, type, condition, trackMembership} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({error: 'Name is required'});
|
return res.status(400).json({error: 'Name is required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!condition || typeof condition !== 'object') {
|
const segmentType = type ?? 'DYNAMIC';
|
||||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
|
||||||
|
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||||
|
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const segment = await SegmentService.create(auth.projectId!, {
|
const segment = await SegmentService.create(auth.projectId!, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +145,56 @@ export class Segments {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /segments/:id/members
|
||||||
|
* Add contacts to a static segment by email
|
||||||
|
*/
|
||||||
|
@Post(':id/members')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const segmentId = req.params.id;
|
||||||
|
const {emails} = req.body;
|
||||||
|
|
||||||
|
if (!segmentId) {
|
||||||
|
return res.status(400).json({error: 'Segment ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) {
|
||||||
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /segments/:id/members
|
||||||
|
* Remove contacts from a static segment by email
|
||||||
|
*/
|
||||||
|
@Delete(':id/members')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const segmentId = req.params.id;
|
||||||
|
const {emails} = req.body;
|
||||||
|
|
||||||
|
if (!segmentId) {
|
||||||
|
return res.status(400).json({error: 'Segment ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) {
|
||||||
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /segments/:id/compute
|
* POST /segments/:id/compute
|
||||||
* Recompute segment membership for all contacts
|
* Recompute segment membership for all contacts
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
|
||||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
@@ -66,11 +66,33 @@ export class SegmentService {
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
): Promise<PaginatedResponse<Contact>> {
|
): Promise<PaginatedResponse<Contact>> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
|
||||||
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
// For static segments, query via SegmentMembership records
|
||||||
|
const [memberships, total] = await Promise.all([
|
||||||
|
prisma.segmentMembership.findMany({
|
||||||
|
where: {segmentId, exitedAt: null},
|
||||||
|
include: {contact: true},
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: {enteredAt: 'desc'},
|
||||||
|
}),
|
||||||
|
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: memberships.map(m => m.contact),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
const [contacts, total] = await Promise.all([
|
const [contacts, total] = await Promise.all([
|
||||||
prisma.contact.findMany({
|
prisma.contact.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -98,23 +120,35 @@ export class SegmentService {
|
|||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
condition: FilterCondition;
|
type?: SegmentType;
|
||||||
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): Promise<Segment> {
|
||||||
// Validate condition
|
const segmentType = data.type ?? 'DYNAMIC';
|
||||||
this.validateCondition(data.condition);
|
let memberCount = 0;
|
||||||
|
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
|
||||||
|
|
||||||
// Compute initial member count
|
if (segmentType === 'DYNAMIC') {
|
||||||
const where = this.buildWhereClause(projectId, data.condition);
|
if (!data.condition) {
|
||||||
const memberCount = await prisma.contact.count({where});
|
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
|
||||||
|
}
|
||||||
|
// Validate condition
|
||||||
|
this.validateCondition(data.condition);
|
||||||
|
|
||||||
|
// Compute initial member count
|
||||||
|
const where = this.buildWhereClause(projectId, data.condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
conditionJson = toPrismaJson(data.condition);
|
||||||
|
}
|
||||||
|
|
||||||
const segment = await prisma.segment.create({
|
const segment = await prisma.segment.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
condition: toPrismaJson(data.condition),
|
type: segmentType,
|
||||||
|
condition: conditionJson,
|
||||||
trackMembership: data.trackMembership ?? false,
|
trackMembership: data.trackMembership ?? false,
|
||||||
memberCount,
|
memberCount,
|
||||||
},
|
},
|
||||||
@@ -145,12 +179,7 @@ export class SegmentService {
|
|||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): Promise<Segment> {
|
||||||
// First verify segment exists and belongs to project
|
// First verify segment exists and belongs to project
|
||||||
await this.get(projectId, segmentId);
|
const existing = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
// Validate condition if provided
|
|
||||||
if (data.condition) {
|
|
||||||
this.validateCondition(data.condition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Prisma.SegmentUpdateInput = {};
|
const updateData: Prisma.SegmentUpdateInput = {};
|
||||||
|
|
||||||
@@ -160,7 +189,9 @@ export class SegmentService {
|
|||||||
if (data.description !== undefined) {
|
if (data.description !== undefined) {
|
||||||
updateData.description = data.description;
|
updateData.description = data.description;
|
||||||
}
|
}
|
||||||
if (data.condition !== undefined) {
|
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||||
|
// Validate condition if provided (only for DYNAMIC segments)
|
||||||
|
this.validateCondition(data.condition);
|
||||||
updateData.condition = toPrismaJson(data.condition);
|
updateData.condition = toPrismaJson(data.condition);
|
||||||
|
|
||||||
// Recompute member count when condition changes
|
// Recompute member count when condition changes
|
||||||
@@ -229,10 +260,16 @@ export class SegmentService {
|
|||||||
*/
|
*/
|
||||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
|
|
||||||
const memberCount = await prisma.contact.count({where});
|
let memberCount: number;
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
} else {
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segmentId},
|
where: {id: segmentId},
|
||||||
@@ -249,7 +286,7 @@ export class SegmentService {
|
|||||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
select: {id: true, condition: true},
|
select: {id: true, type: true, condition: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the database
|
// Process in batches to avoid overwhelming the database
|
||||||
@@ -260,9 +297,17 @@ export class SegmentService {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async segment => {
|
batch.map(async segment => {
|
||||||
try {
|
try {
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
let memberCount: number;
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
const memberCount = await prisma.contact.count({where});
|
if (segment.type === 'STATIC') {
|
||||||
|
memberCount = await prisma.segmentMembership.count({
|
||||||
|
where: {segmentId: segment.id, exitedAt: null},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segment.id},
|
where: {id: segment.id},
|
||||||
@@ -276,6 +321,104 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add contacts to a static segment by email
|
||||||
|
*/
|
||||||
|
public static async addContacts(
|
||||||
|
projectId: string,
|
||||||
|
segmentId: string,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{added: number; notFound: string[]}> {
|
||||||
|
const segment = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
|
if (segment.type !== 'STATIC') {
|
||||||
|
throw new HttpException(400, 'Can only add contacts to STATIC segments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up contacts by email (case-insensitive)
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
email: {in: emails, mode: 'insensitive'},
|
||||||
|
},
|
||||||
|
select: {id: true, email: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||||
|
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||||
|
|
||||||
|
if (contacts.length > 0) {
|
||||||
|
// Check for existing memberships (to reactivate vs create new)
|
||||||
|
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||||
|
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
|
||||||
|
select: {contactId: true},
|
||||||
|
});
|
||||||
|
const existingIds = new Set(existingMemberships.map(m => m.contactId));
|
||||||
|
|
||||||
|
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
|
||||||
|
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
|
||||||
|
|
||||||
|
if (newContactIds.length > 0) {
|
||||||
|
await prisma.segmentMembership.createMany({
|
||||||
|
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reEntryIds.length > 0) {
|
||||||
|
await prisma.segmentMembership.updateMany({
|
||||||
|
where: {segmentId, contactId: {in: reEntryIds}},
|
||||||
|
data: {exitedAt: null, enteredAt: new Date()},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {added: contacts.length, notFound};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove contacts from a static segment by email
|
||||||
|
*/
|
||||||
|
public static async removeContacts(
|
||||||
|
projectId: string,
|
||||||
|
segmentId: string,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{removed: number}> {
|
||||||
|
const segment = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
|
if (segment.type !== 'STATIC') {
|
||||||
|
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up contacts by email
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
email: {in: emails, mode: 'insensitive'},
|
||||||
|
},
|
||||||
|
select: {id: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contacts.length > 0) {
|
||||||
|
const contactIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
await prisma.segmentMembership.updateMany({
|
||||||
|
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
|
||||||
|
data: {exitedAt: new Date()},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {removed: contacts.length};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute or recompute segment membership for all contacts
|
* Compute or recompute segment membership for all contacts
|
||||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
||||||
@@ -290,6 +433,13 @@ export class SegmentService {
|
|||||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
// For static segments, just update the count from memberships — no contact scanning
|
||||||
|
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
|
||||||
|
return {added: 0, removed: 0, total};
|
||||||
|
}
|
||||||
|
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
|
|||||||
@@ -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 type {PaginatedResponse} from '@plunk/types';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
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 Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
type SegmentWithType = Segment & {type: SegmentType};
|
||||||
|
|
||||||
// Count total filters in a condition (recursive)
|
// Count total filters in a condition (recursive)
|
||||||
function countFilters(condition: FilterCondition): number {
|
function countFilters(condition: FilterCondition): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {id} = router.query;
|
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 [contactsPage, setContactsPage] = useState(1);
|
||||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
const {
|
||||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
data: contactsData,
|
||||||
);
|
isLoading: isLoadingContacts,
|
||||||
|
mutate: mutateContacts,
|
||||||
|
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
@@ -57,7 +63,12 @@ export default function SegmentDetailPage() {
|
|||||||
const [isComputing, setIsComputing] = useState(false);
|
const [isComputing, setIsComputing] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = 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) {
|
if (segment) {
|
||||||
setName(segment.name);
|
setName(segment.name);
|
||||||
setDescription(segment.description || '');
|
setDescription(segment.description || '');
|
||||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
|||||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
...(segment?.type !== 'STATIC' && {condition}),
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
toast.success('Segment updated successfully');
|
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 () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await network.fetch('DELETE', `/segments/${id}`);
|
await network.fetch('DELETE', `/segments/${id}`);
|
||||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStatic = segment.type === 'STATIC';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-neutral-500 mt-1">{segment.description}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder (DYNAMIC only) */}
|
||||||
<Card>
|
{!isStatic && (
|
||||||
<CardContent className="pt-6">
|
<Card>
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<CardContent className="pt-6">
|
||||||
</CardContent>
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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 */}
|
{/* Contacts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Matching Contacts</CardTitle>
|
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
<CardDescription>
|
||||||
|
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{trackMembership && (
|
{!isStatic && trackMembership && (
|
||||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||||
{isComputing ? 'Computing...' : 'Recompute'}
|
{isComputing ? 'Computing...' : 'Recompute'}
|
||||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
|||||||
) : contactsData?.data.length === 0 ? (
|
) : contactsData?.data.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">{contact.email}</span>
|
<span className="text-sm font-medium">{contact.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/contacts/${contact.id}`}>
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm">
|
<Link href={`/contacts/${contact.id}`}>
|
||||||
View
|
<Button variant="ghost" size="sm">
|
||||||
</Button>
|
View
|
||||||
</Link>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,24 +464,28 @@ export default function SegmentDetailPage() {
|
|||||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{!isStatic && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Filter className="h-4 w-4 text-neutral-500" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-600">Filters</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Filter className="h-4 w-4 text-neutral-500" />
|
||||||
<span className="text-lg font-semibold text-neutral-900">
|
<span className="text-sm text-neutral-600">Filters</span>
|
||||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
</div>
|
||||||
</span>
|
<span className="text-lg font-semibold text-neutral-900">
|
||||||
</div>
|
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||||
<div className="flex items-center justify-between">
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<Filter className="h-4 w-4 text-neutral-500" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-600">Groups</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Filter className="h-4 w-4 text-neutral-500" />
|
||||||
<span className="text-lg font-semibold text-neutral-900">
|
<span className="text-sm text-neutral-600">Groups</span>
|
||||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
</div>
|
||||||
</span>
|
<span className="text-lg font-semibold text-neutral-900">
|
||||||
</div>
|
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<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 && (
|
{segment.description && (
|
||||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
|||||||
<span className="text-sm text-neutral-600">Filters</span>
|
<span className="text-sm text-neutral-600">Filters</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
{countFiltersInCondition(segment.condition)}
|
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||||
|
? '—'
|
||||||
|
: countFiltersInCondition(segment.condition)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
|||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Save} from 'lucide-react';
|
import {ArrowLeft, Save} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
|||||||
import type {Segment} from '@plunk/db';
|
import type {Segment} from '@plunk/db';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
|
||||||
export default function NewSegmentPage() {
|
export default function NewSegmentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||||
const [trackMembership, setTrackMembership] = useState(false);
|
const [trackMembership, setTrackMembership] = useState(false);
|
||||||
const [condition, setCondition] = useState<FilterCondition>({
|
const [condition, setCondition] = useState<FilterCondition>({
|
||||||
logic: 'AND',
|
logic: 'AND',
|
||||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
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');
|
void router.push('/segments');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
<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>
|
||||||
</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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder or Contact Picker */}
|
||||||
<Card>
|
{segmentType === 'DYNAMIC' ? (
|
||||||
<CardContent className="pt-6">
|
<Card>
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<CardContent className="pt-6">
|
||||||
</CardContent>
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</Card>
|
</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 */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SegmentType" AS ENUM ('DYNAMIC', 'STATIC');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "segments" ADD COLUMN "type" "SegmentType" NOT NULL DEFAULT 'DYNAMIC',
|
||||||
|
ALTER COLUMN "condition" DROP NOT NULL;
|
||||||
@@ -199,8 +199,11 @@ model Segment {
|
|||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
// Filter condition (evaluated dynamically)
|
// Segment type: DYNAMIC (filter-based) or STATIC (manually managed)
|
||||||
condition Json
|
type SegmentType @default(DYNAMIC)
|
||||||
|
|
||||||
|
// Filter condition (evaluated dynamically, null for STATIC segments)
|
||||||
|
condition Json?
|
||||||
// Nested filter structure with AND/OR logic:
|
// Nested filter structure with AND/OR logic:
|
||||||
// {
|
// {
|
||||||
// logic: "OR",
|
// logic: "OR",
|
||||||
@@ -747,6 +750,11 @@ enum StepExecutionStatus {
|
|||||||
FAILED // Failed with error
|
FAILED // Failed with error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SegmentType {
|
||||||
|
DYNAMIC // Filter-based segment evaluated against contacts at query time
|
||||||
|
STATIC // Manually curated list of contacts managed via memberships
|
||||||
|
}
|
||||||
|
|
||||||
enum EmailSourceType {
|
enum EmailSourceType {
|
||||||
TRANSACTIONAL // Sent via API call
|
TRANSACTIONAL // Sent via API call
|
||||||
CAMPAIGN // Sent as part of broadcast
|
CAMPAIGN // Sent as part of broadcast
|
||||||
|
|||||||
@@ -132,15 +132,20 @@ export const SegmentSchemas = {
|
|||||||
create: z.object({
|
create: z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
condition: filterConditionSchema,
|
type: z.enum(['DYNAMIC', 'STATIC']).default('DYNAMIC'),
|
||||||
|
condition: filterConditionSchema.optional(),
|
||||||
trackMembership: z.boolean().default(false),
|
trackMembership: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
update: z.object({
|
update: z.object({
|
||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
|
type: z.enum(['DYNAMIC', 'STATIC']).optional(),
|
||||||
condition: filterConditionSchema.optional(),
|
condition: filterConditionSchema.optional(),
|
||||||
trackMembership: z.boolean().optional(),
|
trackMembership: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
|
members: z.object({
|
||||||
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateSchemas = {
|
export const TemplateSchemas = {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Segment and filter types
|
* Segment and filter types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
|
||||||
// Segment filter types
|
// Segment filter types
|
||||||
export type SegmentFilterOperator =
|
export type SegmentFilterOperator =
|
||||||
// Standard operators (for contact fields)
|
// Standard operators (for contact fields)
|
||||||
@@ -45,13 +47,15 @@ export interface FilterCondition {
|
|||||||
export interface CreateSegmentData {
|
export interface CreateSegmentData {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
condition: FilterCondition;
|
type?: SegmentType;
|
||||||
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSegmentData {
|
export interface UpdateSegmentData {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
type?: SegmentType;
|
||||||
condition?: FilterCondition;
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user