Skip to content

Commit ad73448

Browse files
committed
[TOOL-4291] Dashboard: Show recommended team members in invite section
1 parent 692c25e commit ad73448

File tree

12 files changed

+268
-9
lines changed

12 files changed

+268
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
import { getTeamBySlug } from "@/api/team";
2+
import { getThirdwebClient } from "@/constants/thirdweb.server";
23
import { notFound } from "next/navigation";
4+
import { getAuthToken } from "../../../../api/lib/getAuthToken";
35
import { TeamOnboardingLayout } from "../../../../login/onboarding/onboarding-layout";
46
import { InviteTeamMembers } from "../../../../login/onboarding/team-onboarding/team-onboarding";
57

68
export default async function Page(props: {
79
params: Promise<{ team_slug: string }>;
810
}) {
911
const params = await props.params;
10-
const team = await getTeamBySlug(params.team_slug);
12+
const [team, authToken] = await Promise.all([
13+
getTeamBySlug(params.team_slug),
14+
getAuthToken(),
15+
]);
1116

12-
if (!team) {
17+
if (!team || !authToken) {
1318
notFound();
1419
}
1520

21+
const client = getThirdwebClient({
22+
jwt: authToken,
23+
teamId: team.id,
24+
});
25+
1626
return (
1727
<TeamOnboardingLayout currentStep={2}>
18-
<InviteTeamMembers team={team} />
28+
<InviteTeamMembers team={team} client={client} />
1929
</TeamOnboardingLayout>
2030
);
2131
}

apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Team } from "@/api/team";
22
import type { Meta, StoryObj } from "@storybook/react";
33
import { teamStub } from "stories/stubs";
4-
import { storybookLog } from "stories/utils";
4+
import { storybookLog, storybookThirdwebClient } from "stories/utils";
55
import { TeamOnboardingLayout } from "../onboarding-layout";
66
import { InviteTeamMembersUI } from "./InviteTeamMembers";
77

