feat: enhance contact addition with bulk email lookup and subscription options
This commit is contained in:
@@ -273,6 +273,30 @@ export class Contacts {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /contacts/lookup
|
||||||
|
* Bulk-check which emails already exist in the project (max 500)
|
||||||
|
*/
|
||||||
|
@Post('lookup')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async lookup(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const {emails} = req.body as {emails: string[]};
|
||||||
|
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) {
|
||||||
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length > 500) {
|
||||||
|
return res.status(400).json({error: 'Maximum 500 emails per lookup'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ContactService.lookup(auth.projectId!, emails);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /contacts/import
|
* POST /contacts/import
|
||||||
* Import contacts from CSV file
|
* Import contacts from CSV file
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export class Segments {
|
|||||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
const {emails} = req.body;
|
const {emails, createMissing, subscribed} = req.body as {emails: string[]; createMissing?: boolean; subscribed?: boolean};
|
||||||
|
|
||||||
if (!segmentId) {
|
if (!segmentId) {
|
||||||
return res.status(400).json({error: 'Segment ID is required'});
|
return res.status(400).json({error: 'Segment ID is required'});
|
||||||
@@ -165,7 +165,7 @@ export class Segments {
|
|||||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails, createMissing ?? false, subscribed ?? true);
|
||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ export class ContactService {
|
|||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-check which emails exist in the project — single query, safe for up to 500 addresses.
|
||||||
|
*/
|
||||||
|
public static async lookup(
|
||||||
|
projectId: string,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{found: string[]; notFound: string[]}> {
|
||||||
|
const rows = await prisma.contact.findMany({
|
||||||
|
where: {projectId, email: {in: emails, mode: 'insensitive'}},
|
||||||
|
select: {email: true},
|
||||||
|
});
|
||||||
|
const foundSet = new Set(rows.map(r => r.email.toLowerCase()));
|
||||||
|
const found = emails.filter(e => foundSet.has(e.toLowerCase()));
|
||||||
|
const notFound = emails.filter(e => !foundSet.has(e.toLowerCase()));
|
||||||
|
return {found, notFound};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a contact by email (returns null if not found)
|
* Find a contact by email (returns null if not found)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -328,7 +328,9 @@ export class SegmentService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
segmentId: string,
|
segmentId: string,
|
||||||
emails: string[],
|
emails: string[],
|
||||||
): Promise<{added: number; notFound: string[]}> {
|
createMissing = false,
|
||||||
|
subscribed = true,
|
||||||
|
): Promise<{added: number; created: number; notFound: string[]}> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
if (segment.type !== 'STATIC') {
|
if (segment.type !== 'STATIC') {
|
||||||
@@ -336,7 +338,7 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Look up contacts by email (case-insensitive)
|
// Look up contacts by email (case-insensitive)
|
||||||
const contacts = await prisma.contact.findMany({
|
let contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {
|
||||||
projectId,
|
projectId,
|
||||||
email: {in: emails, mode: 'insensitive'},
|
email: {in: emails, mode: 'insensitive'},
|
||||||
@@ -344,7 +346,24 @@ export class SegmentService {
|
|||||||
select: {id: true, email: true},
|
select: {id: true, email: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
let foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||||
|
const missing = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
if (createMissing && missing.length > 0) {
|
||||||
|
await prisma.contact.createMany({
|
||||||
|
data: missing.map(email => ({projectId, email, subscribed})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
created = missing.length;
|
||||||
|
// Re-fetch to include newly created contacts
|
||||||
|
contacts = await prisma.contact.findMany({
|
||||||
|
where: {projectId, email: {in: emails, mode: 'insensitive'}},
|
||||||
|
select: {id: true, email: true},
|
||||||
|
});
|
||||||
|
foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||||
|
|
||||||
if (contacts.length > 0) {
|
if (contacts.length > 0) {
|
||||||
@@ -377,7 +396,7 @@ export class SegmentService {
|
|||||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {added: contacts.length, notFound};
|
return {added: contacts.length, created, notFound};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,35 +1,61 @@
|
|||||||
import type {Contact} from '@plunk/db';
|
import type {Contact} from '@plunk/db';
|
||||||
import type {CursorPaginatedResponse} from '@plunk/types';
|
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||||
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
import {Button, Input, Label, Popover, PopoverContent, PopoverTrigger, Switch} from '@plunk/ui';
|
||||||
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
import {Check, ChevronsUpDown, ClipboardList, Loader2, MailCheck, MailX, Search, Sparkles, X} from 'lucide-react';
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import {ContactSchemas} from '@plunk/shared';
|
||||||
|
import {network} from '../lib/network';
|
||||||
|
|
||||||
|
type Mode = 'search' | 'paste';
|
||||||
|
|
||||||
interface ContactPickerProps {
|
interface ContactPickerProps {
|
||||||
/** Currently selected emails */
|
/** Currently selected emails (search mode) */
|
||||||
selected: string[];
|
selected: string[];
|
||||||
/** Called when selection changes */
|
/** Called when search-mode selection changes */
|
||||||
onChange: (emails: string[]) => void;
|
onChange: (emails: string[]) => void;
|
||||||
/** Emails already in the segment (shown as disabled) */
|
/**
|
||||||
|
* Called by paste mode to submit directly — skips the chip staging area.
|
||||||
|
* Receives the parsed email list and whether new contacts should be subscribed.
|
||||||
|
*/
|
||||||
|
onAdd: (emails: string[], subscribed: boolean) => Promise<void>;
|
||||||
|
/** Emails already in the segment (shown as disabled in search mode) */
|
||||||
existing?: string[];
|
existing?: string[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const CHIP_LIMIT = 8;
|
||||||
* Searchable multi-select contact picker backed by the /contacts API.
|
|
||||||
* Only fetches when the user types (safe for large contact lists).
|
function parseEmails(raw: string): string[] {
|
||||||
*/
|
return raw
|
||||||
|
.split(/[\n,;]+/)
|
||||||
|
.map(s => s.trim().toLowerCase())
|
||||||
|
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactPicker({
|
export function ContactPicker({
|
||||||
selected,
|
selected,
|
||||||
onChange,
|
onChange,
|
||||||
|
onAdd,
|
||||||
existing = [],
|
existing = [],
|
||||||
placeholder = 'Search contacts...',
|
placeholder = 'Search contacts...',
|
||||||
}: ContactPickerProps) {
|
}: ContactPickerProps) {
|
||||||
|
const [mode, setMode] = useState<Mode>('search');
|
||||||
|
|
||||||
|
// Search mode
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Paste mode
|
||||||
|
const [pasteText, setPasteText] = useState('');
|
||||||
|
const [isLooking, setIsLooking] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{found: string[]; notFound: string[]} | null>(null);
|
||||||
|
const [subscribeNew, setSubscribeNew] = useState(true);
|
||||||
|
const [debouncedPaste, setDebouncedPaste] = useState('');
|
||||||
|
const pasteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||||
@@ -38,7 +64,26 @@ export function ContactPicker({
|
|||||||
};
|
};
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Only fetch when there's a search term — avoids loading all contacts on open
|
useEffect(() => {
|
||||||
|
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
|
||||||
|
pasteDebounceRef.current = setTimeout(() => setDebouncedPaste(pasteText), 400);
|
||||||
|
return () => {
|
||||||
|
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [pasteText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const emails = parseEmails(debouncedPaste).filter(e => !existing.includes(e));
|
||||||
|
if (emails.length === 0) {
|
||||||
|
setPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLooking(true);
|
||||||
|
network.fetch<{found: string[]; notFound: string[]}, typeof ContactSchemas.lookup>(
|
||||||
|
'POST', '/contacts/lookup', {emails},
|
||||||
|
).then(setPreview).finally(() => setIsLooking(false));
|
||||||
|
}, [debouncedPaste]);
|
||||||
|
|
||||||
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||||
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||||
{revalidateOnFocus: false},
|
{revalidateOnFocus: false},
|
||||||
@@ -54,8 +99,47 @@ export function ContactPicker({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsedEmails = parseEmails(pasteText);
|
||||||
|
const newPastedEmails = parsedEmails.filter(e => !existing.includes(e));
|
||||||
|
|
||||||
|
const handlePasteSubmit = async () => {
|
||||||
|
if (newPastedEmails.length === 0) return;
|
||||||
|
await onAdd(newPastedEmails, subscribeNew);
|
||||||
|
setPasteText('');
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleChips = selected.slice(0, CHIP_LIMIT);
|
||||||
|
const overflowCount = selected.length - CHIP_LIMIT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex gap-1 p-0.5 bg-neutral-100 rounded-md w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('search')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
|
mode === 'search' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('paste')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||||
|
mode === 'paste' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" />
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'search' ? (
|
||||||
|
<>
|
||||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -73,7 +157,6 @@ export function ContactPicker({
|
|||||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
{/* Search input */}
|
|
||||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
<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" />
|
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||||
<Input
|
<Input
|
||||||
@@ -84,8 +167,6 @@ export function ContactPicker({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||||
{debouncedSearch.length === 0 ? (
|
{debouncedSearch.length === 0 ? (
|
||||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||||
@@ -97,7 +178,6 @@ export function ContactPicker({
|
|||||||
contacts.map(contact => {
|
contacts.map(contact => {
|
||||||
const isSelected = selected.includes(contact.email);
|
const isSelected = selected.includes(contact.email);
|
||||||
const isExisting = existing.includes(contact.email);
|
const isExisting = existing.includes(contact.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
@@ -112,12 +192,8 @@ export function ContactPicker({
|
|||||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||||
{isExisting && (
|
{isExisting && <span className="text-xs text-neutral-400 shrink-0">already member</span>}
|
||||||
<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" />}
|
||||||
)}
|
|
||||||
{isSelected && !isExisting && (
|
|
||||||
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -126,10 +202,11 @@ export function ContactPicker({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Selected chips */}
|
{/* Chips */}
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selected.map(email => (
|
{visibleChips.map(email => (
|
||||||
<span
|
<span
|
||||||
key={email}
|
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"
|
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"
|
||||||
@@ -145,6 +222,73 @@ export function ContactPicker({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{overflowCount > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-neutral-100 border border-neutral-200 px-3 py-1 text-sm text-neutral-500">
|
||||||
|
+{overflowCount} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Paste mode — self-contained, submits directly via onAdd */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={pasteText}
|
||||||
|
onChange={e => setPasteText(e.target.value)}
|
||||||
|
placeholder={'Paste emails here, one per line or comma-separated...\n\[email protected]\[email protected], [email protected]'}
|
||||||
|
rows={6}
|
||||||
|
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview panel — toggle lives here, inline with the "new contacts" row */}
|
||||||
|
{pasteText.trim() === '' ? (
|
||||||
|
<p className="text-xs text-neutral-400">Accepts newline, comma, or semicolon-separated addresses</p>
|
||||||
|
) : isLooking ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-neutral-400"><Loader2 className="h-3 w-3 animate-spin" /> Checking...</p>
|
||||||
|
) : preview ? (
|
||||||
|
<div className="rounded-md border border-neutral-200 divide-y divide-neutral-100 text-sm overflow-hidden">
|
||||||
|
{preview.found.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 text-neutral-700">
|
||||||
|
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||||
|
<span><span className="font-medium">{preview.found.length}</span> existing contact{preview.found.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview.notFound.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-700">
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
|
<span><span className="font-medium">{preview.notFound.length}</span> new — will be created</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Label htmlFor="subscribe-new" className="text-xs text-neutral-500 cursor-pointer">Subscribe</Label>
|
||||||
|
<Switch id="subscribe-new" checked={subscribeNew} onCheckedChange={setSubscribeNew} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
{parsedEmails.length > 0 ? 'All detected emails are already in this segment' : 'No valid emails detected'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePasteSubmit()}
|
||||||
|
disabled={newPastedEmails.length === 0 || isLooking}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Add {newPastedEmails.length > 0 ? `${newPastedEmails.length} ` : ''}contact{newPastedEmails.length !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,21 +127,19 @@ export default function SegmentDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMembers = async () => {
|
const handleAddMembers = async (emails: string[], subscribed = true) => {
|
||||||
if (pickedEmails.length === 0) {
|
|
||||||
toast.error('Select at least one contact');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAddingMembers(true);
|
setIsAddingMembers(true);
|
||||||
try {
|
try {
|
||||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
const result = await network.fetch<{added: number; created: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||||
'POST',
|
'POST',
|
||||||
`/segments/${id}/members`,
|
`/segments/${id}/members`,
|
||||||
{emails: pickedEmails},
|
{emails, createMissing: true, subscribed},
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
|
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([]);
|
setPickedEmails([]);
|
||||||
void mutate();
|
void mutate();
|
||||||
void mutateContacts();
|
void mutateContacts();
|
||||||
@@ -318,26 +316,26 @@ export default function SegmentDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add Members</CardTitle>
|
<CardTitle>Add Members</CardTitle>
|
||||||
<CardDescription>Search and select contacts to add to this segment</CardDescription>
|
<CardDescription>Search and select contacts, or paste a list of emails</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<ContactPicker
|
<ContactPicker
|
||||||
selected={pickedEmails}
|
selected={pickedEmails}
|
||||||
onChange={setPickedEmails}
|
onChange={setPickedEmails}
|
||||||
|
onAdd={handleAddMembers}
|
||||||
existing={contactsData?.data.map(c => c.email) ?? []}
|
existing={contactsData?.data.map(c => c.email) ?? []}
|
||||||
placeholder="Search contacts to add..."
|
placeholder="Search contacts to add..."
|
||||||
/>
|
/>
|
||||||
|
{pickedEmails.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddMembers}
|
onClick={() => void handleAddMembers(pickedEmails)}
|
||||||
disabled={isAddingMembers || pickedEmails.length === 0}
|
disabled={isAddingMembers}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isAddingMembers
|
{isAddingMembers ? 'Adding...' : `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
|
||||||
? 'Adding...'
|
|
||||||
: pickedEmails.length > 0
|
|
||||||
? `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`
|
|
||||||
: 'Add Contacts'}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,14 +48,15 @@ export default function NewSegmentPage() {
|
|||||||
// For static segments with pre-selected contacts, add them now
|
// For static segments with pre-selected contacts, add them now
|
||||||
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
||||||
try {
|
try {
|
||||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
const result = await network.fetch<{added: number; created: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||||
'POST',
|
'POST',
|
||||||
`/segments/${segment.id}/members`,
|
`/segments/${segment.id}/members`,
|
||||||
{emails: selectedContacts},
|
{emails: selectedContacts, createMissing: true},
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
|
|
||||||
);
|
);
|
||||||
|
const msg = result.created > 0
|
||||||
|
? `Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''} (${result.created} new)`
|
||||||
|
: `Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`;
|
||||||
|
toast.success(msg);
|
||||||
} catch {
|
} catch {
|
||||||
// Segment was created; just warn about members
|
// Segment was created; just warn about members
|
||||||
toast.warning('Segment created, but some contacts could not be added');
|
toast.warning('Segment created, but some contacts could not be added');
|
||||||
@@ -194,6 +195,7 @@ export default function NewSegmentPage() {
|
|||||||
<ContactPicker
|
<ContactPicker
|
||||||
selected={selectedContacts}
|
selected={selectedContacts}
|
||||||
onChange={setSelectedContacts}
|
onChange={setSelectedContacts}
|
||||||
|
onAdd={async (emails, _subscribed) => setSelectedContacts(prev => [...new Set([...prev, ...emails])])}
|
||||||
placeholder="Search and select contacts..."
|
placeholder="Search and select contacts..."
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export const ContactSchemas = {
|
|||||||
bulkAction: z.object({
|
bulkAction: z.object({
|
||||||
contactIds: z.array(uuid).min(1).max(1000),
|
contactIds: z.array(uuid).min(1).max(1000),
|
||||||
}),
|
}),
|
||||||
|
lookup: z.object({
|
||||||
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const segmentFilterSchema = z.object({
|
const segmentFilterSchema = z.object({
|
||||||
@@ -144,6 +147,8 @@ export const SegmentSchemas = {
|
|||||||
}),
|
}),
|
||||||
members: z.object({
|
members: z.object({
|
||||||
emails: z.array(z.string().email()).min(1).max(500),
|
emails: z.array(z.string().email()).min(1).max(500),
|
||||||
|
createMissing: z.boolean().optional(),
|
||||||
|
subscribed: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user