Team Settings page UI (#4304)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving UI components and updating team-related functionalities in the dashboard. Changes include UI refinements, new components, and team-related API updates.

### Detailed summary
- Updated UI components like buttons and icons for better styling
- Added new components like `RoleSelector` and `DangerSettingCard`
- Updated team-related API with additional fields for `Team`
- Improved UI consistency and user experience in the dashboard

> The following files were skipped due to too many changes: `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.tsx`, `apps/dashboard/src/stories/Button.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/_components/sidebar/TeamSettingsSidebar.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/_components/sidebar/TeamsMobileNav.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.stories.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamHeaderUI.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/SettingsCard.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/Sidebar.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/TeamOverviewPage.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/SettingsCard.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamHeaderUI.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/ManageMembersSection.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
MananTank
2024-09-02 04:06:21 +00:00
parent 5475551e5d
commit 18678d45e7
37 changed files with 2026 additions and 325 deletions
+11
View File
@@ -6,11 +6,22 @@ const THIRDWEB_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
export type Team = {
id: string;
name: string;
slug: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
bannedAt: string | null;
// image: string; // -> TODO
billingPlan: "pro" | "growth" | "free";
billingStatus: "validPayment" | (string & {}); // what's the other value?
// billingEmail: string;
// billingExternalId: string;
// billingType: "STRIPE" | ??
// billingCustomerPayload: ?? | null
// paymentAttemptCount: number
// trialPeriodEndedAt: string | null;
};
export async function getTeamBySlug(slug: string) {
@@ -10,12 +10,13 @@ export function CopyButton(props: {
text: string;
className?: string;
iconClassName?: string;
variant?: "ghost" | "primary" | "secondary";
}) {
const [isCopied, setIsCopied] = useState(false);
return (
<ToolTipLabel label="Copy">
<Button
variant="ghost"
variant={props.variant || "ghost"}
aria-label="Copy"
className={cn("h-auto w-auto p-1", props.className)}
onClick={() => {
@@ -49,7 +49,7 @@ export function MobileBurgerMenuButton(props: {
className="!h-auto p-1"
onClick={() => setIsMenuOpen(true)}
>
<MenuIcon className="size-7 text-muted-foreground" />
<MenuIcon className="size-6 text-muted-foreground" />
</Button>
{isMenuOpen && (
@@ -55,7 +55,11 @@ export function ProjectSelectorUI(props: {
>
{/* TODO - when we have overview page, remove /connect */}
<Link href={`/team/${team.slug}/${project.slug}/connect`}>
<span className="truncate"> {project.name} </span>
<div className="flex items-center gap-2">
{/* TODO - placeholder for now */}
<div className="size-4 bg-muted border rounded-full" />
<span className="truncate"> {project.name} </span>
</div>
{isSelected && (
<CheckIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
@@ -23,7 +23,7 @@ export function ResourcesDropdownButton() {
<DropdownMenuTrigger asChild>
<Button
variant="link"
className="text-muted-foreground text-sm gap-1 !p-0 !h-auto hover:!no-underline hover:text-foreground"
className="text-muted-foreground text-sm gap-1 !p-0 !h-auto hover:!no-underline hover:text-foreground font-normal"
>
Resources
<ChevronDownIcon className="size-4" />
@@ -51,18 +51,21 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
aria-expanded={open}
aria-label={`Select a ${props.focus === "project-selection" ? "project" : "team"}`}
>
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground hover:text-foreground size-5" />
<ChevronsUpDownIcon
className="shrink-0 text-muted-foreground hover:text-foreground size-5"
strokeWidth={1.5}
/>
</Button>
</PopoverTrigger>
{/* Dropdown */}
<PopoverContent
sideOffset={5}
className="p-0 w-auto rounded-xl "
className="p-0 w-auto rounded-xl shadow-xl"
align={props.focus === "project-selection" ? "center" : "start"}
>
<DynamicHeight>
<div className="flex [&>div]:w-[280px] no-scrollbar">
<div className="flex [&>div]:min-w-[280px] no-scrollbar">
{/* Left */}
<TeamSelectionUI
currentTeam={currentTeam}
@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ThirdwebProvider } from "thirdweb/react";
import type { Project } from "../../../../@/api/projects";
import type { Team } from "../../../../@/api/team";
import { Button } from "../../../../@/components/ui/button";
import { Separator } from "../../../../@/components/ui/separator";
import { BadgeContainer, useSetStoryTheme } from "../../../../stories/utils";
import { TeamHeaderDesktopUI, TeamHeaderMobileUI } from "./TeamHeaderUI";
const meta = {
title: "Team/Team selector",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
function Story(props: { theme: "light" | "dark" }) {
useSetStoryTheme(props.theme);
return (
<div className="dark:bg-zinc-900 bg-zinc-300 p-6">
<h2 className="text-3xl mb-6 font-semibold tracking-tight">Desktop</h2>
<Variants theme={props.theme} type="desktop" />
<Separator className="my-10" />
<h2 className="mt-10 mb-6 text-3xl font-semibold tracking-tight">
Mobile
</h2>
<div className="w-[400px]">
<Variants theme={props.theme} type="mobile" />
</div>
</div>
);
}
function Variants(props: {
theme: "light" | "dark";
type: "mobile" | "desktop";
}) {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
return (
<ThirdwebProvider>
<div className={`flex flex-col gap-6 ${props.theme}`}>
<BadgeContainer label="Team, Free">
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[0].team}
currentProject={undefined}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
/>
</BadgeContainer>
<BadgeContainer label="Team, Growth">
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[1].team}
currentProject={undefined}
email={undefined}
logout={() => {}}
connectButton={<ConnectButtonStub />}
/>
</BadgeContainer>
<BadgeContainer label="Team, Pro">
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[2].team}
currentProject={undefined}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
/>
</BadgeContainer>
<BadgeContainer label="Team + Project, Pro">
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[2].team}
currentProject={teamsAndProjects[2].projects[0]}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
/>
</BadgeContainer>
</div>
</ThirdwebProvider>
);
}
function ConnectButtonStub() {
return <Button>Connect</Button>;
}
function projectStub(id: string, teamId: string) {
const project: Project = {
bundleIds: [] as string[],
createdAt: new Date(),
domains: [] as string[],
id: id,
updatedAt: new Date(),
teamId: teamId,
redirectUrls: [] as string[],
slug: `project-${id}`,
name: `Project ${id}`,
publishableKey: "pb-key",
lastAccessedAt: null,
deletedAt: null,
bannedAt: null,
};
return project;
}
function teamStub(id: string, billingPlan: "free" | "pro" | "growth"): Team {
const team: Team = {
id: `team-${id}-id`,
billingPlan: billingPlan,
billingStatus: "validPayment",
name: `Team ${id}`,
slug: `team-${id}`,
bannedAt: null,
createdAt: new Date().toISOString(),
deletedAt: null,
updatedAt: new Date().toISOString(),
};
return team;
}
const teamsAndProjects: Array<{ team: Team; projects: Project[] }> = [
{
team: teamStub("1", "free"),
projects: [
projectStub("t1p1", "team-1"),
projectStub("t1p2", "team-1"),
projectStub("t1p3", "team-1"),
projectStub("t1p4", "team-1"),
],
},
{
team: teamStub("2", "growth"),
projects: [projectStub("t2p1", "team-2"), projectStub("t2p2", "team-2")],
},
{
team: teamStub("3", "pro"),
projects: [projectStub("t3p1", "team-3")],
},
];
@@ -34,19 +34,21 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
)}
>
<div className="flex items-center gap-2">
<ThirdwebMiniLogo className="h-5" />
<Link href="/team">
<ThirdwebMiniLogo className="h-5" />
</Link>
<SlashSeparator />
<div className="flex items-center gap-1">
<Link
href={`/team/${currentTeam.slug}`}
className="font-normal text-sm flex flex-row gap-2 items-center text-foreground"
className="font-normal text-sm flex flex-row gap-2 items-center"
>
{/* TODO - replace with team image */}
<div className="bg-border size-7 rounded-full" />
<div className="bg-muted border size-6 rounded-full" />
<span className="font-semibold"> {currentTeam.name} </span>
<span> {currentTeam.name} </span>
<Badge
variant={
teamPlan === "free"
@@ -119,9 +121,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
}
function SlashSeparator() {
return (
<div className="h-5 w-[1px] bg-muted-foreground rotate-[25deg] mx-2" />
);
return <div className="h-5 w-[1.5px] bg-foreground/30 rotate-[25deg] mx-2" />;
}
export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
@@ -133,7 +133,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
return (
<header
className={cn(
"flex flex-row gap-2 items-center bg-background text-foreground justify-between px-4 py-4",
"flex flex-row gap-2 items-center text-foreground justify-between px-4 py-4",
props.className,
)}
>
@@ -146,7 +146,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
)}
>
{/* TODO - replace with team image */}
<div className="bg-border size-7 rounded-full" />
<div className="bg-muted border size-7 rounded-full" />
{!props.currentProject && (
<span className="font-semibold">{currentTeam.name}</span>
@@ -41,10 +41,12 @@ export function TeamSelectionUI(props: {
<div className="p-2 flex flex-col">
{/* TODO - onclick */}
<Button
className={cn("w-full justify-between !opacity-100 px-2")}
className={cn("w-full justify-start px-2 gap-2")}
variant="ghost"
onMouseEnter={() => setHoveredTeam(undefined)}
>
{/* TODO - placeholder for now */}
<div className="size-4 bg-muted border rounded-full" />
My Account
</Button>
@@ -73,7 +75,12 @@ export function TeamSelectionUI(props: {
asChild
>
<Link href={`/team/${team.slug}`}>
<span className="truncate"> {team.name} </span>
<div className="flex items-center gap-2">
{/* TODO - placeholder for now */}
<div className="size-4 bg-muted border rounded-full" />
<span className="truncate"> {team.name} </span>
</div>
{isSelected && (
<CheckIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
@@ -26,7 +26,10 @@ export function TeamSelectorMobileMenuButton(props: TeamSwitcherProps) {
variant="ghost"
aria-label="Select Project"
>
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground hover:text-foreground size-5" />
<ChevronsUpDownIcon
className="shrink-0 text-muted-foreground hover:text-foreground size-5"
strokeWidth={1.5}
/>
</Button>
</DialogTrigger>
@@ -1,7 +1,6 @@
"use client";
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { format } from "date-fns";
@@ -16,6 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CopyButton } from "@/components/ui/CopyButton";
import {
Select,
SelectContent,
@@ -75,7 +75,7 @@ export function TeamOverviewPage(props: {
No projects found
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 ">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{projectsToShow.map((project) => {
return (
<ProjectCard
@@ -101,7 +101,7 @@ function ProjectCard(props: {
return (
<div
key={project.id}
className="border rounded-lg p-4 relative hover:bg-muted flex items-center gap-4 bg-muted/50 transition-colors"
className="border rounded-lg p-4 relative hover:bg-muted/70 flex items-center gap-4 bg-muted/50 transition-colors"
>
{/* TODO - replace with project image */}
<div className="size-10 rounded-full bg-border shrink-0" />
@@ -110,21 +110,21 @@ function ProjectCard(props: {
<Link
className="static group before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:z-0"
// remove /connect when we have overview page
href={`/team/${team_slug}/${project.slug}/connect`}
href={`/team/${team_slug}/${project.slug}/connect/in-app-wallets`}
>
<h2 className="text-base">{project.name}</h2>
<h2 className="text-base font-medium">{project.name}</h2>
</Link>
<CopyTextButton
copyIconPosition="right"
textToCopy={project.publishableKey}
textToShow={truncate(project.publishableKey, 32)}
tooltip="Copy Client ID"
variant="ghost"
className="px-2 -translate-x-2 text-muted-foreground text-xs"
/>
<p className="text-muted-foreground text-xs mb-1 flex items-center gap-0.5">
{truncate(project.publishableKey, 32)}
<CopyButton
text={project.publishableKey}
iconClassName="z-10 size-3"
className="p-2 !h-auto !w-auto hover:bg-background -translate-x-1"
/>
</p>
<p className="text-xs text-muted-foreground my-1">
<p className="text-xs text-muted-foreground/70 my-1">
Created on {format(new Date(project.createdAt), "MMM dd, yyyy")}
</p>
</div>
@@ -146,7 +146,7 @@ function SearchInput(props: {
placeholder="Search Projects by name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="pl-9"
className="pl-9 bg-muted/50"
/>
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
</div>
@@ -157,7 +157,7 @@ function AddNewButton() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="primary" className="gap-2">
<Button variant="default" className="gap-2">
Add New
<ChevronDownIcon className="size-4" />
</Button>
@@ -197,7 +197,7 @@ function SelectBy(props: {
props.onChange(v as SortyById);
}}
>
<SelectTrigger className="min-w-[200px] capitalize">
<SelectTrigger className="min-w-[200px] capitalize bg-muted/50">
Sort by {valueToLabel[props.value]}
</SelectTrigger>
<SelectContent>
@@ -8,6 +8,7 @@ import { usePathname } from "next/navigation";
export function SidebarLink(props: {
href: string;
label: string;
onClick?: () => void;
}) {
const pathname = usePathname();
@@ -15,8 +16,9 @@ export function SidebarLink(props: {
<Button asChild variant="ghost">
<Link
href={props.href}
onClick={props.onClick}
className={cn(
"w-full text-muted-foreground text-left !justify-start",
"w-full text-muted-foreground text-left !justify-start font-normal",
pathname === props.href && "text-foreground",
)}
>
@@ -0,0 +1,45 @@
import { SidebarLink } from "./SidebarLink";
import { getTeamSettingsLinks } from "./getTeamSettingsLinks";
export function TeamSettingsSidebar(props: {
teamSlug: string;
}) {
const teamLinks = getTeamSettingsLinks(props.teamSlug);
const teamGroupLinks = teamLinks.filter((link) => link.group === "team");
const accountGroupLinks = teamLinks.filter(
(link) => link.group === "account",
);
return (
<aside className="w-[250px] hidden lg:flex flex-col gap-4 sticky top-0 self-start lg:-ml-2 grow-0 shrink-0">
<RenderLinkGroup links={teamGroupLinks} title="Team" />
<RenderLinkGroup links={accountGroupLinks} title="Account" />
</aside>
);
}
function RenderLinkGroup(props: {
links: Array<{
name: string;
href: string;
}>;
title: string;
}) {
return (
<>
<div className="flex items-center gap-1.5 px-4">
{/* TODO - using placeholder for now */}
<div className="size-4 bg-muted rounded-full border" />
<p className="text-muted-foreground text-xs">{props.title}</p>
</div>
<ul className="flex flex-col gap-0.5">
{props.links.map((link) => (
<li key={link.href}>
<SidebarLink href={link.href} label={link.name} />
</li>
))}
</ul>
</>
);
}
@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { getTeamSettingsLinks } from "./getTeamSettingsLinks";
export function TeamSettingsMobileNav(props: {
teamSlug: string;
showFull: boolean;
setShowFull: (show: boolean) => void;
activeLink: { name: string; href: string } | undefined;
}) {
const { showFull, setShowFull, activeLink } = props;
const teamLinks = getTeamSettingsLinks(props.teamSlug);
if (!showFull) {
return (
<div className="py-4 px-4 border-b flex items-center gap-2 text-muted-foreground">
<Link
href={teamLinks[0].href}
className="inline-flex items-center gap-1"
onClick={() => {
setShowFull(true);
}}
>
Team Settings
</Link>
{activeLink && activeLink !== teamLinks[0] && (
<>
<span aria-hidden className="opacity-50 font-semibold">
/
</span>
<span className="text-foreground">{activeLink.name}</span>
</>
)}
</div>
);
}
return (
<aside className="flex flex-col gap-5 pb-20">
<ul className="flex flex-col gap-1">
{teamLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="p-4 border-b flex"
onClick={() => {
if (link === teamLinks[0]) {
setShowFull(false);
}
}}
>
{link.name}
</Link>
</li>
))}
</ul>
</aside>
);
}
@@ -0,0 +1,37 @@
export function getTeamSettingsLinks(teamSlug: string) {
const prefix = `/team/${teamSlug}/~/settings`;
const teamLinks: Array<{
name: string;
href: string;
group: "team" | "account";
}> = [
{
name: "General",
href: `${prefix}`,
group: "team",
},
{
name: "Members",
href: `${prefix}/members`,
group: "team",
},
{
name: "Billing",
href: `${prefix}/billing`,
group: "team",
},
{
name: "Credits",
href: `${prefix}/credits`,
group: "team",
},
{
name: "My Notifications",
href: `${prefix}/notifications`,
group: "account",
},
];
return teamLinks;
}
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BadgeContainer } from "../../../../../../../stories/utils";
import { DangerSettingCard } from "./DangerSettingCard";
const meta = {
title: "Team/Settings/components/DangerCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
function Story(props: { theme: "light" | "dark" }) {
return (
<div
className={`min-h-screen bg-background text-foreground ${
props.theme === "light" ? "light" : "dark"
}`}
data-theme={props.theme}
>
<div className="lg:p-10 container max-w-[1000px] flex flex-col gap-8">
<BadgeContainer label="Base">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isLoading={false}
/>
</BadgeContainer>
<BadgeContainer label="Loading">
<DangerSettingCard
title="This is a title"
description="This is a description"
buttonLabel="Some Action"
buttonOnClick={() => {}}
isLoading={true}
/>
</BadgeContainer>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
export function DangerSettingCard(props: {
title: string;
description: string;
buttonLabel: string;
buttonOnClick: () => void;
isLoading: boolean;
}) {
return (
<div className="border border-red-500/70 rounded-lg overflow-hidden">
<div className="px-4 lg:px-6 py-6 bg-muted/50">
<h3 className="text-xl font-semibold tracking-tight">{props.title}</h3>
<p className="text-foreground text-sm mt-1.5 mb-4">
{props.description}
</p>
</div>
<div className="bg-red-100 dark:bg-red-500/20 px-4 lg:px-6 py-4 flex justify-end border-red-500/70 border-t">
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
onClick={props.buttonOnClick}
>
{props.isLoading && <Spinner className="size-3" />}
{props.buttonLabel}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import type { Team } from "@/api/team";
import { Toaster } from "@/components/ui/sonner";
import type { Meta, StoryObj } from "@storybook/react";
import SettingsLayout from "../layout";
import {
DeleteTeamCard,
GeneralSettingsPage,
LeaveTeamCard,
} from "./GeneralSettingsPage";
const meta = {
title: "Team/Settings/General",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
const testTeam: Team = {
id: "team-id-foo-bar",
name: "Team XYZ",
slug: "team-slug-foo-bar",
createdAt: "2023-07-07T19:21:33.604Z",
updatedAt: "2024-07-11T00:01:02.241Z",
deletedAt: null,
bannedAt: null,
billingStatus: "validPayment",
billingPlan: "free",
};
function Story(props: { theme: "light" | "dark" }) {
return (
<div
className={`bg-background min-h-screen text-foreground ${props.theme === "light" ? "light" : "dark"}`}
data-theme={props.theme}
>
<SettingsLayout
params={{
team_slug: testTeam.slug,
}}
>
<GeneralSettingsPage team={testTeam} />
</SettingsLayout>
<ComponentVariantions />
<Toaster richColors />
</div>
);
}
function ComponentVariantions() {
return (
<div className="pt-20 border-t p-6">
<div className="max-w-[1100px] mx-auto">
<h2 className="mb-5 text-3xl"> Component variations </h2>
<div className="flex flex-col gap-6">
<LeaveTeamCard enabled={true} />
<LeaveTeamCard enabled={false} />
<DeleteTeamCard enabled={true} />
<DeleteTeamCard enabled={false} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,317 @@
"use client";
import type { Team } from "@/api/team";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { useMutation } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { useState } from "react";
import { toast } from "sonner";
import { DangerSettingCard } from "./DangerSettingCard";
import { SettingsCard } from "./SettingsCard";
export function GeneralSettingsPage(props: {
team: Team;
}) {
const hasPermissionToDelete = false; // TODO
return (
<div className="flex flex-col gap-8">
<TeamNameFormControl team={props.team} />
<TeamSlugFormControl team={props.team} />
<TeamAvatarFormControl />
<TeamIdCard team={props.team} />
<LeaveTeamCard enabled={false} />
<DeleteTeamCard enabled={hasPermissionToDelete} />
</div>
);
}
function TeamNameFormControl(props: {
team: Team;
}) {
const [teamName, setTeamName] = useState(props.team.name);
const maxTeamNameLength = 32;
// TODO - implement
const updateTeamMutation = useMutation({
mutationFn: async (teamName: string) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating team name to", teamName);
throw new Error("Not implemented");
},
});
function handleSave() {
const promises = updateTeamMutation.mutateAsync(teamName);
toast.promise(promises, {
success: "Team name updated successfully",
error: "Failed to update team name",
});
}
return (
<SettingsCard
header={{
title: "Team Name",
description: "This is your team's name on thirdweb",
}}
bottomText={`Please use ${maxTeamNameLength} characters at maximum.`}
saveButton={{
onClick: handleSave,
disabled: teamName.length === 0,
isLoading: updateTeamMutation.isLoading,
}}
errorText={undefined}
noPermissionText={undefined} // TODO
>
<Input
value={teamName}
onChange={(e) => {
setTeamName(e.target.value.slice(0, maxTeamNameLength));
}}
className="md:w-[450px]"
/>
</SettingsCard>
);
}
function TeamSlugFormControl(props: {
team: Team;
}) {
const [teamSlug, setTeamSlug] = useState(props.team.slug);
const [isTeamTaken] = useState(false);
const maxTeamURLLength = 48;
// TODO - implement
const updateTeamMutation = useMutation({
mutationFn: async (_slug: string) => {
// set isTeamTaken to true if team URL is taken
// Fake loading
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating team slug to", _slug);
throw new Error("Not implemented");
},
});
function handleSave() {
const promises = updateTeamMutation.mutateAsync(teamSlug);
toast.promise(promises, {
success: "Team URL updated successfully",
error: "Failed to update team URL",
});
}
return (
<SettingsCard
header={{
title: "Team URL",
description:
"This is your team's URL namespace on thirdweb. All your team's projects and settings can be accessed using this URL",
}}
bottomText={`Please use ${maxTeamURLLength} characters at maximum.`}
errorText={
isTeamTaken
? "Team URL is taken, Please choose another one."
: undefined
}
saveButton={{
onClick: handleSave,
disabled: teamSlug.length === 0,
isLoading: updateTeamMutation.isLoading,
}}
noPermissionText={undefined} // TODO
>
<div className="relative border rounded-lg flex md:w-[450px]">
<div className="bg-muted/50 text-muted-foreground/80 self-stretch flex items-center px-3 text-sm border-r rounded-l-lg font-mono">
thirdweb.com/team/
</div>
<Input
value={teamSlug}
onChange={(e) => {
setTeamSlug(e.target.value.slice(0, maxTeamURLLength));
}}
className="border-0 font-mono truncate"
/>
</div>
</SettingsCard>
);
}
function TeamAvatarFormControl() {
const [teamAvatar, setTeamAvatar] = useState<File>(); // TODO: prefill with team avatar
// TODO - implement
const updateTeamAvatarMutation = useMutation({
mutationFn: async (_avatar: File | undefined) => {
// Fake loading
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating team name to", _avatar);
throw new Error("Not implemented");
},
});
function handleSave() {
const promises = updateTeamAvatarMutation.mutateAsync(teamAvatar);
toast.promise(promises, {
success: "Team avatar updated successfully",
error: "Failed to update team avatar",
});
}
return (
<SettingsCard
bottomText="An avatar is optional but strongly recommended."
saveButton={{
onClick: handleSave,
disabled: false,
isLoading: updateTeamAvatarMutation.isLoading,
}}
noPermissionText={undefined} // TODO
errorText={undefined}
>
<div className="flex flex-row gap-4 md:justify-between">
<div>
<h3 className="text-xl font-semibold tracking-tight">Team Avatar</h3>
<p className="text-foreground text-sm mt-1.5 mb-4 leading-relaxed">
This is your team's avatar. <br /> Click on the avatar to upload a
custom one
</p>
</div>
<FileInput
accept={{ "image/*": [] }}
value={teamAvatar}
setValue={setTeamAvatar}
className="w-[120px] rounded-full"
disableHelperText
/>
</div>
</SettingsCard>
);
}
function TeamIdCard(props: {
team: Team;
}) {
return (
<SettingsCard
header={{
title: "Team ID",
description: "This is your team's ID on thirdweb",
}}
bottomText={"Used when interacting with the thirdweb API"}
noPermissionText={undefined} // TODO
errorText={undefined}
>
<CopyTextButton
textToCopy={props.team.id}
textToShow={props.team.id}
variant="outline"
className="font-mono px-3 w-full lg:w-[450px] justify-between text-muted-foreground bg-card truncate py-2"
tooltip="Copy Team ID"
copyIconPosition="right"
/>
</SettingsCard>
);
}
export function LeaveTeamCard(props: {
enabled: boolean;
}) {
const title = "Leave Team";
const description =
"Revoke your access to this Team. Any resources you've added to the Team will remain.";
// TODO
const leaveTeam = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting team");
throw new Error("Not implemented");
},
});
function handleLeave() {
const promises = leaveTeam.mutateAsync();
toast.promise(promises, {
success: "Left team successfully",
error: "Failed to leave team",
});
}
if (props.enabled) {
return (
<DangerSettingCard
title={title}
description={description}
buttonLabel={title}
buttonOnClick={handleLeave}
isLoading={leaveTeam.isLoading}
/>
);
}
return (
<SettingsCard
header={{
title,
description,
}}
bottomText="To leave this Team, ensure at least one more Member has the Owner role."
errorText={undefined}
noPermissionText={undefined}
>
{" "}
</SettingsCard>
);
}
export function DeleteTeamCard(props: {
enabled: boolean;
}) {
const title = "Delete Team";
const description =
"Permanently remove your team and all of its contents from the thirdweb platform. This action is not reversible - please continue with caution.";
// TODO
const deleteTeam = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting team");
throw new Error("Not implemented");
},
});
function handleDelete() {
const promises = deleteTeam.mutateAsync();
toast.promise(promises, {
success: "Team deleted successfully",
error: "Failed to delete team",
});
}
if (props.enabled) {
return (
<DangerSettingCard
title={title}
description={description}
buttonLabel={title}
buttonOnClick={handleDelete}
isLoading={deleteTeam.isLoading}
/>
);
}
return (
<SettingsCard
header={{
title,
description,
}}
bottomText=""
errorText={undefined}
noPermissionText={"You need additional permissions to delete your team."}
>
{" "}
</SettingsCard>
);
}
@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BadgeContainer } from "../../../../../../../stories/utils";
import { SettingsCard } from "./SettingsCard";
const meta = {
title: "Team/Settings/components/SettingsCard",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
function Story(props: { theme: "light" | "dark" }) {
return (
<div
className={"min-h-screen bg-background text-foreground"}
data-theme={props.theme}
>
<div className="lg:p-10 container max-w-[1100px] flex flex-col gap-10">
<BadgeContainer label="No Header">
<SettingsCard
bottomText="This is bottom text"
header={undefined}
errorText={undefined}
noPermissionText={undefined}
saveButton={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="Header">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
errorText={undefined}
noPermissionText={undefined}
saveButton={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="Error">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
errorText="This is error text"
noPermissionText={undefined}
saveButton={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="No Permission">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
saveButton={{
disabled: false,
isLoading: false,
onClick: () => {},
}}
noPermissionText={"You do not have permission to edit this"}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="Save">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
saveButton={{
disabled: false,
isLoading: false,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="Save Disabled">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
saveButton={{
disabled: true,
isLoading: false,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
<BadgeContainer label="Save Loading">
<SettingsCard
bottomText="This is bottom text"
header={{
title: "Some Title",
description: "This is some description for this card",
}}
saveButton={{
disabled: false,
isLoading: true,
onClick: () => {},
}}
noPermissionText={undefined}
errorText={undefined}
>
<ChildrenPlaceholder />
</SettingsCard>
</BadgeContainer>
</div>
</div>
);
}
function ChildrenPlaceholder() {
return (
<div className="h-10 flex items-center justify-center text-muted-foreground/50 text-xs">
{"CHILDREN GOES HERE"}
</div>
);
}
@@ -0,0 +1,70 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { cn } from "../../../../../../../@/lib/utils";
export function SettingsCard(props: {
bottomText: string;
header?: {
description: string | undefined;
title: string;
};
children: React.ReactNode;
errorText: string | undefined;
noPermissionText: string | undefined;
saveButton?: {
onClick: () => void;
disabled: boolean;
isLoading: boolean;
};
}) {
return (
<div className="border rounded-lg bg-muted/50 relative">
<div
className={cn(
"border-b px-4 lg:px-6 py-6 relative",
props.noPermissionText && "cursor-not-allowed",
)}
>
{props.header && (
<>
<h3 className="text-xl font-semibold tracking-tight">
{props.header.title}
</h3>
{props.header.description && (
<p className="text-foreground text-sm mt-1.5 mb-4">
{props.header.description}
</p>
)}
</>
)}
{props.children}
</div>
<div className="px-4 lg:px-6 py-3 flex items-center justify-between gap-2 min-h-[60px]">
{props.noPermissionText ? (
<p className="text-muted-foreground text-sm">
{props.noPermissionText}
</p>
) : props.errorText ? (
<p className="text-destructive-text text-sm">{props.errorText}</p>
) : (
<p className="text-muted-foreground text-sm">{props.bottomText}</p>
)}
{props.saveButton && !props.noPermissionText && (
<Button
size="sm"
className="gap-2"
onClick={props.saveButton.onClick}
disabled={props.saveButton.disabled || props.saveButton.isLoading}
variant="outline"
>
{props.saveButton.isLoading && <Spinner className="size-3" />}
{props.saveButton.isLoading ? "Saving" : "Save"}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "../../../../../../../@/components/ui/badge";
import { cn } from "../../../../../../../@/lib/utils";
import { TeamSettingsSidebar } from "../_components/sidebar/TeamSettingsSidebar";
import { TeamSettingsMobileNav } from "../_components/sidebar/TeamsMobileNav";
const meta = {
title: "Team/Settings/components/Sidebar",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
function Story(props: { theme: "light" | "dark" }) {
return (
<div
className={`min-h-screen bg-zinc-800 text-foreground ${
props.theme === "light" ? "light" : "dark"
}`}
data-theme={props.theme}
>
<div className="lg:p-10 container max-w-[1100px]">
<div className="grid gap-8 grid-cols-2">
<HighlightContainer title="Desktop">
<TeamSettingsSidebar teamSlug="foo" />
</HighlightContainer>
<div>
<HighlightContainer
className="max-w-[400px]"
title="Mobile - full nav"
>
<TeamSettingsMobileNav
activeLink={{
href: "/team/foo/settings",
name: "Foo",
}}
teamSlug="foo"
setShowFull={() => {}}
showFull={true}
/>
</HighlightContainer>
<HighlightContainer
className="max-w-[400px]"
title="Mobile - mini - General Page"
>
<TeamSettingsMobileNav
activeLink={{
href: "/team/foo/settings",
name: "General",
}}
teamSlug="foo"
setShowFull={() => {}}
showFull={false}
/>
</HighlightContainer>
<HighlightContainer title="Mobile - mini - Billing page">
<TeamSettingsMobileNav
activeLink={{
href: "/team/foo/billing",
name: "Billing",
}}
teamSlug="foo"
setShowFull={() => {}}
showFull={false}
/>
</HighlightContainer>
</div>
</div>
</div>
</div>
);
}
function HighlightContainer(props: {
children: React.ReactNode;
className?: string;
title: string;
}) {
return (
<div>
<Badge className="mb-2 bg-background"> {props.title} </Badge>
<div
className={cn("p-2 mb-10 rounded-lg bg-background", props.className)}
>
{props.children}
</div>
</div>
);
}
@@ -1,4 +1,15 @@
import { SidebarLink } from "./sidebar";
"use client";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { TeamSettingsSidebar } from "./_components/sidebar/TeamSettingsSidebar";
import { TeamSettingsMobileNav } from "./_components/sidebar/TeamsMobileNav";
import { getTeamSettingsLinks } from "./_components/sidebar/getTeamSettingsLinks";
// on the /~/settings page
// - On desktop: show the general settings as usual
// - On mobile: show the full nav instead of page content and when user clicks on the "General Settings" ( first link ) - hide the full nav and show the page content
export default function Layout(props: {
params: {
@@ -6,75 +17,45 @@ export default function Layout(props: {
};
children: React.ReactNode;
}) {
const [_showFullNavOnMobile, setShowFullNavOnMobile] = useState(true);
const pathname = usePathname();
const isSettingsOverview = (pathname || "").endsWith("/~/settings");
const showFullNavOnMobile = _showFullNavOnMobile && isSettingsOverview;
const links = getTeamSettingsLinks(props.params.team_slug);
const activeLink = links.find((link) => pathname === link.href);
return (
<div className="h-full flex gap-2 container grow">
<TeamSettingsSidebar teamSlug={props.params.team_slug} />
<div className="grow"> {props.children} </div>
<div>
{/* Huge page title */}
<div className="py-10 border-b">
<div className="container max-w-[1200px]">
<h1 className="text-3xl tracking-tight font-semibold">
Team Settings
</h1>
</div>
</div>
<div className="md:hidden">
<TeamSettingsMobileNav
teamSlug={props.params.team_slug}
showFull={showFullNavOnMobile}
setShowFull={setShowFullNavOnMobile}
activeLink={activeLink}
/>
</div>
<div className="flex gap-8 container max-w-[1200px] grow [&>*]:py-8 lg:[&>*]:py-10 lg:min-h-[900px]">
<TeamSettingsSidebar teamSlug={props.params.team_slug} />
<div
className={cn(
"grow",
// if showing full nav on mobile - hide the page content
showFullNavOnMobile && "max-sm:hidden",
)}
>
{props.children}
</div>
</div>
</div>
);
}
function getLinks(teamSlug: string) {
const prefix = `/team/${teamSlug}/~/settings`;
const teamLinks: Array<{
name: string;
href: string;
}> = [
{
name: "General",
href: `${prefix}`,
},
{
name: "Members",
href: `${prefix}/members`,
},
{
name: "Billing",
href: `${prefix}/billing`,
},
{
name: "Credits",
href: `${prefix}/credits`,
},
];
return teamLinks;
}
export function TeamSettingsSidebar(props: {
teamSlug: string;
}) {
const teamLinks = getLinks(props.teamSlug);
return (
<aside className="w-[250px] hidden lg:flex py-8 flex-col gap-6">
<div className="flex items-center gap-1.5 px-4">
<div className="size-5 bg-border rounded-full" />
<p className="text-muted-foreground text-sm">Team</p>
</div>
<ul className="flex flex-col gap-2">
{teamLinks.map((link) => (
<li key={link.href}>
<SidebarLink href={link.href} label={link.name} />
</li>
))}
</ul>
<div className="flex items-center gap-1.5 px-4">
{/* TODO - account image - using placeholder for now */}
<div className="size-5 bg-gradient-to-r from-blue-300 to-blue-500 rounded-full" />
<p className="text-muted-foreground text-sm">Account</p>
</div>
<ul>
<li>
<SidebarLink
href={`/team/${props.teamSlug}/~/settings/notifications`}
label="My Notifications"
/>
</li>
</ul>
</aside>
);
}
@@ -0,0 +1,174 @@
"use client";
import type { Team } from "@/api/team";
import type { TeamAccountRole } from "@/api/team-members";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { ExternalLinkIcon, LinkIcon, UserPlus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { getValidTeamPlan } from "../../../../../../components/Header/TeamHeader/getValidTeamPlan";
export function InviteSection(props: {
team: Team;
userHasEditPermission: boolean;
}) {
const teamPlan = getValidTeamPlan(props.team);
let bottomSection: React.ReactNode = null;
const inviteEnabled = teamPlan === "pro" && props.userHasEditPermission;
if (teamPlan !== "pro") {
bottomSection = (
<div className="px-4 lg:px6 py-4 flex items-center justify-between border-t gap-4">
<p className="text-muted-foreground text-sm">
This feature is only available on the{" "}
<Link
href="https://thirdweb.com/pricing"
target="_blank"
className="text-link-foreground hover:text-foreground"
>
Pro plan <ExternalLinkIcon className="size-3 inline" />
</Link>
</p>
{/* TODO - link to billing settings page */}
<Button variant="outline" size="sm">
Upgrade
</Button>
</div>
);
} else if (!props.userHasEditPermission) {
bottomSection = (
<div className="px-4 lg:px-6 py-4 flex items-center justify-between border-t min-h-[60px]">
<p className="text-muted-foreground text-sm">
You don't have permission to invite members
</p>
</div>
);
} else {
bottomSection = (
<div className="py-4 px-4 lg:px-6 flex items-center lg:justify-end border-t ">
<Button variant="outline" size="sm" className="gap-2 max-sm:w-full">
<UserPlus className="size-3" />
Invite
</Button>
</div>
);
}
return (
<section>
<h2 className="text-2xl font-semibold tracking-tight mb-3">Invite</h2>
{/* Card */}
<div className="border rounded-lg bg-muted/50">
{/* Invite via Link */}
<div
className={cn(
"px-4 lg:px-6 py-4",
!inviteEnabled && "cursor-not-allowed",
)}
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<p className="text-foreground text-sm">
Invite new members via email or link
</p>
<Button
size="sm"
variant="outline"
className="gap-2 max-sm:bg-muted/50"
disabled={!inviteEnabled}
>
<LinkIcon className="size-3" />
Copy Link
</Button>
</div>
</div>
<div className="px-4 mt-2 lg:my-0 lg:px-6">
<Separator />
</div>
{/* Invite via Email Send */}
<div className="px-4 lg:px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label
htmlFor="email"
className={cn(
"text-sm mb-1 block",
!inviteEnabled && "text-muted-foreground",
)}
>
Email
</Label>
<Input
type="email"
id="email"
placeholder="[email protected]"
disabled={!inviteEnabled}
className="disabled:bg-muted"
/>
</div>
<div>
<Label
htmlFor="role"
className={cn(
"text-sm mb-1 block",
!inviteEnabled && "text-muted-foreground",
)}
>
Role
</Label>
<RoleSelector disabled={!inviteEnabled} />
</div>
</div>
</div>
{bottomSection}
</div>
</section>
);
}
function RoleSelector(props: {
disabled?: boolean;
}) {
const roles: TeamAccountRole[] = ["OWNER", "MEMBER"];
const [role, setRole] = useState<TeamAccountRole>("MEMBER");
return (
<Select
value={role}
onValueChange={(v) => {
setRole(v as TeamAccountRole);
}}
>
<SelectTrigger
className="capitalize disabled:bg-muted"
disabled={props.disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role} className="capitalize">
{role.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,245 @@
"use client";
import type { Team } from "@/api/team";
import { Input } from "@/components/ui/input";
import { EllipsisIcon, SearchIcon } from "lucide-react";
import type { TeamAccountRole, TeamMember } from "@/api/team-members";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
import { Button } from "../../../../../../../@/components/ui/button";
import { Checkbox } from "../../../../../../../@/components/ui/checkbox";
import { Label } from "../../../../../../../@/components/ui/label";
export function ManageMembersSection(props: {
team: Team;
userHasEditPermission: boolean;
members: TeamMember[];
}) {
let topSection: React.ReactNode = null;
if (!props.userHasEditPermission) {
topSection = (
<div className="border-b p-4">
<p className="text-muted-foreground text-sm">
You don't have permission to manage members
</p>
</div>
);
} else {
topSection = (
<div className="border-b p-4 flex items-center justify-between">
<div className="flex items-center gap-3 lg:gap-4">
<Checkbox
id="select-all"
className="border-muted-foreground data-[state=checked]:border-inverted"
disabled={
!props.userHasEditPermission || props.members.length === 0
}
/>
<Label
htmlFor="select-all"
className="text-muted-foreground cursor-pointer"
>
Select All ({props.members.length})
</Label>
</div>
<Button
size="icon"
variant="ghost"
className="!h-auto !w-auto p-1.5"
disabled={!props.userHasEditPermission || props.members.length === 0}
>
<EllipsisIcon className="size-4 text-muted-foreground" />
</Button>
</div>
);
}
return (
<section>
<h2 className="text-2xl font-semibold tracking-tight">Team Members</h2>
<div className="h-3" />
<FiltersSection disabled={props.members.length === 0} />
<div className="h-4" />
{/* Card */}
<div className="border rounded-lg bg-muted/50">
{/* Top section */}
{topSection}
{props.members.length > 0 && (
<ul>
{props.members.map((member) => {
return (
<li key={member.accountId}>
<MemberRow
member={member}
userHasEditPermission={props.userHasEditPermission}
/>
</li>
);
})}
</ul>
)}
{/* Empty state */}
{props.members.length === 0 && (
<div className="py-10 px-4 flex justify-center">
<p className="text-muted-foreground text-sm">No Members Found</p>
</div>
)}
</div>
</section>
);
}
function MemberRow(props: {
member: TeamMember;
userHasEditPermission: boolean;
}) {
return (
<div className="flex items-center justify-between py-4 px-4 border-b">
<div className="flex items-center gap-3 lg:gap-4">
{/* Checkbox */}
<Checkbox
id="select-all"
className="border-muted-foreground data-[state=checked]:border-inverted"
disabled={!props.userHasEditPermission}
/>
{/* PFP */}
<div className="size-8 lg:size-9 bg-muted border rounded-full" />
<div>
<p className="text-sm font-semibold mb-0.5">
{props.member.account.name}
</p>
<p className="text-sm text-muted-foreground opacity-70">
{props.member.account.email}
</p>
</div>
</div>
<div className="flex items-center gap-4 lg:gap-6">
<p className="capitalize text-xs lg:text-sm text-muted-foreground">
{props.member.role.toLowerCase()}
</p>
<Button
size="icon"
variant="ghost"
className="!h-auto !w-auto p-1.5"
disabled={!props.userHasEditPermission}
>
<EllipsisIcon className="size-4 text-muted-foreground" />
</Button>
</div>
</div>
);
}
function FiltersSection(props: {
disabled: boolean;
}) {
return (
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
{/* Search */}
<div className="relative grow">
<SearchIcon className="absolute top-1/2 left-3 transform -translate-y-1/2 text-muted-foreground size-4" />
<Input
placeholder="Search Team members"
className="bg-muted/50 pl-9"
disabled={props.disabled}
/>
</div>
<div className="grid grid-cols-2 lg:flex items-center gap-3">
<RoleSelector disabled={props.disabled} />
<SortMembersBy disabled={props.disabled} />
</div>
</div>
);
}
type MemberSortId = "date" | "a-z" | "z-a";
function SortMembersBy(props: {
disabled?: boolean;
}) {
const [sortBy, setSortBy] = useState<MemberSortId>("date");
const valueToLabel: Record<MemberSortId, string> = {
date: "Date",
"a-z": "Name (A-Z)",
"z-a": "Name (Z-A)",
};
const sortByIds: MemberSortId[] = ["date", "a-z", "z-a"];
return (
<Select
value={sortBy}
onValueChange={(v) => {
setSortBy(v as "date" | "a-z" | "z-a");
}}
>
<SelectTrigger
className="capitalize disabled:bg-muted lg:w-[150px] bg-muted/50"
disabled={props.disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortByIds.map((id) => (
<SelectItem key={id} value={id} className="capitalize">
{valueToLabel[id]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function RoleSelector(props: {
disabled?: boolean;
}) {
const roles: (TeamAccountRole | "ALL ROLES")[] = [
"OWNER",
"MEMBER",
"ALL ROLES",
];
const [role, setRole] = useState<TeamAccountRole | "ALL ROLES">("ALL ROLES");
return (
<Select
value={role}
onValueChange={(v) => {
setRole(v as TeamAccountRole);
}}
>
<SelectTrigger
className="capitalize disabled:bg-muted lg:w-[150px] bg-muted/50"
disabled={props.disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role} className="capitalize">
{role.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,39 @@
import type { TeamAccountRole } from "@/api/team-members";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
export function RoleSelector(props: {
disabled?: boolean;
}) {
const roles: TeamAccountRole[] = ["OWNER", "MEMBER"];
const [role, setRole] = useState<TeamAccountRole>("MEMBER");
return (
<Select
value={role}
onValueChange={(v) => {
setRole(v as TeamAccountRole);
}}
>
<SelectTrigger
className="capitalize disabled:bg-muted w-[150px]"
disabled={props.disabled}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role} className="capitalize">
{role.toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,168 @@
import type { Team } from "@/api/team";
import type { TeamAccountRole, TeamMember } from "@/api/team-members";
import { Toaster } from "@/components/ui/sonner";
import type { Meta, StoryObj } from "@storybook/react";
import {
BadgeContainer,
useSetStoryTheme,
} from "../../../../../../../stories/utils";
import SettingsLayout from "../layout";
import { InviteSection } from "./InviteSection";
import { ManageMembersSection } from "./ManageMembersSection";
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
const meta = {
title: "Team/Settings/Members",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
const freeTeam: Team = {
id: "team-id-foo-bar",
name: "Team XYZ",
slug: "team-slug-foo-bar",
createdAt: "2023-07-07T19:21:33.604Z",
updatedAt: "2024-07-11T00:01:02.241Z",
deletedAt: null,
bannedAt: null,
billingStatus: "validPayment",
billingPlan: "free",
};
const proTeam: Team = {
id: "team-id-foo-bar",
name: "Team XYZ",
slug: "team-slug-foo-bar",
createdAt: "2023-07-07T19:21:33.604Z",
updatedAt: "2024-07-11T00:01:02.241Z",
deletedAt: null,
bannedAt: null,
billingStatus: "validPayment",
billingPlan: "pro",
};
function createMemberStub(id: string, role: TeamAccountRole): TeamMember {
const date = new Date();
// add random time to the date
date.setHours(Math.floor(Math.random() * 24));
const member: TeamMember = {
account: {
email: `user-${id}@foo.com`,
name: `username-${id}`,
},
accountId: `account-id-${id}`,
createdAt: date,
deletedAt: null,
role: role,
teamId: "team-id-foo-bar",
updatedAt: new Date(),
};
return member;
}
const membersStub: TeamMember[] = [
createMemberStub("1", "OWNER"),
createMemberStub("2", "MEMBER"),
createMemberStub("3", "OWNER"),
];
function Story(props: { theme: "light" | "dark" }) {
useSetStoryTheme(props.theme);
return (
<div
className={`bg-background min-h-screen text-foreground ${props.theme === "light" ? "light" : "dark"}`}
data-theme={props.theme}
>
<SettingsLayout
params={{
team_slug: freeTeam.slug,
}}
>
<TeamMembersSettingsPage
team={proTeam}
userHasEditPermission={true}
members={membersStub}
/>
</SettingsLayout>
<CompVariants />
<Toaster richColors />
</div>
);
}
function CompVariants() {
return (
<div className="py-10 border-t">
<div className="max-w-[1000px] mx-auto px-4">
<h2 className="text-3xl font-semibold py-4"> Invite Variants </h2>
{/* Invite */}
<div className="flex flex-col gap-10">
<BadgeContainer label="Not a Pro Team">
<InviteSection team={freeTeam} userHasEditPermission={false} />
</BadgeContainer>
<BadgeContainer label="Pro Team, User does not have permission">
<InviteSection team={proTeam} userHasEditPermission={false} />
</BadgeContainer>
<BadgeContainer label="Pro, User has permission">
<InviteSection team={proTeam} userHasEditPermission={true} />
</BadgeContainer>
</div>
<div className="my-10" />
{/* Invite */}
<div className="flex flex-col gap-10">
<BadgeContainer label="Pro Team, has permission">
<ManageMembersSection
team={proTeam}
userHasEditPermission={true}
members={membersStub}
/>
</BadgeContainer>
<BadgeContainer label="Not a Pro Team, No permission">
<ManageMembersSection
team={freeTeam}
userHasEditPermission={false}
members={membersStub}
/>
</BadgeContainer>
<BadgeContainer label="Pro Team, No permission">
<ManageMembersSection
team={proTeam}
userHasEditPermission={false}
members={membersStub}
/>
</BadgeContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import type { Team } from "@/api/team";
import type { TeamMember } from "@/api/team-members";
import { InviteSection } from "./InviteSection";
import { ManageMembersSection } from "./ManageMembersSection";
export function TeamMembersSettingsPage(props: {
team: Team;
userHasEditPermission: boolean;
members: TeamMember[];
}) {
return (
<div>
<InviteSection
team={props.team}
userHasEditPermission={props.userHasEditPermission}
/>
<div className="h-10" />
<ManageMembersSection
team={props.team}
userHasEditPermission={props.userHasEditPermission}
members={props.members}
/>
</div>
);
}
@@ -1,9 +1,28 @@
export default function Page() {
import { getTeamBySlug } from "@/api/team";
import { getMembers } from "@/api/team-members";
import { notFound } from "next/navigation";
import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage";
export default async function Page(props: {
params: {
team_slug: string;
};
}) {
const [team, members] = await Promise.all([
getTeamBySlug(props.params.team_slug),
getMembers(props.params.team_slug),
]);
if (!team) {
notFound();
}
return (
<div className="h-full py-6 container flex items-center justify-center">
<h1 className="text-4xl tracking-tighter text-muted-foreground">
Member Settings
</h1>
</div>
<TeamMembersSettingsPage
team={team}
members={members}
// TODO
userHasEditPermission={true}
/>
);
}
@@ -1,9 +1,17 @@
export default function Page() {
return (
<div className="h-full py-6 container flex items-center justify-center">
<h1 className="text-4xl tracking-tighter text-muted-foreground">
General Settings
</h1>
</div>
);
import { getTeamBySlug } from "@/api/team";
import { notFound } from "next/navigation";
import { GeneralSettingsPage } from "./general/GeneralSettingsPage";
export default async function Page(props: {
params: {
team_slug: string;
};
}) {
const team = await getTeamBySlug(props.params.team_slug);
if (!team) {
notFound();
}
return <GeneralSettingsPage team={team} />;
}
@@ -15,7 +15,7 @@ export function ConnectMobileSidebar(props: {
links={props.links}
trigger={
<Button
className="w-full lg:hidden text-left justify-between gap-2 bg-muted"
className="w-full lg:hidden text-left justify-between gap-2"
variant="outline"
>
{activeLink?.label || "Connect"}
@@ -29,7 +29,7 @@ export function AccountButton(props: {
variant="ghost"
>
{/* TODO - replace with account image */}
<div className="size-9 rounded-full bg-gradient-to-r from-blue-300 to-blue-500" />
<div className="size-9 rounded-full bg-muted border" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -103,6 +103,7 @@ const AppHeader: React.FC = () => {
py={3}
as="header"
alignItems="center"
className="!px-4 lg:!px-6"
>
<Flex align="center" gap={4}>
<Link href="/dashboard">
@@ -177,7 +178,8 @@ const AppHeader: React.FC = () => {
{/* Tabs */}
<TabLinks
className="w-full px-4"
className="w-full"
tabContainerClassName="px-4 lg:px-6"
links={[
{
href: "/dashboard",
@@ -28,6 +28,7 @@ interface IFileInputProps {
showPreview?: boolean;
children?: React.ReactNode;
className?: string;
disableHelperText?: boolean;
}
export const FileInput: React.FC<IFileInputProps> = ({
@@ -44,6 +45,7 @@ export const FileInput: React.FC<IFileInputProps> = ({
showPreview = true,
className,
previewMaxWidth,
disableHelperText,
}) => {
const onDrop = useCallback<
<T extends File>(
@@ -162,9 +164,11 @@ export const FileInput: React.FC<IFileInputProps> = ({
<div className="p-3 bg-background rounded-full flex items-center justify-center border border-border">
<FiUpload className="size-5" />
</div>
<p className="text-sm text-center">
Upload {helperTextOrFile}
</p>
{!disableHelperText && (
<p className="text-sm text-center">
Upload {helperTextOrFile}
</p>
)}
</div>
)}
</div>
+17 -18
View File
@@ -3,28 +3,36 @@ import type { Meta, StoryObj } from "@storybook/react";
const meta = {
title: "Shadcn/Buttons",
component: Story,
component: Variants,
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Story>;
} satisfies Meta<typeof Variants>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllVariants: Story = {
args: {},
export const Light: Story = {
args: {
theme: "light",
},
};
export const Dark: Story = {
args: {
theme: "dark",
},
};
function Variants(props: {
theme: "light" | "dark";
}) {
return (
<div data-theme={props.theme}>
<p className="text-sm mb-2 border px-3 py-2 bg-muted inline-block rounded-xl text-foreground">
{props.theme}
</p>
<div className="flex gap-6 p-6 bg-background text-foreground border rounded-lg">
<div
data-theme={props.theme}
className="min-h-screen bg-background p-6 text-foreground"
>
<div className="flex gap-6">
<Button> Default </Button>
<Button variant="primary"> Primary </Button>
<Button variant="secondary"> Secondary </Button>
@@ -35,12 +43,3 @@ function Variants(props: {
</div>
);
}
function Story() {
return (
<div className="flex flex-col gap-6">
<Variants theme="light" />
<Variants theme="dark" />
</div>
);
}
@@ -1,181 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useLayoutEffect } from "react";
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
import type { Project } from "../@/api/projects";
import type { Team } from "../@/api/team";
import { thirdwebClient } from "../@/constants/client";
import {
TeamHeaderDesktopUI,
TeamHeaderMobileUI,
} from "../app/components/Header/TeamHeader/TeamHeaderUI";
const meta = {
title: "Shadcn/Header",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Dark: Story = {
args: {
theme: "dark",
},
};
export const Light: Story = {
args: {
theme: "light",
},
};
function createFakeProject(id: string, teamId: string) {
const project: Project = {
bundleIds: [] as string[],
createdAt: new Date(),
domains: [] as string[],
id: id,
updatedAt: new Date(),
teamId: teamId,
redirectUrls: [] as string[],
slug: `project-${id}`,
name: `Project ${id}`,
publishableKey: "pb-key",
lastAccessedAt: null,
deletedAt: null,
bannedAt: null,
};
return project;
}
const teamsAndProjects: Array<{ team: Team; projects: Project[] }> = [
{
team: {
billingPlan: "free",
billingStatus: "",
name: "Team 1",
slug: "team-1",
},
projects: [
createFakeProject("t1p1", "team-1"),
createFakeProject("t1p2", "team-1"),
createFakeProject("t1p3", "team-1"),
createFakeProject("t1p4", "team-1"),
],
},
{
team: {
billingPlan: "pro",
billingStatus: "validPayment",
name: "Team 3",
slug: "team-3",
},
projects: [
createFakeProject("t2p1", "team-2"),
createFakeProject("t2p2", "team-2"),
],
},
{
team: {
billingPlan: "growth",
billingStatus: "validPayment",
name: "Team 2",
slug: "team-2",
},
projects: [createFakeProject("t3p1", "team-3")],
},
];
function Variants(props: {
theme: "light" | "dark";
type: "mobile" | "desktop";
}) {
const Comp =
props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI;
return (
<ThirdwebProvider>
<ApplyTheme theme={props.theme} />
<div className="flex flex-col gap-6">
<span className="text-white font-bold">
Connected, No Current Project, Free
</span>
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[0].team}
currentProject={undefined}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButton client={thirdwebClient} />}
/>
<span className="text-white font-bold">
Not yet Connected, No email, No Current Project, Growth
</span>
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[1].team}
currentProject={undefined}
email={undefined}
logout={() => {}}
connectButton={<ConnectButton client={thirdwebClient} />}
/>
<span className="text-white font-bold">
Not yet Connected, No Current Project, Pro
</span>
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[2].team}
currentProject={undefined}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButton client={thirdwebClient} />}
/>
<span className="text-white font-bold">
Not yet Connected, Current Project, Pro
</span>
<Comp
teamsAndProjects={teamsAndProjects}
currentTeam={teamsAndProjects[2].team}
currentProject={teamsAndProjects[2].projects[0]}
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButton client={thirdwebClient} />}
/>
</div>
</ThirdwebProvider>
);
}
function Story(props: { theme: "light" | "dark" }) {
return (
<div className="bg-zinc-700 p-4">
<h2 className="text-3xl mb-2"> Desktop </h2>
<Variants theme={props.theme} type="desktop" />
<h2 className="mt-10 text-3xl mb-2"> Mobile </h2>
<div className="w-[400px]">
<Variants theme={props.theme} type="mobile" />
</div>
</div>
);
}
function ApplyTheme(props: { theme: "light" | "dark" }) {
const { theme } = props;
useLayoutEffect(() => {
if (theme === "light" || theme === "dark") {
document.body.setAttribute("data-theme", theme);
}
}, [theme]);
return null;
}
+37
View File
@@ -0,0 +1,37 @@
import { useLayoutEffect } from "react";
import { Badge } from "../@/components/ui/badge";
export function useSetStoryTheme(theme: "light" | "dark") {
useLayoutEffect(() => {
if (theme === "light") {
document.documentElement.classList.remove("dark");
} else {
document.documentElement.classList.add("dark");
}
}, [theme]);
}
export function StoryBadge(props: {
label: string;
}) {
return (
<Badge
className="self-start mb-3 text-xs px-3 py-1 font-normal bg-background font-mono text-muted-foreground"
variant="outline"
>
{props.label}
</Badge>
);
}
export function BadgeContainer(props: {
label: string;
children: React.ReactNode;
}) {
return (
<div>
<StoryBadge label={props.label} />
<div className="bg-background">{props.children}</div>
</div>
);
}