@@ -61,6 +61,7 @@ function Story(props: {
6161
return (
6262
<TeamOnboardingLayout currentStep={2}>
6363
<InviteTeamMembersUI
64+
client={storybookThirdwebClient}
6465
trackEvent={(params) => {
6566
storybookLog("trackEvent", params);
6667
}}

apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useDashboardRouter } from "@/lib/DashboardRouter";
1818
import type { TrackingParams } from "hooks/analytics/useTrack";
1919
import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react";
2020
import { useState, useTransition } from "react";
21+
import type { ThirdwebClient } from "thirdweb";
2122
import { pollWithTimeout } from "utils/pollWithTimeout";
2223
import { useStripeRedirectEvent } from "../../../stripe-redirect/stripeRedirectChannel";
2324
import {
@@ -32,6 +33,7 @@ export function InviteTeamMembersUI(props: {
3233
onComplete: () => void;
3334
getTeam: () => Promise<Team>;
3435
trackEvent: (params: TrackingParams) => void;
36+
client: ThirdwebClient;
3537
}) {
3638
const [showPlanModal, setShowPlanModal] = useState(false);
3739
const [isPending, startTransition] = useTransition();
@@ -93,6 +95,9 @@ export function InviteTeamMembersUI(props: {
9395
userHasEditPermission={true}
9496
onInviteSuccess={() => setHasSentInvites(true)}
9597
shouldHideInviteButton={hasSentInvites}
98+
client={props.client}
99+
// its a new team, there's no recommended members
100+
recommendedMembers={[]}
96101
customCTASection={
97102
<div className="flex gap-3">
98103
{props.team.billingPlan === "free" && (

apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/team-onboarding.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@ export function TeamInfoForm(props: {
9696

9797
export function InviteTeamMembers(props: {
9898
team: Team;
99+
client: ThirdwebClient;
99100
}) {
100101
const router = useDashboardRouter();
101102
const trackEvent = useTrack();
102103

103104
return (
104105
<InviteTeamMembersUI
105106
trackEvent={trackEvent}
107+
client={props.client}
106108
onComplete={() => {
107109
router.replace(`/team/${props.team.slug}`);
108110
}}

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/add-partner/page.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { getTeamBySlug } from "@/api/team";
2-
import {} from "@/components/ui/breadcrumb";
32
import { notFound } from "next/navigation";
43
import { getAuthToken } from "../../../../../../../../../api/lib/getAuthToken";
54
import { loginRedirect } from "../../../../../../../../../login/loginRedirect";

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/partners/[partner_id]/edit/page.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { getTeamBySlug } from "@/api/team";
2-
import {} from "@/components/ui/breadcrumb";
32
import { notFound } from "next/navigation";
43
import { getAuthToken } from "../../../../../../../../../../../api/lib/getAuthToken";
54
import { loginRedirect } from "../../../../../../../../../../../login/loginRedirect";

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx

+45
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
44
import type { Meta, StoryObj } from "@storybook/react";
55
import { useState } from "react";
66
import { teamStub } from "stories/stubs";
7+
import { storybookThirdwebClient } from "../../../../../../../../stories/utils";
78
import { InviteSection } from "./InviteSection";
89

910
const meta = {
@@ -36,6 +37,14 @@ const TEAM_CONFIGS = [
3637
{ id: "pro", label: "Pro Team", team: teamStub("bar", "pro") },
3738
] as const;
3839

40+
const RECOMMENDED_MEMBERS_COUNTS = [
41+
{ id: "0", label: "No Recommended Members", value: 0 },
42+
{ id: "1", label: "1 Recommended Member", value: 1 },
43+
{ id: "3", label: "2 Recommended Members", value: 2 },
44+
{ id: "5", label: "11 Recommended Members", value: 11 },
45+
{ id: "100", label: "100 Recommended Members", value: 100 },
46+
] as const;
47+
3948
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
4049
type InviteParams = Array<{ email: string; role: TeamAccountRole }>;
4150

@@ -79,12 +88,26 @@ function Story() {
7988
const [hasEditPermission, setHasEditPermission] = useState("true");
8089
const [inviteResult, setInviteResult] =
8190
useState<keyof typeof INVITE_HANDLERS>("success");
91+
const [recommendedMembersCount, setRecommendedMembersCount] =
92+
useState<number>(0);
8293

8394
const showPermissionControls =
8495
selectedTeam.id !== "free" && selectedTeam.id !== "pro";
8596
const showInviteControls =
8697
showPermissionControls && hasEditPermission === "true";
8798

99+
const recommendedMembers = Array.from(
100+
{ length: recommendedMembersCount },
101+
(_, i) => ({
102+
email: `user${i + 1}@example.com`,
103+
name: i % 2 === 0 ? `User ${i + 1}` : null,
104+
image:
105+
i % 3 === 0
106+
? `https://api.dicebear.com/7.x/avataaars/svg?seed=${i + 1}`
107+
: null,
108+
}),
109+
);
110+
88111
return (
89112
<div className="container max-w-6xl py-10">
90113
<div className="mb-8 flex flex-col gap-6">
@@ -154,13 +177,35 @@ function Story() {
154177
</RadioGroup>
155178
</div>
156179
)}
180+
181+
<div>
182+
<h3 className="mb-3 font-medium">Recommended Members</h3>
183+
<RadioGroup
184+
value={recommendedMembersCount.toString()}
185+
onValueChange={(value) => {
186+
setRecommendedMembersCount(Number.parseInt(value));
187+
}}
188+
className="flex gap-4"
189+
>
190+
{RECOMMENDED_MEMBERS_COUNTS.map(({ id, label, value }) => (
191+
<RadioOption
192+
key={id}
193+
id={id}
194+
label={label}
195+
value={value.toString()}
196+
/>
197+
))}
198+
</RadioGroup>
199+
</div>
157200
</div>
158201

159202
<div className="flex flex-col gap-10">
160203
<InviteSection
161204
team={selectedTeam.team}
162205
userHasEditPermission={hasEditPermission === "true"}
163206
inviteTeamMembers={INVITE_HANDLERS[inviteResult]}
207+
recommendedMembers={recommendedMembers}
208+
client={storybookThirdwebClient}
164209
/>
165210
</div>
166211
</div>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx

+155-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22
import type { Team } from "@/api/team";
33
import type { TeamAccountRole } from "@/api/team-members";
4+
import { Img } from "@/components/blocks/Img";
45
import { Spinner } from "@/components/ui/Spinner/Spinner";
56
import { Button } from "@/components/ui/button";
67
import {
@@ -19,13 +20,23 @@ import {
1920
SelectTrigger,
2021
SelectValue,
2122
} from "@/components/ui/select";
23+
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
2224
import { cn } from "@/lib/utils";
2325
import { zodResolver } from "@hookform/resolvers/zod";
2426
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";
2635
import Link from "next/link";
36+
import { useState } from "react";
2737
import { useForm } from "react-hook-form";
2838
import { toast } from "sonner";
39+
import type { ThirdwebClient } from "thirdweb";
2940
import { z } from "zod";
3041
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan";
3142

@@ -40,6 +51,12 @@ const inviteFormSchema = z.object({
4051
.min(1, "No invites added"),
4152
});
4253

54+
export type RecommendedMember = {
55+
email: string;
56+
name: string | null;
57+
image: string | null;
58+
};
59+
4360
type InviteFormValues = z.infer<typeof inviteFormSchema>;
4461

4562
// 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: {
6178
className?: string;
6279
onInviteSuccess?: () => void;
6380
shouldHideInviteButton?: boolean;
81+
recommendedMembers: RecommendedMember[];
82+
client: ThirdwebClient;
6483
}) {
6584
const teamPlan = getValidTeamPlan(props.team);
6685
let bottomSection: React.ReactNode = null;
@@ -241,6 +260,41 @@ export function InviteSection(props: {
241260
</p>
242261
</div>
243262

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+
244298
<div className="px-4 py-6 lg:px-6">
245299
<div className="flex flex-col gap-5">
246300
{form.watch("invites").map((_, index) => (
@@ -372,6 +426,7 @@ export function InviteSection(props: {
372426
</Button>
373427
</div>
374428
</div>
429+
375430
{bottomSection}
376431
</div>
377432
</section>
@@ -410,3 +465,102 @@ function RoleSelector(props: {
410465
</Select>
411466
);
412467
}
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

Comments
 (0)