Skip to content

Commit 62e0a1d

Browse files
authored
feat: add contact-book variable registry for campaign personalization (#359)
* feat: add contact-book variable registry for campaign personalization * test: include contact-book variables default in service expectation * fix: address personalization review issues * fix text * fix: normalize contact variable access across contact flows * stuff * fix
1 parent d97e445 commit 62e0a1d

29 files changed

Lines changed: 1564 additions & 406 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];

apps/web/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ model ContactBook {
296296
id String @id @default(cuid())
297297
name String
298298
teamId Int
299+
variables String[] @default([])
299300
properties Json
300301
doubleOptInEnabled Boolean @default(false)
301302
doubleOptInFrom String?

apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Spinner } from "@usesend/ui/src/spinner";
55
import { Button } from "@usesend/ui/src/button";
66
import { Input } from "@usesend/ui/src/input";
77
import { Editor } from "@usesend/email-editor";
8-
import { use, useState } from "react";
8+
import { use, useMemo, useState } from "react";
99
import { Campaign } from "@prisma/client";
1010
import {
1111
Select,
@@ -65,7 +65,7 @@ export default function EditCampaignPage({
6565
{ campaignId },
6666
{
6767
enabled: !!campaignId,
68-
}
68+
},
6969
);
7070

7171
if (isLoading) {
@@ -102,18 +102,18 @@ function CampaignEditor({
102102
const utils = api.useUtils();
103103

104104
const [json, setJson] = useState<Record<string, any> | undefined>(
105-
campaign.content ? JSON.parse(campaign.content) : undefined
105+
campaign.content ? JSON.parse(campaign.content) : undefined,
106106
);
107107
const [isSaving, setIsSaving] = useState(false);
108108
const [name, setName] = useState(campaign.name);
109109
const [subject, setSubject] = useState(campaign.subject);
110110
const [from, setFrom] = useState(campaign.from);
111111
const [contactBookId, setContactBookId] = useState(campaign.contactBookId);
112112
const [replyTo, setReplyTo] = useState<string | undefined>(
113-
campaign.replyTo[0]
113+
campaign.replyTo[0],
114114
);
115115
const [previewText, setPreviewText] = useState<string | null>(
116-
campaign.previewText
116+
campaign.previewText,
117117
);
118118

119119
const updateCampaignMutation = api.campaign.updateCampaign.useMutation({
@@ -136,13 +136,13 @@ function CampaignEditor({
136136

137137
const deboucedUpdateCampaign = useDebouncedCallback(
138138
updateEditorContent,
139-
1000
139+
1000,
140140
);
141141

142142
const handleFileChange = async (file: File) => {
143143
if (file.size > IMAGE_SIZE_LIMIT) {
144144
throw new Error(
145-
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`
145+
`File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`,
146146
);
147147
}
148148

@@ -165,8 +165,17 @@ function CampaignEditor({
165165
};
166166

167167
const contactBook = contactBooksQuery.data?.find(
168-
(book) => book.id === contactBookId
168+
(book) => book.id === contactBookId,
169169
);
170+
const editorVariables = useMemo(() => {
171+
const baseVariables = ["email", "firstName", "lastName"];
172+
const registryVariables = contactBook?.variables ?? [];
173+
174+
return Array.from(new Set([...baseVariables, ...registryVariables]));
175+
}, [contactBook]);
176+
const variableSuggestionsHelperText = contactBookId
177+
? undefined
178+
: "Select the contact book for related variable";
170179

171180
return (
172181
<div className="p-4 container mx-auto ">
@@ -196,7 +205,7 @@ function CampaignEditor({
196205
toast.error(`${e.message}. Reverting changes.`);
197206
setName(campaign.name);
198207
},
199-
}
208+
},
200209
);
201210
}}
202211
/>
@@ -251,7 +260,7 @@ function CampaignEditor({
251260
toast.error(`${e.message}. Reverting changes.`);
252261
setSubject(campaign.subject);
253262
},
254-
}
263+
},
255264
);
256265
}}
257266
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({
291300
toast.error(`${e.message}. Reverting changes.`);
292301
setFrom(campaign.from);
293302
},
294-
}
303+
},
295304
);
296305
}}
297306
disabled={isApiCampaign}
@@ -327,7 +336,7 @@ function CampaignEditor({
327336
toast.error(`${e.message}. Reverting changes.`);
328337
setReplyTo(campaign.replyTo[0]);
329338
},
330-
}
339+
},
331340
);
332341
}}
333342
disabled={isApiCampaign}
@@ -365,7 +374,7 @@ function CampaignEditor({
365374
toast.error(`${e.message}. Reverting changes.`);
366375
setPreviewText(campaign.previewText ?? "");
367376
},
368-
}
377+
},
369378
);
370379
}}
371380
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({
397406
onError: () => {
398407
setContactBookId(campaign.contactBookId);
399408
},
400-
}
409+
},
401410
);
402411
setContactBookId(val);
403412
}}
@@ -435,13 +444,15 @@ function CampaignEditor({
435444
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
436445
<div className="w-[600px] mx-auto">
437446
<Editor
447+
key={`campaign-editor-${contactBookId ?? "none"}-${editorVariables.join(",")}`}
438448
initialContent={json}
439449
onUpdate={(content) => {
440450
setJson(content.getJSON());
441451
setIsSaving(true);
442452
deboucedUpdateCampaign();
443453
}}
444-
variables={["email", "firstName", "lastName"]}
454+
variables={editorVariables}
455+
variableSuggestionsHelperText={variableSuggestionsHelperText}
445456
uploadImage={
446457
campaign.imageUploadSupported ? handleFileChange : undefined
447458
}

apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { z } from "zod";
2626
import { useForm } from "react-hook-form";
2727
import { zodResolver } from "@hookform/resolvers/zod";
2828
import { toast } from "@usesend/ui/src/toaster";
29+
import type { ReactNode } from "react";
2930

3031
const contactsSchema = z.object({
3132
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
@@ -35,8 +36,14 @@ const contactsSchema = z.object({
3536

3637
export default function AddContact({
3738
contactBookId,
39+
trigger,
40+
open: controlledOpen,
41+
onOpenChange,
3842
}: {
3943
contactBookId: string;
44+
trigger?: ReactNode;
45+
open?: boolean;
46+
onOpenChange?: (open: boolean) => void;
4047
}) {
4148
const [open, setOpen] = useState(false);
4249

@@ -50,6 +57,14 @@ export default function AddContact({
5057
});
5158

5259
const utils = api.useUtils();
60+
const dialogTrigger =
61+
trigger ??
62+
(controlledOpen === undefined ? (
63+
<Button>
64+
<Plus className="h-4 w-4 mr-1" />
65+
Add Contacts
66+
</Button>
67+
) : null);
5368

5469
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
5570
const contactsArray = values.contacts.split(",").map((email) => ({
@@ -64,7 +79,11 @@ export default function AddContact({
6479
{
6580
onSuccess: async () => {
6681
utils.contacts.contacts.invalidate();
67-
setOpen(false);
82+
if (controlledOpen === undefined) {
83+
setOpen(false);
84+
} else {
85+
onOpenChange?.(false);
86+
}
6887
toast.success("Contacts queued for processing");
6988
},
7089
onError: async (error) => {
@@ -76,15 +95,21 @@ export default function AddContact({
7695

7796
return (
7897
<Dialog
79-
open={open}
80-
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
98+
open={controlledOpen ?? open}
99+
onOpenChange={(nextOpen) => {
100+
if (controlledOpen === undefined) {
101+
if (nextOpen !== open) {
102+
setOpen(nextOpen);
103+
}
104+
return;
105+
}
106+
107+
onOpenChange?.(nextOpen);
108+
}}
81109
>
82-
<DialogTrigger asChild>
83-
<Button>
84-
<Plus className="h-4 w-4 mr-1" />
85-
Add Contacts
86-
</Button>
87-
</DialogTrigger>
110+
{dialogTrigger ? (
111+
<DialogTrigger asChild>{dialogTrigger}</DialogTrigger>
112+
) : null}
88113
<DialogContent>
89114
<DialogHeader>
90115
<DialogTitle>Add new contacts</DialogTitle>

0 commit comments

Comments
 (0)