Skip to content

Commit 714a8bf

Browse files
authored
Merge pull request #3462 from dubinc/bulk-partner-invite
Bulk partner invite
2 parents 1fe8a3f + 2c77cf3 commit 714a8bf

File tree

8 files changed

+751
-90
lines changed

8 files changed

+751
-90
lines changed

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx

Lines changed: 175 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
import { parseActionError } from "@/lib/actions/parse-action-errors";
2+
import { bulkInvitePartnersAction } from "@/lib/actions/partners/bulk-invite-partners";
23
import { invitePartnerAction } from "@/lib/actions/partners/invite-partner";
34
import { saveInviteEmailDataAction } from "@/lib/actions/partners/save-invite-email-data";
5+
import { MAX_PARTNERS_INVITES_PER_REQUEST } from "@/lib/constants/program";
46
import { useEmailDomains } from "@/lib/swr/use-email-domains";
57
import useProgram from "@/lib/swr/use-program";
68
import useWorkspace from "@/lib/swr/use-workspace";
79
import { ProgramInviteEmailData, ProgramProps } from "@/lib/types";
8-
import { invitePartnerSchema } from "@/lib/zod/schemas/partners";
10+
import {
11+
bulkInvitePartnersSchema,
12+
invitePartnerSchema,
13+
} from "@/lib/zod/schemas/partners";
914
import { GroupSelector } from "@/ui/partners/groups/group-selector";
1015
import { X } from "@/ui/shared/icons";
1116
import {
17+
AnimatedSizeContainer,
1218
BlurImage,
1319
Button,
1420
InfoTooltip,
21+
MultiValueInput,
22+
type MultiValueInputRef,
1523
RichTextArea,
1624
RichTextProvider,
1725
RichTextToolbar,
1826
Sheet,
1927
useMediaQuery,
2028
} from "@dub/ui";
21-
import { cn } from "@dub/utils";
29+
import { cn, pluralize } from "@dub/utils";
2230
import { useAction } from "next-safe-action/hooks";
2331
import {
2432
Dispatch,
@@ -30,13 +38,18 @@ import {
3038
} from "react";
3139
import { useForm } from "react-hook-form";
3240
import { toast } from "sonner";
33-
import * as z from "zod/v4";
3441

3542
interface InvitePartnerSheetProps {
3643
setIsOpen: Dispatch<SetStateAction<boolean>>;
3744
}
3845

39-
type InvitePartnerFormData = z.infer<typeof invitePartnerSchema>;
46+
type InvitePartnerFormData = {
47+
email: string;
48+
emails: string[];
49+
name?: string;
50+
username?: string;
51+
groupId: string | null;
52+
};
4053

4154
type EmailContent = {
4255
subject: string;
@@ -92,21 +105,55 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
92105
setValue,
93106
} = useForm<InvitePartnerFormData>({
94107
defaultValues: {
108+
email: "",
109+
emails: [],
95110
groupId: program?.defaultGroupId || "",
96111
},
97112
});
98113

99-
const email = watch("email");
114+
const multiValueInputRef = useRef<MultiValueInputRef>(null);
115+
const emails = watch("emails") ?? [];
116+
const hasMultipleRecipients = emails.length > 1;
100117

101-
const { executeAsync, isPending } = useAction(invitePartnerAction, {
102-
onSuccess: () => {
103-
toast.success("Invitation sent to partner!");
104-
setIsOpen(false);
105-
},
106-
onError({ error }) {
107-
toast.error(error.serverError);
118+
const { executeAsync: invitePartner, isPending } = useAction(
119+
invitePartnerAction,
120+
{
121+
onSuccess: () => {
122+
toast.success("Invitation sent to partner!");
123+
setIsOpen(false);
124+
},
125+
onError({ error }) {
126+
toast.error(error.serverError);
127+
},
108128
},
109-
});
129+
);
130+
131+
const { executeAsync: bulkInvitePartners, isPending: isBulkPending } =
132+
useAction(bulkInvitePartnersAction, {
133+
onSuccess: ({ data: { invitedCount, skippedCount } }) => {
134+
const parts: string[] = [];
135+
136+
if (invitedCount > 0) {
137+
parts.push(
138+
invitedCount === 1
139+
? "Invitation sent to 1 partner."
140+
: `Invitations sent to ${invitedCount} partners.`,
141+
);
142+
}
143+
144+
if (skippedCount > 0) {
145+
parts.push(
146+
`${skippedCount} ${pluralize("partner", skippedCount)} were skipped because they're already enrolled or previously invited.`,
147+
);
148+
}
149+
150+
toast.success(parts.join(" "));
151+
setIsOpen(false);
152+
},
153+
onError({ error }) {
154+
toast.error(error.serverError);
155+
},
156+
});
110157

111158
const { executeAsync: saveEmailDataAsync, isPending: isSavingEmailData } =
112159
useAction(saveInviteEmailDataAction, {
@@ -133,10 +180,44 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
133180
return;
134181
}
135182

136-
await executeAsync({
137-
...data,
183+
const finalEmails =
184+
multiValueInputRef.current?.commitPendingInput() ?? data.emails ?? [];
185+
186+
if (finalEmails.length === 0) {
187+
toast.error("Please enter at least one email address.");
188+
return;
189+
}
190+
191+
if (finalEmails.length === 1) {
192+
const parsed = invitePartnerSchema.safeParse({
193+
workspaceId,
194+
email: finalEmails[0],
195+
name: data.name,
196+
username: data.username,
197+
groupId: data.groupId ?? null,
198+
});
199+
200+
if (!parsed.success) {
201+
toast.error(parsed.error.issues[0]?.message ?? "Invalid input");
202+
return;
203+
}
204+
205+
await invitePartner(parsed.data);
206+
return;
207+
}
208+
209+
const parsed = bulkInvitePartnersSchema.safeParse({
138210
workspaceId,
211+
emails: finalEmails,
212+
groupId: data.groupId ?? null,
139213
});
214+
215+
if (!parsed.success) {
216+
toast.error(parsed.error.issues[0]?.message ?? "Invalid input");
217+
return;
218+
}
219+
220+
await bulkInvitePartners(parsed.data);
140221
};
141222

142223
const handleStartEditing = () => {
@@ -213,70 +294,95 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
213294
<div className="grid grid-cols-1 gap-6">
214295
<div>
215296
<label
216-
htmlFor="email"
297+
htmlFor="partner-email-input"
217298
className="block text-sm font-medium text-neutral-900"
218299
>
219300
Email
220301
</label>
221302

222-
<div className="relative mt-2 rounded-md shadow-sm">
223-
<input
224-
{...register("email", { required: true })}
225-
className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
303+
<div className="mt-2">
304+
<MultiValueInput
305+
ref={multiValueInputRef}
306+
id="partner-email-input"
307+
values={emails}
308+
onChange={(values) => {
309+
setValue("emails", values, {
310+
shouldDirty: true,
311+
shouldValidate: true,
312+
});
313+
setValue("email", values[0] ?? "", {
314+
shouldDirty: true,
315+
shouldValidate: true,
316+
});
317+
}}
226318
placeholder="panic@thedis.co"
227-
type="email"
228-
autoComplete="off"
319+
normalize={(v) => v.trim().toLowerCase()}
320+
maxValues={MAX_PARTNERS_INVITES_PER_REQUEST}
321+
disabled={isEditingEmail || isSavingEmailData}
229322
autoFocus={!isMobile}
230323
/>
231324
</div>
325+
<p className="mt-2 text-xs text-neutral-500">
326+
Separate multiple emails with commas, or paste a list
327+
</p>
232328
</div>
233329

234330
<div>
235-
<label
236-
htmlFor="name"
237-
className="block text-sm font-medium text-neutral-900"
331+
<AnimatedSizeContainer
332+
height
333+
className="overflow-visible"
334+
transition={{ ease: "easeOut", duration: 0.35 }}
238335
>
239-
Name <span className="text-neutral-500">(optional)</span>
240-
</label>
241-
242-
<div className="relative mt-2 rounded-md shadow-sm">
243-
<input
244-
{...register("name")}
245-
className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
246-
placeholder="John Doe"
247-
type="text"
248-
autoComplete="off"
249-
/>
250-
</div>
251-
</div>
252-
253-
<div>
254-
<div className="flex items-center gap-2">
255-
<label
256-
htmlFor="username"
257-
className="block text-sm font-medium text-neutral-900"
258-
>
259-
Short link{" "}
260-
<span className="text-neutral-500">(optional)</span>
261-
</label>
262-
</div>
336+
{!hasMultipleRecipients && (
337+
<div className="grid grid-cols-1 gap-6 pb-6">
338+
<div>
339+
<label
340+
htmlFor="name"
341+
className="block text-sm font-medium text-neutral-900"
342+
>
343+
Name{" "}
344+
<span className="text-neutral-500">(optional)</span>
345+
</label>
346+
347+
<div className="relative mt-2 rounded-md shadow-sm">
348+
<input
349+
{...register("name")}
350+
className="block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
351+
placeholder="John Doe"
352+
type="text"
353+
autoComplete="off"
354+
/>
355+
</div>
356+
</div>
263357

264-
<div className="mt-2 flex">
265-
<span className="inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
266-
{program?.domain}
267-
</span>
268-
<input
269-
{...register("username")}
270-
type="text"
271-
id="username"
272-
className="block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
273-
placeholder="johndoe"
274-
autoComplete="off"
275-
/>
276-
</div>
277-
</div>
358+
<div>
359+
<div className="flex items-center gap-2">
360+
<label
361+
htmlFor="username"
362+
className="block text-sm font-medium text-neutral-900"
363+
>
364+
Short link{" "}
365+
<span className="text-neutral-500">(optional)</span>
366+
</label>
367+
</div>
278368

279-
<div>
369+
<div className="mt-2 flex">
370+
<span className="inline-flex items-center rounded-l-md border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
371+
{program?.domain}
372+
</span>
373+
<input
374+
{...register("username")}
375+
type="text"
376+
id="username"
377+
className="block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
378+
placeholder="johndoe"
379+
autoComplete="off"
380+
/>
381+
</div>
382+
</div>
383+
</div>
384+
)}
385+
</AnimatedSizeContainer>
280386
<label className="block text-sm font-medium text-neutral-900">
281387
Group <span className="text-neutral-500">(optional)</span>
282388
</label>
@@ -315,16 +421,18 @@ function InvitePartnerSheetContent({ setIsOpen }: InvitePartnerSheetProps) {
315421
onClick={() => setIsOpen(false)}
316422
text="Cancel"
317423
className="w-fit"
318-
disabled={isPending}
424+
disabled={isPending || isBulkPending}
319425
/>
320426
<Button
321427
type="submit"
322428
variant="primary"
323429
text="Send invite"
324430
className="w-fit"
325-
loading={isPending || isSubmitting || isSubmitSuccessful}
431+
loading={
432+
isPending || isBulkPending || isSubmitting || isSubmitSuccessful
433+
}
326434
disabled={
327-
isPending || !email || isEditingEmail || isSavingEmailData
435+
isPending || isBulkPending || isEditingEmail || isSavingEmailData
328436
}
329437
/>
330438
</div>

0 commit comments

Comments
 (0)