Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e066c611 | ||
|
|
0abd8f462c | ||
|
|
5fe9476475 | ||
|
|
5385f6bde1 | ||
|
|
f092b2aeac | ||
|
|
df42167f94 | ||
|
|
edce1bf2f7 | ||
|
|
1f7bf82fe0 | ||
|
|
28c46b3bc4 | ||
|
|
40f01e5e89 | ||
|
|
6077fedd75 | ||
|
|
17e041642f | ||
|
|
05866381b4 | ||
|
|
aabce93330 | ||
|
|
b4e149101d | ||
|
|
0596fa24c2 | ||
|
|
eda37f3031 | ||
|
|
c3800d6c07 | ||
|
|
adbac73065 | ||
|
|
4f2c15f3cd | ||
|
|
af13b5600d | ||
|
|
6991ac57f6 | ||
|
|
bd3e04d717 | ||
|
|
723137820f | ||
|
|
7bad5ef1ad | ||
|
|
52b2ec23e2 | ||
|
|
d6e9def577 | ||
|
|
75855ea345 | ||
|
|
b76a82c303 | ||
|
|
25ad74fb55 | ||
|
|
20a74eb0f5 | ||
|
|
c96e2d9777 | ||
|
|
beb894a355 | ||
|
|
495aa042ba | ||
|
|
02aabbf97d | ||
|
|
4762ca4dd9 | ||
|
|
fe1ff63bf9 | ||
|
|
bcd033b0ef | ||
|
|
052f56a433 | ||
|
|
2166dec9cb | ||
|
|
865c5e3b16 | ||
|
|
5d77096129 | ||
|
|
ba3c8d0ea9 | ||
|
|
5c4b1ae6a6 | ||
|
|
a1d0792f58 | ||
|
|
f914379421 | ||
|
|
8cc1d36a9e | ||
|
|
8a58ca7639 | ||
|
|
9aa5b621ed | ||
|
|
8636c28d0f | ||
|
|
50212762f3 | ||
|
|
a1b17b9de3 | ||
|
|
75b9131dc8 | ||
|
|
25654aed5f | ||
|
|
6353cb7331 | ||
|
|
85facd88e4 | ||
|
|
c321692e5c | ||
|
|
0607d73340 | ||
|
|
7a4fadc38e | ||
|
|
ed39b3d5f5 | ||
|
|
9ddc625c37 | ||
|
|
adf3404261 | ||
|
|
eb88ed65a4 | ||
|
|
8119db08b3 | ||
|
|
bb9ce26d6b | ||
|
|
1ceaa72bef | ||
|
|
efc12ee720 | ||
|
|
f9953d36b1 | ||
|
|
85c951a2e6 | ||
|
|
e8bf4c54d0 | ||
|
|
eb2d469c9e | ||
|
|
bedac13b86 | ||
|
|
be76d991ff | ||
|
|
80e0bfe4c5 | ||
|
|
371edea5be | ||
|
|
baf1a21d69 | ||
|
|
4fcbe10ebc | ||
|
|
42feff3a3a | ||
|
|
7d508aa243 | ||
|
|
83de802c06 | ||
|
|
d8e7458b6c | ||
|
|
763e4390f7 | ||
|
|
2b5101a56d | ||
|
|
f1d087e24e | ||
|
|
9d84aff266 | ||
|
|
e3b4b4fc8c | ||
|
|
f3a6179e37 | ||
|
|
f66bccaba3 | ||
|
|
7dd2869e24 | ||
|
|
0978befdf0 | ||
|
|
1fbc1df7f3 | ||
|
|
aae90406a4 | ||
|
|
2aadeb7266 | ||
|
|
c546b65e17 | ||
|
|
88f89f2d84 |
@@ -5,7 +5,7 @@ module.exports = {
|
||||
"plugin:import/typescript",
|
||||
"plugin:@next/next/recommended",
|
||||
"plugin:promise/recommended",
|
||||
"plugin:storybook/recommended"
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
rules: {
|
||||
"react-compiler/react-compiler": "error",
|
||||
@@ -87,6 +87,12 @@ module.exports = {
|
||||
message:
|
||||
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
|
||||
},
|
||||
{
|
||||
name: "lucide-react",
|
||||
importNames: ["Link", "Table", "Sidebar"],
|
||||
message:
|
||||
'This is likely a mistake. If you really want to import this - postfix the imported name with Icon. Example - "LinkIcon"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -51,28 +51,28 @@ function StoryLayout(props: {
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end p-4 border-b gap-2">
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "default" : "ghost"}
|
||||
>
|
||||
<MoonIcon className="size-5" />
|
||||
</Button>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground min-w-0">
|
||||
<div className="flex justify-end p-4 border-b gap-2">
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "default" : "ghost"}
|
||||
>
|
||||
<MoonIcon className="size-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setTheme("light")}
|
||||
size="sm"
|
||||
variant={theme === "light" ? "default" : "ghost"}
|
||||
>
|
||||
<SunIcon className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTheme("light")}
|
||||
size="sm"
|
||||
variant={theme === "light" ? "default" : "ghost"}
|
||||
>
|
||||
<SunIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grow flex flex-col min-w-0">{props.children}</div>
|
||||
</div>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-12
@@ -32,11 +32,14 @@
|
||||
"@n8tb1t/use-scroll-position": "^2.0.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -46,7 +49,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "1.1.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/nextjs": "^8.26.0",
|
||||
"@sentry/nextjs": "8.30.0",
|
||||
"@shazow/whatsabi": "^0.14.1",
|
||||
"@stripe/react-stripe-js": "^2.7.3",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
@@ -66,20 +69,20 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"ethers": "5.7.2",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "^11.2.12",
|
||||
"framer-motion": "11.5.4",
|
||||
"fuse.js": "7.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.399.0",
|
||||
"next": "14.2.8",
|
||||
"lucide-react": "0.439.0",
|
||||
"next": "14.2.9",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.128.3",
|
||||
"posthog-js": "1.161.3",
|
||||
"posthog-js-opensource": "npm:[email protected]",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -94,7 +97,7 @@
|
||||
"react-intersection-observer": "^9.10.3",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-otp-input": "^3.1.1",
|
||||
"react-query-v5": "npm:@tanstack/react-query@^5.40.1",
|
||||
"react-query-v5": "npm:@tanstack/react-query@5.55.4",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "^2.12.7",
|
||||
@@ -115,9 +118,9 @@
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "^1.5.0",
|
||||
"@next/bundle-analyzer": "^14.2.4",
|
||||
"@next/eslint-plugin-next": "^14.2.4",
|
||||
"@playwright/test": "1.46.1",
|
||||
"@next/bundle-analyzer": "14.2.9",
|
||||
"@next/eslint-plugin-next": "^14.2.9",
|
||||
"@playwright/test": "1.47.0",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-interactions": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
@@ -142,16 +145,16 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"checkly": "^4.8.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "^1.7.3",
|
||||
"eslint-config-biome": "1.8.3",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"eslint-plugin-promise": "^6.2.0",
|
||||
"eslint-plugin-react-compiler": "0.0.0-experimental-51a85ea-20240601",
|
||||
"eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-unused": "^0.0.6",
|
||||
"storybook": "^8.2.9",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"next-unused": {
|
||||
"alias": {},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -270,11 +270,6 @@ function redirects() {
|
||||
destination: "/in-app-wallets",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect/ecosystem/:slug((?!create$)[^/]+)",
|
||||
destination: "/dashboard/connect/ecosystem/:slug/permissions",
|
||||
permanent: false,
|
||||
},
|
||||
// temporarily redirect cli login to support page
|
||||
{
|
||||
source: "/cli/login",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProvider, useColorMode } from "@chakra-ui/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
import chakraTheme from "../../theme";
|
||||
|
||||
export function ChakraProviderSetup(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ChakraProvider theme={chakraTheme}>
|
||||
{props.children}
|
||||
<SyncTheme />
|
||||
</ChakraProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncTheme() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { setColorMode } = useColorMode();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setColorMode(theme === "light" ? "light" : "dark");
|
||||
}, [setColorMode, theme]);
|
||||
|
||||
// handle dashboard with now old "system" set
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (theme === "system") {
|
||||
setTheme("dark");
|
||||
setColorMode("dark");
|
||||
}
|
||||
}, [theme, setTheme, setColorMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,18 +2,44 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
import type { SidebarLink } from "./Sidebar";
|
||||
|
||||
export function MobileSidebar(props: {
|
||||
links?: { href: string; label: React.ReactNode }[];
|
||||
links?: SidebarLink[];
|
||||
footer?: React.ReactNode;
|
||||
trigger: React.ReactNode;
|
||||
trigger?: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const activeLink = props.links?.find((link) => {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
});
|
||||
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full lg:hidden text-left justify-between gap-2 bg-muted/50",
|
||||
props.triggerClassName,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{activeLink?.label}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
|
||||
<DialogTrigger asChild>{props.trigger || defaultTrigger}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="p-4 rounded-t-xl rounded-b-none"
|
||||
dialogCloseClassName="hidden"
|
||||
@@ -31,6 +57,8 @@ export function MobileSidebar(props: {
|
||||
href={link.href}
|
||||
className="!text-left justify-start flex gap-2 h-auto py-3"
|
||||
activeClassName="bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
tracking={link.tracking}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
|
||||
export type SidebarLink = {
|
||||
href: string;
|
||||
label: React.ReactNode;
|
||||
exactMatch?: boolean;
|
||||
tracking?: {
|
||||
category: string;
|
||||
action: string;
|
||||
label: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SidebarContentProps = {
|
||||
header?: React.ReactNode;
|
||||
links: { href: string; label: React.ReactNode; exactMatch?: boolean }[];
|
||||
links: SidebarLink[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Sidebar(props: SidebarContentProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn("w-[240px] flex-shrink-0 hidden lg:block", props.className)}
|
||||
className={cn(
|
||||
"w-[230px] flex-shrink-0 hidden lg:block self-start sticky top-0",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="sticky top-0 pt-8">
|
||||
<div className="pt-7">
|
||||
{props.header}
|
||||
<div className="flex flex-col border-l-2 border-border">
|
||||
{props.links?.map((link) => (
|
||||
<NavLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm py-1.5 pl-4 text-muted-foreground hover:text-foreground border-l-2 ml-[-2px] border-transparent hover:border-foreground transition-colors"
|
||||
activeClassName="text-foreground border-link-foreground"
|
||||
exactMatch={link.exactMatch}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
{props.links?.map((link) => {
|
||||
const isExternal = link.href.startsWith("http");
|
||||
return (
|
||||
<NavLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="py-2 hover:bg-muted rounded-md px-3 text-muted-foreground text-sm flex items-center gap-2"
|
||||
activeClassName="text-foreground"
|
||||
exactMatch={link.exactMatch}
|
||||
>
|
||||
{link.label}
|
||||
{isExternal && <ExternalLinkIcon className="size-3" />}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { mobileViewport } from "../../../stories/utils";
|
||||
import { SidebarLayout } from "./SidebarLayout";
|
||||
|
||||
const meta = {
|
||||
title: "layouts/SidebarLayout",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
href: "/foo",
|
||||
label: "consectetur",
|
||||
},
|
||||
{
|
||||
href: "/bar",
|
||||
label: "adipiscing",
|
||||
},
|
||||
{
|
||||
href: "/bar",
|
||||
label: "eiusmod",
|
||||
},
|
||||
{
|
||||
label: "incididunt",
|
||||
href: "https://playground.thirdweb.com/",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContentPlaceholder />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentPlaceholder() {
|
||||
return (
|
||||
<div className="bg-muted grow flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-3xl"> CHILDREN </p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from "../../lib/utils";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { Sidebar, type SidebarLink } from "./Sidebar";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
desktopSidebarClassName?: string;
|
||||
mobileSidebarClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { sidebarLinks, children } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row gap-6 container px-4 w-full grow max-sm:pt-6",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Sidebar links={sidebarLinks} className={props.desktopSidebarClassName} />
|
||||
<MobileSidebar
|
||||
links={sidebarLinks}
|
||||
triggerClassName={props.mobileSidebarClassName}
|
||||
/>
|
||||
{/* min-w-0 is enough to ensure the content inside does not stretch this container */}
|
||||
<main className="grow max-sm:w-full flex flex-col lg:pt-8 pb-10 min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Check, Copy, ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
Blobbie,
|
||||
MediaRenderer,
|
||||
type SocialProfile,
|
||||
useSocialProfiles,
|
||||
} from "thirdweb/react";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
import { thirdwebClient } from "../../constants/client";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function WalletAddress(props: {
|
||||
address: string | undefined;
|
||||
shortenAddress?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
// default back to zero address if no address provided
|
||||
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
|
||||
const [shortenedAddress, lessShortenedAddress] = useMemo(() => {
|
||||
return [
|
||||
props.shortenAddress !== false
|
||||
? `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
: address,
|
||||
`${address.slice(0, 14)}...${address.slice(-12)}`,
|
||||
];
|
||||
}, [address, props.shortenAddress]);
|
||||
|
||||
const profiles = useSocialProfiles({
|
||||
address: address,
|
||||
client: thirdwebClient,
|
||||
});
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
|
||||
} catch (err) {
|
||||
console.error("Failed to copy: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="link"
|
||||
className={cn(
|
||||
"flex flex-row gap-2 items-center px-0",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{address && (
|
||||
<WalletAvatar address={address} profiles={profiles.data || []} />
|
||||
)}
|
||||
<span className="font-mono cursor-pointer">
|
||||
{profiles.data?.[0]?.name || shortenedAddress}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 border-border"
|
||||
onClick={(e) => {
|
||||
// do not close the hover card when clicking anywhere in the content
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Wallet Address</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{isCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm font-mono bg-muted p-2 rounded text-center">
|
||||
{lessShortenedAddress}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">Social Profiles</h3>
|
||||
{profiles.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading profiles...</p>
|
||||
) : !profiles.data?.length ? (
|
||||
<p className="text-sm text-muted-foreground">No profiles found</p>
|
||||
) : (
|
||||
profiles.data?.map((profile) => (
|
||||
<div
|
||||
className="flex flex-row gap-2 items-center"
|
||||
key={profile.type + profile.name}
|
||||
>
|
||||
{profile.avatar &&
|
||||
(profile.avatar.startsWith("http") ||
|
||||
profile.avatar?.startsWith("ipfs")) && (
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={resolveScheme({
|
||||
client: thirdwebClient,
|
||||
uri: profile.avatar,
|
||||
})}
|
||||
alt={profile.name}
|
||||
/>
|
||||
{profile.name && (
|
||||
<AvatarFallback>
|
||||
{profile.name.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex flex-row gap-4 w-full items-center justify-between">
|
||||
<h4 className="text-md font-semibold">{profile.name}</h4>
|
||||
<Badge variant="outline">{profile.type}</Badge>
|
||||
</div>
|
||||
{profile.bio && (
|
||||
<p className="text-sm text-muted-foreground whitespace-normal line-clamp-1">
|
||||
{profile.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
variant="upsell"
|
||||
className="text-sm flex flex-row gap-2 items-center"
|
||||
size="sm"
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://blog.thirdweb.com/changelog/introducing-the-social-sdk?ref=dashboard-social-wallet"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletAvatar(props: {
|
||||
address: string;
|
||||
profiles: SocialProfile[];
|
||||
}) {
|
||||
const avatar = useMemo(() => {
|
||||
return props.profiles.find(
|
||||
(profile) =>
|
||||
profile.avatar &&
|
||||
(profile.avatar.startsWith("http") ||
|
||||
profile.avatar.startsWith("ipfs")),
|
||||
)?.avatar;
|
||||
}, [props.profiles]);
|
||||
return (
|
||||
<div className="size-6 overflow-hidden rounded-full">
|
||||
{avatar ? (
|
||||
<MediaRenderer
|
||||
client={thirdwebClient}
|
||||
src={avatar}
|
||||
className="size-6"
|
||||
/>
|
||||
) : (
|
||||
<Blobbie address={props.address} size={24} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,22 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTrack } from "../../../hooks/analytics/useTrack";
|
||||
|
||||
export type NavButtonProps = {
|
||||
className?: string;
|
||||
activeClassName?: string;
|
||||
href: string;
|
||||
exactMatch?: boolean;
|
||||
tracking?: {
|
||||
category: string;
|
||||
action: string;
|
||||
label: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
|
||||
const track = useTrack();
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname
|
||||
? props.exactMatch
|
||||
@@ -23,6 +30,16 @@ export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
|
||||
href={props.href}
|
||||
className={cn(props.className, isActive && props.activeClassName)}
|
||||
target={props.href.startsWith("http") ? "_blank" : undefined}
|
||||
onClick={() => {
|
||||
if (props.tracking) {
|
||||
track({
|
||||
category: props.tracking.category,
|
||||
action: props.tracking.action,
|
||||
label: props.tracking.label,
|
||||
url: props.href,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.scrollShadowY {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
@@ -10,7 +9,6 @@
|
||||
.scrollShadowX {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ScrollShadow(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(props.className, "relative overflow-hidden z-base")}
|
||||
className={cn(props.className, "relative overflow-hidden")}
|
||||
ref={wrapperEl}
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -20,6 +20,8 @@ const buttonVariants = cva(
|
||||
"bg-secondary hover:bg-secondary/80 text-semibold text-secondary-foreground ",
|
||||
ghost: "hover:bg-accent text-semibold hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline text-semibold",
|
||||
upsell:
|
||||
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
|
||||
const meta = {
|
||||
title: "Shadcn/select",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const AllVariants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
function randomName() {
|
||||
return Math.random().toString(36).substring(7);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foregroun flex flex-col gap-10">
|
||||
<BadgeContainer label="5 items, no placeholder, no label, value filled">
|
||||
<SelectDemo listItems={5} selectFirst />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="50 items, no placeholder, no label, value filled">
|
||||
<SelectDemo listItems={50} selectFirst />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="5 items, no placeholder, no label, no value">
|
||||
<SelectDemo listItems={5} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="5 items, placeholder, no label, no value">
|
||||
<SelectDemo listItems={5} placeholder="some placeholder" />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectDemo(props: {
|
||||
listItems: number;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
selectFirst?: boolean;
|
||||
}) {
|
||||
const listItems = Array.from({ length: props.listItems }, () => randomName());
|
||||
|
||||
return (
|
||||
<Select value={props.selectFirst ? listItems[0] : undefined}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={props.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{props.label && <SelectLabel> {props.label}</SelectLabel>}
|
||||
|
||||
{listItems.map((item) => {
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -32,40 +32,40 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
// const SelectScrollUpButton = React.forwardRef<
|
||||
// React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
// React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
// >(({ className, ...props }, ref) => (
|
||||
// <SelectPrimitive.ScrollUpButton
|
||||
// ref={ref}
|
||||
// className={cn(
|
||||
// "flex cursor-default items-center justify-center py-1",
|
||||
// className,
|
||||
// )}
|
||||
// {...props}
|
||||
// >
|
||||
// <ChevronUp className="h-4 w-4" />
|
||||
// </SelectPrimitive.ScrollUpButton>
|
||||
// ));
|
||||
// SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
// const SelectScrollDownButton = React.forwardRef<
|
||||
// React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
// React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
// >(({ className, ...props }, ref) => (
|
||||
// <SelectPrimitive.ScrollDownButton
|
||||
// ref={ref}
|
||||
// className={cn(
|
||||
// "flex cursor-default items-center justify-center py-1",
|
||||
// className,
|
||||
// )}
|
||||
// {...props}
|
||||
// >
|
||||
// <ChevronDown className="h-4 w-4" />
|
||||
// </SelectPrimitive.ScrollDownButton>
|
||||
// ));
|
||||
// SelectScrollDownButton.displayName =
|
||||
// SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
@@ -95,7 +95,7 @@ const SelectContent = React.forwardRef<
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
{/* <SelectScrollUpButton /> */}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
@@ -105,7 +105,7 @@ const SelectContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
{/* <SelectScrollDownButton /> */}
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
@@ -167,6 +167,6 @@ export {
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
// SelectScrollUpButton,
|
||||
// SelectScrollDownButton,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Mobile: Story = {
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foregroun flex flex-col gap-6 max-w-[1000px] mx-auto">
|
||||
<div className="flex flex-col gap-6 lg:max-w-[1000px] lg:mx-auto min-w-0 py-6 px-4">
|
||||
<BadgeContainer label="Normal">
|
||||
<TableDemo />
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
|
||||
import { Button } from "./button";
|
||||
@@ -177,3 +178,30 @@ function useUnderline<El extends HTMLElement>() {
|
||||
|
||||
return { containerRef, lineRef, activeTabRef };
|
||||
}
|
||||
|
||||
export function TabPathLinks(props: {
|
||||
links: {
|
||||
name: string;
|
||||
path: string;
|
||||
exactMatch?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}[];
|
||||
className?: string;
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
}) {
|
||||
const pathname = usePathname() || "";
|
||||
const { links, ...restProps } = props;
|
||||
return (
|
||||
<TabLinks
|
||||
{...restProps}
|
||||
links={links.map((l) => ({
|
||||
name: l.name,
|
||||
href: l.path,
|
||||
isActive: l.exactMatch
|
||||
? pathname === l.path
|
||||
: pathname.startsWith(l.path),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@ import type { ComponentWithChildren } from "types/component-with-children";
|
||||
|
||||
interface AdminOnlyProps {
|
||||
contract: ThirdwebContract;
|
||||
failOpen?: boolean;
|
||||
fallback?: JSX.Element;
|
||||
}
|
||||
|
||||
export const AdminOnly: ComponentWithChildren<AdminOnlyProps> = ({
|
||||
children,
|
||||
contract,
|
||||
failOpen = true,
|
||||
fallback,
|
||||
}) => {
|
||||
const isAdmin = useIsAdmin(contract);
|
||||
const isAdmin = useIsAdmin(contract, failOpen);
|
||||
if (!isAdmin) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hasRole } from "thirdweb/extensions/permissions";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import type { RequiredParam } from "utils/types";
|
||||
|
||||
export function useIsAdmin(contract: ThirdwebContract) {
|
||||
export function useIsAdmin(contract: ThirdwebContract, failOpen = true) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const { data: userIsAdmin, isError } = useReadContract(hasRole, {
|
||||
contract,
|
||||
@@ -14,7 +14,7 @@ export function useIsAdmin(contract: ThirdwebContract) {
|
||||
|
||||
// If the request results in an error, it's likely that the contract is a non-thirdweb contract
|
||||
// in that case we return `true` anyway
|
||||
return userIsAdmin || isError;
|
||||
return userIsAdmin || (failOpen && isError);
|
||||
}
|
||||
|
||||
export function useIsAdminOrSelf(
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { MobileSidebar } from "@/components/blocks/MobileSidebar";
|
||||
import { Sidebar } from "@/components/blocks/Sidebar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeftIcon, MenuIcon, TicketCheckIcon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ChevronDownIcon,
|
||||
MenuIcon,
|
||||
TicketCheckIcon,
|
||||
} from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -59,116 +78,142 @@ export default async function ChainPageLayout({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container flex flex-row h-full">
|
||||
<Sidebar
|
||||
header={
|
||||
<div className="flex items-center gap-1.5 mb-5 flex-wrap">
|
||||
<Link
|
||||
href="/chainlist"
|
||||
className="text-muted-foreground hover:text-foreground text-sm"
|
||||
>
|
||||
Chainlist
|
||||
</Link>
|
||||
<span className="text-muted-foreground text-sm"> / </span>
|
||||
<Link href={`/${chain.slug}`} className="text-foreground text-sm">
|
||||
{chain.name.replace("Mainnet", "")}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
links={sidebarLinks}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-full pb-10">
|
||||
{/* Icon + Background */}
|
||||
<ChainHeader
|
||||
headerImageUrl={chainMetadata?.headerImgUrl}
|
||||
logoUrl={chain.icon?.url}
|
||||
chain={chain}
|
||||
/>
|
||||
|
||||
<div className="flex lg:hidden justify-end">
|
||||
<MobileSidebar
|
||||
links={sidebarLinks}
|
||||
trigger={
|
||||
<Button size="icon" variant="outline">
|
||||
<MenuIcon strokeWidth={1} />
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div className="mt-5 border-t pt-6 pb-2">
|
||||
<>
|
||||
<div className="border-b border-border h-14 pl-7 flex">
|
||||
<Breadcrumb className="my-auto">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/chainlist">Chainlist</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<DropdownMenu>
|
||||
<Link
|
||||
href="/chainlist"
|
||||
className="px-2 text-sm flex justify-start gap-2 items-center text-muted-foreground hover:text-foreground"
|
||||
href={
|
||||
chain.testnet
|
||||
? "/chainlist/testnets"
|
||||
: "/chainlist/mainnets"
|
||||
}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
View all chains
|
||||
{chain.testnet ? "Testnets" : "Mainnets"}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>
|
||||
<Link href="/chainlist/mainnets">Mainnets</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/chainlist/testnets">Testnets</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
{chain.name.replace("Mainnet", "")}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="container flex flex-row h-full">
|
||||
<Sidebar links={sidebarLinks} />
|
||||
|
||||
<div className="flex flex-col w-full pb-10">
|
||||
{/* Icon + Background */}
|
||||
<ChainHeader
|
||||
headerImageUrl={chainMetadata?.headerImgUrl}
|
||||
logoUrl={chain.icon?.url}
|
||||
chain={chain}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4 md:h-8" />
|
||||
|
||||
<div className="flex flex-col gap-3 md:gap-2">
|
||||
{/* Gas Sponsored badge - Mobile */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
<div className="md:hidden flex">
|
||||
<GasSponseredBadge />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chain name */}
|
||||
<div className="flex flex-row gap-1.5 items-center">
|
||||
<h1 className="text-2xl font-semibold lg:text-3xl lg:font-semibold tracking-tight">
|
||||
{chain.name}
|
||||
</h1>
|
||||
|
||||
{/* Favorite */}
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
iconClassName="size-5"
|
||||
className="p-1"
|
||||
<div className="flex lg:hidden justify-end">
|
||||
<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
|
||||
href="/chainlist"
|
||||
className="px-2 text-sm flex justify-start gap-2 items-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
View all chains
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gas Sponsored badge - Desktop */}
|
||||
<div className="h-4 md:h-8" />
|
||||
|
||||
<div className="flex flex-col gap-3 md:gap-2">
|
||||
{/* Gas Sponsored badge - Mobile */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
<div className="hidden md:block">
|
||||
<div className="md:hidden flex">
|
||||
<GasSponseredBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
{chainMetadata?.about && (
|
||||
<p className="text-muted-foreground text-sm lg:text-base mb-2">
|
||||
{chainMetadata.about}
|
||||
</p>
|
||||
)}
|
||||
{/* Chain name */}
|
||||
<div className="flex flex-row gap-1.5 items-center">
|
||||
<h1 className="text-2xl font-semibold lg:text-3xl lg:font-semibold tracking-tight">
|
||||
{chain.name}
|
||||
</h1>
|
||||
|
||||
{/* mobile action row */}
|
||||
<div className="sm:hidden w-full">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<AddChainToWallet
|
||||
chain={
|
||||
// Do not include chain overrides for chain pages
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
mapV4ChainToV5Chain(chain)
|
||||
}
|
||||
{/* Favorite */}
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
iconClassName="size-5"
|
||||
className="p-1"
|
||||
/>
|
||||
<Button variant="primary">
|
||||
<Link href="https://thirdweb.com/dashboard" target="_blank">
|
||||
Get started
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Gas Sponsored badge - Desktop */}
|
||||
{chainMetadata?.gasSponsored && (
|
||||
<div className="hidden md:block">
|
||||
<GasSponseredBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
{chainMetadata?.about && (
|
||||
<p className="text-muted-foreground text-sm lg:text-base mb-2">
|
||||
{chainMetadata.about}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mobile action row */}
|
||||
<div className="sm:hidden w-full">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<AddChainToWallet
|
||||
chain={
|
||||
// Do not include chain overrides for chain pages
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
mapV4ChainToV5Chain(chain)
|
||||
}
|
||||
/>
|
||||
<Button variant="primary">
|
||||
<Link href="https://thirdweb.com/dashboard" target="_blank">
|
||||
Get started
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
{children}
|
||||
<div className="h-8" />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
ChainServiceFilter,
|
||||
} from "../components/client/filters";
|
||||
import { SearchInput } from "../components/client/search";
|
||||
import { ChainListView } from "../components/client/view";
|
||||
import { AddYourChainButton } from "../components/server/add-chain-button";
|
||||
import {
|
||||
ChainsData,
|
||||
type SearchParams,
|
||||
} from "../components/server/chain-table";
|
||||
|
||||
export function generateMetadata(props: {
|
||||
params: { chain_type: "mainnets" | "testnets" };
|
||||
}) {
|
||||
if (props.params.chain_type === "mainnets") {
|
||||
return {
|
||||
title: "List of Mainnets | Explorers, Popular Contracts & Chain IDs",
|
||||
description:
|
||||
"A list of EVM mainnets with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
|
||||
};
|
||||
}
|
||||
if (props.params.chain_type === "testnets") {
|
||||
return {
|
||||
title: "List of Testnets | Explorers, Popular Contracts & Chain IDs",
|
||||
description:
|
||||
"Browse all Testnets across web3 ecosystems, BNB Smart Chain Testnet, Ethereum Sepolia Testnets, Chain IDs, Enabled Services and more. ",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Chainlist: RPCs, Block Explorers, Faucets",
|
||||
description:
|
||||
"A list of EVM networks with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
|
||||
};
|
||||
}
|
||||
|
||||
// we use headers() to determine if we should default to table or grid view by checking viewport
|
||||
// so this page needs to be forced as dynamic
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChainListLayout(props: {
|
||||
params: { chain_type: "mainnets" | "testnets" };
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const headersList = headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = props.searchParams.view
|
||||
? props.searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
const searchParams = { type: props.params.chain_type, ...props.searchParams };
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<div className="border-b border-border h-14 pl-7 flex">
|
||||
<Breadcrumb className="my-auto">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/chainlist">Chainlist</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<DropdownMenu>
|
||||
<Link
|
||||
href={
|
||||
props.params.chain_type === "testnets"
|
||||
? "/chainlist/testnets"
|
||||
: "/chainlist/mainnets"
|
||||
}
|
||||
>
|
||||
{props.params.chain_type === "testnets"
|
||||
? "Testnets"
|
||||
: "Mainnets"}
|
||||
</Link>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem>
|
||||
<Link href="/chainlist/mainnets">Mainnets</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/chainlist/testnets">Testnets</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<section className="container mx-auto py-10 px-4 h-full flex flex-col">
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex gap-4 flex-col lg:flex-row lg:justify-between lg:items-center">
|
||||
<div className="flex gap-4 flex-row items-center justify-between lg:justify-start lg:flex-col">
|
||||
<h1 className="font-semibold text-4xl lg:text-5xl tracking-tighter">
|
||||
List of{" "}
|
||||
{props.params.chain_type.charAt(0).toUpperCase() +
|
||||
props.params.chain_type.slice(1)}
|
||||
</h1>
|
||||
<AddYourChainButton className="lg:hidden" />
|
||||
</div>
|
||||
<div className="flex flex-row lg:flex-col gap-4 items-end">
|
||||
<div className="flex flex-row gap-4 w-full">
|
||||
<SearchInput />
|
||||
<ChainListView activeView={activeView} />
|
||||
<AddYourChainButton className="hidden lg:flex" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<AllFilters hideChainType={true} />
|
||||
<div className="hidden lg:flex flex-row gap-2">
|
||||
<ChainOptionsFilter />
|
||||
<ChainServiceFilter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
{/* we used to have suspense + spinner here, that feels more jarring than the page loading _minutely_ slower */}
|
||||
<ChainsData searchParams={searchParams} activeView={activeView} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+20
-27
@@ -24,7 +24,7 @@ function cleanUrl(url: string) {
|
||||
return url;
|
||||
}
|
||||
|
||||
export function AllFilters() {
|
||||
export function AllFilters(props: { hideChainType?: boolean }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -44,7 +44,7 @@ export function AllFilters() {
|
||||
<h3 className="font-bold">Filters</h3>
|
||||
|
||||
{/* Chain Type */}
|
||||
<ChainTypeFilter sectionOnly />
|
||||
{!props.hideChainType && <ChainTypeFilter sectionOnly />}
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -126,7 +126,6 @@ export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
|
||||
sectionOnly,
|
||||
}) => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const value = useMemo(
|
||||
@@ -134,25 +133,19 @@ export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const makeUrl = useCallback(
|
||||
(newValue: string) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
switch (newValue) {
|
||||
case "mainnet":
|
||||
case "testnet": {
|
||||
params.set("type", newValue);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
params.delete("type");
|
||||
}
|
||||
const makeUrl = useCallback((newValue: string) => {
|
||||
switch (newValue) {
|
||||
case "mainnets": {
|
||||
return "/chainlist/mainnets";
|
||||
}
|
||||
// reset page alway
|
||||
params.delete("page");
|
||||
return cleanUrl(`${pathname}?${params.toString()}`);
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
case "testnets": {
|
||||
return "/chainlist/testnets";
|
||||
}
|
||||
default: {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const section = (
|
||||
<FilterSection title="Chain Type">
|
||||
@@ -168,12 +161,12 @@ export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
|
||||
<Label htmlFor="all">All Chains</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="mainnet" id="mainnet" />
|
||||
<Label htmlFor="mainnet">Mainnets Only</Label>
|
||||
<RadioGroupItem value="mainnets" id="mainnets" />
|
||||
<Label htmlFor="mainnest">Mainnets Only</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="testnet" id="testnet" />
|
||||
<Label htmlFor="testnet">Testnets Only</Label>
|
||||
<RadioGroupItem value="testnets" id="testnets" />
|
||||
<Label htmlFor="testnets">Testnets Only</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FilterSection>
|
||||
@@ -191,9 +184,9 @@ export const ChainTypeFilter: React.FC<ChainTypeFilterProps> = ({
|
||||
variant="outline"
|
||||
className={value === "all" ? undefined : "pr-11 border-primary"}
|
||||
>
|
||||
{value === "mainnet"
|
||||
{value === "mainnets"
|
||||
? "Mainnets Only"
|
||||
: value === "testnet"
|
||||
: value === "testnets"
|
||||
? "Testnets Only"
|
||||
: "Chain Type"}
|
||||
{value === "all" && <ChevronDownIcon className="ml-2 size-4" />}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function AddYourChainButton(props: { className?: string }) {
|
||||
return (
|
||||
<Button asChild variant="default" className={props.className}>
|
||||
<Link
|
||||
href="https://share.hsforms.com/1XDi-ieM9Rl6oIkn7ynK6Lgea58c"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add your chain
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import Fuse from "fuse.js";
|
||||
import { StarButton } from "../../../components/client/star-button";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainSupportedService,
|
||||
} from "../../../types/chain";
|
||||
import { getChains } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { ChainListCard } from "../server/chainlist-card";
|
||||
import { ChainListRow } from "../server/chainlist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
type: "mainnets" | "testnets";
|
||||
service: ChainSupportedService[] | ChainSupportedService;
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
|
||||
// table or grid style?
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 24 because it is cleanly divisible by 2,3 and 4 (for card grid)
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getChainsToRender(params: SearchParams) {
|
||||
const chains = await getChains();
|
||||
|
||||
// sort the chains
|
||||
const sortedChains = chains.sort((a, b) => {
|
||||
// sort by number of services first
|
||||
const aServices = a.services.filter((s) => s.enabled).length;
|
||||
const bServices = b.services.filter((s) => s.enabled).length;
|
||||
if (aServices > bServices) {
|
||||
return -1;
|
||||
}
|
||||
if (aServices < bServices) {
|
||||
return 1;
|
||||
}
|
||||
// if they have the same number of services, sort by chainId
|
||||
if (a.chainId > b.chainId) {
|
||||
return 1;
|
||||
}
|
||||
if (a.chainId < b.chainId) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const filteredChains: ChainMetadataWithServices[] = [];
|
||||
for (const chain of sortedChains) {
|
||||
// handle deprecated chains
|
||||
if (!params.includeDeprecated) {
|
||||
// if chain is deprecated, return false to filter it out
|
||||
if (chain.status === "deprecated") {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// handle testnet and mainnet filter cases (if )
|
||||
if (params.type) {
|
||||
// if the filter is testnet and the chain is not a testnet, return false to filter it out
|
||||
if (params.type === "testnets" && !chain.testnet) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
// if the filter is mainnet and the chain is a testnet, return false to filter it out
|
||||
if (params.type === "mainnets" && chain.testnet) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// handle services filter (if no filter set, all chains pass through here)
|
||||
if (params.service) {
|
||||
const urlServiceArray = Array.isArray(params.service)
|
||||
? params.service
|
||||
: [params.service];
|
||||
|
||||
// if the chain does not have all of the services in the filter, return false to filter it out
|
||||
if (
|
||||
!urlServiceArray.every((service) => {
|
||||
return chain.services.find((s) => s.enabled && s.service === service);
|
||||
})
|
||||
) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// if we got here, push the chain to the filtered chains array
|
||||
filteredChains.push(chain);
|
||||
}
|
||||
|
||||
const totalCount = chains.length;
|
||||
const filteredCount = filteredChains.length;
|
||||
|
||||
if (params.query) {
|
||||
const fuse = new Fuse(filteredChains, {
|
||||
keys: [
|
||||
{
|
||||
name: "name",
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "chainId",
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
threshold: 0.2,
|
||||
});
|
||||
return {
|
||||
chainsToRender: fuse
|
||||
.search(params.query, {
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
.map((e) => e.item),
|
||||
totalCount,
|
||||
filteredCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
chainsToRender: filteredChains,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ChainsData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
}) {
|
||||
const { chainsToRender, totalCount, filteredCount } = await getChainsToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
// pagination
|
||||
const totalPages = Math.ceil(chainsToRender.length / DEFAULT_PAGE_SIZE);
|
||||
|
||||
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedChains = chainsToRender.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{paginatedChains.length === 0 ? (
|
||||
<div className="border p-8 h-[300px] lg:h-[500px] flex justify-center items-center rounded-lg">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* empty space for the icon */}
|
||||
<TableHead />
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Chain ID</TableHead>
|
||||
<TableHead>Native Token</TableHead>
|
||||
<TableHead>Enabled Services</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedChains.map((chain) => (
|
||||
<ChainListRow
|
||||
key={chain.chainId}
|
||||
chainId={chain.chainId}
|
||||
chainName={chain.name}
|
||||
chainSlug={chain.slug}
|
||||
currencySymbol={chain.nativeCurrency.symbol}
|
||||
enabledServices={chain.services
|
||||
.filter((c) => c.enabled)
|
||||
.map((c) => c.service)}
|
||||
isDeprecated={chain.status === "deprecated"}
|
||||
favoriteButton={
|
||||
<div className="relative h-6 w-6">
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
className="absolute z-10 top-0 h-full w-full left-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
iconUrl={chain.icon?.url}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedChains.map((chain) => (
|
||||
<li key={chain.chainId} className="h-full">
|
||||
<ChainListCard
|
||||
key={chain.chainId}
|
||||
chainId={chain.chainId}
|
||||
chainName={chain.name}
|
||||
chainSlug={chain.slug}
|
||||
currencySymbol={chain.nativeCurrency.symbol}
|
||||
enabledServices={chain.services
|
||||
.filter((c) => c.enabled)
|
||||
.map((c) => c.service)}
|
||||
isDeprecated={chain.status === "deprecated"}
|
||||
favoriteButton={
|
||||
<div className="relative h-6 w-6">
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
className="absolute z-10 top-0 h-full w-full left-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
iconUrl={chain.icon?.url}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-sm text-center text-muted-foreground text-balance">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{paginatedChains.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
chains that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> chains.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +1,15 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import Fuse from "fuse.js";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { StarButton } from "../components/client/star-button";
|
||||
import type {
|
||||
ChainMetadataWithServices,
|
||||
ChainSupportedService,
|
||||
} from "../types/chain";
|
||||
import { getChains } from "../utils";
|
||||
import {
|
||||
AllFilters,
|
||||
ChainOptionsFilter,
|
||||
ChainServiceFilter,
|
||||
ChainTypeFilter,
|
||||
} from "./components/client/filters";
|
||||
import { ChainlistPagination } from "./components/client/pagination";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { ChainListView } from "./components/client/view";
|
||||
import { ChainListCard } from "./components/server/chainlist-card";
|
||||
import { ChainListRow } from "./components/server/chainlist-row";
|
||||
|
||||
type SearchParams = Partial<{
|
||||
type: "mainnet" | "testnet";
|
||||
service: ChainSupportedService[] | ChainSupportedService;
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
|
||||
// table or grid style?
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 24 because it is cleanly divisible by 2,3 and 4 (for card grid)
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
const DEFAULT_PAGE = 1;
|
||||
|
||||
async function getChainsToRender(params: SearchParams) {
|
||||
const chains = await getChains();
|
||||
|
||||
// sort the chains
|
||||
const sortedChains = chains.sort((a, b) => {
|
||||
// sort by number of services first
|
||||
const aServices = a.services.filter((s) => s.enabled).length;
|
||||
const bServices = b.services.filter((s) => s.enabled).length;
|
||||
if (aServices > bServices) {
|
||||
return -1;
|
||||
}
|
||||
if (aServices < bServices) {
|
||||
return 1;
|
||||
}
|
||||
// if they have the same number of services, sort by chainId
|
||||
if (a.chainId > b.chainId) {
|
||||
return 1;
|
||||
}
|
||||
if (a.chainId < b.chainId) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const filteredChains: ChainMetadataWithServices[] = [];
|
||||
for (const chain of sortedChains) {
|
||||
// handle deprecated chains
|
||||
if (!params.includeDeprecated) {
|
||||
// if chain is deprecated, return false to filter it out
|
||||
if (chain.status === "deprecated") {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// handle testnet and mainnet filter cases (if )
|
||||
if (params.type) {
|
||||
// if the filter is testnet and the chain is not a testnet, return false to filter it out
|
||||
if (params.type === "testnet" && !chain.testnet) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
// if the filter is mainnet and the chain is a testnet, return false to filter it out
|
||||
if (params.type === "mainnet" && chain.testnet) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// handle services filter (if no filter set, all chains pass through here)
|
||||
if (params.service) {
|
||||
const urlServiceArray = Array.isArray(params.service)
|
||||
? params.service
|
||||
: [params.service];
|
||||
|
||||
// if the chain does not have all of the services in the filter, return false to filter it out
|
||||
if (
|
||||
!urlServiceArray.every((service) => {
|
||||
return chain.services.find((s) => s.enabled && s.service === service);
|
||||
})
|
||||
) {
|
||||
// skip to the next chain
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// if we got here, push the chain to the filtered chains array
|
||||
filteredChains.push(chain);
|
||||
}
|
||||
|
||||
const totalCount = chains.length;
|
||||
const filteredCount = filteredChains.length;
|
||||
|
||||
if (params.query) {
|
||||
const fuse = new Fuse(filteredChains, {
|
||||
keys: [
|
||||
{
|
||||
name: "name",
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "chainId",
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
threshold: 0.2,
|
||||
});
|
||||
return {
|
||||
chainsToRender: fuse
|
||||
.search(params.query, {
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
})
|
||||
.map((e) => e.item),
|
||||
totalCount,
|
||||
filteredCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
chainsToRender: filteredChains,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
};
|
||||
}
|
||||
import { AddYourChainButton } from "./components/server/add-chain-button";
|
||||
import { ChainsData, type SearchParams } from "./components/server/chain-table";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Chainlist: RPCs, Block Explorers, Faucets",
|
||||
@@ -207,136 +68,3 @@ export default function ChainListPage(props: { searchParams: SearchParams }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChainsData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
}) {
|
||||
const { chainsToRender, totalCount, filteredCount } = await getChainsToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
// pagination
|
||||
const totalPages = Math.ceil(chainsToRender.length / DEFAULT_PAGE_SIZE);
|
||||
|
||||
const activePage = Number(props.searchParams.page || DEFAULT_PAGE);
|
||||
const pageSize = DEFAULT_PAGE_SIZE;
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedChains = chainsToRender.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{paginatedChains.length === 0 ? (
|
||||
<div className="border p-8 h-[300px] lg:h-[500px] flex justify-center items-center rounded-lg">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* empty space for the icon */}
|
||||
<TableHead />
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Chain ID</TableHead>
|
||||
<TableHead>Native Token</TableHead>
|
||||
<TableHead>Enabled Services</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedChains.map((chain) => (
|
||||
<ChainListRow
|
||||
key={chain.chainId}
|
||||
chainId={chain.chainId}
|
||||
chainName={chain.name}
|
||||
chainSlug={chain.slug}
|
||||
currencySymbol={chain.nativeCurrency.symbol}
|
||||
enabledServices={chain.services
|
||||
.filter((c) => c.enabled)
|
||||
.map((c) => c.service)}
|
||||
isDeprecated={chain.status === "deprecated"}
|
||||
favoriteButton={
|
||||
<div className="relative h-6 w-6">
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
className="absolute z-10 top-0 h-full w-full left-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
iconUrl={chain.icon?.url}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedChains.map((chain) => (
|
||||
<li key={chain.chainId} className="h-full">
|
||||
<ChainListCard
|
||||
key={chain.chainId}
|
||||
chainId={chain.chainId}
|
||||
chainName={chain.name}
|
||||
chainSlug={chain.slug}
|
||||
currencySymbol={chain.nativeCurrency.symbol}
|
||||
enabledServices={chain.services
|
||||
.filter((c) => c.enabled)
|
||||
.map((c) => c.service)}
|
||||
isDeprecated={chain.status === "deprecated"}
|
||||
favoriteButton={
|
||||
<div className="relative h-6 w-6">
|
||||
<StarButton
|
||||
chainId={chain.chainId}
|
||||
className="absolute z-10 top-0 h-full w-full left-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
iconUrl={chain.icon?.url}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination totalPages={totalPages} activePage={activePage} />
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-sm text-center text-muted-foreground text-balance">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{paginatedChains.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
chains that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> chains.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddYourChainButton(props: { className?: string }) {
|
||||
return (
|
||||
<Button asChild variant="default" className={props.className}>
|
||||
<Link
|
||||
href="https://share.hsforms.com/1XDi-ieM9Rl6oIkn7ynK6Lgea58c"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add your chain
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ const chainMetaRecord = {
|
||||
"Ancient8 is building an ETH gaming Layer 2 built with OP Stack, offering a suite of Web3 gaming infrastructure tools that serve as the distribution and marketing channel for games globally. With Space3 Game Publishing Platform, Ancient8 Gaming Guild, Reneverse Web3 Ads engine, A8ID, and Gosu Network, Ancient8 is dedicated to onboard millions of gamers to Web3 gaming, while providing unparalleled support to game developers looking to reach more players. Ancient8’s products have helped 100+ Web3 games and 200K+ users better navigate Web3.\n\nAncient8 has raised $10M in total financing from leading investors including Pantera, Dragonfly, Hashed, Makers Fund, Mechanism, Coinbase, IOSG, Jump and Animoca.",
|
||||
// TODO: add CTA
|
||||
headerImgUrl: ancient8Banner.src,
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
// mantle
|
||||
5000: {
|
||||
@@ -185,6 +187,7 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
buttonLink: "https://x.com/0xMantleDevs",
|
||||
},
|
||||
gasSponsored: true,
|
||||
},
|
||||
// rootstock
|
||||
30: {
|
||||
@@ -275,12 +278,8 @@ const chainMetaRecord = {
|
||||
headerImgUrl: thirdwebBanner.src,
|
||||
about:
|
||||
"Monetize your passion, your way. Discover the freedom to grow with our creator-focused platform.",
|
||||
cta: {
|
||||
backgroundImageUrl: thirdwebCTA.src,
|
||||
title: "",
|
||||
buttonLink: "https://www.donatuz.org/",
|
||||
buttonText: "Learn more",
|
||||
},
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
},
|
||||
//Fuse
|
||||
122: {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NavLink } from "../../../../components/nav-link.client";
|
||||
import type { SidebarNavSection } from "./types";
|
||||
|
||||
export type SidebarSectionProps = {
|
||||
section: SidebarNavSection;
|
||||
};
|
||||
|
||||
export const SidebarSection: React.FC<SidebarSectionProps> = ({ section }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-7 relative">
|
||||
{/* title element */}
|
||||
<label className="text-sm text-muted-foreground">{section.title}</label>
|
||||
<nav>
|
||||
<ul>
|
||||
{section.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
prefetch={false}
|
||||
href={item.href}
|
||||
className="pl-3 border-l-2 h-8 text-sm text-muted-foreground flex items-center hover:text-accent-foreground hover:border-foreground"
|
||||
activeClassName="border-primary hover:border-primary text-accent-foreground font-semibold"
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SidebarSection } from "./section";
|
||||
import type { SidebarNavSection } from "./types";
|
||||
|
||||
export type SideBarNavProps = {
|
||||
sections: Array<SidebarNavSection>;
|
||||
};
|
||||
|
||||
export const SidebarNav: React.FC<SideBarNavProps> = ({ sections }) => {
|
||||
const currentPath = usePathname() || "";
|
||||
|
||||
const activeLinkInSections = useMemo(() => {
|
||||
for (const section of sections) {
|
||||
return section.items.find((item) => item.href.startsWith(currentPath));
|
||||
}
|
||||
}, [sections, currentPath]);
|
||||
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
|
||||
// legitimate use case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// close mobile sidebar when path changes
|
||||
if (currentPath) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<aside className="w-full md:w-[250px] lg:w-[300px] border-b md:border-b-0 relative md:sticky top-0 flex-shrink-0">
|
||||
{/* mobile toggle */}
|
||||
<div className="flex flex-row items-center justify-between w-full py-4 md:hidden">
|
||||
<label className="flex items-center h-8 pl-3 text-sm font-semibold border-l-2 text-accent-foreground border-primary">
|
||||
{activeLinkInSections?.title || "Dashboard"}
|
||||
</label>
|
||||
<Button
|
||||
onClick={() => setIsMobileOpen((prev) => !prev)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{/* actual link sections */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all md:transition-none max-h-0 md:max-h-full md:h-full overflow-hidden md:overflow-auto absolute md:bg-transparent md:relative w-full px-4 md:py-10",
|
||||
isMobileOpen &&
|
||||
"h-svh max-h-svh top-full mt-[1px] md:top-0 overflow-auto backdrop-blur-lg md:backdrop-blur-none py-4 bg-background/50 z-50",
|
||||
)}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<SidebarSection section={section} key={section.id} />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
export type SidebarNavLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type SidebarNavSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
// TODO: make sections be able to be in items when we need nested sections
|
||||
items: Array<SidebarNavLink>;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
|
||||
export function ConnectSidebarLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarLayout
|
||||
sidebarLinks={[
|
||||
{
|
||||
label: "Analytics",
|
||||
href: "/dashboard/connect/analytics",
|
||||
},
|
||||
{
|
||||
label: "In-App Wallets",
|
||||
href: "/dashboard/connect/in-app-wallets",
|
||||
},
|
||||
{
|
||||
label: "Ecosystem Wallets",
|
||||
href: "/dashboard/connect/ecosystem",
|
||||
},
|
||||
{
|
||||
label: "Account Abstraction",
|
||||
href: "/dashboard/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
label: "Pay",
|
||||
href: "/dashboard/connect/pay",
|
||||
},
|
||||
{
|
||||
label: "Playground",
|
||||
href: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { BookMarkedIcon, ExternalLinkIcon, MoveRightIcon } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import headerImage from "./assets/header.png";
|
||||
import { fetchEcosystemList } from "./utils/fetchEcosystemList";
|
||||
|
||||
export async function EcosystemLandingPage(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? cookiesManager.get(`${COOKIE_PREFIX_TOKEN}${activeAccount}`)?.value
|
||||
: null;
|
||||
|
||||
// if user is logged in and has an ecosystem, redirect to the first ecosystem
|
||||
if (authToken) {
|
||||
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
});
|
||||
if (ecosystems.length > 0) {
|
||||
redirect(`${props.ecosystemLayoutPath}/${ecosystems[0].slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise we fall through to the page
|
||||
|
||||
return (
|
||||
<main className="container flex flex-col max-w-2xl gap-8 pb-6 mx-auto">
|
||||
<Image
|
||||
src={headerImage}
|
||||
alt="Ecosystems"
|
||||
sizes="100vw"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-left">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl text-foreground text-balance">
|
||||
One wallet, a whole ecosystem of apps and games
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
With Ecosystem Wallets, your users can access their assets across
|
||||
hundreds of apps and games within your ecosystem. You can control
|
||||
which apps join your ecosystem and how their users interact with your
|
||||
wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
|
||||
{!authToken ? (
|
||||
<ToolTipLabel label="Connect your wallet to create an ecosystem">
|
||||
<Button variant="primary" className="opacity-50">
|
||||
Create Ecosystem
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
) : (
|
||||
<Button variant="primary" asChild>
|
||||
<Link href={`${props.ecosystemLayoutPath}/create`}>
|
||||
Create Ecosystem
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2"
|
||||
>
|
||||
Read The Docs
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full h-0 my-2 border-t" />
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
className="mb-6 group"
|
||||
>
|
||||
<Card className="flex flex-col gap-3 p-6 md:p-8 md:px-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarkedIcon className="size-5 text-muted-foreground" />
|
||||
<h4 className="text-lg font-bold">Learn more</h4>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-md">
|
||||
Learn how to create and manage Ecosystem Wallets in our docs.
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-2 mt-4 transition-all group-hover:gap-3 group-hover:text-primary/80 text-primary text-md">
|
||||
View Docs <MoveRightIcon className="size-4" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
import { EcosystemPartnersSection } from "./components/server/ecosystem-partners-section";
|
||||
import { IntegrationPermissionsSection } from "./components/server/integration-permissions-section";
|
||||
import { useEcosystem } from "./hooks/use-ecosystem";
|
||||
|
||||
export function EcosystemPermissionsPage({
|
||||
params,
|
||||
}: { params: { slug: string } }) {
|
||||
const { ecosystem } = useEcosystem({ slug: params.slug });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<IntegrationPermissionsSection ecosystem={ecosystem} />
|
||||
{ecosystem?.permission === "PARTNER_WHITELIST" && (
|
||||
<EcosystemPartnersSection ecosystem={ecosystem} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { fetchEcosystem } from "../../utils/fetchEcosystem";
|
||||
import { EcosystemHeader } from "./components/client/ecosystem-header.client";
|
||||
|
||||
export async function EcosystemLayoutSlug({
|
||||
children,
|
||||
params,
|
||||
ecosystemLayoutPath,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string };
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? cookies().get(COOKIE_PREFIX_TOKEN + getAddress(activeAccount))?.value
|
||||
: null;
|
||||
|
||||
if (!authToken) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const ecosystem = await fetchEcosystem(params.slug, authToken);
|
||||
|
||||
if (!ecosystem) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-10">
|
||||
<EcosystemHeader
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={ecosystemLayoutPath}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-7
@@ -60,7 +60,10 @@ function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
}
|
||||
}
|
||||
|
||||
function EcosystemSelect(props: { ecosystem: Ecosystem }) {
|
||||
function EcosystemSelect(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const { data: ecosystems, isLoading } = useEcosystemList();
|
||||
|
||||
return isLoading ? (
|
||||
@@ -81,7 +84,7 @@ function EcosystemSelect(props: { ecosystem: Ecosystem }) {
|
||||
{ecosystems?.map((ecosystem) => (
|
||||
<DropdownMenuItem key={ecosystem.id} asChild>
|
||||
<Link
|
||||
href={`/dashboard/connect/ecosystem/${ecosystem.slug}`}
|
||||
href={`${props.ecosystemLayoutPath}/${ecosystem.slug}`}
|
||||
className="relative flex items-center pl-8 pr-3 cursor-pointer"
|
||||
>
|
||||
{ecosystem.slug === props.ecosystem.slug && (
|
||||
@@ -93,7 +96,7 @@ function EcosystemSelect(props: { ecosystem: Ecosystem }) {
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href="/dashboard/connect/ecosystem/create" className="">
|
||||
<Link href={`${props.ecosystemLayoutPath}/create`} className="">
|
||||
<DropdownMenuItem className="relative flex items-center pl-8 pr-3 cursor-pointer">
|
||||
<PlusCircleIcon className="absolute w-4 h-4 left-2" />
|
||||
<div className="truncate">New Ecosystem</div>
|
||||
@@ -104,7 +107,10 @@ function EcosystemSelect(props: { ecosystem: Ecosystem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function EcosystemHeader(props: { ecosystem: Ecosystem }) {
|
||||
export function EcosystemHeader(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { data: fetchedEcosystem } = useEcosystem({
|
||||
slug: props.ecosystem.slug,
|
||||
@@ -166,15 +172,19 @@ export function EcosystemHeader(props: { ecosystem: Ecosystem }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 md:items-end">
|
||||
<EcosystemSelect ecosystem={ecosystem} />
|
||||
<EcosystemSelect
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TabLinks
|
||||
links={[
|
||||
{
|
||||
name: "Permissions",
|
||||
href: `/dashboard/connect/ecosystem/${ecosystem.slug}/permissions`,
|
||||
isActive: pathname?.endsWith("/permissions") || false, // pathname will almost never be null: https://nextjs.org/docs/app/api-reference/functions/use-pathname
|
||||
href: `${props.ecosystemLayoutPath}/${ecosystem.slug}`,
|
||||
isActive:
|
||||
pathname === `${props.ecosystemLayoutPath}/${ecosystem.slug}`,
|
||||
},
|
||||
{
|
||||
name: "Analytics (Coming Soon)",
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { CopyButton } from "@/components/ui/CopyButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Pencil, Table, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import type { Ecosystem, Partner } from "../../../../types";
|
||||
|
||||
+6
-44
@@ -1,54 +1,16 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAddress } from "thirdweb";
|
||||
import type { Ecosystem } from "../../types";
|
||||
import { EcosystemHeader } from "./components/client/ecosystem-header.client";
|
||||
|
||||
async function fetchEcosystem(slug: string, authToken: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com"}/v1/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem };
|
||||
return data.result;
|
||||
}
|
||||
import { EcosystemLayoutSlug } from "./EcosystemSlugLayout";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: { children: React.ReactNode; params: { slug: string } }) {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? cookies().get(COOKIE_PREFIX_TOKEN + getAddress(activeAccount))?.value
|
||||
: null;
|
||||
|
||||
if (!authToken) {
|
||||
redirect("/dashboard/connect/ecosystem");
|
||||
}
|
||||
|
||||
const ecosystem = await fetchEcosystem(params.slug, authToken);
|
||||
|
||||
if (!ecosystem) {
|
||||
redirect("/dashboard/connect/ecosystem");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-10 px-2 py-10 sm:px-4">
|
||||
<EcosystemHeader ecosystem={ecosystem} />
|
||||
<EcosystemLayoutSlug
|
||||
params={params}
|
||||
ecosystemLayoutPath="/dashboard/connect/ecosystem"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EcosystemLayoutSlug>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { EcosystemPermissionsPage } from "./EcosystemPermissionsPage";
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
return <EcosystemPermissionsPage params={params} />;
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
import { EcosystemPartnersSection } from "../components/server/ecosystem-partners-section";
|
||||
import { IntegrationPermissionsSection } from "../components/server/integration-permissions-section";
|
||||
import { useEcosystem } from "../hooks/use-ecosystem";
|
||||
|
||||
export default function Page({ params }: { params: { slug: string } }) {
|
||||
const { ecosystem } = useEcosystem({ slug: params.slug });
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-12">
|
||||
<IntegrationPermissionsSection ecosystem={ecosystem} />
|
||||
{ecosystem?.permission === "PARTNER_WHITELIST" && (
|
||||
<EcosystemPartnersSection ecosystem={ecosystem} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { CreateEcosystemForm } from "./components/client/create-ecosystem-form.client";
|
||||
import { EcosystemWalletPricingCard } from "./components/pricing-card";
|
||||
|
||||
export function EcosystemCreatePage(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h2 className="text-3xl font-semibold text-foreground tracking-tight">
|
||||
Create an Ecosystem
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create wallets that work across every chain and every app.
|
||||
</p>
|
||||
</header>
|
||||
<main className="grid w-full max-w-sm gap-6 md:max-w-lg lg:max-w-4xl lg:grid-cols-2">
|
||||
<section className="flex items-start">
|
||||
<EcosystemWalletPricingCard />
|
||||
</section>
|
||||
<section className="mb-12 lg:px-4">
|
||||
<CreateEcosystemForm
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -42,7 +42,9 @@ const formSchema = z.object({
|
||||
permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]),
|
||||
});
|
||||
|
||||
export function CreateEcosystemForm() {
|
||||
export function CreateEcosystemForm(props: {
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
// When set, the confirmation modal is open the this contains the form data to be submitted
|
||||
const [formDataToBeConfirmed, setFormDataToBeConfirmed] = useState<
|
||||
z.infer<typeof formSchema> | undefined
|
||||
@@ -65,7 +67,7 @@ export function CreateEcosystemForm() {
|
||||
},
|
||||
onSuccess: (slug: string) => {
|
||||
form.reset();
|
||||
router.push(`/dashboard/connect/ecosystem/${slug}`);
|
||||
router.push(`${props.ecosystemLayoutPath}/${slug}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import { CreateEcosystemForm } from "./components/client/create-ecosystem-form.client";
|
||||
import { EcosystemWalletPricingCard } from "./components/pricing-card";
|
||||
import { EcosystemCreatePage } from "./EcosystemCreatePage";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-10 px-2 py-10 sm:px-4">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h2 className="text-4xl font-bold text-foreground">
|
||||
Create an Ecosystem
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Create wallets that work across every chain and every app.
|
||||
</p>
|
||||
</header>
|
||||
<main className="grid w-full max-w-sm gap-8 md:max-w-lg lg:max-w-4xl xl:gap-12 lg:grid-cols-2">
|
||||
<section className="flex items-start">
|
||||
<EcosystemWalletPricingCard />
|
||||
</section>
|
||||
<section className="mb-12 lg:px-4">
|
||||
<CreateEcosystemForm />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<EcosystemCreatePage ecosystemLayoutPath="/dashboard/connect/ecosystem" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { BookMarkedIcon, ExternalLinkIcon, MoveRightIcon } from "lucide-react";
|
||||
import { cookies } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import headerImage from "./assets/header.png";
|
||||
import type { Ecosystem } from "./types";
|
||||
|
||||
async function fetchEcosystemList(authToken: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com"}/v1/ecosystem-wallet/list`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
throw new Error(data?.error?.message ?? "Failed to fetch ecosystems");
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem[] };
|
||||
return data.result;
|
||||
}
|
||||
import { EcosystemLandingPage } from "./EcosystemLandingPage";
|
||||
|
||||
export default async function Page() {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const authToken = activeAccount
|
||||
? cookiesManager.get(`${COOKIE_PREFIX_TOKEN}${activeAccount}`)?.value
|
||||
: null;
|
||||
|
||||
// if user is logged in and has an ecosystem, redirect to the first ecosystem
|
||||
if (authToken) {
|
||||
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
});
|
||||
if (ecosystems.length > 0) {
|
||||
redirect(`/dashboard/connect/ecosystem/${ecosystems[0].slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise we fall through to the page
|
||||
|
||||
return (
|
||||
<main className="container flex flex-col max-w-2xl gap-8 pb-6 mx-auto">
|
||||
<Image
|
||||
src={headerImage}
|
||||
alt="Ecosystems"
|
||||
sizes="100vw"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center sm:text-left">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl text-foreground text-balance">
|
||||
One wallet, a whole ecosystem of apps and games
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
With Ecosystem Wallets, your users can access their assets across
|
||||
hundreds of apps and games within your ecosystem. You can control
|
||||
which apps join your ecosystem and how their users interact with your
|
||||
wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
|
||||
{!authToken ? (
|
||||
<ToolTipLabel label="Connect your wallet to create an ecosystem">
|
||||
<Button variant="primary" className="opacity-50">
|
||||
Create Ecosystem
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
) : (
|
||||
<Link href="/dashboard/connect/ecosystem/create">
|
||||
<Button variant="primary">Create Ecosystem</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
target="_blank"
|
||||
className="flex flex-row gap-2"
|
||||
>
|
||||
Read The Docs
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full h-0 my-2 border-t" />
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/ecosystems/overview"
|
||||
className="mb-6 group"
|
||||
>
|
||||
<Card className="flex flex-col gap-3 p-6 md:p-8 md:px-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarkedIcon className="size-5 text-muted-foreground" />
|
||||
<h4 className="text-lg font-bold">Learn more</h4>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-md">
|
||||
Learn how to create and manage Ecosystem Wallets in our docs.
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-2 mt-4 transition-all group-hover:gap-3 group-hover:text-primary/80 text-primary text-md">
|
||||
View Docs <MoveRightIcon className="size-4" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</main>
|
||||
<EcosystemLandingPage ecosystemLayoutPath="/dashboard/connect/ecosystem" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type { Ecosystem } from "../types";
|
||||
|
||||
export async function fetchEcosystem(slug: string, authToken: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com"}/v1/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem };
|
||||
return data.result;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { Ecosystem } from "../types";
|
||||
|
||||
export async function fetchEcosystemList(authToken: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com"}/v1/ecosystem-wallet/list`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
throw new Error(data?.error?.message ?? "Failed to fetch ecosystems");
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem[] };
|
||||
return data.result;
|
||||
}
|
||||
@@ -1,52 +1,7 @@
|
||||
import { SidebarNav } from "../components/sidebar/sidebar.client";
|
||||
import type { SidebarNavSection } from "../components/sidebar/types";
|
||||
import { ConnectSidebarLayout } from "./DashboardConnectLayout";
|
||||
|
||||
const SIDEBAR_SECTIONS = [
|
||||
{
|
||||
id: "connect",
|
||||
title: "Connect",
|
||||
items: [
|
||||
{
|
||||
id: "analytics",
|
||||
title: "Analytics",
|
||||
href: "/dashboard/connect/analytics",
|
||||
},
|
||||
{
|
||||
id: "embedded-wallets",
|
||||
title: "In-App Wallets",
|
||||
href: "/dashboard/connect/in-app-wallets",
|
||||
},
|
||||
{
|
||||
id: "ecosystem",
|
||||
title: "Ecosystem Wallets",
|
||||
href: "/dashboard/connect/ecosystem",
|
||||
},
|
||||
{
|
||||
id: "account-abstraction",
|
||||
title: "Account Abstraction",
|
||||
href: "/dashboard/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
id: "pay-settings",
|
||||
title: "Pay",
|
||||
href: "/dashboard/connect/pay",
|
||||
},
|
||||
{
|
||||
id: "playground",
|
||||
title: "Playground",
|
||||
href: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies Array<SidebarNavSection>;
|
||||
|
||||
export default function DashboardConnectLayout(props: {
|
||||
export default function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:h-full container px-4">
|
||||
<SidebarNav sections={SIDEBAR_SECTIONS} />
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
return <ConnectSidebarLayout>{props.children}</ConnectSidebarLayout>;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ export default async function DashboardConnectPayPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-6 w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="flex flex-col lg:flex-row gap-6 justify-between items-start">
|
||||
<div className="max-w-[800px]">
|
||||
<h1 className="text-5xl tracking-tight font-bold mb-5">Pay</h1>
|
||||
<h1 className="text-3xl lg:text-4xl tracking-tight font-bold mb-3">
|
||||
Pay
|
||||
</h1>
|
||||
<p className="text-muted-foreground leading-7">
|
||||
Pay allows your users to purchase cryptocurrencies and execute
|
||||
transactions with their credit card or debit card, or with any token
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "../../../@/components/ui/alert";
|
||||
import { Badge } from "../../../@/components/ui/badge";
|
||||
import { Button } from "../../../@/components/ui/button";
|
||||
import { Checkbox } from "../../../@/components/ui/checkbox";
|
||||
import { Input } from "../../../@/components/ui/input";
|
||||
import { Label } from "../../../@/components/ui/label";
|
||||
|
||||
export const metadata = {
|
||||
robots: "noindex, nofollow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type SortBy, type TimeRange, fetchTopContracts } from "lib/search";
|
||||
import type { Metadata } from "next";
|
||||
import { Sidebar } from "../../../@/components/blocks/Sidebar";
|
||||
import { ContractsSidebarLayout } from "../../../core-ui/sidebar/contracts";
|
||||
import { TrendingContractSection } from "./components/trending-table";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,42 +25,16 @@ export default async function DashboardContractTrendingPage(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container px-4 flex gap-2">
|
||||
<Sidebar
|
||||
links={[
|
||||
{
|
||||
href: "/dashboard/contracts/deploy",
|
||||
label: "Deploy",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/contracts/publish",
|
||||
label: "Publish",
|
||||
},
|
||||
{
|
||||
href: "/explore",
|
||||
label: "Explore",
|
||||
},
|
||||
{
|
||||
href: "/trending",
|
||||
label: "Trending",
|
||||
},
|
||||
{
|
||||
href: "/dashboard/contracts/build",
|
||||
label: "Build",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="py-8">
|
||||
<h1 className="text-3xl md:text-4xl tracking-tight font-semibold mb-5">
|
||||
Trending Contracts
|
||||
</h1>
|
||||
<ContractsSidebarLayout>
|
||||
<h1 className="text-2xl md:text-3xl tracking-tight font-semibold mb-5">
|
||||
Trending Contracts
|
||||
</h1>
|
||||
|
||||
<TrendingContractSection
|
||||
topContracts={topContracts}
|
||||
searchParams={props.searchParams}
|
||||
showPagination={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TrendingContractSection
|
||||
topContracts={topContracts}
|
||||
searchParams={props.searchParams}
|
||||
showPagination={true}
|
||||
/>
|
||||
</ContractsSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
AccountHeaderMobileUI,
|
||||
} from "./AccountHeaderUI";
|
||||
|
||||
export function AccountHeader(props: {
|
||||
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
|
||||
}) {
|
||||
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: AccountHeaderCompProps = {
|
||||
teamsAndProjects: props.teamsAndProjects,
|
||||
email: myAccountQuery.data?.email,
|
||||
logout: logout,
|
||||
connectButton: <CustomConnectWallet />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AccountHeaderDesktopUI {...headerProps} className="max-lg:hidden" />
|
||||
<AccountHeaderMobileUI {...headerProps} className="lg:hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { teamsAndProjectsStub } from "../../../stories/stubs";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import {
|
||||
AccountHeaderDesktopUI,
|
||||
AccountHeaderMobileUI,
|
||||
} from "./AccountHeaderUI";
|
||||
|
||||
const meta = {
|
||||
title: "Account/Account Header",
|
||||
component: Variants,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Variants>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {
|
||||
type: "desktop",
|
||||
},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
type: "mobile",
|
||||
},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Variants(props: {
|
||||
type: "mobile" | "desktop";
|
||||
}) {
|
||||
const Comp =
|
||||
props.type === "mobile" ? AccountHeaderMobileUI : AccountHeaderDesktopUI;
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div
|
||||
className={"flex flex-col gap-6 bg-gray-700 px-4 py-10 min-h-screen"}
|
||||
>
|
||||
<BadgeContainer label="Logged in">
|
||||
<Comp
|
||||
teamsAndProjects={teamsAndProjectsStub}
|
||||
email="[email protected]"
|
||||
logout={() => {}}
|
||||
connectButton={<ConnectButtonStub />}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectButtonStub() {
|
||||
return <Button>Connect</Button>;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
|
||||
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
|
||||
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
|
||||
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
|
||||
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
|
||||
|
||||
export type AccountHeaderCompProps = {
|
||||
className?: string;
|
||||
email: string | undefined;
|
||||
logout: () => void;
|
||||
connectButton: React.ReactNode;
|
||||
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
|
||||
};
|
||||
|
||||
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-row gap-2 items-center text-foreground justify-between px-6 py-4",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
</Link>
|
||||
|
||||
<SlashSeparator />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href="/account"
|
||||
className="font-normal text-sm flex flex-row gap-2 items-center"
|
||||
>
|
||||
{/* TODO - replace with account image */}
|
||||
<div className="bg-muted border border-border size-6 rounded-full" />
|
||||
<span> My Account </span>
|
||||
</Link>
|
||||
|
||||
<TeamAndProjectSelectorPopoverButton
|
||||
currentProject={undefined}
|
||||
currentTeam={undefined}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
focus="team-selection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SecondaryNav
|
||||
email={props.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-row gap-2 items-center 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={"/account"}
|
||||
className={cn(
|
||||
"font-normal text-sm flex flex-row items-center text-foreground gap-2",
|
||||
)}
|
||||
>
|
||||
{/* TODO - replace with account image */}
|
||||
<div className="bg-muted border border-border size-7 rounded-full" />
|
||||
<span> My Account </span>
|
||||
</Link>
|
||||
|
||||
<TeamSelectorMobileMenuButton
|
||||
currentTeam={undefined}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
email={props.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SlashSeparator() {
|
||||
return <div className="h-5 w-[1.5px] bg-foreground/30 rotate-[25deg] mx-2" />;
|
||||
}
|
||||
@@ -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 bg-muted/50"
|
||||
/>
|
||||
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import type React from "react";
|
||||
import { TWAutoConnect } from "../components/autoconnect";
|
||||
import { AccountHeader } from "./components/AccountHeader";
|
||||
|
||||
export default async function AccountLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-background">
|
||||
<div className="grow flex flex-col">
|
||||
<HeaderAndNav />
|
||||
<div className="py-10 border-b border-border">
|
||||
<div className="max-w-[1100px] mx-auto px-6">
|
||||
<h1 className="text-3xl tracking-tight font-semibold">
|
||||
My Account
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLayout
|
||||
className="max-w-[1100px]"
|
||||
sidebarLinks={[
|
||||
{
|
||||
href: "/account",
|
||||
label: "Overview",
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
href: "/account/wallets",
|
||||
label: "Wallets",
|
||||
},
|
||||
{
|
||||
href: "/account/settings",
|
||||
label: "Settings",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
<TWAutoConnect />
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function HeaderAndNav() {
|
||||
const teams = await getTeams();
|
||||
|
||||
const teamsAndProjects = await Promise.all(
|
||||
teams.map(async (team) => ({
|
||||
team,
|
||||
projects: await getProjects(team.slug),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 border-b">
|
||||
<AccountHeader teamsAndProjects={teamsAndProjects} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { teamStub } from "../../../stories/stubs";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { AccountTeamsUI } from "./AccountTeamsUI";
|
||||
|
||||
const meta = {
|
||||
title: "Account/Pages/Teams",
|
||||
component: Variants,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Variants>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {
|
||||
type: "desktop",
|
||||
},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
type: "mobile",
|
||||
},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Variants() {
|
||||
return (
|
||||
<div className="container py-10 max-w-[1100px] mx-auto w-full flex flex-col gap-10">
|
||||
<BadgeContainer label="4 Teams">
|
||||
<AccountTeamsUI
|
||||
teamsWithRole={[
|
||||
{
|
||||
team: teamStub("1", "free"),
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
team: teamStub("2", "pro"),
|
||||
role: "OWNER",
|
||||
},
|
||||
{
|
||||
team: teamStub("3", "growth"),
|
||||
role: "MEMBER",
|
||||
},
|
||||
{
|
||||
team: teamStub("4", "growth"),
|
||||
role: "MEMBER",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="1 Team">
|
||||
<AccountTeamsUI
|
||||
teamsWithRole={[
|
||||
{
|
||||
team: teamStub("1", "free"),
|
||||
role: "MEMBER",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { TeamAccountRole } from "@/api/team-members";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { EllipsisIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { TeamPlanBadge } from "../../components/TeamPlanBadge";
|
||||
import { getValidTeamPlan } from "../../team/components/TeamHeader/getValidTeamPlan";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
|
||||
export function AccountTeamsUI(props: {
|
||||
teamsWithRole: {
|
||||
team: Team;
|
||||
role: TeamAccountRole;
|
||||
}[];
|
||||
}) {
|
||||
const [teamSearchValue, setTeamSearchValue] = useState("");
|
||||
const teamsToShow = !teamSearchValue
|
||||
? props.teamsWithRole
|
||||
: props.teamsWithRole.filter((v) => {
|
||||
return v.team.name
|
||||
.toLowerCase()
|
||||
.includes(teamSearchValue.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start gap-4 flex-col lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight mb-0.5">Teams</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The teams that are associated with your thirdweb account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToolTipLabel label="Coming Soon">
|
||||
<Button variant="primary" disabled className="max-sm:w-full gap-2">
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Team
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search Teams"
|
||||
value={teamSearchValue}
|
||||
onValueChange={setTeamSearchValue}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<ul className="bg-muted/50 border rounded-lg">
|
||||
{/* Teams List */}
|
||||
{teamsToShow.map((v) => {
|
||||
return (
|
||||
<li
|
||||
key={v.team.id}
|
||||
className="border-b border-border p-4 last:border-b-0"
|
||||
>
|
||||
<TeamRow team={v.team} role={v.role} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* No Result Found */}
|
||||
{teamsToShow.length === 0 && (
|
||||
<div className="p-4 h-[200px] justify-center flex items-center">
|
||||
<div className="text-center">
|
||||
<p className="mb-3 text-sm">No teams found</p>
|
||||
{teamSearchValue && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your search for {`"${teamSearchValue}"`} did not match any
|
||||
teams
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRow(props: {
|
||||
team: Team;
|
||||
role: TeamAccountRole;
|
||||
}) {
|
||||
const plan = getValidTeamPlan(props.team);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
{/* start */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* TODO - render team avatar */}
|
||||
<div className="border rounded-full size-8 bg-muted" />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-semibold text-sm">{props.team.name}</p>
|
||||
<TeamPlanBadge plan={plan} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm capitalize">
|
||||
{props.role.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* end */}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="!h-auto !w-auto p-1.5">
|
||||
<EllipsisIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-[180px]">
|
||||
<DropdownMenuItem>
|
||||
<Link href={`/team/${props.team.slug}`} className="w-full p-1 ">
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
href={`/team/${props.team.slug}/~/settings`}
|
||||
className="w-full p-1 "
|
||||
>
|
||||
Manange
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { AccountTeamsUI } from "./overview/AccountTeamsUI";
|
||||
|
||||
export default async function Page() {
|
||||
const teams = await getTeams();
|
||||
|
||||
const teamsWithRole = teams.map((team) => ({
|
||||
team,
|
||||
// TODO fetch the role of current user in this team
|
||||
role: "MEMBER" as const, // THIS IS A PLACEHOLDER !!!
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grow">
|
||||
<AccountTeamsUI teamsWithRole={teamsWithRole} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { mobileViewport } from "../../../stories/utils";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
const meta = {
|
||||
title: "Account/Pages/Settings",
|
||||
component: Variants,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Variants>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {
|
||||
type: "desktop",
|
||||
},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
type: "mobile",
|
||||
},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Variants() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container py-10 max-w-[1132px] mx-auto w-full flex flex-col gap-10">
|
||||
<AccountSettingsPageUI
|
||||
account={{
|
||||
name: "John Doe",
|
||||
email: "[email protected]",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { EllipsisIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { FileInput } from "../../../components/shared/FileInput";
|
||||
import { DangerSettingCard } from "../../team/[team_slug]/(team)/~/settings/general/DangerSettingCard";
|
||||
import { SettingsCard } from "../../team/[team_slug]/(team)/~/settings/general/SettingsCard";
|
||||
|
||||
type MinimalAccount = Pick<
|
||||
Account,
|
||||
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail"
|
||||
>;
|
||||
|
||||
export function AccountSettingsPageUI(props: {
|
||||
account: MinimalAccount;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<AccountAvatarFormControl />
|
||||
<AccountNameFormControl name={props.account.name || ""} />
|
||||
<AccountEmailFormControl
|
||||
email={props.account.email || ""}
|
||||
// TODO - is this correct way to check this?
|
||||
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
|
||||
/>
|
||||
|
||||
<DeleteAccountCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountAvatarFormControl() {
|
||||
const [avatar, setAvatar] = useState<File>(); // TODO: prefill with account avatar
|
||||
|
||||
// TODO - implement
|
||||
const updateAvatarMutation = useMutation({
|
||||
mutationFn: async (_avatar: File | undefined) => {
|
||||
// Fake loading
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Updating account image to", _avatar);
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promises = updateAvatarMutation.mutateAsync(avatar);
|
||||
toast.promise(promises, {
|
||||
success: "Account avatar updated successfully",
|
||||
error: "Failed to update account avatar",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
bottomText="An avatar is optional but strongly recommended."
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: false,
|
||||
isLoading: updateAvatarMutation.isLoading,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<div className="flex flex-row gap-4 md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-tight">Avatar</h3>
|
||||
<p className="text-foreground text-sm mt-1.5 mb-4 leading-relaxed">
|
||||
This is your account's avatar. <br /> Click on the avatar to upload
|
||||
a custom one
|
||||
</p>
|
||||
</div>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={avatar}
|
||||
setValue={setAvatar}
|
||||
className="w-20 lg:w-28 rounded-full"
|
||||
disableHelperText
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountNameFormControl(props: {
|
||||
name: string;
|
||||
}) {
|
||||
const [accountName, setAccountName] = useState(props.name);
|
||||
const maxAccountNameLength = 32;
|
||||
|
||||
// TODO - implement
|
||||
const updateAccountNameMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Updating account name to", name);
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promises = updateAccountNameMutation.mutateAsync(accountName);
|
||||
toast.promise(promises, {
|
||||
success: "Account name updated successfully",
|
||||
error: "Failed to update account name",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
header={{
|
||||
title: "Display Name",
|
||||
description: "This is your account's name displayed on thirdweb",
|
||||
}}
|
||||
bottomText={`Please use ${maxAccountNameLength} characters at maximum.`}
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: accountName.length === 0,
|
||||
isLoading: updateAccountNameMutation.isLoading,
|
||||
}}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined} // TODO
|
||||
>
|
||||
<Input
|
||||
value={accountName}
|
||||
onChange={(e) => {
|
||||
setAccountName(e.target.value.slice(0, maxAccountNameLength));
|
||||
}}
|
||||
className="md:w-[400px]"
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountCard() {
|
||||
const router = useDashboardRouter();
|
||||
const title = "Delete Account";
|
||||
const description =
|
||||
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
|
||||
|
||||
// TODO
|
||||
const deleteAccount = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Deleting account");
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push("/team");
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
const promises = deleteAccount.mutateAsync();
|
||||
toast.promise(promises, {
|
||||
success: "Account deleted successfully",
|
||||
error: "Failed to delete account",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerSettingCard
|
||||
title={title}
|
||||
description={description}
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleDelete}
|
||||
isLoading={deleteAccount.isLoading}
|
||||
confirmationDialog={{
|
||||
title: "Are you sure you want to delete your account?",
|
||||
description:
|
||||
"This action is not reversible and will delete all of your data.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountEmailFormControl(props: {
|
||||
email: string;
|
||||
status: "unverified" | "verfication-sent" | "verified";
|
||||
}) {
|
||||
// TODO - query for account changes when the email is updated
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
header={{
|
||||
title: "Email",
|
||||
description:
|
||||
"Enter the email address you want to use to log in with thirdweb. This email will be used for account related notifications",
|
||||
}}
|
||||
bottomText={
|
||||
"Emails must be verified to be able to login with them or be used as primary email"
|
||||
}
|
||||
errorText={undefined}
|
||||
noPermissionText={undefined}
|
||||
>
|
||||
<div className="flex gap-4 justify-between items-center border rounded-lg p-4 bg-card">
|
||||
{/* Start */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center items-start gap-2 lg:gap-3">
|
||||
<p className="text-sm"> {props.email}</p>
|
||||
<Badge
|
||||
className="capitalize "
|
||||
variant={props.status === "unverified" ? "outline" : "default"}
|
||||
>
|
||||
{props.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* End */}
|
||||
<EmailUpdateDialog
|
||||
currentEmail={props.email}
|
||||
trigger={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="!h-auto !w-auto p-1.5"
|
||||
>
|
||||
<EllipsisIcon className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const emailUpdateFormSchema = z.object({
|
||||
email: z.string().min(1, "Email can not be empty").max(100),
|
||||
});
|
||||
|
||||
function EmailUpdateDialog(props: {
|
||||
currentEmail: string;
|
||||
trigger: React.ReactNode;
|
||||
}) {
|
||||
const form = useForm<z.infer<typeof emailUpdateFormSchema>>({
|
||||
resolver: zodResolver(emailUpdateFormSchema),
|
||||
values: {
|
||||
email: props.currentEmail,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO - implement
|
||||
const updateEmailMutation = useMutation({
|
||||
mutationFn: async (_email: string) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Updating account email to", _email);
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof emailUpdateFormSchema>) {
|
||||
const promises = updateEmailMutation.mutateAsync(values.email);
|
||||
toast.promise(promises, {
|
||||
success: "Email updated successfully",
|
||||
error: "Failed to update email",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
|
||||
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6 flex flex-col gap-4 pb-10">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="text-2xl">Update Email</DialogTitle>
|
||||
<DialogDescription>
|
||||
A confirmation email will be sent to verify email address
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
className="selection:bg-foreground/20"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-4 lg:gap-1 bg-muted/50 p-6 border-t">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"> Cancel </Button>
|
||||
</DialogClose>
|
||||
<Button className="min-w-24 gap-2" type="submit">
|
||||
{updateEmailMutation.isLoading && (
|
||||
<Spinner className="size-4" />
|
||||
)}
|
||||
{updateEmailMutation.isLoading ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
|
||||
export async function getAccount() {
|
||||
const authToken = getAuthToken();
|
||||
const apiServerURL = new URL(
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com",
|
||||
);
|
||||
|
||||
apiServerURL.pathname = "/v1/account/me";
|
||||
|
||||
const res = await fetch(apiServerURL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
console.error(json.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return json.data as Account;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
import { getAccount } from "./getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const account = await getAccount();
|
||||
|
||||
if (!account) {
|
||||
redirect(`/login?next=${encodeURIComponent("/account")}`);
|
||||
}
|
||||
|
||||
return <AccountSettingsPageUI account={account} />;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
|
||||
export function LinkWalletUI(props: {
|
||||
wallets: string[];
|
||||
}) {
|
||||
const [searchValue, setSerchValue] = useState("");
|
||||
const walletsToShow = !searchValue
|
||||
? props.wallets
|
||||
: props.wallets.filter((v) => {
|
||||
return v.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start gap-4 flex-col lg:flex-row lg:justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl tracking-tight mb-0.5">
|
||||
Linked Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The wallets that are linked to your thirdweb account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TODO - handle linking */}
|
||||
<Button variant="primary" className="max-sm:w-full gap-2">
|
||||
<PlusIcon className="size-4" />
|
||||
Link a Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search wallet address"
|
||||
value={searchValue}
|
||||
onValueChange={setSerchValue}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<ul className="bg-muted/50 border rounded-lg">
|
||||
{walletsToShow.map((v) => {
|
||||
return (
|
||||
<li key={v} className="border-b border-border p-4 last:border-b-0">
|
||||
<WalletRow address={v} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* No Result Found */}
|
||||
{walletsToShow.length === 0 && (
|
||||
<div className="p-4 h-[200px] justify-center flex items-center">
|
||||
<div className="text-center flex flex-col gap-3">
|
||||
<p className="text-sm">No Wallets Found</p>
|
||||
{searchValue && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your search for {`"${searchValue}"`} did not match any wallets
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletRow(props: {
|
||||
address: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
{/* start */}
|
||||
<WalletAddress
|
||||
address={props.address}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
|
||||
{/* end */}
|
||||
{/* TODO - handle unlink */}
|
||||
<Button size="sm" variant="outline" className="gap-2">
|
||||
<MinusIcon className="size-4" />
|
||||
Unlink
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { BadgeContainer, mobileViewport } from "../../../stories/utils";
|
||||
import { LinkWalletUI } from "./LinkWalletUI";
|
||||
|
||||
const meta = {
|
||||
title: "Account/Pages/Link Wallets",
|
||||
component: Variants,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Variants>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {
|
||||
type: "desktop",
|
||||
},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {
|
||||
type: "mobile",
|
||||
},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Variants() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container py-10 max-w-[1100px] mx-auto w-full flex flex-col gap-10">
|
||||
<BadgeContainer label="4 wallets">
|
||||
<LinkWalletUI
|
||||
wallets={[
|
||||
"0x51696930092b42243dee1077c8dd237074fb28d4", // jns.eth - ens
|
||||
"0xd8da6bf26964af9d7eed9e03e53415d37aa96045", // vitalik.eth - ens, farcaster, lens
|
||||
"0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", // no ens
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="1 wallet">
|
||||
<LinkWalletUI
|
||||
wallets={[
|
||||
"0x51696930092b42243dee1077c8dd237074fb28d4", // jns.eth
|
||||
]}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 wallets">
|
||||
<LinkWalletUI wallets={[]} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LinkWalletUI } from "./LinkWalletUI";
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="grow">
|
||||
<LinkWalletUI
|
||||
// TODO - fetch the wallets of current user
|
||||
wallets={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { getAuthToken } from "./getAuthToken";
|
||||
|
||||
export async function getAPIKey(apiKeyId: string) {
|
||||
const authToken = getAuthToken();
|
||||
const apiServerURL = new URL(
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com",
|
||||
);
|
||||
|
||||
apiServerURL.pathname = `/v1/keys/${apiKeyId}`;
|
||||
|
||||
const res = await fetch(apiServerURL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
console.error(json.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return json.data as ApiKey;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export function getAuthToken() {
|
||||
const cookiesManager = cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
: null;
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -8,6 +8,9 @@ const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
|
||||
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
// Note: This handler cannot use "edge" runtime because of Redis usage.
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
@@ -33,10 +36,22 @@ export const GET = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if faucet is disabled for this chain.
|
||||
let isFaucetDisabled = false;
|
||||
if (DISABLE_FAUCET_CHAIN_IDS) {
|
||||
try {
|
||||
const disableFaucetChainIds = DISABLE_FAUCET_CHAIN_IDS.split(",").map(
|
||||
(chainId) => Number.parseInt(chainId.trim()),
|
||||
);
|
||||
isFaucetDisabled = disableFaucetChainIds.includes(chainId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (
|
||||
!THIRDWEB_ENGINE_URL ||
|
||||
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
!THIRDWEB_ACCESS_TOKEN
|
||||
!THIRDWEB_ACCESS_TOKEN ||
|
||||
isFaucetDisabled
|
||||
) {
|
||||
const res: CanClaimResponseType = {
|
||||
canClaim: false,
|
||||
@@ -45,12 +60,15 @@ export const GET = async (req: NextRequest) => {
|
||||
return NextResponse.json(res);
|
||||
}
|
||||
|
||||
// vercel provides this for us in the request object || fall back to the header
|
||||
const ipAddress = req.ip || req.headers.get("X-Forwarded-For");
|
||||
// CF header, fallback to req.ip, then X-Forwarded-For
|
||||
const ipAddress =
|
||||
req.headers.get("CF-Connecting-IP") ||
|
||||
req.ip ||
|
||||
req.headers.get("X-Forwarded-For");
|
||||
if (!ipAddress) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Could not validate elligibility.",
|
||||
error: "Could not validate eligibility.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { startOfToday } from "date-fns";
|
||||
import { cacheGet, cacheSet } from "lib/redis";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
|
||||
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
|
||||
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
|
||||
interface RequestTestnetFundsPayload {
|
||||
chainId: number;
|
||||
toAddress: string;
|
||||
@@ -15,11 +21,6 @@ export const POST = async (req: NextRequest) => {
|
||||
throw new Error("Invalid chain ID.");
|
||||
}
|
||||
|
||||
const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET;
|
||||
const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
|
||||
if (
|
||||
!THIRDWEB_ENGINE_URL ||
|
||||
!NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET ||
|
||||
@@ -31,8 +32,11 @@ export const POST = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// vercel provides this for us in the request object || fall back to the header
|
||||
const ipAddress = req.ip || req.headers.get("X-Forwarded-For");
|
||||
// CF header, fallback to req.ip, then X-Forwarded-For
|
||||
const ipAddress =
|
||||
req.headers.get("CF-Connecting-IP") ||
|
||||
req.ip ||
|
||||
req.headers.get("X-Forwarded-For");
|
||||
if (!ipAddress) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -42,28 +46,44 @@ export const POST = async (req: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
|
||||
const ipCacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
|
||||
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
|
||||
|
||||
// Assert 1 request per IP/chain every 24 hours.
|
||||
// get the cached value
|
||||
const cachedValue = await cacheGet(cacheKey);
|
||||
const [ipCacheValue, addressCache] = await Promise.all([
|
||||
cacheGet(ipCacheKey),
|
||||
cacheGet(addressCacheKey),
|
||||
]);
|
||||
// if we have a cached value, return an error
|
||||
if (cachedValue !== null) {
|
||||
if (ipCacheValue !== null || addressCache !== null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Already requested funds on this chain in the past 24 hours." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Set an idempotencyKey to prevent duplicate claims for this chain/ip/time period.
|
||||
const todayLocal = startOfToday();
|
||||
const todayUTC = new Date(
|
||||
todayLocal.getTime() - todayLocal.getTimezoneOffset() * 60000,
|
||||
);
|
||||
const todayUTCSeconds = Math.floor(todayUTC.getTime() / 1000);
|
||||
const idempotencyKey = `${ipCacheKey}:${todayUTCSeconds}`;
|
||||
|
||||
try {
|
||||
// Store the claim request for 24 hours.
|
||||
await cacheSet(cacheKey, "claimed", 24 * 60 * 60);
|
||||
await Promise.all([
|
||||
cacheSet(ipCacheKey, "claimed", 24 * 60 * 60),
|
||||
cacheSet(addressCacheKey, "claimed", 24 * 60 * 60),
|
||||
]);
|
||||
// then actually transfer the funds
|
||||
const url = `${THIRDWEB_ENGINE_URL}/backend-wallet/${chainId}/transfer`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-idempotency-key": idempotencyKey,
|
||||
"x-backend-wallet-address": NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET,
|
||||
Authorization: `Bearer ${THIRDWEB_ACCESS_TOKEN}`,
|
||||
},
|
||||
|
||||
+21
-21
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { CmdKSearchModal } from "components/cmd-k-search";
|
||||
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);
|
||||
@@ -29,48 +30,47 @@ export function ResourcesDropdownButton() {
|
||||
<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"
|
||||
>
|
||||
<DropdownMenuContent className="min-w-[230px]" sideOffset={14}>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/chainlist" className="w-full p-1">
|
||||
Chainlist
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
href="https://playground.thirdweb.com/"
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-2"
|
||||
className="w-full p-1"
|
||||
>
|
||||
Playground
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Link
|
||||
href="/explore"
|
||||
className="text-muted-foreground hover:text-foreground text-sm"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/explore" className="w-full p-1">
|
||||
Explore Contracts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<Link
|
||||
href="/trending"
|
||||
className="text-muted-foreground hover:text-foreground text-sm"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Link href="/trending" className="w-full p-1">
|
||||
Popular Contracts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<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"
|
||||
variant="ghost"
|
||||
className="text-sm gap-2 !p-1 !h-auto text-left justify-between w-full"
|
||||
onClick={() => setIsCMDSearchModalOpen(true)}
|
||||
>
|
||||
Search Contracts
|
||||
<span className="items-center flex gap-0.5 text-xs opacity-80">
|
||||
<span className="items-center flex gap-0.5 text-xs text-muted-foreground">
|
||||
<CommandIcon className="size-3" />K
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
|
||||
import { AccountButton } from "./account-button.client";
|
||||
|
||||
export function SecondaryNav(props: {
|
||||
email: string | undefined;
|
||||
logout: () => void;
|
||||
connectButton: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+14
-14
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
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;
|
||||
@@ -29,18 +28,19 @@ export function AccountButton(props: {
|
||||
variant="ghost"
|
||||
>
|
||||
{/* TODO - replace with account image */}
|
||||
<div className="size-9 rounded-full bg-muted border" />
|
||||
<div className="size-9 rounded-full bg-muted border border-border" />
|
||||
</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>
|
||||
<DropdownMenuContent className="p-0 w-[300px] rounded-lg" align="end">
|
||||
<div className="p-4 pb-5 border-b border-border">
|
||||
<SkeletonContainer
|
||||
skeletonData="[email protected]"
|
||||
loadedData={props.email}
|
||||
render={(v) => <p className="text-sm text-muted-foreground">{v}</p>}
|
||||
/>
|
||||
|
||||
<div className="h-3" />
|
||||
<div className="[&>button]:!w-full">{props.connectButton}</div>
|
||||
<div className="[&>*]:!w-full">{props.connectButton}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 py-5">
|
||||
@@ -54,7 +54,7 @@ export function AccountButton(props: {
|
||||
<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">
|
||||
<div className="ml-auto flex items-center border border-border px-2 py-0.5 rounded-lg gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => setTheme("light")}
|
||||
@@ -83,7 +83,7 @@ export function AccountButton(props: {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/team"
|
||||
href="/"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
Home Page
|
||||
@@ -2,7 +2,7 @@ export const headerLinks = {
|
||||
left: [
|
||||
{
|
||||
name: "Connect",
|
||||
href: "/dashboard/connect",
|
||||
href: "/dashboard/connect/analytics",
|
||||
},
|
||||
{
|
||||
name: "Contracts",
|
||||
|
||||
+2
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LogOutIcon,
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
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";
|
||||
import { CmdKSearchModal } from "../../components/cmd-k-search";
|
||||
|
||||
export function MobileBurgerMenuButton(props: {
|
||||
email: string | undefined;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
plan: "free" | "growth" | "pro";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
props.plan === "free"
|
||||
? "secondary"
|
||||
: props.plan === "growth"
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
className={cn("capitalize px-1.5", props.className)}
|
||||
>
|
||||
{props.plan}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function TeamOverviewPage(props: {
|
||||
|
||||
{/* Projects */}
|
||||
{projectsToShow.length === 0 ? (
|
||||
<div className="border rounded-lg h-[450px] flex items-center justify-center ">
|
||||
<div className="border border-border rounded-lg h-[450px] flex items-center justify-center ">
|
||||
No projects found
|
||||
</div>
|
||||
) : (
|
||||
@@ -101,7 +101,7 @@ function ProjectCard(props: {
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="border rounded-lg p-4 relative hover:bg-muted/70 flex items-center gap-4 bg-muted/50 transition-colors"
|
||||
className="border border-border rounded-lg p-4 relative hover:bg-muted/70 flex items-center gap-4 bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* TODO - replace with project image */}
|
||||
<div className="size-10 rounded-full bg-border shrink-0" />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getProjects } from "../../../../@/api/projects";
|
||||
import { TeamHeader } from "../../../components/Header/TeamHeader/TeamHeader";
|
||||
import TeamTabs from "../components/tab-switcher.client";
|
||||
import { TeamHeader } from "../../components/TeamHeader/TeamHeader";
|
||||
|
||||
export default async function TeamLayout(props: {
|
||||
children: React.ReactNode;
|
||||
breadcrumbNav: React.ReactNode;
|
||||
params: { team_slug: string };
|
||||
}) {
|
||||
const teams = await getTeams();
|
||||
@@ -32,19 +31,20 @@ export default async function TeamLayout(props: {
|
||||
currentProject={undefined}
|
||||
/>
|
||||
|
||||
<TeamTabs
|
||||
<TabPathLinks
|
||||
tabContainerClassName="px-4 lg:px-6"
|
||||
links={[
|
||||
{
|
||||
href: `/team/${props.params.team_slug}`,
|
||||
path: `/team/${props.params.team_slug}`,
|
||||
name: "Projects",
|
||||
strictMatch: true,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
href: `/team/${props.params.team_slug}/~/usage`,
|
||||
path: `/team/${props.params.team_slug}/~/usage`,
|
||||
name: "Usage",
|
||||
},
|
||||
{
|
||||
href: `/team/${props.params.team_slug}/~/settings`,
|
||||
path: `/team/${props.params.team_slug}/~/settings`,
|
||||
name: "Settings",
|
||||
},
|
||||
]}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ function RenderLinkGroup(props: {
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-4">
|
||||
{/* TODO - using placeholder for now */}
|
||||
<div className="size-4 bg-muted rounded-full border" />
|
||||
<div className="size-4 bg-muted rounded-full border border-border" />
|
||||
<p className="text-muted-foreground text-xs">{props.title}</p>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ export function TeamSettingsMobileNav(props: {
|
||||
|
||||
if (!showFull) {
|
||||
return (
|
||||
<div className="py-4 px-4 border-b flex items-center gap-2 text-muted-foreground">
|
||||
<div className="py-4 px-4 border-b border-border flex items-center gap-2 text-muted-foreground">
|
||||
<Link
|
||||
href={teamLinks[0].href}
|
||||
className="inline-flex items-center gap-1"
|
||||
@@ -43,7 +43,7 @@ export function TeamSettingsMobileNav(props: {
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="p-4 border-b flex"
|
||||
className="p-4 border-b flex border-border"
|
||||
onClick={() => {
|
||||
if (link === teamLinks[0]) {
|
||||
setShowFull(false);
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { Billing } from "components/settings/Account/Billing";
|
||||
|
||||
export const SettingsBillingPage = () => {
|
||||
const meQuery = useAccount({
|
||||
refetchInterval: (account) =>
|
||||
[
|
||||
AccountStatus.InvalidPayment,
|
||||
AccountStatus.InvalidPaymentMethod,
|
||||
].includes(account?.status as AccountStatus)
|
||||
? 1000
|
||||
: false,
|
||||
});
|
||||
|
||||
const { data: account } = meQuery;
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Billing account={account} />;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { SettingsBillingPage } from "./BillingSettingsPage";
|
||||
|
||||
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>
|
||||
<ChakraProviderSetup>
|
||||
<SettingsBillingPage />
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { ApplyForOpCreditsModal } from "components/onboarding/ApplyForOpCreditsModal";
|
||||
import { Heading, LinkButton } from "tw-components";
|
||||
|
||||
export const SettingsGasCreditsPage = () => {
|
||||
const { isLoading } = useLoggedInUser();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid w-full min-h-[400px] place-items-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={8}>
|
||||
<Flex direction="row" gap={4} align="center">
|
||||
<Heading size="title.lg" as="h1">
|
||||
Apply to the Optimism Superchain App Accelerator
|
||||
</Heading>
|
||||
<LinkButton
|
||||
display={{ base: "none", md: "inherit" }}
|
||||
isExternal
|
||||
href="https://blog.thirdweb.com/accelerating-the-superchain-with-optimism/"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Learn More
|
||||
</LinkButton>
|
||||
</Flex>
|
||||
|
||||
<ApplyForOpCreditsModal />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { SettingsGasCreditsPage } from "./SettingsCreditsPage";
|
||||
|
||||
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>
|
||||
<ChakraProviderSetup>
|
||||
<SettingsGasCreditsPage />
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
+8
@@ -40,6 +40,10 @@ function Story() {
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isLoading={false}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -50,6 +54,10 @@ function Story() {
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isLoading={true}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
+55
-8
@@ -1,12 +1,27 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function DangerSettingCard(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonOnClick: () => void;
|
||||
isLoading: boolean;
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-red-500/70 rounded-lg overflow-hidden">
|
||||
@@ -19,14 +34,46 @@ export function DangerSettingCard(props: {
|
||||
</div>
|
||||
|
||||
<div className="bg-red-100 dark:bg-red-500/20 px-4 lg:px-6 py-4 flex justify-end border-red-500/70 border-t">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
|
||||
onClick={props.buttonOnClick}
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
|
||||
disabled={props.isLoading}
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-4 gap-4 lg:gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
|
||||
onClick={props.buttonOnClick}
|
||||
disabled={props.isLoading}
|
||||
>
|
||||
{props.isLoading && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-5
@@ -65,14 +65,14 @@ function Story() {
|
||||
|
||||
function ComponentVariantions() {
|
||||
return (
|
||||
<div className="pt-20 border-t p-6">
|
||||
<div className="pt-20 border-t p-6 border-border">
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<h2 className="mb-5 text-3xl"> Component variations </h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<LeaveTeamCard enabled={true} />
|
||||
<LeaveTeamCard enabled={false} />
|
||||
<DeleteTeamCard enabled={true} />
|
||||
<DeleteTeamCard enabled={false} />
|
||||
<LeaveTeamCard enabled={true} teamName="foo" />
|
||||
<LeaveTeamCard enabled={false} teamName="foo" />
|
||||
<DeleteTeamCard enabled={true} teamName="foo" />
|
||||
<DeleteTeamCard enabled={false} teamName="foo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-5
@@ -3,6 +3,7 @@
|
||||
import type { Team } from "@/api/team";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useState } from "react";
|
||||
@@ -20,8 +21,11 @@ export function GeneralSettingsPage(props: {
|
||||
<TeamSlugFormControl team={props.team} />
|
||||
<TeamAvatarFormControl />
|
||||
<TeamIdCard team={props.team} />
|
||||
<LeaveTeamCard enabled={false} />
|
||||
<DeleteTeamCard enabled={hasPermissionToDelete} />
|
||||
<LeaveTeamCard enabled={false} teamName={props.team.name} />
|
||||
<DeleteTeamCard
|
||||
enabled={hasPermissionToDelete}
|
||||
teamName={props.team.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,8 +125,8 @@ function TeamSlugFormControl(props: {
|
||||
}}
|
||||
noPermissionText={undefined} // TODO
|
||||
>
|
||||
<div className="relative border rounded-lg flex md:w-[450px]">
|
||||
<div className="bg-muted/50 text-muted-foreground/80 self-stretch flex items-center px-3 text-sm border-r rounded-l-lg font-mono">
|
||||
<div className="relative border border-border rounded-lg flex md:w-[450px]">
|
||||
<div className="bg-muted/50 text-muted-foreground/80 self-stretch flex items-center px-3 text-sm border-r border-border rounded-l-lg font-mono">
|
||||
thirdweb.com/team/
|
||||
</div>
|
||||
<Input
|
||||
@@ -181,7 +185,7 @@ function TeamAvatarFormControl() {
|
||||
accept={{ "image/*": [] }}
|
||||
value={teamAvatar}
|
||||
setValue={setTeamAvatar}
|
||||
className="w-[120px] rounded-full"
|
||||
className="w-20 lg:w-28 rounded-full"
|
||||
disableHelperText
|
||||
/>
|
||||
</div>
|
||||
@@ -216,6 +220,7 @@ function TeamIdCard(props: {
|
||||
|
||||
export function LeaveTeamCard(props: {
|
||||
enabled: boolean;
|
||||
teamName: string;
|
||||
}) {
|
||||
const title = "Leave Team";
|
||||
const description =
|
||||
@@ -246,6 +251,11 @@ export function LeaveTeamCard(props: {
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleLeave}
|
||||
isLoading={leaveTeam.isLoading}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to leave team "${props.teamName}" ?`,
|
||||
description:
|
||||
"This will revoke your access to this Team. Any resources you've added to the Team will remain.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -267,7 +277,9 @@ export function LeaveTeamCard(props: {
|
||||
|
||||
export function DeleteTeamCard(props: {
|
||||
enabled: boolean;
|
||||
teamName: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const title = "Delete Team";
|
||||
const description =
|
||||
"Permanently remove your team and all of its contents from the thirdweb platform. This action is not reversible - please continue with caution.";
|
||||
@@ -279,6 +291,9 @@ export function DeleteTeamCard(props: {
|
||||
console.log("Deleting team");
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push("/team");
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
@@ -297,6 +312,10 @@ export function DeleteTeamCard(props: {
|
||||
buttonLabel={title}
|
||||
buttonOnClick={handleDelete}
|
||||
isLoading={deleteTeam.isLoading}
|
||||
confirmationDialog={{
|
||||
title: `Are you sure you want to delete team "${props.teamName}" ?`,
|
||||
description: description,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../../../../../../../@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SettingsCard(props: {
|
||||
bottomText: string;
|
||||
@@ -21,7 +21,7 @@ export function SettingsCard(props: {
|
||||
<div className="border rounded-lg bg-muted/50 relative">
|
||||
<div
|
||||
className={cn(
|
||||
"border-b px-4 lg:px-6 py-6 relative",
|
||||
"border-b border-border px-4 lg:px-6 py-6 relative",
|
||||
props.noPermissionText && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
|
||||
+1
-3
@@ -38,9 +38,7 @@ function Story(props: {
|
||||
type: "mobile" | "desktop";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={"min-h-screen text-foreground lg:max-w-[1100px] py-6 mx-auto"}
|
||||
>
|
||||
<div className={"min-h-screen text-foreground container py-6"}>
|
||||
{props.type === "desktop" && <TeamSettingsSidebar teamSlug="foo" />}
|
||||
|
||||
{props.type === "mobile" && (
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function Layout(props: {
|
||||
return (
|
||||
<div>
|
||||
{/* Huge page title */}
|
||||
<div className="py-10 border-b">
|
||||
<div className="container max-w-[1200px]">
|
||||
<div className="py-10 border-b border-border">
|
||||
<div className="container">
|
||||
<h1 className="text-3xl tracking-tight font-semibold">
|
||||
Team Settings
|
||||
</h1>
|
||||
@@ -44,7 +44,7 @@ export default function Layout(props: {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 container max-w-[1200px] grow [&>*]:py-8 lg:[&>*]:py-10 lg:min-h-[900px]">
|
||||
<div className="flex gap-8 container grow [&>*]:py-8 lg:[&>*]:py-10 lg:min-h-[900px]">
|
||||
<TeamSettingsSidebar teamSlug={props.params.team_slug} />
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ExternalLinkIcon, LinkIcon, UserPlus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { getValidTeamPlan } from "../../../../../../components/Header/TeamHeader/getValidTeamPlan";
|
||||
import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan";
|
||||
|
||||
export function InviteSection(props: {
|
||||
team: Team;
|
||||
@@ -29,7 +29,7 @@ export function InviteSection(props: {
|
||||
|
||||
if (teamPlan !== "pro") {
|
||||
bottomSection = (
|
||||
<div className="px-4 lg:px6 py-4 flex items-center justify-between border-t gap-4">
|
||||
<div className="px-4 lg:px6 py-4 flex items-center justify-between border-t border-border gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This feature is only available on the{" "}
|
||||
<Link
|
||||
@@ -49,7 +49,7 @@ export function InviteSection(props: {
|
||||
);
|
||||
} else if (!props.userHasEditPermission) {
|
||||
bottomSection = (
|
||||
<div className="px-4 lg:px-6 py-4 flex items-center justify-between border-t min-h-[60px]">
|
||||
<div className="px-4 lg:px-6 py-4 flex items-center justify-between border-t border-border min-h-[60px]">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You don't have permission to invite members
|
||||
</p>
|
||||
@@ -57,7 +57,7 @@ export function InviteSection(props: {
|
||||
);
|
||||
} else {
|
||||
bottomSection = (
|
||||
<div className="py-4 px-4 lg:px-6 flex items-center lg:justify-end border-t ">
|
||||
<div className="py-4 px-4 lg:px-6 flex items-center lg:justify-end border-t border-border">
|
||||
<Button variant="outline" size="sm" className="gap-2 max-sm:w-full">
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
@@ -71,7 +71,7 @@ export function InviteSection(props: {
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-3">Invite</h2>
|
||||
|
||||
{/* Card */}
|
||||
<div className="border rounded-lg bg-muted/50">
|
||||
<div className="border border-border rounded-lg bg-muted/50">
|
||||
{/* Invite via Link */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user