11import { parseActionError } from "@/lib/actions/parse-action-errors" ;
2+ import { bulkInvitePartnersAction } from "@/lib/actions/partners/bulk-invite-partners" ;
23import { invitePartnerAction } from "@/lib/actions/partners/invite-partner" ;
34import { saveInviteEmailDataAction } from "@/lib/actions/partners/save-invite-email-data" ;
5+ import { MAX_PARTNERS_INVITES_PER_REQUEST } from "@/lib/constants/program" ;
46import { useEmailDomains } from "@/lib/swr/use-email-domains" ;
57import useProgram from "@/lib/swr/use-program" ;
68import useWorkspace from "@/lib/swr/use-workspace" ;
79import { 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" ;
914import { GroupSelector } from "@/ui/partners/groups/group-selector" ;
1015import { X } from "@/ui/shared/icons" ;
1116import {
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" ;
2230import { useAction } from "next-safe-action/hooks" ;
2331import {
2432 Dispatch ,
@@ -30,13 +38,18 @@ import {
3038} from "react" ;
3139import { useForm } from "react-hook-form" ;
3240import { toast } from "sonner" ;
33- import * as z from "zod/v4" ;
3441
3542interface 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
4154type 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