diff --git a/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql b/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql new file mode 100644 index 00000000..5424b353 --- /dev/null +++ b/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index da4479f0..58e26f68 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -296,6 +296,7 @@ model ContactBook { id String @id @default(cuid()) name String teamId Int + variables String[] @default([]) properties Json doubleOptInEnabled Boolean @default(false) doubleOptInFrom String? diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx index e445731c..2cef3002 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@usesend/ui/src/spinner"; import { Button } from "@usesend/ui/src/button"; import { Input } from "@usesend/ui/src/input"; import { Editor } from "@usesend/email-editor"; -import { use, useState } from "react"; +import { use, useMemo, useState } from "react"; import { Campaign } from "@prisma/client"; import { Select, @@ -65,7 +65,7 @@ export default function EditCampaignPage({ { campaignId }, { enabled: !!campaignId, - } + }, ); if (isLoading) { @@ -102,7 +102,7 @@ function CampaignEditor({ const utils = api.useUtils(); const [json, setJson] = useState | undefined>( - campaign.content ? JSON.parse(campaign.content) : undefined + campaign.content ? JSON.parse(campaign.content) : undefined, ); const [isSaving, setIsSaving] = useState(false); const [name, setName] = useState(campaign.name); @@ -110,10 +110,10 @@ function CampaignEditor({ const [from, setFrom] = useState(campaign.from); const [contactBookId, setContactBookId] = useState(campaign.contactBookId); const [replyTo, setReplyTo] = useState( - campaign.replyTo[0] + campaign.replyTo[0], ); const [previewText, setPreviewText] = useState( - campaign.previewText + campaign.previewText, ); const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ @@ -136,13 +136,13 @@ function CampaignEditor({ const deboucedUpdateCampaign = useDebouncedCallback( updateEditorContent, - 1000 + 1000, ); const handleFileChange = async (file: File) => { if (file.size > IMAGE_SIZE_LIMIT) { throw new Error( - `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB` + `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`, ); } @@ -165,8 +165,17 @@ function CampaignEditor({ }; const contactBook = contactBooksQuery.data?.find( - (book) => book.id === contactBookId + (book) => book.id === contactBookId, ); + const editorVariables = useMemo(() => { + const baseVariables = ["email", "firstName", "lastName"]; + const registryVariables = contactBook?.variables ?? []; + + return Array.from(new Set([...baseVariables, ...registryVariables])); + }, [contactBook]); + const variableSuggestionsHelperText = contactBookId + ? undefined + : "Select the contact book for related variable"; return (
@@ -196,7 +205,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setName(campaign.name); }, - } + }, ); }} /> @@ -251,7 +260,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setSubject(campaign.subject); }, - } + }, ); }} className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" @@ -291,7 +300,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setFrom(campaign.from); }, - } + }, ); }} disabled={isApiCampaign} @@ -327,7 +336,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setReplyTo(campaign.replyTo[0]); }, - } + }, ); }} disabled={isApiCampaign} @@ -365,7 +374,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setPreviewText(campaign.previewText ?? ""); }, - } + }, ); }} className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border" @@ -397,7 +406,7 @@ function CampaignEditor({ onError: () => { setContactBookId(campaign.contactBookId); }, - } + }, ); setContactBookId(val); }} @@ -435,13 +444,15 @@ function CampaignEditor({
{ setJson(content.getJSON()); setIsSaving(true); deboucedUpdateCampaign(); }} - variables={["email", "firstName", "lastName"]} + variables={editorVariables} + variableSuggestionsHelperText={variableSuggestionsHelperText} uploadImage={ campaign.imageUploadSupported ? handleFileChange : undefined } diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx index 2e057af1..eebacf26 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx @@ -26,6 +26,7 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@usesend/ui/src/toaster"; +import type { ReactNode } from "react"; const contactsSchema = z.object({ contacts: z.string({ required_error: "Contacts are required" }).min(1, { @@ -35,8 +36,14 @@ const contactsSchema = z.object({ export default function AddContact({ contactBookId, + trigger, + open: controlledOpen, + onOpenChange, }: { contactBookId: string; + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { const [open, setOpen] = useState(false); @@ -50,6 +57,14 @@ export default function AddContact({ }); const utils = api.useUtils(); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); async function onContactsAdd(values: z.infer) { const contactsArray = values.contacts.split(",").map((email) => ({ @@ -64,7 +79,11 @@ export default function AddContact({ { onSuccess: async () => { utils.contacts.contacts.invalidate(); - setOpen(false); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } toast.success("Contacts queued for processing"); }, onError: async (error) => { @@ -76,15 +95,21 @@ export default function AddContact({ return ( (_open !== open ? setOpen(_open) : null)} + open={controlledOpen ?? open} + onOpenChange={(nextOpen) => { + if (controlledOpen === undefined) { + if (nextOpen !== open) { + setOpen(nextOpen); + } + return; + } + + onOpenChange?.(nextOpen); + }} > - - - + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Add new contacts diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx index e512763e..56c3071e 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, type ReactNode } from "react"; import { api } from "~/trpc/react"; +import { getCanonicalContactVariableName } from "~/lib/contact-properties"; import { Dialog, DialogContent, @@ -28,24 +29,41 @@ import { toast } from "@usesend/ui/src/toaster"; interface BulkUploadContactsProps { contactBookId: string; + contactBookVariables?: string[]; + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } interface ParsedContact { email: string; firstName?: string; lastName?: string; + properties?: Record; subscribed?: boolean; isValid: boolean; } export default function BulkUploadContacts({ contactBookId, + contactBookVariables, + trigger, + open: controlledOpen, + onOpenChange, }: BulkUploadContactsProps) { const [open, setOpen] = useState(false); const [inputText, setInputText] = useState(""); const [error, setError] = useState(null); const [processing, setProcessing] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); const utils = api.useUtils(); @@ -67,7 +85,11 @@ export default function BulkUploadContacts({ setInputText(""); setError(null); setProcessing(false); - setOpen(false); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } }; const validateEmail = (email: string): boolean => { @@ -77,11 +99,12 @@ export default function BulkUploadContacts({ const parseContactLine = ( line: string, - isFirstLine: boolean = false, + headers?: string[], ): { email: string; firstName?: string; lastName?: string; + properties?: Record; subscribed?: boolean; } | null => { const trimmedLine = line.trim(); @@ -107,27 +130,95 @@ export default function BulkUploadContacts({ if (parts.length === 0 || !parts[0]) return null; - // Check if this is a header row (case-insensitive) - if (isFirstLine) { - const firstPart = parts[0]?.toLowerCase(); - if ( - firstPart === "email" || - firstPart === "e-mail" || - firstPart === "email address" - ) { - return null; // Skip header row - } + const firstPart = parts[0]?.toLowerCase(); + if ( + firstPart === "email" || + firstPart === "e-mail" || + firstPart === "email address" + ) { + return null; } - const email = parts[0]!.toLowerCase(); + const getHeader = (index: number) => headers?.[index]?.trim().toLowerCase(); + + const email = ( + headers + ? parts[ + headers.findIndex((header) => { + const normalized = header.trim().toLowerCase(); + return ( + normalized === "email" || + normalized === "e-mail" || + normalized === "email address" + ); + }) + ] + : parts[0] + )?.toLowerCase(); // Skip if doesn't look like an email - if (!email.includes("@")) return null; + if (!email || !email.includes("@")) return null; // Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...) let subscribed: boolean | undefined = undefined; let firstName: string | undefined = undefined; let lastName: string | undefined = undefined; + const properties: Record = {}; + + if (headers) { + for (let i = 0; i < parts.length; i++) { + const header = getHeader(i); + if (!header) { + continue; + } + + if ( + header === "email" || + header === "e-mail" || + header === "email address" + ) { + continue; + } + + if (header === "firstname" || header === "first name") { + firstName = parts[i] || undefined; + continue; + } + + if (header === "lastname" || header === "last name") { + lastName = parts[i] || undefined; + continue; + } + + if (header === "subscribed") { + const subscribedValue = parts[i]?.toLowerCase(); + if (subscribedValue === "yes" || subscribedValue === "true") { + subscribed = true; + } else if (subscribedValue === "no" || subscribedValue === "false") { + subscribed = false; + } + continue; + } + + if (parts[i]) { + const propertyKey = + getCanonicalContactVariableName( + headers[i]!.trim(), + contactBookVariables ?? [], + ) ?? headers[i]!.trim(); + + properties[propertyKey] = parts[i]!; + } + } + + return { + email, + firstName, + lastName, + subscribed, + properties: Object.keys(properties).length > 0 ? properties : undefined, + }; + } if (parts.length >= 4) { // Could be: email,firstName,lastName,subscribed @@ -152,6 +243,7 @@ export default function BulkUploadContacts({ email, firstName, lastName, + properties: Object.keys(properties).length > 0 ? properties : undefined, subscribed, }; }; @@ -159,9 +251,25 @@ export default function BulkUploadContacts({ const parseContacts = (text: string): ParsedContact[] => { const lines = text.split("\n"); const contactsMap = new Map(); + const firstLineParts = lines[0] + ?.split(",") + .map((part) => part.trim().replace(/^"|"$/g, "")); + const hasEmailHeader = + firstLineParts?.some((part) => { + const normalized = part.toLowerCase(); + return ( + normalized === "email" || + normalized === "e-mail" || + normalized === "email address" + ); + }) ?? false; + const hasDataEmail = + firstLineParts?.some((part) => validateEmail(part)) ?? false; + const hasHeader = hasEmailHeader && !hasDataEmail; + const headers = hasHeader ? firstLineParts : undefined; for (let i = 0; i < lines.length; i++) { - const parsed = parseContactLine(lines[i]!, i === 0); + const parsed = parseContactLine(lines[i]!, headers); if (parsed) { // Use email as key to deduplicate if (!contactsMap.has(parsed.email)) { @@ -267,6 +375,7 @@ export default function BulkUploadContacts({ email: c.email, firstName: c.firstName, lastName: c.lastName, + properties: c.properties, subscribed: c.subscribed, })), }); @@ -293,13 +402,20 @@ export default function BulkUploadContacts({ ); return ( - - - - + { + if (controlledOpen === undefined) { + setOpen(nextOpen); + return; + } + + onOpenChange?.(nextOpen); + }} + > + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Bulk Upload Contacts @@ -385,7 +501,8 @@ Format: email,firstName,lastName,subscribed (all fields except email are optiona : "Upload a .txt or .csv file or drag and drop here"}

- Format: email,firstName,lastName,subscribed (one per line) + Format: email,firstName,lastName,subscribed (+ optional + custom columns)

diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx index d0db475e..b8eb5523 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx @@ -26,6 +26,7 @@ import EditContact from "./edit-contact"; import { ResendDoubleOptInConfirmation } from "./resend-double-opt-in-confirmation"; import { Input } from "@usesend/ui/src/input"; import { useDebouncedCallback } from "use-debounce"; +import { getContactPropertyValue } from "~/lib/contact-properties"; import { Tooltip, TooltipContent, @@ -72,10 +73,12 @@ export default function ContactList({ contactBookId, contactBookName, doubleOptInEnabled, + contactBookVariables, }: { contactBookId: string; contactBookName?: string; doubleOptInEnabled?: boolean; + contactBookVariables?: string[]; }) { const [page, setPage] = useUrlState("page", "1"); const [status, setStatus] = useUrlState("status"); @@ -141,6 +144,7 @@ export default function ContactList({ "Subscribed", "Unsubscribe Reason", "Created At", + ...(contactBookVariables ?? []), ]; // CSV Rows @@ -151,6 +155,15 @@ export default function ContactList({ escapeCell(contact.subscribed ? "Yes" : "No"), escapeCell(contact.unsubscribeReason ?? ""), escapeCell(contact.createdAt.toISOString()), + ...(contactBookVariables ?? []).map((variable) => + escapeCell( + getContactPropertyValue( + (contact.properties as Record | undefined) ?? {}, + variable, + contactBookVariables ?? [], + ) ?? "", + ), + ), ]); // Build CSV with UTF-8 BOM @@ -314,7 +327,10 @@ export default function ContactList({ email={contact.email} /> ) : null} - +
diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx index f4da6dc9..78f9f861 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx @@ -12,7 +12,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -20,31 +19,67 @@ import { } from "@usesend/ui/src/form"; import { api } from "~/trpc/react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Edit } from "lucide-react"; -import { useRouter } from "next/navigation"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@usesend/ui/src/toaster"; import { Switch } from "@usesend/ui/src/switch"; import { Contact } from "@prisma/client"; +import { + getContactPropertyValue, + replaceContactVariableValues, +} from "~/lib/contact-properties"; const contactSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), firstName: z.string().optional(), lastName: z.string().optional(), + properties: z.record(z.string()).optional(), subscribed: z.boolean().optional(), }); export const EditContact: React.FC<{ contact: Partial & { id: string; contactBookId: string }; -}> = ({ contact }) => { + contactBookVariables?: string[]; +}> = ({ contact, contactBookVariables }) => { const [open, setOpen] = useState(false); const updateContactMutation = api.contacts.updateContact.useMutation(); + const initialVariableValues = useMemo(() => { + const contactProperties = + contact.properties && typeof contact.properties === "object" + ? (contact.properties as Record) + : {}; + + return (contactBookVariables ?? []).reduce( + (acc, variable) => { + acc[variable] = + getContactPropertyValue( + contactProperties, + variable, + contactBookVariables ?? [], + ) ?? ""; + return acc; + }, + {} as Record, + ); + }, [contact.properties, contactBookVariables]); + const [variableValues, setVariableValues] = useState(initialVariableValues); + + useEffect(() => { + setVariableValues((prev) => + Object.keys(initialVariableValues).reduce( + (acc, key) => { + acc[key] = prev[key] ?? initialVariableValues[key] ?? ""; + return acc; + }, + {} as Record, + ), + ); + }, [initialVariableValues]); const utils = api.useUtils(); - const router = useRouter(); const contactForm = useForm>({ resolver: zodResolver(contactSchema), @@ -62,6 +97,14 @@ export const EditContact: React.FC<{ contactId: contact.id, contactBookId: contact.contactBookId, ...values, + properties: replaceContactVariableValues( + (contact.properties as Record | null | undefined) ?? + {}, + Object.fromEntries( + Object.entries(variableValues).filter(([, value]) => value.trim()), + ), + contactBookVariables ?? [], + ), }, { onSuccess: async () => { @@ -72,7 +115,7 @@ export const EditContact: React.FC<{ onError: async (error) => { toast.error(error.message); }, - } + }, ); } @@ -151,6 +194,28 @@ export const EditContact: React.FC<{ )} /> + {(contactBookVariables ?? []).map((variable) => { + const variableInputId = `contact-variable-${contact.id}-${variable.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + + return ( + + {variable} + + { + setVariableValues((prev) => ({ + ...prev, + [variable]: e.target.value, + })); + }} + /> + + + ); + })}
+ + +
+ + + {contactBookName ? ( + + ) : null} + {contactBookName ? ( + + ) : null} +
+
+ + + + + {contactBookName ? ( + + utils.contacts.getContactBookDetails.invalidate({ contactBookId }) + } + /> + ) : null} + {contactBookName ? ( + { + await utils.contacts.getContactBookDetails.invalidate({ + contactBookId, + }); + router.push("/contacts"); + }} + /> + ) : null} + + ); +} export default function ContactsPage({ params, @@ -132,10 +266,11 @@ export default function ContactsPage({
-
- - -
+
@@ -212,6 +347,23 @@ export default function ContactsPage({ : "--"}

+
+

Variables

+
+ {(contactBookDetailQuery.data?.variables ?? []).length > 0 ? ( + contactBookDetailQuery.data?.variables.map((variable) => ( + + {variable} + + )) + ) : ( + -- + )} +
+
@@ -329,6 +481,7 @@ export default function ContactsPage({ contactBookId={contactBookId} contactBookName={contactBookDetailQuery.data?.name} doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled} + contactBookVariables={contactBookDetailQuery.data?.variables} /> diff --git a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx index 02801728..3e097662 100644 --- a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx @@ -33,6 +33,7 @@ const contactBookSchema = z.object({ name: z.string({ required_error: "Name is required" }).min(1, { message: "Name is required", }), + variables: z.string().optional(), }); export default function AddContactBook() { @@ -51,6 +52,7 @@ export default function AddContactBook() { resolver: zodResolver(contactBookSchema), defaultValues: { name: "", + variables: "", }, }); @@ -63,6 +65,10 @@ export default function AddContactBook() { createContactBookMutation.mutate( { name: values.name, + variables: values.variables + ?.split(",") + .map((variable) => variable.trim()) + .filter(Boolean), }, { onSuccess: () => { @@ -71,6 +77,9 @@ export default function AddContactBook() { setOpen(false); toast.success("Contact book created successfully"); }, + onError: (error) => { + toast.error(error.message); + }, }, ); } @@ -125,6 +134,25 @@ export default function AddContactBook() { )} /> + ( + + Variables + + + + + Optional comma-separated variable names for campaign + personalization. + + + )} + />
+ ) : null); + return ( - - - } + open={open} + onOpenChange={onOpenChange} + trigger={dialogTrigger} confirmLabel="Delete Contact Book" /> ); diff --git a/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx index d8f130a0..89cf2062 100644 --- a/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx @@ -12,6 +12,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -24,63 +25,99 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@usesend/ui/src/toaster"; +import type { ReactNode } from "react"; const contactBookSchema = z.object({ name: z.string().min(1, { message: "Name is required" }), + variables: z.string().optional(), }); export const EditContactBook: React.FC<{ - contactBook: { id: string; name: string }; -}> = ({ contactBook }) => { + contactBook: { id: string; name: string; variables?: string[] }; + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onSuccess?: () => void | Promise; +}> = ({ + contactBook, + trigger, + open: controlledOpen, + onOpenChange, + onSuccess, +}) => { const [open, setOpen] = useState(false); const updateContactBookMutation = api.contacts.updateContactBook.useMutation(); const utils = api.useUtils(); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); const contactBookForm = useForm>({ resolver: zodResolver(contactBookSchema), defaultValues: { name: contactBook.name || "", + variables: (contactBook.variables ?? []).join(", "), }, }); async function onContactBookUpdate( - values: z.infer + values: z.infer, ) { updateContactBookMutation.mutate( { contactBookId: contactBook.id, - ...values, + name: values.name, + variables: values.variables + ?.split(",") + .map((variable) => variable.trim()) + .filter(Boolean), }, { onSuccess: async () => { utils.contacts.getContactBooks.invalidate(); - setOpen(false); + await onSuccess?.(); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } toast.success("Contact book updated successfully"); }, onError: async (error) => { toast.error(error.message); }, - } + }, ); } return ( (_open !== open ? setOpen(_open) : null)} + open={controlledOpen ?? open} + onOpenChange={(nextOpen) => { + if (controlledOpen === undefined) { + if (nextOpen !== open) { + setOpen(nextOpen); + } + return; + } + + onOpenChange?.(nextOpen); + }} > - - - + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Edit Contact Book @@ -104,6 +141,25 @@ export const EditContactBook: React.FC<{ )} /> + ( + + Variables + + + + + Comma-separated variable names available in campaigns for + this contact book. + + + )} + />
)} + {props.helperText ? ( +
+ {props.helperText} +
+ ) : null}
); }); @@ -82,14 +87,15 @@ export const VariableList = forwardRef((props: any, ref) => { VariableList.displayName = "VariableList"; export function getVariableSuggestions( - variables: Array = [] + variables: Array = [], + helperText?: string, ): Omit { return { items: ({ query }) => { return variables .concat(query.length > 0 ? [query] : []) .filter((item) => item.toLowerCase().startsWith(query.toLowerCase())) - .slice(0, 5); + .slice(0, 10); }, render: () => { @@ -102,6 +108,10 @@ export function getVariableSuggestions( props, editor: props.editor, }); + component.updateProps({ + ...props, + helperText, + }); if (!props.clientRect) { return; @@ -119,7 +129,10 @@ export function getVariableSuggestions( }, onUpdate(props) { - component.updateProps(props); + component.updateProps({ + ...props, + helperText, + }); if (!props.clientRect) { return; @@ -180,7 +193,7 @@ export function VariableComponent(props: NodeViewProps) {