1
1
"use client" ;
2
2
import type { Team } from "@/api/team" ;
3
3
import type { TeamAccountRole } from "@/api/team-members" ;
4
+ import { Img } from "@/components/blocks/Img" ;
4
5
import { Spinner } from "@/components/ui/Spinner/Spinner" ;
5
6
import { Button } from "@/components/ui/button" ;
6
7
import {
@@ -19,13 +20,23 @@ import {
19
20
SelectTrigger ,
20
21
SelectValue ,
21
22
} from "@/components/ui/select" ;
23
+ import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler" ;
22
24
import { cn } from "@/lib/utils" ;
23
25
import { zodResolver } from "@hookform/resolvers/zod" ;
24
26
import { useMutation } from "@tanstack/react-query" ;
25
- import { ExternalLinkIcon , PlusIcon , Trash2Icon , UserPlus } from "lucide-react" ;
27
+ import {
28
+ CheckIcon ,
29
+ ExternalLinkIcon ,
30
+ PlusIcon ,
31
+ SearchIcon ,
32
+ Trash2Icon ,
33
+ UserPlus ,
34
+ } from "lucide-react" ;
26
35
import Link from "next/link" ;
36
+ import { useState } from "react" ;
27
37
import { useForm } from "react-hook-form" ;
28
38
import { toast } from "sonner" ;
39
+ import type { ThirdwebClient } from "thirdweb" ;
29
40
import { z } from "zod" ;
30
41
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan" ;
31
42
@@ -40,6 +51,12 @@ const inviteFormSchema = z.object({
40
51
. min ( 1 , "No invites added" ) ,
41
52
} ) ;
42
53
54
+ export type RecommendedMember = {
55
+ email : string ;
56
+ name : string | null ;
57
+ image : string | null ;
58
+ } ;
59
+
43
60
type InviteFormValues = z . infer < typeof inviteFormSchema > ;
44
61
45
62
// Note: This component is also used in team onboarding flow and not just in team settings page
@@ -61,6 +78,8 @@ export function InviteSection(props: {
61
78
className ?: string ;
62
79
onInviteSuccess ?: ( ) => void ;
63
80
shouldHideInviteButton ?: boolean ;
81
+ recommendedMembers : RecommendedMember [ ] ;
82
+ client : ThirdwebClient ;
64
83
} ) {
65
84
const teamPlan = getValidTeamPlan ( props . team ) ;
66
85
let bottomSection : React . ReactNode = null ;
@@ -241,6 +260,41 @@ export function InviteSection(props: {
241
260
</ p >
242
261
</ div >
243
262
263
+ { props . recommendedMembers . length > 0 && (
264
+ < RecommendedMembersSection
265
+ client = { props . client }
266
+ isDisabled = { ! inviteEnabled }
267
+ recommendedMembers = { props . recommendedMembers }
268
+ selectedMembers = { form
269
+ . watch ( "invites" )
270
+ . map ( ( invite ) => invite . email ) }
271
+ onToggleMember = { ( email ) => {
272
+ const currentInvites = form
273
+ . getValues ( "invites" )
274
+ . filter ( ( x ) => x . email !== "" ) ;
275
+
276
+ const inviteIndex = currentInvites . findIndex (
277
+ ( invite ) => invite . email === email ,
278
+ ) ;
279
+ if ( inviteIndex !== - 1 ) {
280
+ currentInvites . splice ( inviteIndex , 1 ) ;
281
+ } else {
282
+ currentInvites . push ( { email, role : "MEMBER" } ) ;
283
+ }
284
+
285
+ // must show at least one (even if its empty)
286
+ if ( currentInvites . length === 0 ) {
287
+ currentInvites . push ( { email : "" , role : "MEMBER" } ) ;
288
+ }
289
+
290
+ form . setValue ( "invites" , currentInvites , {
291
+ shouldDirty : true ,
292
+ shouldTouch : true ,
293
+ } ) ;
294
+ } }
295
+ />
296
+ ) }
297
+
244
298
< div className = "px-4 py-6 lg:px-6" >
245
299
< div className = "flex flex-col gap-5" >
246
300
{ form . watch ( "invites" ) . map ( ( _ , index ) => (
@@ -372,6 +426,7 @@ export function InviteSection(props: {
372
426
</ Button >
373
427
</ div >
374
428
</ div >
429
+
375
430
{ bottomSection }
376
431
</ div >
377
432
</ section >
@@ -410,3 +465,102 @@ function RoleSelector(props: {
410
465
</ Select >
411
466
) ;
412
467
}
468
+
469
+ function RecommendedMembersSection ( props : {
470
+ recommendedMembers : RecommendedMember [ ] ;
471
+ selectedMembers : string [ ] ;
472
+ onToggleMember : ( email : string ) => void ;
473
+ isDisabled : boolean ;
474
+ client : ThirdwebClient ;
475
+ } ) {
476
+ const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
477
+ const filteredMembers = props . recommendedMembers . filter ( ( member ) =>
478
+ member . email . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) ,
479
+ ) ;
480
+
481
+ return (
482
+ < div className = "relative border-b" >
483
+ < div className = "flex flex-col gap-4 px-4 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-6" >
484
+ < div className = "" >
485
+ < h3 className = "font-medium text-base" > Recommended Members</ h3 >
486
+ < p className = "text-muted-foreground text-sm" >
487
+ Users with your team's verified domain in their email address that
488
+ aren't added to your team yet
489
+ </ p >
490
+ </ div >
491
+
492
+ < div className = "relative flex items-center gap-2" >
493
+ < SearchIcon className = "absolute left-3 size-4 text-muted-foreground" />
494
+ < Input
495
+ placeholder = "Search Email"
496
+ className = "w-full bg-card pl-9 lg:w-72"
497
+ value = { searchQuery }
498
+ onChange = { ( e ) => {
499
+ setSearchQuery ( e . target . value ) ;
500
+ } }
501
+ />
502
+ </ div >
503
+ </ div >
504
+
505
+ { filteredMembers . length === 0 && (
506
+ < div className = "px-4 pb-6 lg:px-6" >
507
+ < div className = "flex min-h-[200px] items-center justify-center rounded-lg bg-muted/50 text-muted-foreground" >
508
+ No members found
509
+ </ div >
510
+ </ div >
511
+ ) }
512
+
513
+ { filteredMembers . length > 0 && (
514
+ < div className = "grid max-h-[294px] grid-cols-1 gap-3 overflow-y-auto px-4 pb-6 md:grid-cols-2 lg:px-6 xl:grid-cols-3" >
515
+ { filteredMembers . map ( ( member ) => {
516
+ const isSelected = props . selectedMembers . includes ( member . email ) ;
517
+ return (
518
+ < Button
519
+ key = { member . email }
520
+ variant = "outline"
521
+ className = { cn (
522
+ "relative flex h-auto w-auto items-center justify-between gap-2 rounded-lg border-dashed p-2.5 text-start hover:border-active-border hover:border-solid hover:bg-accent/50 disabled:opacity-100" ,
523
+ isSelected &&
524
+ "border-active-border border-solid bg-accent/50" ,
525
+ ) }
526
+ onClick = { ( ) => {
527
+ props . onToggleMember ( member . email ) ;
528
+ } }
529
+ disabled = { props . isDisabled }
530
+ >
531
+ < div className = "flex items-center gap-2.5 overflow-hidden" >
532
+ < Img
533
+ src = {
534
+ member . image
535
+ ? resolveSchemeWithErrorHandler ( {
536
+ uri : member . image ,
537
+ client : props . client ,
538
+ } )
539
+ : ""
540
+ }
541
+ className = "size-7 rounded-full border"
542
+ fallback = {
543
+ < div className = "flex items-center justify-center bg-muted/70 font-semibold text-muted-foreground uppercase" >
544
+ { member . email [ 0 ] }
545
+ </ div >
546
+ }
547
+ />
548
+
549
+ < div className = "truncate text-foreground text-sm" >
550
+ { member . email }
551
+ </ div >
552
+ </ div >
553
+
554
+ { isSelected && (
555
+ < div className = "p-1" >
556
+ < CheckIcon className = "size-5 text-foreground" />
557
+ </ div >
558
+ ) }
559
+ </ Button >
560
+ ) ;
561
+ } ) }
562
+ </ div >
563
+ ) }
564
+ </ div >
565
+ ) ;
566
+ }
0 commit comments