[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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
+4
-8
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+86
@@ -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>
|
||||
);
|
||||
}
|
||||
+27
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+9
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user