Skip to content

Commit 436efe7

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

File tree

9 files changed

+234
-6
lines changed

9 files changed

+234
-6
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)/~/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

+123-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,22 @@ import {
1920
SelectTrigger,
2021
SelectValue,
2122
} from "@/components/ui/select";
23+
import {} from "@/components/ui/table";
24+
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
2225
import { cn } from "@/lib/utils";
2326
import { zodResolver } from "@hookform/resolvers/zod";
2427
import { useMutation } from "@tanstack/react-query";
25-
import { ExternalLinkIcon, PlusIcon, Trash2Icon, UserPlus } from "lucide-react";
28+
import {
29+
CheckIcon,
30+
ExternalLinkIcon,
31+
PlusIcon,
32+
Trash2Icon,
33+
UserPlus,
34+
} from "lucide-react";
2635
import Link from "next/link";
2736
import { useForm } from "react-hook-form";
2837
import { toast } from "sonner";
38+
import type { ThirdwebClient } from "thirdweb";
2939
import { z } from "zod";
3040
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan";
3141

@@ -40,6 +50,12 @@ const inviteFormSchema = z.object({
4050
.min(1, "No invites added"),
4151
});
4252

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

4561
// Note: This component is also used in team onboarding flow and not just in team settings page
@@ -61,6 +77,8 @@ export function InviteSection(props: {
6177
className?: string;
6278
onInviteSuccess?: () => void;
6379
shouldHideInviteButton?: boolean;
80+
recommendedMembers: RecommendedMember[];
81+
client: ThirdwebClient;
6482
}) {
6583
const teamPlan = getValidTeamPlan(props.team);
6684
let bottomSection: React.ReactNode = null;
@@ -241,6 +259,41 @@ export function InviteSection(props: {
241259
</p>
242260
</div>
243261

262+
{props.recommendedMembers.length > 0 && (
263+
<RecommendedMembersSection
264+
client={props.client}
265+
isDisabled={!inviteEnabled}
266+
recommendedMembers={props.recommendedMembers}
267+
selectedMembers={form
268+
.watch("invites")
269+
.map((invite) => invite.email)}
270+
onToggleMember={(email) => {
271+
const currentInvites = form
272+
.getValues("invites")
273+
.filter((x) => x.email !== "");
274+
275+
const inviteIndex = currentInvites.findIndex(
276+
(invite) => invite.email === email,
277+
);
278+
if (inviteIndex !== -1) {
279+
currentInvites.splice(inviteIndex, 1);
280+
} else {
281+
currentInvites.push({ email, role: "MEMBER" });
282+
}
283+
284+
// must show at least one (even if its empty)
285+
if (currentInvites.length === 0) {
286+
currentInvites.push({ email: "", role: "MEMBER" });
287+
}
288+
289+
form.setValue("invites", currentInvites, {
290+
shouldDirty: true,
291+
shouldTouch: true,
292+
});
293+
}}
294+
/>
295+
)}
296+
244297
<div className="px-4 py-6 lg:px-6">
245298
<div className="flex flex-col gap-5">
246299
{form.watch("invites").map((_, index) => (
@@ -372,6 +425,7 @@ export function InviteSection(props: {
372425
</Button>
373426
</div>
374427
</div>
428+
375429
{bottomSection}
376430
</div>
377431
</section>
@@ -410,3 +464,71 @@ function RoleSelector(props: {
410464
</Select>
411465
);
412466
}
467+
468+
function RecommendedMembersSection(props: {
469+
recommendedMembers: RecommendedMember[];
470+
selectedMembers: string[];
471+
onToggleMember: (email: string) => void;
472+
isDisabled: boolean;
473+
client: ThirdwebClient;
474+
}) {
475+
return (
476+
<div className="relative border-b">
477+
<div className="px-4 py-4 lg:px-6">
478+
<h3 className="font-medium text-base">Recommended Members</h3>
479+
<p className="text-muted-foreground text-sm">
480+
User's that have your team's verified domain in their email address
481+
but haven't been added to your team yet.
482+
</p>
483+
</div>
484+
<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">
485+
{props.recommendedMembers.map((member) => {
486+
const isSelected = props.selectedMembers.includes(member.email);
487+
return (
488+
<Button
489+
key={member.email}
490+
variant="outline"
491+
className={cn(
492+
"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",
493+
isSelected && "border-active-border border-solid bg-accent/50",
494+
)}
495+
onClick={() => {
496+
props.onToggleMember(member.email);
497+
}}
498+
disabled={props.isDisabled}
499+
>
500+
<div className="flex items-center gap-2.5 overflow-hidden">
501+
<Img
502+
src={
503+
member.image
504+
? resolveSchemeWithErrorHandler({
505+
uri: member.image,
506+
client: props.client,
507+
})
508+
: ""
509+
}
510+
className="size-7 rounded-full border"
511+
fallback={
512+
<div className="flex items-center justify-center bg-muted/70 font-semibold text-muted-foreground uppercase">
513+
{member.email[0]}
514+
</div>
515+
}
516+
/>
517+
518+
<div className="truncate text-foreground text-sm">
519+
{member.email}
520+
</div>
521+
</div>
522+
523+
{isSelected && (
524+
<div className="p-1">
525+
<CheckIcon className="size-5 text-foreground" />
526+
</div>
527+
)}
528+
</Button>
529+
);
530+
})}
531+
</div>
532+
</div>
533+
);
534+
}

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { TabButtons } from "@/components/ui/tabs";
99
import { useDashboardRouter } from "@/lib/DashboardRouter";
1010
import { useState } from "react";
1111
import type { ThirdwebClient } from "thirdweb";
12-
import { InviteSection } from "./InviteSection";
12+
import { InviteSection, type RecommendedMember } from "./InviteSection";
1313
import { ManageInvitesSection } from "./ManageInvitesSection";
1414
import { ManageMembersSection } from "./ManageMembersSection";
1515

@@ -19,6 +19,7 @@ export function TeamMembersSettingsPage(props: {
1919
members: TeamMember[];
2020
client: ThirdwebClient;
2121
teamInvites: TeamInvite[];
22+
recommendedMembers: RecommendedMember[];
2223
}) {
2324
const [manageTab, setManageTab] = useState<"members" | "invites">("members");
2425
const router = useDashboardRouter();
@@ -34,6 +35,7 @@ export function TeamMembersSettingsPage(props: {
3435

3536
<InviteSection
3637
team={props.team}
38+
client={props.client}
3739
userHasEditPermission={props.userHasEditPermission}
3840
inviteTeamMembers={async (params) => {
3941
const res = await sendTeamInvites({
@@ -51,6 +53,8 @@ export function TeamMembersSettingsPage(props: {
5153
results: res.results,
5254
};
5355
}}
56+
// TODO
57+
recommendedMembers={props.recommendedMembers}
5458
/>
5559

5660
<div className="h-10" />

0 commit comments

Comments
 (0)