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:
@@ -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" />
|
||||
|
||||
+6
-3
@@ -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" />
|
||||
)}
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
+3
-1
@@ -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",
|
||||
)}
|
||||
>
|
||||
+45
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+60
@@ -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>
|
||||
);
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+61
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+83
@@ -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>
|
||||
);
|
||||
}
|
||||
+317
@@ -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>
|
||||
);
|
||||
}
|
||||
+165
@@ -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>
|
||||
);
|
||||
}
|
||||
+110
@@ -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>
|
||||
);
|
||||
}
|
||||
+245
@@ -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>
|
||||
);
|
||||
}
|
||||
+168
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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} />;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user