[Feature] Initial Projects and Teams setup in dashboard (#3682)

<!-- start pr-codex -->

## PR-Codex overview
This PR adds new pages and components related to team and project settings, navigation improvements, and styling updates.

### Detailed summary
- Added new pages for settings, contracts, and various connect options
- Implemented navigation improvements with NavLink and SidebarLink components
- Styling updates for scrollbar and badge variants
- Created components like TWAutoConnect and TeamTabs for better UI/UX

> The following files were skipped due to too many changes: `apps/dashboard/src/@/components/ui/badge.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/SearchInput.tsx`, `apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ConnectMobileSidebar.tsx`, `apps/dashboard/src/app/team/[team_slug]/components/active-wallet-logo.tsx`, `apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/layout.tsx`, `apps/dashboard/src/components/app-layouts/provider-setup.tsx`, `apps/dashboard/src/@/components/ui/select.tsx`, `apps/dashboard/src/@/components/blocks/BurgerMenuButton.tsx`, `apps/dashboard/.storybook/preview.tsx`, `apps/dashboard/src/components/icons/ChainIcon.tsx`, `apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/layout.tsx`, `apps/dashboard/src/@/api/team-members.ts`, `apps/dashboard/src/app/components/Header/TeamHeader/ProjectSelectorMobileMenuButton.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/layout.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamSelectorMobileMenuButton.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamHeader.tsx`, `apps/dashboard/src/@/api/team.ts`, `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/layout.tsx`, `apps/dashboard/src/@/api/projects.ts`, `apps/dashboard/src/app/team/[team_slug]/[project_slug]/layout.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/ResourcesDropdownButton.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamAndProjectSelectorPopoverButton.tsx`, `apps/dashboard/src/app/team/[team_slug]/components/account-button.client.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/ProjectSelectorUI.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamSelectionUI.tsx`, `apps/dashboard/src/components/cmd-k-search/index.tsx`, `apps/dashboard/src/@/components/ui/tabs.tsx`, `apps/dashboard/src/stories/Header.stories.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/MobileBurgerMenuButton.tsx`, `apps/dashboard/src/app/components/Header/TeamHeader/TeamHeaderUI.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/TeamOverviewPage.tsx`

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

<!-- end pr-codex -->
This commit is contained in:
jnsdls
2024-08-27 21:59:13 +00:00
parent a716bbbce5
commit 1ef192100c
61 changed files with 2349 additions and 92 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
import type { StorybookConfig } from "@storybook/nextjs";
import { dirname, join } from "node:path";
import type { StorybookConfig } from "@storybook/nextjs";
/**
* This function is used to resolve the absolute path of a package.
+12 -3
View File
@@ -1,13 +1,16 @@
import type { Preview } from "@storybook/react";
import "@/styles/globals.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Inter as interFont } from "next/font/google";
// biome-ignore lint/correctness/noUnusedImports: <explanation>
import React from "react";
import React, { useEffect } from "react";
import { cn } from "../src/@/lib/utils";
// Note: Wrapping the Stoy with AppRouterProviders makes the storybook server time SUPER SLOW
// so let's just not have it there - all stories should be independent from context anyway and just rely on props
const queryClient = new QueryClient();
const fontSans = interFont({
subsets: ["latin"],
variable: "--font-sans",
@@ -25,10 +28,16 @@ const preview: Preview = {
decorators: [
(Story) => {
useEffect(() => {
document.body.className = cn(
"font-sans antialiased",
fontSans.variable,
);
});
return (
<div className={cn("font-sans antialiased", fontSans.variable)}>
<QueryClientProvider client={queryClient}>
<Story />
</div>
</QueryClientProvider>
);
},
],
+73
View File
@@ -0,0 +1,73 @@
import "server-only";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { cookies } from "next/headers";
const THIRDWEB_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
export type Project = {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
bannedAt: Date | null;
domains: string[];
bundleIds: string[];
redirectUrls: string[];
lastAccessedAt: Date | null;
slug: string;
teamId: string;
publishableKey: string;
// image: string; // TODO
};
export async function getProjects(teamSlug: string) {
const cookiesManager = cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
if (!token) {
return [];
}
const teamsRes = await fetch(
`${THIRDWEB_API_HOST}/v1/teams/${teamSlug}/projects`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (teamsRes.ok) {
return (await teamsRes.json())?.result as Project[];
}
return [];
}
export async function getProject(teamSlug: string, projectSlug: string) {
const cookiesManager = cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
if (!token) {
return null;
}
const teamsRes = await fetch(
`${THIRDWEB_API_HOST}/v1/teams/${teamSlug}/projects/${projectSlug}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (teamsRes.ok) {
return (await teamsRes.json())?.result as Project;
}
return null;
}
+53
View File
@@ -0,0 +1,53 @@
import "server-only";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { cookies } from "next/headers";
const THIRDWEB_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
const TeamAccountRole = {
OWNER: "OWNER",
MEMBER: "MEMBER",
} as const;
export type TeamAccountRole =
(typeof TeamAccountRole)[keyof typeof TeamAccountRole];
export type TeamMember = {
account: {
name: string;
email: string | null;
};
} & {
deletedAt: Date | null;
accountId: string;
teamId: string;
createdAt: Date;
updatedAt: Date;
role: TeamAccountRole;
};
export async function getMembers(teamSlug: string) {
const cookiesManager = cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
if (!token) {
return [];
}
const teamsRes = await fetch(
`${THIRDWEB_API_HOST}/v1/teams/${teamSlug}/members`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (teamsRes.ok) {
return (await teamsRes.json())?.result as TeamMember[];
}
return [];
}
+58
View File
@@ -0,0 +1,58 @@
import "server-only";
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
import { cookies } from "next/headers";
const THIRDWEB_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
export type Team = {
name: string;
slug: string;
// image: string; // -> TODO
billingPlan: "pro" | "growth" | "free";
billingStatus: "validPayment" | (string & {}); // what's the other value?
};
export async function getTeamBySlug(slug: string) {
const cookiesManager = cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
if (!token) {
return null;
}
const teamRes = await fetch(`${THIRDWEB_API_HOST}/v1/teams/${slug}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (teamRes.ok) {
return (await teamRes.json())?.result as Team;
}
return null;
}
export async function getTeams() {
const cookiesManager = cookies();
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
const token = activeAccount
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
: null;
if (!token) {
return [];
}
const teamsRes = await fetch(`${THIRDWEB_API_HOST}/v1/teams`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (teamsRes.ok) {
return (await teamsRes.json())?.result as Team[];
}
return [];
}
@@ -2,24 +2,20 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { MenuIcon } from "lucide-react";
import { useState } from "react";
import { NavLink } from "../ui/NavLink";
export function BurgerMenuButton(props: {
export function MobileSidebar(props: {
links?: { href: string; label: React.ReactNode }[];
footer?: React.ReactNode;
trigger: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="icon" variant="outline">
<MenuIcon strokeWidth={1} />
</Button>
</DialogTrigger>
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
<DialogContent
className="p-6 rounded-xl"
className="p-4 rounded-t-xl rounded-b-none"
dialogCloseClassName="hidden"
onClick={(e) => {
if (e.target instanceof HTMLAnchorElement) {
@@ -4,7 +4,7 @@ import { NavLink } from "../ui/NavLink";
export type SidebarContentProps = {
header?: React.ReactNode;
links?: { href: string; label: React.ReactNode }[];
links: { href: string; label: React.ReactNode }[];
className?: string;
};
@@ -12,11 +12,12 @@ export type NavButtonProps = {
export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
const pathname = usePathname();
const isActive = pathname === props.href;
const isActive = pathname ? pathname.startsWith(props.href) : false;
return (
<Link
href={props.href}
className={cn(props.className, isActive && props.activeClassName)}
target={props.href.startsWith("http") ? "_blank" : undefined}
>
{props.children}
</Link>
+2 -4
View File
@@ -8,16 +8,14 @@ const badgeVariants = cva(
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
default: "border-transparent bg-primary/[0.08] text-primary",
secondary:
"border-transparent bg-accent text-accent-foreground hover:bg-accent/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
warning: "border-transparent bg-warning text-warning-foreground",
outline: "text-foreground",
success:
"border-transparent bg-success text-success-foreground hover:bg-success/80",
success: "border-transparent bg-success text-success-foreground",
},
},
defaultVariants: {
+13 -1
View File
@@ -73,7 +73,19 @@ const SelectContent = React.forwardRef<
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
// Fixes https://github.com/radix-ui/primitives/issues/1658
ref={(instance) => {
if (typeof ref === "function") {
ref(instance);
} else if (ref) {
ref.current = instance;
}
if (!instance) return;
instance.ontouchstart = (e) => {
e.preventDefault();
};
}}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
+47 -29
View File
@@ -7,21 +7,31 @@ import { useCallback, useRef, useState } from "react";
import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
import { Button } from "./button";
export type TabLink = {
name: string;
href: string;
isActive: boolean;
isEnabled?: boolean;
};
export function TabLinks(props: {
links: {
name: string;
href: string;
isActive: boolean;
isEnabled?: boolean;
}[];
links: TabLink[];
className?: string;
tabContainerClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLAnchorElement>();
return (
<div className="relative">
<div className={cn("relative", props.className)}>
{/* Bottom line */}
<div className="h-[1px] bg-border absolute bottom-0 left-0 right-0" />
<ScrollShadow scrollableClassName="pb-[8px] relative">
<div className="flex" ref={containerRef}>
<div
className={cn("flex", props.tabContainerClassName)}
ref={containerRef}
>
{props.links.map((tab) => {
return (
<Button
@@ -36,7 +46,7 @@ export function TabLinks(props: {
href={tab.href}
aria-disabled={!tab.isEnabled}
className={cn(
"rounded-lg hover:bg-muted px-3 font-medium text-sm lg:text-base relative h-auto",
"rounded-lg hover:bg-muted px-3 font-normal text-sm lg:text-sm relative h-auto",
!tab.isActive &&
tab.isEnabled &&
"opacity-50 hover:opacity-100",
@@ -56,8 +66,6 @@ export function TabLinks(props: {
className="absolute left-0 bottom-0 h-[2px] bg-foreground rounded-lg fade-in-0 animate-in"
/>
</ScrollShadow>
{/* Bottom line */}
<div className="h-[1px] bg-border -translate-y-[2px]" />
</div>
);
}
@@ -80,6 +88,9 @@ export function TabButtons(props: {
return (
<div className={cn("relative", props.containerClassName)}>
{/* Bottom line */}
<div className="h-[1px] bg-border absolute bottom-0 left-0 right-0" />
<ScrollShadow scrollableClassName="pb-[8px] relative">
<div
className={cn("flex", props.tabContainerClassName)}
@@ -114,8 +125,6 @@ export function TabButtons(props: {
className="absolute left-0 bottom-0 h-[2px] bg-foreground rounded-lg fade-in-0 animate-in"
/>
</ScrollShadow>
{/* Bottom line */}
<div className="h-[1px] bg-border -translate-y-[2px]" />
</div>
);
}
@@ -130,24 +139,33 @@ function useUnderline<El extends HTMLElement>() {
}, []);
useIsomorphicLayoutEffect(() => {
if (activeTabEl && containerRef.current && lineRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${
rect.left - containerRect.left
}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
}, 0);
function update() {
if (activeTabEl && containerRef.current && lineRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${
rect.left - containerRect.left
}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
}, 0);
activeTabEl.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
activeTabEl.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}
update();
// add event listener for resize
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
};
}, [activeTabEl]);
return { containerRef, lineRef, activeTabRef };
+3
View File
@@ -1,9 +1,12 @@
const LOGGED_IN_ONLY_PATHS = [
// anything that _starts_ with /dashboard is logged in only
"/dashboard",
// team pages are logged in only
"/team",
// anything that _starts_ with /cli is logged in only
"/cli",
"/support",
// TODO: add any other logged in only paths here
];
+14
View File
@@ -124,3 +124,17 @@
opacity: 0.5;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar,
.no-scrollbar *::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar,
.no-scrollbar * {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
@@ -27,6 +27,7 @@ import { popularChains } from "../popularChains";
export const CustomConnectWallet = (props: {
loginRequired?: boolean;
connectButtonClassName?: string;
detailsButtonClassName?: string;
}) => {
const loginRequired =
props.loginRequired === undefined ? true : props.loginRequired;
@@ -119,6 +120,9 @@ export const CustomConnectWallet = (props: {
connectButton={{
className: props.connectButtonClassName,
}}
detailsButton={{
className: props.detailsButtonClassName,
}}
chains={allChains}
detailsModal={{
networkSelector: {
@@ -1,7 +1,7 @@
import { BurgerMenuButton } from "@/components/blocks/BurgerMenuButton";
import { MobileSidebar } from "@/components/blocks/MobileSidebar";
import { Sidebar } from "@/components/blocks/Sidebar";
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, TicketCheckIcon } from "lucide-react";
import { ArrowLeftIcon, MenuIcon, TicketCheckIcon } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -86,8 +86,13 @@ export default async function ChainPageLayout({
/>
<div className="flex lg:hidden justify-end">
<BurgerMenuButton
<MobileSidebar
links={sidebarLinks}
trigger={
<Button size="icon" variant="outline">
<MenuIcon strokeWidth={1} />
</Button>
}
footer={
<div className="mt-5 border-t pt-6 pb-2">
<Link
@@ -0,0 +1,192 @@
"use client";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import {
LogOutIcon,
MenuIcon,
Moon,
SettingsIcon,
Sun,
XIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useLayoutEffect, useState } from "react";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import { CmdKSearchModal } from "../../../../components/cmd-k-search";
export function MobileBurgerMenuButton(props: {
email: string | undefined;
logout: () => void;
connectButton: React.ReactNode;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { setTheme, theme } = useTheme();
const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
useLayoutEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "initial";
}
return () => {
document.body.style.overflow = "initial";
};
}, [isMenuOpen]);
return (
<>
<CmdKSearchModal
open={isCMDSearchModalOpen}
setOpen={setIsCMDSearchModalOpen}
/>
<Button
variant="outline"
className="!h-auto p-1"
onClick={() => setIsMenuOpen(true)}
>
<MenuIcon className="size-7 text-muted-foreground" />
</Button>
{isMenuOpen && (
<div
className={
"fixed p-6 inset-0 z-50 bg-background fade-in-0 animate-in duration-200 flex flex-col"
}
>
<Button
variant="ghost"
className="!h-auto p-1 absolute top-4 right-4"
onClick={() => setIsMenuOpen(false)}
>
<XIcon className="size-7 text-muted-foreground" />
</Button>
<SkeletonContainer
skeletonData="[email protected]"
loadedData={props.email}
className="inline-block self-start"
render={(email) => <p className="text-foreground">{email}</p>}
/>
<div className="h-4" />
<div className="[&>button]:!w-full">{props.connectButton}</div>
<div className="h-6" />
<div className="flex flex-col gap-4">
<Link
href="/account"
className="text-muted-foreground text-base hover:text-foreground gap-2 flex items-center "
>
<SettingsIcon className="size-4" />
Account Settings
</Link>
<Button
variant="link"
className="gap-2 py-1 text-base text-start justify-start text-muted-foreground px-0 !h-auto hover:!no-underline hover:text-foreground"
onClick={props.logout}
>
<LogOutIcon className="size-4" />
Log Out
</Button>
</div>
<div className="h-6" />
<Separator />
<div className="h-6" />
<div className="flex flex-col gap-5">
<Button
variant="link"
className="text-muted-foreground text-base gap-2 !p-0 !h-auto hover:!no-underline hover:text-foreground text-left justify-between"
onClick={() => {
setIsCMDSearchModalOpen(true);
setIsMenuOpen(false);
}}
>
Search Contracts
</Button>
<Link
href="/chainlist"
className="text-muted-foreground hover:text-foreground "
>
Chainlist
</Link>
<Link
href="https://playground.thirdweb.com/"
target="_blank"
className="text-muted-foreground hover:text-foreground "
>
Playground
</Link>
<Link
href="/explore"
className="text-muted-foreground hover:text-foreground "
>
Explore Contracts
</Link>
<Link
href="/trending"
className="text-muted-foreground hover:text-foreground "
>
Popular Contracts
</Link>
<Link
href="/"
className="text-muted-foreground text-base hover:text-foreground"
>
Home Page
</Link>
</div>
<div className="mt-auto">
<Separator />
<div className="h-6" />
{/* Theme */}
<div className="flex justify-between gap-2 items-center">
<p className="text-muted-foreground text-base">Theme</p>
<div className="ml-auto flex items-center border px-2 py-0.5 rounded-lg gap-1">
<Button
size="icon"
onClick={() => setTheme("light")}
variant="ghost"
className={cn(
"!p-1 !h-auto !w-auto",
theme === "light" ? "opacity-100" : "opacity-30",
)}
>
<Sun className="size-4" />
</Button>
<Button
size="icon"
onClick={() => setTheme("dark")}
variant="ghost"
className={cn(
"!p-1 !h-auto !w-auto",
theme === "dark" ? "opacity-100" : "opacity-30",
)}
>
<Moon className="size-4" />
</Button>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,45 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronsUpDownIcon } from "lucide-react";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
type ProjectSelectorMobileMenuButtonProps = {
currentProject: Project;
projects: Project[];
team: Team;
};
export function ProjectSelectorMobileMenuButton(
props: ProjectSelectorMobileMenuButtonProps,
) {
return (
<Dialog>
{/* Trigger */}
<DialogTrigger asChild>
<Button
size="icon"
className="px-1 w-auto !h-auto py-2 rounded-xl"
variant="ghost"
aria-label="Select Project"
>
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground hover:text-foreground size-5" />
</Button>
</DialogTrigger>
<DialogContent dialogCloseClassName="hidden" className="p-0">
<DynamicHeight>
<ProjectSelectorUI
currentProject={props.currentProject}
projects={props.projects}
team={props.team}
/>
</DynamicHeight>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon, CirclePlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SearchInput } from "./SearchInput";
export function ProjectSelectorUI(props: {
projects: Project[];
currentProject: Project | undefined;
team: Team;
}) {
const { projects, currentProject, team } = props;
const [searchProjectTerm, setSearchProjectTerm] = useState("");
const filteredProjects = searchProjectTerm
? projects.filter((project) => project.name.includes(searchProjectTerm))
: projects;
return (
<div className="flex flex-col border-l fade-in-0 animate-in duration-300">
<SearchInput
placeholder="Search Projects"
value={searchProjectTerm}
onValueChange={setSearchProjectTerm}
/>
<Separator />
<ScrollShadow
scrollableClassName="max-h-[400px] lg:max-h-[600px]"
className="grow"
>
<div className="flex flex-col p-2">
<h2 className="text-muted-foreground text-xs mx-2 mb-2 mt-2 font-medium">
Projects
</h2>
<ul className="flex flex-col gap-1">
{filteredProjects.map((project) => {
const isSelected = project.slug === currentProject?.slug;
return (
<li key={project.slug}>
<Button
className={cn(
"gap-2 pl-2 w-full justify-between !opacity-100 disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed",
isSelected && "bg-accent",
)}
variant="ghost"
asChild
>
{/* TODO - when we have overview page, remove /connect */}
<Link href={`/team/${team.slug}/${project.slug}/connect`}>
<span className="truncate"> {project.name} </span>
{isSelected && (
<CheckIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</Link>
</Button>
</li>
);
})}
</ul>
</div>
</ScrollShadow>
<div className="p-2 border-t">
<Button
className="px-2 w-full gap-2 justify-start disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed"
variant="ghost"
disabled
>
<CirclePlusIcon className="size-4 text-link-foreground" />
Create Project
<Badge className="ml-auto" variant="secondary">
Soon{"™️"}
</Badge>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDownIcon, CommandIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "../../../../@/components/ui/button";
import { CmdKSearchModal } from "../../../../components/cmd-k-search";
export function ResourcesDropdownButton() {
const [isCMDSearchModalOpen, setIsCMDSearchModalOpen] = useState(false);
return (
<>
<CmdKSearchModal
open={isCMDSearchModalOpen}
setOpen={setIsCMDSearchModalOpen}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
className="text-muted-foreground text-sm gap-1 !p-0 !h-auto hover:!no-underline hover:text-foreground"
>
Resources
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-4 min-w-[230px]" sideOffset={14}>
<div className="flex flex-col gap-4">
<Link
href="/chainlist"
className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-2"
>
Chainlist
</Link>
<Link
href="https://playground.thirdweb.com/"
target="_blank"
className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-2"
>
Playground
</Link>
<Link
href="/explore"
className="text-muted-foreground hover:text-foreground text-sm"
>
Explore Contracts
</Link>
<Link
href="/trending"
className="text-muted-foreground hover:text-foreground text-sm"
>
Popular Contracts
</Link>
<Button
variant="link"
className="text-muted-foreground text-sm gap-2 !p-0 !h-auto hover:!no-underline hover:text-foreground text-left justify-between"
onClick={() => setIsCMDSearchModalOpen(true)}
>
Search Contracts
<span className="items-center flex gap-0.5 text-xs opacity-80">
<CommandIcon className="size-3" />K
</span>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}
@@ -0,0 +1,20 @@
import { Input } from "@/components/ui/input";
import { SearchIcon } from "lucide-react";
export function SearchInput(props: {
placeholder: string;
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative">
<Input
placeholder={props.placeholder}
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="pl-9 py-6 focus-visible:ring-0 rounded-none border-0 bg-transparent focus:shadow-none focus-visible:ring-transparent !ring-offset-transparent"
/>
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
@@ -0,0 +1,86 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
import { TeamSelectionUI } from "./TeamSelectionUI";
type TeamSwitcherProps = {
currentTeam: Team;
currentProject: Project | undefined;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
focus: "project-selection" | "team-selection";
};
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
const [open, setOpen] = useState(false);
const { currentTeam, teamsAndProjects } = props;
const [hoveredTeam, setHoveredTeam] = useState<Team>();
const projectsToShowOfTeam = hoveredTeam || currentTeam;
const projectsToShow = teamsAndProjects.find(
(x) => x.team.slug === projectsToShowOfTeam.slug,
)?.projects;
return (
<Popover
open={open}
onOpenChange={(_open) => {
setOpen(_open);
if (!_open) {
setHoveredTeam(undefined);
}
}}
>
{/* Trigger */}
<PopoverTrigger asChild>
<Button
size="icon"
className="px-1 w-auto !h-auto py-2 rounded-xl"
variant="ghost"
role="combobox"
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" />
</Button>
</PopoverTrigger>
{/* Dropdown */}
<PopoverContent
sideOffset={5}
className="p-0 w-auto rounded-xl "
align={props.focus === "project-selection" ? "center" : "start"}
>
<DynamicHeight>
<div className="flex [&>div]:w-[280px] no-scrollbar">
{/* Left */}
<TeamSelectionUI
currentTeam={currentTeam}
setHoveredTeam={setHoveredTeam}
teamsAndProjects={teamsAndProjects}
/>
{/* Right */}
{projectsToShow && (
<ProjectSelectorUI
currentProject={props.currentProject}
projects={projectsToShow}
team={projectsToShowOfTeam}
/>
)}
</div>
</DynamicHeight>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,50 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { useCallback } from "react";
import { useDashboardRouter } from "../../../../@/lib/DashboardRouter";
import { CustomConnectWallet } from "../../../../@3rdweb-sdk/react/components/connect-wallet";
import { useAccount } from "../../../../@3rdweb-sdk/react/hooks/useApi";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
TeamHeaderMobileUI,
} from "./TeamHeaderUI";
export function TeamHeader(props: {
currentTeam: Team;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
currentProject: Project | undefined;
}) {
const myAccountQuery = useAccount();
const router = useDashboardRouter();
const logout = useCallback(async () => {
// log out the user
try {
await fetch("/api/auth/logout", {
method: "POST",
});
router.push("/login");
} catch (e) {
console.error("Failed to log out", e);
}
}, [router]);
const headerProps: TeamHeaderCompProps = {
currentProject: props.currentProject,
currentTeam: props.currentTeam,
teamsAndProjects: props.teamsAndProjects,
email: myAccountQuery.data?.email,
logout: logout,
connectButton: <CustomConnectWallet />,
};
return (
<div>
<TeamHeaderDesktopUI {...headerProps} className="max-lg:hidden" />
<TeamHeaderMobileUI {...headerProps} className="lg:hidden" />
</div>
);
}
@@ -0,0 +1,190 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { AccountButton } from "../../../team/[team_slug]/components/account-button.client";
import { ThirdwebMiniLogo } from "../../ThirdwebMiniLogo";
import { MobileBurgerMenuButton } from "./MobileBurgerMenuButton";
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
import { getValidTeamPlan } from "./getValidTeamPlan";
export type TeamHeaderCompProps = {
currentTeam: Team;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
currentProject: Project | undefined;
className?: string;
email: string | undefined;
logout: () => void;
connectButton: React.ReactNode;
};
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
const { currentTeam } = props;
const teamPlan = getValidTeamPlan(currentTeam);
return (
<header
className={cn(
"flex flex-row gap-2 items-center bg-background text-foreground justify-between px-6 py-4",
props.className,
)}
>
<div className="flex items-center gap-2">
<ThirdwebMiniLogo className="h-5" />
<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"
>
{/* TODO - replace with team image */}
<div className="bg-border size-7 rounded-full" />
<span className="font-semibold"> {currentTeam.name} </span>
<Badge
variant={
teamPlan === "free"
? "secondary"
: teamPlan === "growth"
? "success"
: "default"
}
className="capitalize"
>
{teamPlan}
</Badge>
</Link>
<TeamAndProjectSelectorPopoverButton
currentProject={props.currentProject}
currentTeam={props.currentTeam}
teamsAndProjects={props.teamsAndProjects}
focus="team-selection"
/>
</div>
{props.currentProject && (
<>
<SlashSeparator />
<div className="flex items-center gap-1">
<Link
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
className="font-semibold text-sm flex flex-row gap-1 items-center"
>
{props.currentProject.name}
</Link>
<TeamAndProjectSelectorPopoverButton
currentProject={props.currentProject}
currentTeam={props.currentTeam}
teamsAndProjects={props.teamsAndProjects}
focus="project-selection"
/>
</div>
</>
)}
</div>
<div className="flex items-center gap-6">
<ResourcesDropdownButton />
<Link
href="/support"
className="text-muted-foreground text-sm hover:text-foreground"
>
Support
</Link>
<Link
href="https://portal.thirdweb.com/"
className="text-muted-foreground text-sm hover:text-foreground"
>
Docs
</Link>
<AccountButton
email={props.email}
logout={props.logout}
connectButton={props.connectButton}
/>
</div>
</header>
);
}
function SlashSeparator() {
return (
<div className="h-5 w-[1px] bg-muted-foreground rotate-[25deg] mx-2" />
);
}
export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
const { currentTeam } = props;
const projects = props.teamsAndProjects.find(
(x) => x.team.slug === props.currentTeam.slug,
)?.projects;
return (
<header
className={cn(
"flex flex-row gap-2 items-center bg-background text-foreground justify-between px-4 py-4",
props.className,
)}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Link
href={`/team/${currentTeam.slug}`}
className={cn(
"font-normal text-sm flex flex-row items-center text-foreground gap-2",
)}
>
{/* TODO - replace with team image */}
<div className="bg-border size-7 rounded-full" />
{!props.currentProject && (
<span className="font-semibold">{currentTeam.name}</span>
)}
</Link>
<TeamSelectorMobileMenuButton
currentTeam={props.currentTeam}
teamsAndProjects={props.teamsAndProjects}
/>
</div>
{props.currentProject && projects && (
<>
<SlashSeparator />
<div className="flex items-center gap-1">
<Link
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
className="font-semibold text-sm truncate max-lg:max-w-[130px]"
>
{props.currentProject.name}
</Link>
<ProjectSelectorMobileMenuButton
currentProject={props.currentProject}
projects={projects}
team={props.currentTeam}
/>
</div>
</>
)}
</div>
<MobileBurgerMenuButton
email={props.email}
logout={props.logout}
connectButton={props.connectButton}
/>
</header>
);
}
@@ -0,0 +1,118 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon, CirclePlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SearchInput } from "./SearchInput";
import { getValidTeamPlan } from "./getValidTeamPlan";
export function TeamSelectionUI(props: {
setHoveredTeam: (team: Team | undefined) => void;
currentTeam: Team;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
}) {
const { setHoveredTeam, currentTeam, teamsAndProjects } = props;
const teamPlan = getValidTeamPlan(currentTeam);
const teams = teamsAndProjects.map((x) => x.team);
const [searchTeamTerm, setSearchTeamTerm] = useState("");
const filteredTeams = searchTeamTerm
? teams.filter((team) => team.name.includes(searchTeamTerm))
: teams;
return (
<div className="flex flex-col">
<SearchInput
placeholder="Search Teams"
value={searchTeamTerm}
onValueChange={setSearchTeamTerm}
/>
<Separator />
<ScrollShadow
scrollableClassName="max-h-[400px] lg:max-h-[600px]"
className="grow"
>
<div className="p-2 flex flex-col">
{/* TODO - onclick */}
<Button
className={cn("w-full justify-between !opacity-100 px-2")}
variant="ghost"
onMouseEnter={() => setHoveredTeam(undefined)}
>
My Account
</Button>
<h2 className="text-muted-foreground text-xs mx-2 mb-2 mt-4 font-medium">
Teams
</h2>
<ul>
{filteredTeams.map((team) => {
const isSelected = team.slug === currentTeam.slug;
return (
// biome-ignore lint/a11y/useKeyWithMouseEvents: <explanation>
<li
key={team.slug}
className="py-0.5"
onMouseOver={() => {
setHoveredTeam(team);
}}
>
<Button
className={cn(
"gap-2 pl-2 w-full justify-between !opacity-100",
isSelected && "bg-accent",
)}
variant="ghost"
asChild
>
<Link href={`/team/${team.slug}`}>
<span className="truncate"> {team.name} </span>
{isSelected && (
<CheckIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</Link>
</Button>
</li>
);
})}
<li className="py-0.5">
<Button
className="px-2 w-full gap-2 justify-start disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed"
variant="ghost"
disabled
>
<CirclePlusIcon className="size-4 text-link-foreground" />
Create Team
<Badge className="ml-auto" variant="secondary">
Soon{"™️"}
</Badge>
</Button>
</li>
</ul>
</div>
</ScrollShadow>
{/* TODO - what do we do on this button click? */}
{/* Bottom */}
{teamPlan !== "pro" && (
<div className="p-2 border-t">
<Button
variant="primary"
className="w-full"
onMouseEnter={() => setHoveredTeam(undefined)}
>
Upgrade Team
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronsUpDownIcon } from "lucide-react";
import { TeamSelectionUI } from "./TeamSelectionUI";
type TeamSwitcherProps = {
currentTeam: Team;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
};
export function TeamSelectorMobileMenuButton(props: TeamSwitcherProps) {
const { currentTeam, teamsAndProjects } = props;
return (
<Dialog>
{/* Trigger */}
<DialogTrigger asChild>
<Button
size="icon"
className="px-1 w-auto !h-auto py-2 rounded-xl"
variant="ghost"
aria-label="Select Project"
>
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground hover:text-foreground size-5" />
</Button>
</DialogTrigger>
<DialogContent dialogCloseClassName="hidden" className="p-0">
<DynamicHeight>
<TeamSelectionUI
currentTeam={currentTeam}
setHoveredTeam={() => {}} // don't care on mobile
teamsAndProjects={teamsAndProjects}
/>
</DynamicHeight>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,17 @@
import type { Team } from "@/api/team";
export function getValidTeamPlan(team: Team): Team["billingPlan"] {
switch (true) {
case team.billingPlan === "pro" && team.billingStatus === "validPayment": {
return "pro";
}
case team.billingPlan === "growth" &&
team.billingStatus === "validPayment": {
return "growth";
}
default: {
return "free";
}
}
}
@@ -0,0 +1,212 @@
"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";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
type SortyById = "name" | "createdAt";
export function TeamOverviewPage(props: {
projects: Project[];
team_slug: string;
}) {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortyById>("createdAt");
let projectsToShow = !searchTerm
? projects
: projects.filter(
(project) =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.publishableKey
.toLowerCase()
.includes(searchTerm.toLowerCase()),
);
if (sortBy === "name") {
projectsToShow = projectsToShow.sort((a, b) =>
a.name.localeCompare(b.name),
);
} else if (sortBy === "createdAt") {
projectsToShow = projectsToShow.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
return (
<div className="container ">
<div className="h-10" />
{/* Filters + Add New */}
<div className="flex flex-col md:flex-row md:items-center gap-4">
<SearchInput value={searchTerm} onValueChange={setSearchTerm} />
<div className="flex gap-4">
<SelectBy value={sortBy} onChange={setSortBy} />
<AddNewButton />
</div>
</div>
<div className="h-6" />
{/* Projects */}
{projectsToShow.length === 0 ? (
<div className="border rounded-lg h-[450px] flex items-center justify-center ">
No projects found
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 ">
{projectsToShow.map((project) => {
return (
<ProjectCard
key={project.id}
project={project}
team_slug={props.team_slug}
/>
);
})}
</div>
)}
<div className="h-10" />
</div>
);
}
function ProjectCard(props: {
project: Project;
team_slug: string;
}) {
const { project, team_slug } = props;
return (
<div
key={project.id}
className="border rounded-lg p-4 relative hover:bg-muted flex items-center gap-4 bg-muted/20 transition-colors"
>
{/* TODO - replace with project image */}
<div className="size-10 rounded-full bg-border shrink-0" />
<div>
<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`}
>
<h2 className="text-base">{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-xs text-muted-foreground my-1">
Created on {format(new Date(project.createdAt), "MMM dd, yyyy")}
</p>
</div>
</div>
);
}
function truncate(str: string, stringLimit: number) {
return str.length > stringLimit ? `${str.slice(0, stringLimit)}...` : str;
}
function SearchInput(props: {
value: string;
onValueChange: (value: string) => void;
}) {
return (
<div className="relative grow">
<Input
placeholder="Search Projects by name or Client ID"
value={props.value}
onChange={(e) => props.onValueChange(e.target.value)}
className="pl-9"
/>
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
function AddNewButton() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="primary" className="gap-2">
Add New
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-2 min-w-[220px]" sideOffset={12}>
<div className="flex flex-col gap-1">
{/* TODO - remove soon when we have it */}
<Button variant="ghost" className="justify-between px-2" disabled>
Project
<Badge variant="outline"> Soon</Badge>
</Button>
{/* TODO - remove soon when we have it */}
<Button variant="ghost" className="justify-between px-2" disabled>
Team Member
<Badge variant="outline"> Soon</Badge>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function SelectBy(props: {
value: SortyById;
onChange: (value: SortyById) => void;
}) {
const values: SortyById[] = ["name", "createdAt"];
const valueToLabel: Record<SortyById, string> = {
name: "Name",
createdAt: "Creation Date",
};
return (
<Select
value={props.value}
onValueChange={(v) => {
props.onChange(v as SortyById);
}}
>
<SelectTrigger className="min-w-[200px] capitalize">
Sort by {valueToLabel[props.value]}
</SelectTrigger>
<SelectContent>
{values.map((value) => (
<SelectItem key={value} value={value}>
{valueToLabel[value]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,55 @@
import { getTeams } from "@/api/team";
import { redirect } from "next/navigation";
import { getProjects } from "../../../../@/api/projects";
import { TeamHeader } from "../../../components/Header/TeamHeader/TeamHeader";
import TeamTabs from "../components/tab-switcher.client";
export default async function TeamLayout(props: {
children: React.ReactNode;
breadcrumbNav: React.ReactNode;
params: { team_slug: string };
}) {
const teams = await getTeams();
const team = teams.find((t) => t.slug === props.params.team_slug);
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
team,
projects: await getProjects(team.slug),
})),
);
if (!team) {
// not a valid team, redirect back to 404
redirect("/404");
}
return (
<div className="h-full flex flex-col">
<TeamHeader
currentTeam={team}
teamsAndProjects={teamsAndProjects}
currentProject={undefined}
/>
<TeamTabs
links={[
{
href: `/team/${props.params.team_slug}`,
name: "Projects",
strictMatch: true,
},
{
href: `/team/${props.params.team_slug}/~/usage`,
name: "Usage",
},
{
href: `/team/${props.params.team_slug}/~/settings`,
name: "Settings",
},
]}
/>
<main className="grow">{props.children}</main>
</div>
);
}
@@ -0,0 +1,12 @@
import { getProjects } from "@/api/projects";
import { TeamOverviewPage } from "./TeamOverviewPage";
export default async function Page(props: {
params: { team_slug: string };
}) {
const projects = await getProjects(props.params.team_slug);
return (
<TeamOverviewPage projects={projects} team_slug={props.params.team_slug} />
);
}
@@ -0,0 +1,9 @@
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">
Billing Settings
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Credits Settings
</h1>
</div>
);
}
@@ -0,0 +1,80 @@
import { SidebarLink } from "./sidebar";
export default function Layout(props: {
params: {
team_slug: string;
};
children: React.ReactNode;
}) {
return (
<div className="h-full flex gap-2 container grow">
<TeamSettingsSidebar teamSlug={props.params.team_slug} />
<div className="grow"> {props.children} </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,9 @@
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">
Member Settings
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Notification Settings
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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>
);
}
@@ -0,0 +1,27 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function SidebarLink(props: {
href: string;
label: string;
}) {
const pathname = usePathname();
return (
<Button asChild variant="ghost">
<Link
href={props.href}
className={cn(
"w-full text-muted-foreground text-left !justify-start",
pathname === props.href && "text-foreground",
)}
>
{props.label}
</Link>
</Button>
);
}
@@ -0,0 +1,7 @@
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">Usage</h1>
</div>
);
}
@@ -0,0 +1,27 @@
"use client";
import { MobileSidebar } from "@/components/blocks/MobileSidebar";
import { Button } from "@/components/ui/button";
import { ChevronDownIcon } from "lucide-react";
import { usePathname } from "next/navigation";
export function ConnectMobileSidebar(props: {
links: { label: string; href: string }[];
}) {
const pathname = usePathname();
const activeLink = props.links.find((link) => pathname === link.href);
return (
<MobileSidebar
links={props.links}
trigger={
<Button
className="w-full lg:hidden text-left justify-between gap-2 bg-muted"
variant="outline"
>
{activeLink?.label || "Connect"}
<ChevronDownIcon className="size-5 text-muted-foreground" />
</Button>
}
/>
);
}
@@ -0,0 +1,9 @@
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">
Account Abstraction
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Analytics
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Ecosystem Wallets
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
In-app wallets
</h1>
</div>
);
}
@@ -0,0 +1,49 @@
import { Sidebar } from "@/components/blocks/Sidebar";
import { ConnectMobileSidebar } from "./ConnectMobileSidebar";
export default function Layout(props: {
params: {
team_slug: string;
project_slug: string;
};
children: React.ReactNode;
}) {
const { team_slug, project_slug } = props.params;
const links: { label: string; href: string }[] = [
{
label: "In-App Wallets",
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
},
{
label: "Analytics",
href: `/team/${team_slug}/${project_slug}/connect/analytics`,
},
{
label: "Ecosystem Wallets",
href: `/team/${team_slug}/${project_slug}/connect/ecosystem`,
},
{
label: "Account Abstraction",
href: `/team/${team_slug}/${project_slug}/connect/account-abstraction`,
},
{
label: "Pay",
href: `/team/${team_slug}/${project_slug}/connect/pay`,
},
{
label: "Playground",
href: "https://playground.thirdweb.com/connect/sign-in/button",
},
];
return (
<div className="h-full container flex gap-4">
<Sidebar links={links} />
<div className="grow py-6">
<ConnectMobileSidebar links={links} />
{props.children}
</div>
</div>
);
}
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
export default function Page(props: {
params: {
team_slug: string;
project_slug: string;
};
}) {
const { team_slug, project_slug } = props.params;
redirect(`/team/${team_slug}/${project_slug}/connect/in-app-wallets`);
}
@@ -0,0 +1,7 @@
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">Pay</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Contracts
</h1>
</div>
);
}
@@ -0,0 +1,9 @@
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">
Engine
</h1>
</div>
);
}
@@ -0,0 +1,66 @@
import { getProjects } from "@/api/projects";
import { getTeams } from "@/api/team";
import { redirect } from "next/navigation";
import { TeamHeader } from "../../../components/Header/TeamHeader/TeamHeader";
import TeamTabs from "../components/tab-switcher.client";
export default async function TeamLayout(props: {
children: React.ReactNode;
breadcrumbNav: React.ReactNode;
params: { team_slug: string; project_slug: string };
}) {
const teams = await getTeams();
const team = teams.find((t) => t.slug === props.params.team_slug);
if (!team) {
// not a valid team, redirect back to 404
redirect("/404");
}
const teamsAndProjects = await Promise.all(
teams.map(async (team) => ({
team,
projects: await getProjects(team.slug),
})),
);
const project = teamsAndProjects
.find((t) => t.team.slug === props.params.team_slug)
?.projects.find((p) => p.slug === props.params.project_slug);
if (!project) {
// not a valid project, redirect back to team page
redirect(`/team/${props.params.team_slug}`);
}
return (
<div className="h-full flex flex-col">
<TeamHeader
currentProject={project}
currentTeam={team}
teamsAndProjects={teamsAndProjects}
/>
<TeamTabs
links={[
{
href: `/team/${props.params.team_slug}/${props.params.project_slug}/connect`,
name: "Connect",
},
{
href: `/team/${props.params.team_slug}/${props.params.project_slug}/contracts`,
name: "Contracts",
},
{
href: `/team/${props.params.team_slug}/${props.params.project_slug}/engine`,
name: "Engine",
},
{
href: `/team/${props.params.team_slug}/${props.params.project_slug}/settings`,
name: "Settings",
},
]}
/>
<main className="grow">{props.children}</main>
</div>
);
}
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
export default function ProjectOverviewPage(props: {
params: { team_slug: string; project_slug: string };
}) {
// TODO: implement overview page for project
// redirect to connect for now
redirect(
`/team/${props.params.team_slug}/${props.params.project_slug}/connect`,
);
}
@@ -0,0 +1,7 @@
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">Usage</h1>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOutIcon, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { cn } from "../../../../@/lib/utils";
export function AccountButton(props: {
email: string | undefined;
logout: () => void;
connectButton: React.ReactNode;
}) {
const { setTheme, theme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="rounded-full !p-0 !h-auto size-10 hover:ring-2 hover:ring-offset-2"
variant="ghost"
>
{/* TODO - replace with account image */}
<div className="size-9 rounded-full bg-gradient-to-r from-blue-300 to-blue-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="p-0 w-[300px] rounded-lg"
align="end"
sideOffset={16}
>
<div className="p-4 pb-5 border-b">
<p className="text-sm text-muted-foreground">{props.email}</p>
<div className="h-3" />
<div className="[&>button]:!w-full">{props.connectButton}</div>
</div>
<div className="flex flex-col gap-4 px-4 py-5">
<Link
href="/account"
className="text-muted-foreground text-sm hover:text-foreground"
>
Account Settings
</Link>
<div className="flex justify-between gap-2 items-center">
<p className="text-muted-foreground text-sm">Theme</p>
<div className="ml-auto flex items-center border px-2 py-0.5 rounded-lg gap-1">
<Button
size="icon"
onClick={() => setTheme("light")}
variant="ghost"
aria-label="Light theme"
className={cn(
"!p-1 !h-auto !w-auto",
theme === "light" ? "opacity-100" : "opacity-30",
)}
>
<Sun className="size-4" />
</Button>
<Button
size="icon"
onClick={() => setTheme("dark")}
variant="ghost"
aria-label="Dark theme"
className={cn(
"!p-1 !h-auto !w-auto",
theme === "dark" ? "opacity-100" : "opacity-30",
)}
>
<Moon className="size-4" />
</Button>
</div>
</div>
<Link
href="/team"
className="text-muted-foreground text-sm hover:text-foreground"
>
Home Page
</Link>
<Button
variant="link"
className="gap-2 py-1 text-start justify-start text-muted-foreground px-0 !h-auto hover:!no-underline hover:text-foreground"
onClick={props.logout}
>
<LogOutIcon className="size-4" />
Log Out
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,28 @@
"use client";
import { cn } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
export function ActiveWalletLogo(props: {
className?: string;
walletId: WalletId | undefined;
}) {
const { walletId } = props;
const imageQuery = useQuery({
queryKey: ["walletInfo", walletId],
queryFn: () => {
if (!walletId) {
throw new Error("No wallet connected");
}
return getWalletInfo(walletId, true);
},
enabled: !!walletId,
});
return imageQuery.data ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageQuery.data} alt="Account" className={props.className} />
) : (
<div className={cn("bg-muted", props.className)} />
);
}
@@ -0,0 +1,11 @@
"use client";
// don't know why - but getting compilation error without adding "use client" even though it's already added
// where we import AutoConnect from in thirdweb/react
import { thirdwebClient } from "@/constants/client";
import { AutoConnect } from "thirdweb/react";
export function TWAutoConnect() {
return <AutoConnect client={thirdwebClient} />;
}
@@ -0,0 +1,22 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink(props: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
return (
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start gap-2",
pathname === props.href && "bg-accent text-accent-foreground",
)}
>
<Link href={props.href}>{props.children}</Link>
</Button>
);
}
@@ -0,0 +1,26 @@
"use client";
import { type TabLink, TabLinks } from "@/components/ui/tabs";
import { usePathname } from "next/navigation";
export default function TeamTabs(props: {
links: (Omit<TabLink, "isActive" | "isEnabled"> & {
strictMatch?: boolean;
})[];
}) {
const pathname = usePathname();
return (
<TabLinks
tabContainerClassName="px-4 lg:px-6"
links={props.links.map((l, i) => ({
...l,
isActive: l.strictMatch
? pathname === l.href
: pathname
? pathname.startsWith(l.href)
: i === 0,
isEnabled: true,
}))}
/>
);
}
@@ -0,0 +1,15 @@
import { AppFooter } from "../../../@/components/blocks/app-footer";
import { TWAutoConnect } from "./components/autoconnect";
export default async function RootTeamLayout(props: {
children: React.ReactNode;
params: { team_slug: string };
}) {
return (
<div className="h-full flex flex-col ">
<div className="grow">{props.children}</div>
<TWAutoConnect />
<AppFooter />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { getTeams } from "@/api/team";
import { redirect } from "next/navigation";
export default async function TeamRootPage() {
// get all teams, then go to the first team
const teams = await getTeams();
const firstTeam = teams[0];
if (!firstTeam) {
return redirect("/404");
}
redirect(`/team/${firstTeam.slug}`);
}
@@ -49,13 +49,19 @@ const THIRDWEB_API_HOST = new URL(
.toString()
.slice(0, -1);
const isLocalHost = THIRDWEB_API_HOST.includes("localhost");
const DOMAIN = isLocalHost
? "thirdweb-dev.com"
: THIRDWEB_API_HOST.replace("https://api.", "");
// do this upfront
setThirdwebDomains({
bundler: `bundler.${THIRDWEB_API_HOST.replace("https://api.", "")}`,
inAppWallet: `embedded-wallet.${THIRDWEB_API_HOST.replace("https://api.", "")}`,
pay: `pay.${THIRDWEB_API_HOST.replace("https://api.", "")}`,
rpc: `rpc.${THIRDWEB_API_HOST.replace("https://api.", "")}`,
storage: `storage.${THIRDWEB_API_HOST.replace("https://api.", "")}`,
bundler: `bundler.${DOMAIN}`,
inAppWallet: `embedded-wallet.${DOMAIN}`,
pay: `pay.${DOMAIN}`,
rpc: `rpc.${DOMAIN}`,
storage: `storage.${DOMAIN}`,
});
export interface DashboardThirdwebProviderProps {
@@ -12,6 +12,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import {
type QueryClient,
@@ -23,7 +24,6 @@ import { useTrack } from "hooks/analytics/useTrack";
import { type TrendingContract, fetchTopContracts } from "lib/search";
import { ArrowRightIcon, CommandIcon, SearchIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { shortenIfAddress } from "utils/usedapp-external";
@@ -77,8 +77,11 @@ function contractTypesenseSearchQuery(
};
}
export const CmdKSearch: React.FC = () => {
const [open, setOpen] = useState(false);
export const CmdKSearchModal = (props: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { open, setOpen } = props;
const trackEvent = useTrack();
const queryClient = useQueryClient();
@@ -93,7 +96,7 @@ export const CmdKSearch: React.FC = () => {
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
}, [setOpen]);
const [searchValue, setSearchValue] = useState("");
@@ -133,13 +136,13 @@ export const CmdKSearch: React.FC = () => {
}, [debouncedSearchValue, searchValue, typesenseSearchQuery.isFetching]);
const [activeIndex, setActiveIndex] = useState(0);
const router = useRouter();
const router = useDashboardRouter();
const handleClose = useCallback(() => {
setOpen(false);
setSearchValue("");
setActiveIndex(0);
}, []);
}, [setOpen]);
// legitimate use-case
// eslint-disable-next-line no-restricted-syntax
@@ -203,28 +206,6 @@ export const CmdKSearch: React.FC = () => {
}
}}
>
<div className="hidden lg:block w-[300px] relative">
<Input
onClick={() => setOpen(true)}
placeholder="Search any contract"
className="bg-transparent pr-4"
/>
<div className="flex items-center text-sm text-muted-foreground absolute right-3 top-1/2 -translate-y-1/2 gap-[2px]">
<CommandIcon className="size-3" /> K
</div>
</div>
<Button
className="lg:hidden p-2"
aria-label="Search any contract"
variant="ghost"
onClick={() => setOpen(true)}
>
<SearchIcon className="size-4" />
</Button>
{/* modal below here */}
<DialogContent
className="p-0 gap-0 z-[10000001]"
dialogOverlayClassName="z-[10000000]"
@@ -308,6 +289,36 @@ export const CmdKSearch: React.FC = () => {
);
};
export const CmdKSearch: React.FC = () => {
const [open, setOpen] = useState(false);
return (
<>
<div className="hidden lg:block w-[300px] relative">
<Input
onClick={() => setOpen(true)}
placeholder="Search any contract"
className="bg-transparent pr-4"
/>
<div className="flex items-center text-sm text-muted-foreground absolute right-3 top-1/2 -translate-y-1/2 gap-[2px]">
<CommandIcon className="size-3" /> K
</div>
</div>
<Button
className="lg:hidden p-2"
aria-label="Search any contract"
variant="ghost"
onClick={() => setOpen(true)}
>
<SearchIcon className="size-4" />
</Button>
<CmdKSearchModal open={open} setOpen={setOpen} />
</>
);
};
interface SearchResultProps {
result: TrendingContract;
isActive: boolean;
@@ -1,25 +1,26 @@
import { Image, type ImageProps, forwardRef } from "@chakra-ui/react";
/* eslint-disable @next/next/no-img-element */
import { replaceIpfsUrl } from "lib/sdk";
import { forwardRef } from "react";
const fallbackIcon = replaceIpfsUrl(
"ipfs://QmU1r24UsmGg2w2RePz98zV5hR3CnjvakLZzB6yH4prPFh/globe.svg",
);
type ChainIconProps = {
type ImageProps = React.ComponentProps<"img">;
type ChainIconProps = ImageProps & {
ipfsSrc?: string;
size: ImageProps["width"];
};
export const ChainIcon = forwardRef<ChainIconProps, typeof Image>(
export const ChainIcon = forwardRef<HTMLImageElement, ChainIconProps>(
({ ipfsSrc, size, ...restProps }, ref) => {
const src = ipfsSrc ? replaceIpfsUrl(ipfsSrc) : fallbackIcon;
// treat size of number as "px"
size = typeof size === "number" ? `${size}px` : size;
// TODO - use sizes to create srcset
return (
<Image
<img
{...restProps}
ref={ref}
// render different image element if src changes to avoid showing old image while loading new one
@@ -30,7 +31,7 @@ export const ChainIcon = forwardRef<ChainIconProps, typeof Image>(
style={{
objectFit: "contain",
}}
loading="lazy"
loading={restProps.loading || "lazy"}
decoding="async"
alt=""
// fallbackSrc is not working
@@ -0,0 +1,181 @@
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;
}