Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc892bd136 | ||
|
|
3d7c009d72 | ||
|
|
6dbed0d4fc | ||
|
|
adee1903d5 | ||
|
|
a1d785b8ed | ||
|
|
ba92cd2cbe | ||
|
|
c3d0ea0059 | ||
|
|
35c5f57179 | ||
|
|
2a1f4be48b | ||
|
|
0121ccd702 | ||
|
|
12bcfc596a | ||
|
|
a4ff738268 | ||
|
|
b49c191355 | ||
|
|
06f0765665 | ||
|
|
12adbd8314 | ||
|
|
48e014d426 | ||
|
|
826748bb5d | ||
|
|
d4f5a9db12 | ||
|
|
3b827ea32c | ||
|
|
2231db7980 | ||
|
|
f9872f442c | ||
|
|
473a25237e | ||
|
|
2d4c5ff07c | ||
|
|
ab738eb7ba | ||
|
|
8e6651e591 | ||
|
|
e5026a10ab | ||
|
|
9c1c5f2fc8 | ||
|
|
45cdb86911 | ||
|
|
fe3351ce79 | ||
|
|
fc657e0b89 | ||
|
|
cff2481c06 | ||
|
|
e0e31826a8 | ||
|
|
fe8b3ce5a5 | ||
|
|
3c120a7584 | ||
|
|
b51157c0ff | ||
|
|
f1df19e612 | ||
|
|
98781531bb | ||
|
|
d3a5ea7833 | ||
|
|
1a9448e152 | ||
|
|
2fe16119f5 | ||
|
|
c549d5934e | ||
|
|
dd65506dfb | ||
|
|
26cfd1b30e | ||
|
|
495d8edfa2 | ||
|
|
88c213b6d5 | ||
|
|
d9091bd2e7 | ||
|
|
ab7bf1ad1d | ||
|
|
21389bbef6 | ||
|
|
4f51201249 | ||
|
|
a0cee488e9 | ||
|
|
0b8907cb7c | ||
|
|
85a78d4460 | ||
|
|
2a6778eb42 | ||
|
|
882fd3dcbd | ||
|
|
744491f853 | ||
|
|
d3f788471b | ||
|
|
6db81ef68d | ||
|
|
f8b2bdc64b | ||
|
|
c407bd10c7 | ||
|
|
e540dc11f5 | ||
|
|
7476b7927d | ||
|
|
ccb641698e | ||
|
|
aa9d858f1c | ||
|
|
d8540216f1 | ||
|
|
654f8795b4 | ||
|
|
279fb918f5 | ||
|
|
c5cff26faa | ||
|
|
78901457cf | ||
|
|
47f8cd6d8d |
@@ -28,23 +28,3 @@ jobs:
|
||||
|
||||
- name: Run TypeDoc
|
||||
run: pnpm typedoc
|
||||
|
||||
- name: Update Gist
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GIST_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('./packages/thirdweb/typedoc/parsed.json', 'utf8');
|
||||
const gistId = '678fe1f331a01270bb002fee660f285d';
|
||||
|
||||
await github.rest.gists.update({
|
||||
gist_id: gistId,
|
||||
files: {
|
||||
'data.json': {
|
||||
content: content
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Permalink: https://gist.githubusercontent.com/raw/${gistId}/data.json`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import "@/styles/globals.css";
|
||||
import "../src/global.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.482.0",
|
||||
"next": "15.2.2",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -106,8 +106,8 @@
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@next/bundle-analyzer": "15.2.2",
|
||||
"@next/eslint-plugin-next": "15.2.2",
|
||||
"@next/bundle-analyzer": "15.2.3",
|
||||
"@next/eslint-plugin-next": "15.2.3",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@storybook/addon-essentials": "8.6.4",
|
||||
"@storybook/addon-interactions": "8.6.4",
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
isMoralisSupported,
|
||||
transformMoralisResponseToNFT,
|
||||
} from "lib/wallet/nfts/moralis";
|
||||
import {
|
||||
generateSimpleHashUrl,
|
||||
isSimpleHashSupported,
|
||||
transformSimpleHashResponseToNFT,
|
||||
} from "lib/wallet/nfts/simpleHash";
|
||||
import type { WalletNFT } from "lib/wallet/nfts/types";
|
||||
import { getVercelEnv } from "../../lib/vercel-utils";
|
||||
import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../constants/env";
|
||||
|
||||
type WalletNFTApiReturn =
|
||||
| { result: WalletNFT[]; error?: undefined }
|
||||
@@ -24,40 +21,20 @@ type WalletNFTApiReturn =
|
||||
export async function getWalletNFTs(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
isInsightSupported: boolean;
|
||||
}): Promise<WalletNFTApiReturn> {
|
||||
const { chainId, owner } = params;
|
||||
const supportedChainSlug = await isSimpleHashSupported(chainId);
|
||||
|
||||
if (supportedChainSlug && process.env.SIMPLEHASH_API_KEY) {
|
||||
const url = generateSimpleHashUrl({ chainSlug: supportedChainSlug, owner });
|
||||
if (params.isInsightSupported) {
|
||||
const response = await getWalletNFTsFromInsight({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-KEY": process.env.SIMPLEHASH_API_KEY,
|
||||
},
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: response.statusText,
|
||||
error: response.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformSimpleHashResponseToNFT(
|
||||
parsedResponse,
|
||||
owner,
|
||||
);
|
||||
|
||||
return { result };
|
||||
} catch {
|
||||
return { error: "error parsing response" };
|
||||
}
|
||||
return { result: response.data };
|
||||
}
|
||||
|
||||
if (isAlchemySupported(chainId)) {
|
||||
@@ -115,3 +92,115 @@ export async function getWalletNFTs(params: {
|
||||
|
||||
return { error: "unsupported chain" };
|
||||
}
|
||||
|
||||
type OwnedNFTInsightResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
background_color: string;
|
||||
external_url: string;
|
||||
metadata_url: string;
|
||||
extra_metadata: {
|
||||
customImage?: string;
|
||||
customAnimationUrl?: string;
|
||||
animation_original_url?: string;
|
||||
image_original_url?: string;
|
||||
};
|
||||
collection: {
|
||||
name: string;
|
||||
description: string;
|
||||
extra_metadata: Record<string, string>;
|
||||
};
|
||||
contract: {
|
||||
chain_id: number;
|
||||
address: string;
|
||||
type: "erc1155" | "erc721";
|
||||
name: string;
|
||||
};
|
||||
owner_addresses: string[];
|
||||
token_id: string;
|
||||
balance: string;
|
||||
token_type: "erc1155" | "erc721";
|
||||
};
|
||||
|
||||
async function getWalletNFTsFromInsight(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
}): Promise<
|
||||
| {
|
||||
data: WalletNFT[];
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { chainId, owner } = params;
|
||||
|
||||
const thirdwebDomain =
|
||||
getVercelEnv() === "production" ? "thirdweb" : "thirdweb-dev";
|
||||
const url = new URL(`https://insight.${thirdwebDomain}.com/v1/nfts`);
|
||||
url.searchParams.append("chain", chainId.toString());
|
||||
url.searchParams.append("limit", "10");
|
||||
url.searchParams.append("owner_address", owner);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const nftsResponse = (await response.json()) as {
|
||||
data: OwnedNFTInsightResponse[];
|
||||
};
|
||||
|
||||
const isDev = getVercelEnv() !== "production";
|
||||
|
||||
// NOTE: ipfscdn.io/ to thirdwebstorage-dev.com/ replacement is temporary
|
||||
// This should be fixed in the insight dev endpoint
|
||||
|
||||
const walletNFTs = nftsResponse.data.map((nft) => {
|
||||
const walletNFT: WalletNFT = {
|
||||
id: nft.token_id,
|
||||
contractAddress: nft.contract.address,
|
||||
metadata: {
|
||||
uri: isDev
|
||||
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.metadata_url,
|
||||
name: nft.name,
|
||||
description: nft.description,
|
||||
image: isDev
|
||||
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
animation_url: isDev
|
||||
? nft.extra_metadata.animation_original_url?.replace(
|
||||
"ipfscdn.io/",
|
||||
"thirdwebstorage-dev.com/",
|
||||
)
|
||||
: nft.extra_metadata.animation_original_url,
|
||||
external_url: nft.external_url,
|
||||
background_color: nft.background_color,
|
||||
},
|
||||
owner: params.owner,
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
supply: nft.balance,
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
});
|
||||
|
||||
return {
|
||||
data: walletNFTs,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,30 +19,7 @@ export function MobileSidebar(props: {
|
||||
triggerClassName?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
function isActive(link: SidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
for (const link of props.links) {
|
||||
if ("group" in link) {
|
||||
for (const subLink of link.links) {
|
||||
if (isActive(subLink)) {
|
||||
return subLink;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isActive(link)) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [props.links, pathname]);
|
||||
const activeLink = useActiveSidebarLink(props.links);
|
||||
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
@@ -75,3 +52,32 @@ export function MobileSidebar(props: {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function useActiveSidebarLink(links: SidebarLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
function isActive(link: SidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
if ("group" in link) {
|
||||
for (const subLink of link.links) {
|
||||
if (isActive(subLink)) {
|
||||
return subLink;
|
||||
}
|
||||
}
|
||||
} else if ("href" in link) {
|
||||
if (isActive(link)) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [links, pathname]);
|
||||
|
||||
return activeLink;
|
||||
}
|
||||
|
||||
@@ -18,17 +18,39 @@ export function MultiNetworkSelector(props: {
|
||||
onChange: (chainIds: number[]) => void;
|
||||
disableChainId?: boolean;
|
||||
className?: string;
|
||||
priorityChains?: number[];
|
||||
}) {
|
||||
const { allChains, idToChain } = useAllChainsData();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return allChains.map((chain) => {
|
||||
let sortedChains = allChains;
|
||||
|
||||
if (props.priorityChains) {
|
||||
const priorityChainsSet = new Set();
|
||||
for (const chainId of props.priorityChains || []) {
|
||||
priorityChainsSet.add(chainId);
|
||||
}
|
||||
|
||||
const priorityChains = (props.priorityChains || [])
|
||||
.map((chainId) => {
|
||||
return idToChain.get(chainId);
|
||||
})
|
||||
.filter((v) => !!v);
|
||||
|
||||
const otherChains = allChains.filter(
|
||||
(chain) => !priorityChainsSet.has(chain.chainId),
|
||||
);
|
||||
|
||||
sortedChains = [...priorityChains, ...otherChains];
|
||||
}
|
||||
|
||||
return sortedChains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [allChains]);
|
||||
}, [allChains, props.priorityChains, idToChain]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
|
||||
@@ -2,11 +2,13 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
export type SidebarBaseLink = {
|
||||
href: string;
|
||||
label: React.ReactNode;
|
||||
exactMatch?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
tracking?: {
|
||||
category: string;
|
||||
action: string;
|
||||
@@ -19,6 +21,9 @@ export type SidebarLink =
|
||||
| {
|
||||
group: string;
|
||||
links: SidebarBaseLink[];
|
||||
}
|
||||
| {
|
||||
separator: true;
|
||||
};
|
||||
|
||||
type SidebarContentProps = {
|
||||
@@ -27,21 +32,18 @@ type SidebarContentProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Sidebar(props: SidebarContentProps) {
|
||||
export function CustomSidebar(props: SidebarContentProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden w-[230px] flex-shrink-0 self-start lg:block",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="py-7">
|
||||
{props.header}
|
||||
<div className="flex flex-col gap-1">
|
||||
<RenderSidebarLinks links={props.links} />
|
||||
<div className={cn("hidden w-[230px] shrink-0 lg:block", props.className)}>
|
||||
<aside className="sticky top-0 self-start">
|
||||
<div className="py-7">
|
||||
{props.header}
|
||||
<div className="flex flex-col gap-1">
|
||||
<RenderSidebarLinks links={props.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +63,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
if ("separator" in link) {
|
||||
return <Separator className="my-2" />;
|
||||
}
|
||||
|
||||
const isExternal = link.href.startsWith("http");
|
||||
return (
|
||||
<NavLink
|
||||
@@ -68,9 +74,10 @@ export function RenderSidebarLinks(props: { links: SidebarLink[] }) {
|
||||
key={i}
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
activeClassName="text-foreground"
|
||||
activeClassName="text-foreground bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
{link.label}
|
||||
{isExternal && <ExternalLinkIcon className="size-3" />}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { Sidebar, type SidebarLink } from "./Sidebar";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
|
||||
import { CustomSidebar, type SidebarLink } from "./Sidebar";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
@@ -17,7 +33,10 @@ export function SidebarLayout(props: {
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Sidebar links={sidebarLinks} className={props.desktopSidebarClassName} />
|
||||
<CustomSidebar
|
||||
links={sidebarLinks}
|
||||
className={props.desktopSidebarClassName}
|
||||
/>
|
||||
<MobileSidebar
|
||||
links={sidebarLinks}
|
||||
triggerClassName={props.mobileSidebarClassName}
|
||||
@@ -29,3 +48,118 @@ export function SidebarLayout(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullWidthSidebarLayout(props: {
|
||||
contentSidebarLinks: SidebarLink[];
|
||||
footerSidebarLinks?: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 overflow-y-hidden",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar collapsible="icon" side="left">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<RenderSidebarGroup
|
||||
sidebarLinks={contentSidebarLinks}
|
||||
groupName={undefined}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{footerSidebarLinks && (
|
||||
<SidebarFooter className="pb-3">
|
||||
<RenderSidebarGroup
|
||||
sidebarLinks={footerSidebarLinks}
|
||||
groupName={undefined}
|
||||
/>
|
||||
</SidebarFooter>
|
||||
)}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* right - content */}
|
||||
<div className="flex h-full flex-grow flex-col overflow-y-auto">
|
||||
<MobileSidebarTrigger
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="container z-0 flex min-w-0 max-w-[1280px] grow flex-col pb-20 max-sm:w-full lg:pt-6">
|
||||
{children}
|
||||
</main>
|
||||
{props.footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarGroup(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
groupName: string | undefined;
|
||||
}) {
|
||||
const { sidebarLinks } = props;
|
||||
const sidebar = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{sidebarLinks.map((link) => {
|
||||
if ("href" in link) {
|
||||
return (
|
||||
<SidebarMenuItem key={link.href}>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
href={link.href}
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
activeClassName="text-foreground bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
tracking={link.tracking}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if ("separator" in link) {
|
||||
return <SidebarSeparator className="my-1" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RenderSidebarGroup
|
||||
sidebarLinks={link.links}
|
||||
groupName={link.group}
|
||||
key={link.group}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
|
||||
const activeLink = useActiveSidebarLink(props.links);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type NavButtonProps = {
|
||||
action: string;
|
||||
label: string;
|
||||
};
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
|
||||
@@ -30,7 +31,9 @@ export function NavLink(props: React.PropsWithChildren<NavButtonProps>) {
|
||||
href={props.href}
|
||||
className={cn(props.className, isActive && props.activeClassName)}
|
||||
target={props.href.startsWith("http") ? "_blank" : undefined}
|
||||
prefetch={false}
|
||||
onClick={() => {
|
||||
props.onClick?.();
|
||||
if (props.tracking) {
|
||||
track({
|
||||
category: props.tracking.category,
|
||||
|
||||
@@ -96,7 +96,10 @@ const BreadcrumbEllipsis = ({
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
|
||||
@@ -94,7 +94,10 @@ const PaginationEllipsis = ({
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer relative hidden flex-shrink-0 text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 hidden h-full w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:-inset-2 after:absolute after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:-inset-2 after:absolute after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />);
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import invariant from "tiny-invariant";
|
||||
export function useWalletNFTs(params: {
|
||||
chainId: number;
|
||||
walletAddress?: string;
|
||||
isInsightSupported: boolean;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ["walletNfts", params.chainId, params.walletAddress],
|
||||
@@ -13,6 +14,7 @@ export function useWalletNFTs(params: {
|
||||
return getWalletNFTs({
|
||||
chainId: params.chainId,
|
||||
owner: params.walletAddress,
|
||||
isInsightSupported: params.isInsightSupported,
|
||||
});
|
||||
},
|
||||
enabled: !!params.walletAddress,
|
||||
|
||||
+3
-2
@@ -28,7 +28,7 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
<div className="h-6" />
|
||||
|
||||
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
|
||||
Buy Funds on {sanitizedChainName} using thirdweb Pay
|
||||
Buy Funds on {sanitizedChainName} using Universal Bridge
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
@@ -50,8 +50,9 @@ export function BuyFundsSection(props: { chain: ChainMetadata }) {
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/pay/overview"
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about thirdweb Pay
|
||||
Learn more about Universal Bridge
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+6
-4
@@ -13,7 +13,6 @@ import { ListerOnly } from "@3rdweb-sdk/react/components/roles/lister-only";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
|
||||
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
|
||||
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
@@ -25,6 +24,7 @@ interface CreateListingButtonProps {
|
||||
createText?: string;
|
||||
type?: "direct-listings" | "english-auctions";
|
||||
twAccount: Account | undefined;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
const LISTING_MODES = ["Select NFT", "Manual"] as const;
|
||||
@@ -34,6 +34,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
type,
|
||||
contract,
|
||||
twAccount,
|
||||
isInsightSupported,
|
||||
...restButtonProps
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
@@ -41,13 +42,12 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
const [listingMode, setListingMode] =
|
||||
useState<(typeof LISTING_MODES)[number]>("Select NFT");
|
||||
|
||||
const simplehashQuery = useSimplehashSupport(contract.chain.id);
|
||||
|
||||
const isSupportedChain =
|
||||
contract.chain.id &&
|
||||
(simplehashQuery.data ||
|
||||
(isInsightSupported ||
|
||||
isAlchemySupported(contract.chain.id) ||
|
||||
isMoralisSupported(contract.chain.id));
|
||||
|
||||
return (
|
||||
<ListerOnly contract={contract}>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -83,6 +83,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
actionText={createText}
|
||||
setOpen={setOpen}
|
||||
mode={listingMode === "Select NFT" ? "automatic" : "manual"}
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -95,6 +96,7 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
actionText={createText}
|
||||
setOpen={setOpen}
|
||||
mode="manual"
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+6
-3
@@ -21,7 +21,6 @@ import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
|
||||
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
|
||||
import { useSimplehashSupport } from "lib/wallet/nfts/simpleHash";
|
||||
import type { WalletNFT } from "lib/wallet/nfts/types";
|
||||
import { CircleAlertIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -87,6 +86,7 @@ type CreateListingsFormProps = {
|
||||
mode: "automatic" | "manual";
|
||||
type?: "direct-listings" | "english-auctions";
|
||||
twAccount: Account | undefined;
|
||||
isInsightSupported: boolean;
|
||||
};
|
||||
|
||||
const auctionTimes = [
|
||||
@@ -106,16 +106,17 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
setOpen,
|
||||
twAccount,
|
||||
mode,
|
||||
isInsightSupported,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const chainId = contract.chain.id;
|
||||
const { idToChain } = useAllChainsData();
|
||||
const network = idToChain.get(chainId);
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
const simplehashQuery = useSimplehashSupport(contract.chain.id);
|
||||
|
||||
const isSupportedChain =
|
||||
chainId &&
|
||||
(!!simplehashQuery.data ||
|
||||
(isInsightSupported ||
|
||||
isAlchemySupported(chainId) ||
|
||||
isMoralisSupported(chainId));
|
||||
|
||||
@@ -124,7 +125,9 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
|
||||
chainId,
|
||||
walletAddress: account?.address,
|
||||
isInsightSupported,
|
||||
});
|
||||
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const listingNotifications = useTxNotifications(
|
||||
"NFT listed Successfully",
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ export function ContractDirectListingsPageClient(props: {
|
||||
<ContractDirectListingsPage
|
||||
contract={props.contract}
|
||||
twAccount={props.twAccount}
|
||||
isInsightSupported={metadataQuery.data.isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -8,17 +8,19 @@ import { DirectListingsTable } from "./components/table";
|
||||
interface ContractDirectListingsPageProps {
|
||||
contract: ThirdwebContract;
|
||||
twAccount: Account | undefined;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const ContractDirectListingsPage: React.FC<
|
||||
ContractDirectListingsPageProps
|
||||
> = ({ contract, twAccount }) => {
|
||||
> = ({ contract, twAccount, isInsightSupported }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="text-lg">Contract Listings</p>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
isInsightSupported={isInsightSupported}
|
||||
contract={contract}
|
||||
type="direct-listings"
|
||||
createText="Create Direct Listing"
|
||||
|
||||
+7
-4
@@ -29,15 +29,18 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { isDirectListingSupported } = await getContractPageMetadata(
|
||||
info.contract,
|
||||
);
|
||||
const { isDirectListingSupported, isInsightSupported } =
|
||||
await getContractPageMetadata(info.contract);
|
||||
|
||||
if (!isDirectListingSupported) {
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDirectListingsPage contract={info.contract} twAccount={account} />
|
||||
<ContractDirectListingsPage
|
||||
contract={info.contract}
|
||||
twAccount={account}
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ export function ContractEnglishAuctionsPageClient(props: {
|
||||
<ContractEnglishAuctionsPage
|
||||
contract={props.contract}
|
||||
twAccount={props.twAccount}
|
||||
isInsightSupported={metadataQuery.data.isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -8,11 +8,12 @@ import { EnglishAuctionsTable } from "./components/table";
|
||||
interface ContractEnglishAuctionsProps {
|
||||
contract: ThirdwebContract;
|
||||
twAccount: Account | undefined;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const ContractEnglishAuctionsPage: React.FC<
|
||||
ContractEnglishAuctionsProps
|
||||
> = ({ contract, twAccount }) => {
|
||||
> = ({ contract, twAccount, isInsightSupported }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
@@ -23,6 +24,7 @@ export const ContractEnglishAuctionsPage: React.FC<
|
||||
type="english-auctions"
|
||||
createText="Create English Auction"
|
||||
twAccount={twAccount}
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -30,9 +30,8 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { isEnglishAuctionSupported } = await getContractPageMetadata(
|
||||
info.contract,
|
||||
);
|
||||
const { isEnglishAuctionSupported, isInsightSupported } =
|
||||
await getContractPageMetadata(info.contract);
|
||||
|
||||
if (!isEnglishAuctionSupported) {
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
@@ -42,6 +41,7 @@ export default async function Page(props: {
|
||||
<ContractEnglishAuctionsPage
|
||||
contract={info.contract}
|
||||
twAccount={twAccount}
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-8
@@ -21,7 +21,7 @@ import {
|
||||
} from "./detectedFeatures/permissions";
|
||||
import { supportedERCs } from "./detectedFeatures/supportedERCs";
|
||||
import { type EmbedTypeToShow, getEmbedTypeToShow } from "./getEmbedTypeToShow";
|
||||
import { isAnalyticsSupportedForChain } from "./isAnalyticsSupportedForChain";
|
||||
import { isInsightSupportedForChain } from "./isAnalyticsSupportedForChain";
|
||||
|
||||
export type ContractPageMetadata = {
|
||||
supportedERCs: {
|
||||
@@ -36,7 +36,7 @@ export type ContractPageMetadata = {
|
||||
isPermissionsEnumerableSupported: boolean;
|
||||
isModularCore: boolean;
|
||||
embedType: EmbedTypeToShow;
|
||||
isAnalyticsSupported: boolean;
|
||||
isInsightSupported: boolean;
|
||||
isSplitSupported: boolean;
|
||||
isERC721ClaimConditionsSupported: boolean;
|
||||
isERC20ClaimConditionsSupported: boolean;
|
||||
@@ -47,7 +47,7 @@ export type ContractPageMetadata = {
|
||||
};
|
||||
|
||||
export async function getContractPageMetadata(contract: ThirdwebContract) {
|
||||
return getContractPageMetadataSetup(contract, isAnalyticsSupportedForChain);
|
||||
return getContractPageMetadataSetup(contract, isInsightSupportedForChain);
|
||||
}
|
||||
|
||||
export async function getContractPageMetadataSetup(
|
||||
@@ -56,7 +56,7 @@ export async function getContractPageMetadataSetup(
|
||||
): Promise<ContractPageMetadata> {
|
||||
const [
|
||||
functionSelectorsResult,
|
||||
isAnalyticsSupportedResult,
|
||||
isInsightSupportedResult,
|
||||
contractTypeResult,
|
||||
] = await Promise.allSettled([
|
||||
resolveFunctionSelectors(contract),
|
||||
@@ -69,9 +69,9 @@ export async function getContractPageMetadataSetup(
|
||||
? functionSelectorsResult.value
|
||||
: [];
|
||||
|
||||
const isAnalyticsSupported =
|
||||
isAnalyticsSupportedResult.status === "fulfilled"
|
||||
? isAnalyticsSupportedResult.value
|
||||
const isInsightSupported =
|
||||
isInsightSupportedResult.status === "fulfilled"
|
||||
? isInsightSupportedResult.value
|
||||
: true;
|
||||
|
||||
const contractType =
|
||||
@@ -86,7 +86,7 @@ export async function getContractPageMetadataSetup(
|
||||
isPermissionsEnumerableSupported(functionSelectors),
|
||||
isModularCore: isModularCoreContract(functionSelectors),
|
||||
embedType: getEmbedTypeToShow(functionSelectors),
|
||||
isAnalyticsSupported: isAnalyticsSupported,
|
||||
isInsightSupported: isInsightSupported,
|
||||
isSplitSupported: contractType === "Split",
|
||||
isVoteContract: contractType === "VoteERC20",
|
||||
isERC721ClaimConditionsSupported:
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export function getContractPageSidebarLinks(data: {
|
||||
{
|
||||
label: "Analytics",
|
||||
href: `${layoutPrefix}/analytics`,
|
||||
hide: !data.metadata.isAnalyticsSupported,
|
||||
hide: !data.metadata.isInsightSupported,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { getVercelEnv } from "lib/vercel-utils";
|
||||
const thirdwebDomain =
|
||||
getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb";
|
||||
|
||||
export async function isAnalyticsSupportedForChain(
|
||||
export async function isInsightSupportedForChain(
|
||||
chainId: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ export function AccountPageClient(props: {
|
||||
contract={props.contract}
|
||||
chainMetadata={props.chainMetadata}
|
||||
twAccount={props.twAccount}
|
||||
isInsightSupported={metadataQuery.data.isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -12,12 +12,14 @@ interface AccountPageProps {
|
||||
contract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
twAccount: Account | undefined;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const AccountPage: React.FC<AccountPageProps> = ({
|
||||
contract,
|
||||
chainMetadata,
|
||||
twAccount,
|
||||
isInsightSupported,
|
||||
}) => {
|
||||
const symbol = chainMetadata.nativeCurrency.symbol || "Native Token";
|
||||
|
||||
@@ -43,7 +45,7 @@ export const AccountPage: React.FC<AccountPageProps> = ({
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Heading size="title.sm">NFTs owned</Heading>
|
||||
</div>
|
||||
<NftsOwned contract={contract} />
|
||||
<NftsOwned contract={contract} isInsightSupported={isInsightSupported} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-1
@@ -6,12 +6,17 @@ import { NFTCards } from "../../_components/NFTCards";
|
||||
|
||||
interface NftsOwnedProps {
|
||||
contract: ThirdwebContract;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const NftsOwned: React.FC<NftsOwnedProps> = ({ contract }) => {
|
||||
export const NftsOwned: React.FC<NftsOwnedProps> = ({
|
||||
contract,
|
||||
isInsightSupported,
|
||||
}) => {
|
||||
const { data: walletNFTs, isPending: isWalletNFTsLoading } = useWalletNFTs({
|
||||
chainId: contract.chain.id,
|
||||
walletAddress: contract.address,
|
||||
isInsightSupported: isInsightSupported,
|
||||
});
|
||||
|
||||
const nfts = walletNFTs?.result || [];
|
||||
|
||||
+3
-1
@@ -33,7 +33,8 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { isAccount } = await getContractPageMetadata(contract);
|
||||
const { isAccount, isInsightSupported } =
|
||||
await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccount) {
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
@@ -44,6 +45,7 @@ export default async function Page(props: {
|
||||
contract={contract}
|
||||
chainMetadata={chainMetadata}
|
||||
twAccount={account}
|
||||
isInsightSupported={isInsightSupported}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export function ContractAnalyticsPageClient(props: {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isAnalyticsSupported) {
|
||||
if (!metadataQuery.data.isInsightSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -34,9 +34,9 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { isAnalyticsSupported } = await getContractPageMetadata(info.contract);
|
||||
const { isInsightSupported } = await getContractPageMetadata(info.contract);
|
||||
|
||||
if (!isAnalyticsSupported) {
|
||||
if (!isInsightSupported) {
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export function ContractOverviewPageClient(props: {
|
||||
contractPageMetadata.isPermissionsEnumerableSupported
|
||||
}
|
||||
chainSlug={chainMetadata.slug}
|
||||
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
|
||||
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
|
||||
functionSelectors={contractPageMetadata.functionSelectors}
|
||||
// TODO - create a fully client rendered version of publishedBy and ContractCard and plug it here
|
||||
publishedBy={undefined}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function Page(props: {
|
||||
contractPageMetadata.isPermissionsEnumerableSupported
|
||||
}
|
||||
chainSlug={chainMetadata.slug}
|
||||
isAnalyticsSupported={contractPageMetadata.isAnalyticsSupported}
|
||||
isAnalyticsSupported={contractPageMetadata.isInsightSupported}
|
||||
functionSelectors={contractPageMetadata.functionSelectors}
|
||||
publishedBy={
|
||||
<ErrorBoundary fallback={null}>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 406 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
@@ -47,6 +47,8 @@ import metalBanner from "./temp-assets/metalBanner.png";
|
||||
import plumeBanner from "./temp-assets/plumeBanner.png";
|
||||
|
||||
import plumeCTA from "./temp-assets/plumeCTA.png";
|
||||
import reactiveBanner from "./temp-assets/reactiveBanner.jpg";
|
||||
import reactiveCTA from "./temp-assets/reactiveCTA.jpg";
|
||||
import rivalzBanner from "./temp-assets/rivalzBanner.png";
|
||||
import rivalzCTA from "./temp-assets/rivalzCTA.png";
|
||||
import rootStockBanner from "./temp-assets/rootstock-banner.png";
|
||||
@@ -575,6 +577,18 @@ const chainMetaRecord = {
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Reactive Mainnet
|
||||
1597: {
|
||||
headerImgUrl: reactiveBanner.src,
|
||||
about:
|
||||
"Web3’s first IFTTT infrastructure. Automate multi-chain workflows with event-driven logic – execute actions without compromising decentralization.",
|
||||
cta: {
|
||||
backgroundImageUrl: reactiveCTA.src,
|
||||
title: "Turn Smart Contracts Reactive",
|
||||
buttonLink: "https://dev.reactive.network",
|
||||
buttonText: "Learn More",
|
||||
},
|
||||
},
|
||||
//Saakuru Mainnet
|
||||
7225878: {
|
||||
headerImgUrl: saakuruBanner.src,
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DeployedContractsPageHeader(props: {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div>
|
||||
<ImportModal
|
||||
isOpen={importModalOpen}
|
||||
onClose={() => {
|
||||
@@ -23,27 +23,25 @@ export function DeployedContractsPageHeader(props: {
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-3 py-10 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
|
||||
</div>
|
||||
<div className="flex gap-3 [&>*]:grow">
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
variant="outline"
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Import contract
|
||||
</Button>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href="/explore">
|
||||
<PlusIcon className="size-4" />
|
||||
Deploy contract
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 pb-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Contracts</h1>
|
||||
</div>
|
||||
<div className="flex gap-3 [&>*]:grow">
|
||||
<Button
|
||||
className="gap-2 bg-card"
|
||||
variant="outline"
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Import contract
|
||||
</Button>
|
||||
<Button asChild className="gap-2">
|
||||
<Link href="/explore">
|
||||
<PlusIcon className="size-4" />
|
||||
Deploy contract
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,12 @@ export function DeployedContractsPage(props: {
|
||||
authToken: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col pb-20">
|
||||
<div className="flex grow flex-col">
|
||||
<DeployedContractsPageHeader
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
<div className="h-10" />
|
||||
<div className="container flex grow flex-col">
|
||||
<div className="flex grow flex-col">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DeployedContractsPageAsync {...props} />
|
||||
</Suspense>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type React from "react";
|
||||
import { TabPathLinks } from "../../@/components/ui/tabs";
|
||||
import { AnnouncementBanner } from "../../components/notices/AnnouncementBanner";
|
||||
import { getAuthTokenWalletAddress } from "../api/lib/getAuthToken";
|
||||
import { TWAutoConnect } from "../components/autoconnect";
|
||||
import { loginRedirect } from "../login/loginRedirect";
|
||||
@@ -53,6 +54,7 @@ async function HeaderAndNav(props: {
|
||||
|
||||
return (
|
||||
<div className="bg-card">
|
||||
<AnnouncementBanner />
|
||||
<AccountHeader
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
account={props.twAccount}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const chainIdStr = searchParams.get("chainId");
|
||||
|
||||
if (!chainIdStr) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Missing chain ID parameter.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const chainId = Number.parseInt(chainIdStr);
|
||||
|
||||
if (!Number.isInteger(chainId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid chain ID parameter.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const found = await isSimpleHashSupported(chainId);
|
||||
|
||||
return NextResponse.json({ result: found });
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@/styles/globals.css";
|
||||
import "../global.css";
|
||||
import { DashboardRouterTopProgressBar } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
@@ -6,7 +6,6 @@ import PlausibleProvider from "next-plausible";
|
||||
import { Inter } from "next/font/google";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { Suspense } from "react";
|
||||
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
|
||||
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
|
||||
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
|
||||
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
|
||||
@@ -72,7 +71,6 @@ export default function RootLayout({
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
<OrganizeContractsToProjectsBanner />
|
||||
<AppRouterProviders>
|
||||
{children}
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { type NebulaContext, promptNebula } from "../api/chat";
|
||||
import { createSession, updateSession } from "../api/session";
|
||||
import type { SessionInfo } from "../api/types";
|
||||
@@ -32,7 +32,6 @@ export function ChatPageContent(props: {
|
||||
initialPrompt: string | undefined;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const activeChain = useActiveWalletChain();
|
||||
const client = useThirdwebClient(props.authToken);
|
||||
const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false);
|
||||
const [messages, setMessages] = useState<Array<ChatMessage>>(() => {
|
||||
@@ -133,14 +132,9 @@ export function ChatPageContent(props: {
|
||||
// ignore local storage errors
|
||||
}
|
||||
|
||||
// else - use the active chain
|
||||
updatedContextFilters.chainIds = activeChain
|
||||
? [activeChain.id.toString()]
|
||||
: [];
|
||||
|
||||
return updatedContextFilters;
|
||||
});
|
||||
}, [address, isNewSession, hasUserUpdatedContextFilters, activeChain]);
|
||||
}, [address, isNewSession, hasUserUpdatedContextFilters]);
|
||||
|
||||
const [sessionId, _setSessionId] = useState<string | undefined>(
|
||||
props.session?.id,
|
||||
|
||||
@@ -199,6 +199,17 @@ export function ContextFiltersForm(props: {
|
||||
onChange={(values) => {
|
||||
form.setValue("chainIds", values.join(","));
|
||||
}}
|
||||
priorityChains={[
|
||||
1, // ethereum
|
||||
56, // bnb smart chain mainnet (bsc)
|
||||
42161, // arbitrum one mainnet
|
||||
8453, // base mainnet
|
||||
43114, // avalanche mainnet
|
||||
146, // sonic
|
||||
137, // polygon
|
||||
80094, // berachain mainnet
|
||||
10, // optimism
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -22,7 +22,7 @@ Send native and ERC-20 currencies
|
||||
|
||||
### Deploy
|
||||
Deploy published contracts
|
||||
- Deploy a Token ERC20 Contract
|
||||
- Deploy a Token ERC20 Contract with name "Hello World" and description "My Hello Contract" on Ethereum.
|
||||
- Deploy a Split contract with two recipients.
|
||||
- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum'
|
||||
|
||||
@@ -30,7 +30,7 @@ Deploy published contracts
|
||||
Retrieve information about smart contracts.
|
||||
- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum?
|
||||
- What functions can I use to mint more of my contract's NFTs?
|
||||
- What is the total supply of NFTs for my contract?
|
||||
- What is the total supply of NFTs on 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e?
|
||||
|
||||
### Interact
|
||||
Query wallet balances, addresses, and token holdings.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
@@ -42,6 +44,7 @@ export default async function TeamLayout(props: {
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<AnnouncementBanner />
|
||||
<div className="bg-card">
|
||||
<TeamHeaderLoggedIn
|
||||
currentTeam={team}
|
||||
@@ -92,6 +95,7 @@ export default async function TeamLayout(props: {
|
||||
</div>
|
||||
|
||||
<main className="flex grow flex-col">{props.children}</main>
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ export const CredentialForm = ({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<TrackedLinkTW
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
label="learn-more"
|
||||
category="engine"
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export const WalletCredentialsSection: React.FC<WalletCredentialsProps> = ({
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Manage your wallet credentials for different providers.{" "}
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/infrastructure/engine/features/wallet-credentials"
|
||||
href="https://portal.thirdweb.com/engine/features/wallet-credentials"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about wallet credentials.
|
||||
|
||||
@@ -75,6 +75,11 @@ export function TeamProjectsPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// @TODO: HACK hide Engine projects from the list.
|
||||
_projectsToShow = _projectsToShow.filter(
|
||||
(project) => !project.name.startsWith("Cloud-hosted Engine ("),
|
||||
);
|
||||
|
||||
return _projectsToShow;
|
||||
}, [searchTerm, sortBy, projects]);
|
||||
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import {
|
||||
BookTextIcon,
|
||||
BoxIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
|
||||
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
|
||||
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
|
||||
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
|
||||
|
||||
export function ProjectSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { layoutPath, children } = props;
|
||||
|
||||
const tracking = (label: string) => ({
|
||||
category: "project-sidebar",
|
||||
action: "click",
|
||||
label,
|
||||
});
|
||||
|
||||
return (
|
||||
<FullWidthSidebarLayout
|
||||
footer={<AppFooter />}
|
||||
contentSidebarLinks={[
|
||||
{
|
||||
href: layoutPath,
|
||||
exactMatch: true,
|
||||
label: "Overview",
|
||||
icon: HomeIcon,
|
||||
tracking: tracking("overview"),
|
||||
},
|
||||
{
|
||||
label: "In-App Wallets",
|
||||
href: `${layoutPath}/connect/in-app-wallets`,
|
||||
icon: WalletIcon,
|
||||
tracking: tracking("in-app-wallets"),
|
||||
},
|
||||
{
|
||||
label: "Account Abstraction",
|
||||
href: `${layoutPath}/connect/account-abstraction`,
|
||||
icon: SmartAccountIcon,
|
||||
tracking: tracking("account-abstraction"),
|
||||
},
|
||||
{
|
||||
label: "Universal Bridge",
|
||||
href: `${layoutPath}/connect/universal-bridge`,
|
||||
icon: PayIcon,
|
||||
tracking: tracking("universal-bridge"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/contracts`,
|
||||
label: "Contracts",
|
||||
icon: ContractIcon,
|
||||
tracking: tracking("contracts"),
|
||||
},
|
||||
{
|
||||
href: `${layoutPath}/insight`,
|
||||
label: "Insight",
|
||||
icon: InsightIcon,
|
||||
tracking: tracking("insight"),
|
||||
},
|
||||
]}
|
||||
footerSidebarLinks={[
|
||||
{
|
||||
href: `${layoutPath}/settings`,
|
||||
label: "Project Settings",
|
||||
icon: SettingsIcon,
|
||||
tracking: tracking("project-settings"),
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com",
|
||||
label: "Documentation",
|
||||
icon: BookTextIcon,
|
||||
tracking: tracking("documentation"),
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
label: "Playground",
|
||||
icon: BoxIcon,
|
||||
tracking: tracking("playground"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</FullWidthSidebarLayout>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const LAST_USED_PROJECT_ID = "last-used-project-id";
|
||||
export const LAST_USED_TEAM_ID = "last-used-team-id";
|
||||
|
||||
export function SaveLastUsedProject(props: {
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(LAST_USED_PROJECT_ID, props.projectId);
|
||||
localStorage.setItem(LAST_USED_TEAM_ID, props.teamId);
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
}, [props.projectId, props.teamId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { team_slug, project_slug } = await props.params;
|
||||
|
||||
const links: SidebarLink[] = [
|
||||
{
|
||||
label: "In-App Wallets",
|
||||
href: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
|
||||
},
|
||||
{
|
||||
label: "Account Abstraction",
|
||||
href: `/team/${team_slug}/${project_slug}/connect/account-abstraction`,
|
||||
},
|
||||
{
|
||||
label: "Universal Bridge",
|
||||
href: `/team/${team_slug}/${project_slug}/connect/universal-bridge`,
|
||||
},
|
||||
{
|
||||
label: "Playground",
|
||||
href: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
},
|
||||
];
|
||||
|
||||
return <SidebarLayout sidebarLinks={links}>{props.children}</SidebarLayout>;
|
||||
}
|
||||
@@ -10,15 +10,11 @@ import Link from "next/link";
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container flex max-w-[1000px] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Insight
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-6 font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Insight
|
||||
</h1>
|
||||
|
||||
<div className="container max-w-[1000px] py-8 pb-20">
|
||||
<div className="pb-20">
|
||||
<BlueprintCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AnnouncementBanner } from "../../../../components/notices/AnnouncementBanner";
|
||||
import { getValidAccount } from "../../../account/settings/getAccount";
|
||||
import { getAuthTokenWalletAddress } from "../../../api/lib/getAuthToken";
|
||||
import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logged-in.client";
|
||||
import { ProjectTabs } from "./tabs";
|
||||
import { ProjectSidebarLayout } from "./components/ProjectSidebarLayout";
|
||||
import { SaveLastUsedProject } from "./components/SaveLastUsedProject";
|
||||
|
||||
export default async function TeamLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -46,21 +49,26 @@ export default async function TeamLayout(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const layoutPath = `/team/${params.team_slug}/${params.project_slug}`;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="bg-card">
|
||||
<TeamHeaderLoggedIn
|
||||
currentProject={project}
|
||||
currentTeam={team}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
account={account}
|
||||
accountAddress={accountAddress}
|
||||
/>
|
||||
<ProjectTabs
|
||||
layoutPath={`/team/${params.team_slug}/${params.project_slug}`}
|
||||
/>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-dvh min-w-0 grow flex-col">
|
||||
<div className="sticky top-0 z-10 border-border border-b bg-card">
|
||||
<AnnouncementBanner />
|
||||
<TeamHeaderLoggedIn
|
||||
currentProject={project}
|
||||
currentTeam={team}
|
||||
teamsAndProjects={teamsAndProjects}
|
||||
account={account}
|
||||
accountAddress={accountAddress}
|
||||
/>
|
||||
</div>
|
||||
<ProjectSidebarLayout layoutPath={layoutPath}>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
<div className="flex grow flex-col">{props.children}</div>
|
||||
</div>
|
||||
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,13 +29,14 @@ import {
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { type WalletId, getWalletInfo } from "thirdweb/wallets";
|
||||
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader";
|
||||
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard";
|
||||
import { PieChartCard } from "../../components/Analytics/PieChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsCharts } from "./components/Transactions";
|
||||
|
||||
import { RangeSelector } from "components/analytics/range-selector";
|
||||
|
||||
interface PageParams {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
@@ -80,8 +81,8 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="w-full border-border border-b">
|
||||
<AnalyticsHeader
|
||||
<div className="w-full">
|
||||
<Header
|
||||
title={project.name}
|
||||
interval={interval}
|
||||
range={range}
|
||||
@@ -90,11 +91,11 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
</div>
|
||||
|
||||
{!isActive ? (
|
||||
<div className="container py-8 pb-20">
|
||||
<div className="pt-6">
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="container flex grow flex-col py-6">
|
||||
<div className="flex grow flex-col pt-6">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<ProjectAnalytics
|
||||
project={project}
|
||||
@@ -189,7 +190,7 @@ async function ProjectAnalytics(props: {
|
||||
link="https://portal.thirdweb.com/connect/quickstart"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{walletConnections.status === "fulfilled" &&
|
||||
walletConnections.value.length > 0 ? (
|
||||
<WalletDistributionCard data={walletConnections.value} />
|
||||
@@ -461,3 +462,23 @@ async function TotalSponsoredCard({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header(props: {
|
||||
title: string;
|
||||
interval: "day" | "week";
|
||||
range: Range;
|
||||
showRangeSelector: boolean;
|
||||
}) {
|
||||
const { title, interval, range, showRangeSelector } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
{showRangeSelector && <RangeSelector interval={interval} range={range} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,10 @@ export default async function Layout(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container max-w-[1000px]">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Project Settings
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container max-w-[1000px] py-6">{props.children}</div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Project Settings
|
||||
</h1>
|
||||
<div className="py-6">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
|
||||
export function ProjectTabs(props: {
|
||||
layoutPath: string;
|
||||
}) {
|
||||
const { layoutPath } = props;
|
||||
|
||||
return (
|
||||
<TabPathLinks
|
||||
tabContainerClassName="px-4 lg:px-6"
|
||||
links={[
|
||||
{
|
||||
path: layoutPath,
|
||||
exactMatch: true,
|
||||
name: "Overview",
|
||||
},
|
||||
{
|
||||
// directly link to /in-app-wallets to skip 1 redirect and use `isActive` for checking if the tab is active or not
|
||||
path: `${layoutPath}/connect/in-app-wallets`,
|
||||
name: "Connect",
|
||||
isActive: (path) => path.startsWith(`${layoutPath}/connect`),
|
||||
},
|
||||
{
|
||||
path: `${layoutPath}/contracts`,
|
||||
name: "Contracts",
|
||||
},
|
||||
|
||||
{
|
||||
path: `${layoutPath}/insight`,
|
||||
name: "Insight",
|
||||
},
|
||||
{
|
||||
path: `${layoutPath}/settings`,
|
||||
name: "Settings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isTeamOnboardingComplete } from "../../login/onboarding/isOnboardingRequired";
|
||||
import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage";
|
||||
@@ -37,7 +36,6 @@ export default async function RootTeamLayout(props: {
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
<AppFooter />
|
||||
<SaveLastVisitedTeamPage />
|
||||
</div>
|
||||
);
|
||||
|
||||
+43
-6
@@ -44,10 +44,16 @@ import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { isZkSyncChain } from "thirdweb/utils";
|
||||
import { FormHelperText, FormLabel, Text } from "tw-components";
|
||||
import {
|
||||
LAST_USED_PROJECT_ID,
|
||||
LAST_USED_TEAM_ID,
|
||||
} from "../../../app/team/[team_slug]/[project_slug]/components/SaveLastUsedProject";
|
||||
import { useAddContractToProject } from "../../../app/team/[team_slug]/[project_slug]/hooks/project-contracts";
|
||||
import { useCustomFactoryAbi, useFunctionParamsFromABI } from "../hooks";
|
||||
import {
|
||||
AddToProjectCardUI,
|
||||
type MinimalProject,
|
||||
type MinimalTeam,
|
||||
type MinimalTeamsAndProjects,
|
||||
} from "./add-to-project-card";
|
||||
import { Fieldset } from "./common";
|
||||
@@ -151,9 +157,42 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
const thirdwebClient = useThirdwebClient(jwt);
|
||||
|
||||
const [isImportEnabled, setIsImportEnabled] = useState(true);
|
||||
const [importSelection, setImportSelection] = useState({
|
||||
team: teamsAndProjects[0]?.team,
|
||||
project: teamsAndProjects[0]?.projects[0],
|
||||
|
||||
const [importSelection, setImportSelection] = useState<{
|
||||
team: MinimalTeam | undefined;
|
||||
project: MinimalProject | undefined;
|
||||
}>(() => {
|
||||
const defaultSelection = {
|
||||
team: teamsAndProjects[0]?.team,
|
||||
project: teamsAndProjects[0]?.projects[0],
|
||||
};
|
||||
|
||||
try {
|
||||
const lastUsedTeamSlug = localStorage.getItem(LAST_USED_TEAM_ID);
|
||||
const lastUsedProjectSlug = localStorage.getItem(LAST_USED_PROJECT_ID);
|
||||
|
||||
if (!lastUsedTeamSlug || !lastUsedProjectSlug) {
|
||||
return defaultSelection;
|
||||
}
|
||||
|
||||
const teamWithProjects = teamsAndProjects.find(
|
||||
(t) => t.team.id === lastUsedTeamSlug,
|
||||
);
|
||||
const project = teamWithProjects?.projects.find(
|
||||
(p) => p.id === lastUsedProjectSlug,
|
||||
);
|
||||
|
||||
if (teamWithProjects && project) {
|
||||
return {
|
||||
team: teamWithProjects.team,
|
||||
project,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
|
||||
return defaultSelection;
|
||||
});
|
||||
|
||||
const activeAccount = useActiveAccount();
|
||||
@@ -473,9 +512,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
name: params.contractMetadata?.name || "",
|
||||
contractURI: _contractURI,
|
||||
defaultAdmin: params.deployParams._defaultAdmin as string,
|
||||
platformFeeBps: hasInbuiltDefaultFeeConfig
|
||||
? DEFAULT_FEE_BPS_NEW
|
||||
: DEFAULT_FEE_BPS,
|
||||
platformFeeBps: DEFAULT_FEE_BPS_NEW,
|
||||
platformFeeRecipient: DEFAULT_FEE_RECIPIENT,
|
||||
trustedForwarders: params.deployParams._trustedForwarders
|
||||
? JSON.parse(params.deployParams._trustedForwarders as string)
|
||||
|
||||
+13
-6
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import { CircleCheck, CircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
|
||||
export type DeployModalStep = {
|
||||
type: "deploy" | "setNFTMetadata";
|
||||
@@ -79,9 +80,9 @@ export function DeployStatusModal(props: {
|
||||
dialogCloseClassName="hidden"
|
||||
className="gap-0 p-0 md:max-w-[480px]"
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semibold text-2xl tracking-tight">
|
||||
<DialogTitle className="font-semibold text-xl tracking-tight">
|
||||
Deploy Status
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -125,7 +126,10 @@ type DeployModalStepProps = {
|
||||
|
||||
function RenderDeployModalStep(props: DeployModalStepProps) {
|
||||
const { isActive, hasCompleted } = props;
|
||||
const { title, description } = getStepInfo(props.step);
|
||||
const wallet = useActiveWallet();
|
||||
const requiresSignature = wallet?.id !== "inApp";
|
||||
|
||||
const { title, description } = getStepInfo(props.step, requiresSignature);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div
|
||||
@@ -158,13 +162,16 @@ type TitleAndDesc = {
|
||||
description: React.ReactNode;
|
||||
};
|
||||
|
||||
function getStepInfo(step: DeployModalStep): TitleAndDesc {
|
||||
function getStepInfo(
|
||||
step: DeployModalStep,
|
||||
requiresSignature: boolean,
|
||||
): TitleAndDesc {
|
||||
switch (step.type) {
|
||||
case "deploy": {
|
||||
return {
|
||||
title: "Deploying contract",
|
||||
description:
|
||||
step.signatureCount > 0
|
||||
step.signatureCount > 0 && requiresSignature
|
||||
? `Your wallet will prompt you to sign ${
|
||||
step.signatureCount === 1 ? "the" : step.signatureCount || 1
|
||||
} transaction${step.signatureCount > 1 ? "s" : ""}.`
|
||||
@@ -177,7 +184,7 @@ function getStepInfo(step: DeployModalStep): TitleAndDesc {
|
||||
title: "Setting NFT metadata",
|
||||
description: (
|
||||
<>
|
||||
{step.signatureCount > 0
|
||||
{step.signatureCount > 0 && requiresSignature
|
||||
? "Your wallet will prompt you to sign the transaction. "
|
||||
: "This may take a few seconds."}
|
||||
</>
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { CircleAlertIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
export function AnnouncementBanner(props: {
|
||||
function AnnouncementBannerUI(props: {
|
||||
href: string;
|
||||
label: string;
|
||||
trackingLabel: string;
|
||||
}) {
|
||||
const layoutSegment = useSelectedLayoutSegment();
|
||||
const [hasDismissedAnnouncement, setHasDismissedAnnouncement] =
|
||||
useLocalStorage(`dismissed-${props.trackingLabel}`, false, true);
|
||||
|
||||
if (
|
||||
layoutSegment === "/_not-found" ||
|
||||
hasDismissedAnnouncement ||
|
||||
layoutSegment === "login" ||
|
||||
layoutSegment === "nebula-app" ||
|
||||
layoutSegment === "join" ||
|
||||
layoutSegment === "get-started"
|
||||
) {
|
||||
if (hasDismissedAnnouncement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,7 +26,6 @@ export function AnnouncementBanner(props: {
|
||||
target={props.href.startsWith("http") ? "_blank" : undefined}
|
||||
className="ontainer flex cursor-pointer items-center gap-2 lg:justify-center "
|
||||
>
|
||||
<CircleAlertIcon className="hidden size-5 shrink-0 lg:block" />
|
||||
<span className="inline-block font-medium leading-normal hover:underline">
|
||||
{props.label}
|
||||
</span>
|
||||
@@ -53,12 +44,12 @@ export function AnnouncementBanner(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizeContractsToProjectsBanner() {
|
||||
export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBanner
|
||||
href="https://blog.thirdweb.com/organize-contracts/"
|
||||
label="Your contracts are now organized into projects. Learn more"
|
||||
trackingLabel="organize-contracts-to-projects"
|
||||
<AnnouncementBannerUI
|
||||
href="https://playground.thirdweb.com/connect/pay"
|
||||
label="Let users pay with whatever they have without leaving your app"
|
||||
trackingLabel="ub-launch"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
<SettingsCard
|
||||
bottomText="Shared Fees will are sent to recipient address"
|
||||
bottomText="Shared Fees are sent to recipient address"
|
||||
errorText={form.getFieldState("payoutAddress").error?.message}
|
||||
saveButton={{
|
||||
type: "submit",
|
||||
@@ -139,9 +139,8 @@ export const PayConfig: React.FC<PayConfigProps> = (props) => {
|
||||
Fee Sharing
|
||||
</h3>
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm">
|
||||
thirdweb collects a 1% fee per end user transaction through{" "}
|
||||
<strong>Buy With Crypto</strong>. We share 70% of this fee with
|
||||
you.{" "}
|
||||
thirdweb collects a 0.3% protocol fee on swap transactions. You
|
||||
may set your own developer fee in addition to this fee.
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/connect/pay/fee-sharing"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Toaster } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
|
||||
import { type ActiveCouponResponse, ApplyCouponCardUI } from "./CouponCard";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/Coupons/ApplyCoupon",
|
||||
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 statusStub(status: number) {
|
||||
return async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const data: ActiveCouponResponse | null =
|
||||
status === 200
|
||||
? {
|
||||
id: "xyz",
|
||||
start: 1727992716,
|
||||
end: 1759528716,
|
||||
coupon: {
|
||||
id: "XYZTEST",
|
||||
name: "TEST COUPON",
|
||||
duration: "repeating",
|
||||
duration_in_months: 12,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="Prefill code - XYZ, Success - 200">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(200)}
|
||||
onCouponApplied={undefined}
|
||||
prefillPromoCode="XYZ"
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Valid Payment setup">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(200)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Success - 200">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(200)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Invalid - 400">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(400)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Not Authorized - 401">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(401)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Already applied - 409">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(409)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Rate Limited - 429">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(429)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Other - 500">
|
||||
<ApplyCouponCardUI
|
||||
submit={statusStub(500)}
|
||||
onCouponApplied={undefined}
|
||||
isPaymentSetup={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { format, fromUnixTime } from "date-fns";
|
||||
import { TagIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
|
||||
export type ActiveCouponResponse = {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number | null;
|
||||
coupon: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
duration: "forever" | "once" | "repeating";
|
||||
duration_in_months: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
function ApplyCouponCard(props: {
|
||||
teamId: string | undefined;
|
||||
onCouponApplied: (data: ActiveCouponResponse) => void;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const couponCode = searchParams?.get("coupon");
|
||||
return (
|
||||
<ApplyCouponCardUI
|
||||
onCouponApplied={props.onCouponApplied}
|
||||
prefillPromoCode={couponCode || undefined}
|
||||
scrollIntoView={!!couponCode}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
submit={async (promoCode: string) => {
|
||||
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
|
||||
method: "POST",
|
||||
pathname: "/v1/coupons/redeem",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
promoCode,
|
||||
teamId: props.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = res.data;
|
||||
return {
|
||||
status: 200,
|
||||
data: json.data,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
data: null,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const couponFormSchema = z.object({
|
||||
promoCode: z.string().min(1, "Coupon code is required"),
|
||||
});
|
||||
|
||||
export function ApplyCouponCardUI(props: {
|
||||
submit: (promoCode: string) => Promise<{
|
||||
status: number;
|
||||
data: null | ActiveCouponResponse;
|
||||
}>;
|
||||
onCouponApplied: ((data: ActiveCouponResponse) => void) | undefined;
|
||||
prefillPromoCode?: string;
|
||||
scrollIntoView?: boolean;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLFormElement | null>(null);
|
||||
const form = useForm<z.infer<typeof couponFormSchema>>({
|
||||
resolver: zodResolver(couponFormSchema),
|
||||
defaultValues: {
|
||||
promoCode: props.prefillPromoCode || "",
|
||||
},
|
||||
});
|
||||
|
||||
const scrolled = useRef(false);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (props.scrollIntoView && !scrolled.current) {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.querySelector("input")?.focus();
|
||||
scrolled.current = true;
|
||||
}
|
||||
}
|
||||
}, [props.scrollIntoView]);
|
||||
|
||||
const applyCoupon = useMutation({
|
||||
mutationFn: (promoCode: string) => props.submit(promoCode),
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof couponFormSchema>) {
|
||||
if (!props.isPaymentSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await applyCoupon.mutateAsync(values.promoCode);
|
||||
switch (res.status) {
|
||||
case 200: {
|
||||
toast.success("Coupon applied successfully");
|
||||
if (res.data) {
|
||||
props.onCouponApplied?.(res.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 400: {
|
||||
toast.error("Coupon code is invalid");
|
||||
break;
|
||||
}
|
||||
case 401: {
|
||||
toast.error("You are not authorized to apply coupons", {
|
||||
description: "Login to dashboard and try again",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 409: {
|
||||
toast.error("Coupon already applied");
|
||||
break;
|
||||
}
|
||||
case 429: {
|
||||
toast.error("Too many coupons applied in a short period", {
|
||||
description: "Please try again after some time",
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
toast.error("Failed to apply coupon");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to apply coupon");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} ref={containerRef}>
|
||||
<SettingsCard
|
||||
header={{
|
||||
title: "Apply Coupon",
|
||||
description:
|
||||
"Enter your coupon code to apply discounts or free trials on thirdweb products",
|
||||
}}
|
||||
bottomText={
|
||||
props.isPaymentSetup
|
||||
? ""
|
||||
: "A valid payment method must be added to apply a coupon."
|
||||
}
|
||||
saveButton={{
|
||||
variant: "outline",
|
||||
disabled: !props.isPaymentSetup,
|
||||
isPending: applyCoupon.isPending,
|
||||
label: "Apply Coupon",
|
||||
type: "submit",
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={
|
||||
form.getFieldState("promoCode", form.formState).error?.message
|
||||
}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="promoCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Coupon Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="lg:max-w-[450px]"
|
||||
disabled={!props.isPaymentSetup}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CouponDetailsCardUI(props: {
|
||||
activeCoupon: ActiveCouponResponse;
|
||||
deleteCoupon: {
|
||||
mutateAsync: () => Promise<void>;
|
||||
isPending: boolean;
|
||||
};
|
||||
}) {
|
||||
const { activeCoupon, deleteCoupon } = props;
|
||||
return (
|
||||
<DangerSettingCard
|
||||
className="border-border"
|
||||
footerClassName="!bg-transparent !border-border"
|
||||
title="Coupon Applied"
|
||||
description="To apply another coupon, you must remove this coupon"
|
||||
buttonLabel="Remove Coupon"
|
||||
buttonOnClick={() => {
|
||||
const promise = deleteCoupon.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Coupon Removed Successfully",
|
||||
error: "Failed To Remove Coupon",
|
||||
});
|
||||
}}
|
||||
confirmationDialog={{
|
||||
title: `Delete Coupon "${activeCoupon.coupon.name}" ?`,
|
||||
description: "Offers added by this coupon will no longer be available.",
|
||||
}}
|
||||
isPending={deleteCoupon.isPending}
|
||||
>
|
||||
<div className="mt-3 flex max-w-[600px] items-center gap-3 rounded-lg border border-border bg-background p-4">
|
||||
<TagIcon className="hidden size-6 text-muted-foreground lg:block" />
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">
|
||||
{activeCoupon.coupon.name || `Coupon #${activeCoupon.coupon.id}`}
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Valid from {format(fromUnixTime(activeCoupon.start), "MMM d, yyyy")}{" "}
|
||||
{activeCoupon.end && (
|
||||
<>to {format(fromUnixTime(activeCoupon.end), "MMMM d, yyyy")}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DangerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function CouponSection(props: {
|
||||
teamId: string | undefined;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
const [optimisticCouponData, setOptimisticCouponData] = useState<
|
||||
| {
|
||||
type: "added";
|
||||
data: ActiveCouponResponse;
|
||||
}
|
||||
| {
|
||||
type: "deleted";
|
||||
data: null;
|
||||
}
|
||||
>();
|
||||
|
||||
const activeCoupon = useQuery({
|
||||
queryKey: ["active-coupon", address, props.teamId],
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy<{ data: ActiveCouponResponse }>({
|
||||
method: "GET",
|
||||
pathname: "/v1/active-coupon",
|
||||
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const json = res.data;
|
||||
return json.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteActiveCoupon = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
pathname: "/v1/active-coupon",
|
||||
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete coupon");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOptimisticCouponData({
|
||||
type: "deleted",
|
||||
data: null,
|
||||
});
|
||||
activeCoupon.refetch().then(() => {
|
||||
setOptimisticCouponData(undefined);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (activeCoupon.isPending) {
|
||||
return <LoadingCouponSection />;
|
||||
}
|
||||
|
||||
const couponData = optimisticCouponData
|
||||
? optimisticCouponData.data
|
||||
: activeCoupon.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
{couponData ? (
|
||||
<CouponDetailsCardUI
|
||||
activeCoupon={couponData}
|
||||
deleteCoupon={{
|
||||
mutateAsync: deleteActiveCoupon.mutateAsync,
|
||||
isPending: deleteActiveCoupon.isPending,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Suspense fallback={<LoadingCouponSection />}>
|
||||
<ApplyCouponCard
|
||||
teamId={props.teamId}
|
||||
onCouponApplied={(coupon) => {
|
||||
setOptimisticCouponData({
|
||||
type: "added",
|
||||
data: coupon,
|
||||
});
|
||||
|
||||
activeCoupon.refetch().then(() => {
|
||||
setOptimisticCouponData(undefined);
|
||||
});
|
||||
}}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCouponSection() {
|
||||
return (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border border-border bg-card">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { mobileViewport } from "../../../../stories/utils";
|
||||
import { CouponDetailsCardUI } from "./CouponCard";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/Coupons/CouponDetails",
|
||||
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() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
return (
|
||||
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
|
||||
<CouponDetailsCardUI
|
||||
activeCoupon={{
|
||||
id: "xyz",
|
||||
start: 1727992716,
|
||||
end: 1759528716,
|
||||
coupon: {
|
||||
id: "XYZFOOBAR",
|
||||
name: "TEST COUPON",
|
||||
duration: "repeating",
|
||||
duration_in_months: 12,
|
||||
},
|
||||
}}
|
||||
deleteCoupon={{
|
||||
mutateAsync: async () => {
|
||||
setIsPending(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setIsPending(false);
|
||||
},
|
||||
isPending: isPending,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type CouponData, CouponsUI } from "./CouponsUI";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/Coupons",
|
||||
component: Story,
|
||||
} satisfies Meta<typeof CouponsUI>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CouponsUI>;
|
||||
|
||||
const couponStubs: CouponData[] = [
|
||||
// Forever
|
||||
{
|
||||
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
|
||||
start: 1729011691,
|
||||
end: null,
|
||||
coupon: {
|
||||
id: "forever_active",
|
||||
name: "Example 1",
|
||||
duration: "forever",
|
||||
duration_in_months: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Once
|
||||
{
|
||||
id: "once",
|
||||
start: 1733391052,
|
||||
end: null,
|
||||
coupon: {
|
||||
id: "once_active",
|
||||
name: "Example 2",
|
||||
duration: "once",
|
||||
duration_in_months: null,
|
||||
},
|
||||
},
|
||||
|
||||
// Repeating - 3 months
|
||||
{
|
||||
id: "repeating-3",
|
||||
start: 1733391629,
|
||||
end: 1743391629, // Will end in the future
|
||||
coupon: {
|
||||
id: "repeating_active",
|
||||
name: "Example 3",
|
||||
duration: "repeating",
|
||||
duration_in_months: 3,
|
||||
},
|
||||
},
|
||||
|
||||
// Repeating - 1 month
|
||||
{
|
||||
id: "repeating-1",
|
||||
start: 1733391713,
|
||||
end: 1736391713,
|
||||
coupon: {
|
||||
id: "repeating_single",
|
||||
name: "Example 4",
|
||||
duration: "repeating",
|
||||
duration_in_months: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Variants: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
function Story() {
|
||||
const [status, setStatus] = useState<"pending" | "error" | "success">(
|
||||
"success",
|
||||
);
|
||||
|
||||
const [activeCouponsType, setActiveCouponsType] = useState<
|
||||
"multiple" | "zero" | "one"
|
||||
>("multiple");
|
||||
|
||||
const activeCoupons = useMemo(() => {
|
||||
if (activeCouponsType === "multiple") {
|
||||
return couponStubs;
|
||||
}
|
||||
|
||||
if (activeCouponsType === "zero") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (activeCouponsType === "one") {
|
||||
return couponStubs.slice(0, 1);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeCouponsType]);
|
||||
|
||||
const [hasAccountCoupon, setHasAccountCoupon] = useState(true);
|
||||
const [isPaymentSetup, setIsPaymentSetup] = useState(true);
|
||||
const [applyCouponStatus, setApplyCouponStatus] = useState<
|
||||
200 | 400 | 401 | 409 | 429 | 500
|
||||
>(200);
|
||||
|
||||
return (
|
||||
<div className="container max-w-[1100px] py-10">
|
||||
<div className="mb-8 space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Status</h3>
|
||||
<RadioGroup
|
||||
value={status}
|
||||
onValueChange={(value) =>
|
||||
setStatus(value as "pending" | "error" | "success")
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pending" id="status-pending" />
|
||||
<Label htmlFor="status-pending">Pending</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="error" id="status-error" />
|
||||
<Label htmlFor="status-error">Error</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="success" id="status-success" />
|
||||
<Label htmlFor="status-success">Success</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Active Coupons</h3>
|
||||
<RadioGroup
|
||||
value={activeCouponsType}
|
||||
onValueChange={(value) =>
|
||||
setActiveCouponsType(value as "multiple" | "zero" | "one")
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="multiple" id="coupons-multiple" />
|
||||
<Label htmlFor="coupons-multiple">Multiple</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="zero" id="coupons-zero" />
|
||||
<Label htmlFor="coupons-zero">Zero</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="one" id="coupons-one" />
|
||||
<Label htmlFor="coupons-one">One</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Apply Coupon Status</h3>
|
||||
<RadioGroup
|
||||
value={applyCouponStatus.toString()}
|
||||
className="flex gap-4"
|
||||
onValueChange={(value) =>
|
||||
setApplyCouponStatus(
|
||||
Number.parseInt(value) as 200 | 400 | 401 | 409 | 429 | 500,
|
||||
)
|
||||
}
|
||||
>
|
||||
{[200, 400, 401, 409, 429, 500].map((code) => (
|
||||
<div key={code} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={code.toString()}
|
||||
id={`status-${code}`}
|
||||
/>
|
||||
<Label htmlFor={`status-${code}`}>{code}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Other Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
id="has-account-coupon"
|
||||
checked={hasAccountCoupon}
|
||||
onCheckedChange={setHasAccountCoupon}
|
||||
/>
|
||||
<Label htmlFor="has-account-coupon">Has Account Coupon</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
id="payment-setup"
|
||||
checked={isPaymentSetup}
|
||||
onCheckedChange={setIsPaymentSetup}
|
||||
/>
|
||||
<Label htmlFor="payment-setup">Payment Setup</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CouponsUI
|
||||
status={status}
|
||||
activeCoupons={activeCoupons}
|
||||
accountCouponId={hasAccountCoupon ? activeCoupons[0]?.id : undefined}
|
||||
isPaymentSetup={isPaymentSetup}
|
||||
applyCoupon={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
status: applyCouponStatus,
|
||||
data:
|
||||
applyCouponStatus === 200
|
||||
? {
|
||||
id: "xyz",
|
||||
start: 1727992716,
|
||||
end: 1759528716,
|
||||
coupon: {
|
||||
id: "test",
|
||||
name: "TEST COUPON",
|
||||
duration: "repeating",
|
||||
duration_in_months: 12,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}}
|
||||
deleteCoupon={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import { Suspense } from "react";
|
||||
import { getAuthToken } from "../../../../../app/api/lib/getAuthToken";
|
||||
import { CouponsClient } from "./CouponsClient";
|
||||
import type { CouponData } from "./CouponsUI";
|
||||
|
||||
async function AsyncCoupons(props: {
|
||||
teamId: string;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const allCouponsPromise = fetch(
|
||||
`${API_SERVER_URL}/v1/active-coupons?teamId=${props.teamId}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const accountCouponPromise = fetch(
|
||||
`${API_SERVER_URL}/v1/active-coupon?teamId=${props.teamId}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [allCouponsRes, accountCouponRes] = await Promise.allSettled([
|
||||
allCouponsPromise,
|
||||
accountCouponPromise,
|
||||
]);
|
||||
|
||||
if (
|
||||
allCouponsRes.status === "rejected" ||
|
||||
accountCouponRes.status === "rejected"
|
||||
) {
|
||||
return (
|
||||
<CouponsClient
|
||||
status="error"
|
||||
activeCoupons={[]}
|
||||
accountCouponId={undefined}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const allCouponsData = (await allCouponsRes.value.json()) as {
|
||||
data: CouponData[];
|
||||
};
|
||||
|
||||
const accountCouponData = (await accountCouponRes.value.json()) as {
|
||||
data: CouponData | null;
|
||||
};
|
||||
|
||||
return (
|
||||
<CouponsClient
|
||||
status="success"
|
||||
activeCoupons={allCouponsData.data}
|
||||
accountCouponId={accountCouponData.data?.id}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Coupons(props: {
|
||||
teamId: string;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<CouponsClient
|
||||
status="pending"
|
||||
activeCoupons={[]}
|
||||
accountCouponId={undefined}
|
||||
isPaymentSetup={true}
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncCoupons
|
||||
teamId={props.teamId}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { type CouponData, CouponsUI, type CouponsUIProps } from "./CouponsUI";
|
||||
|
||||
export function CouponsClient(
|
||||
props: Omit<
|
||||
CouponsUIProps,
|
||||
"applyCoupon" | "onCouponApplied" | "deleteCoupon"
|
||||
> & {
|
||||
teamId: string;
|
||||
},
|
||||
) {
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<CouponsUI
|
||||
{...props}
|
||||
deleteCoupon={async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
pathname: "/v1/active-coupon",
|
||||
searchParams: props.teamId ? { teamId: props.teamId } : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete coupon");
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
applyCoupon={async (promoCode: string) => {
|
||||
const res = await apiServerProxy<{ data: CouponData }>({
|
||||
method: "POST",
|
||||
pathname: "/v1/coupons/redeem",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
promoCode,
|
||||
teamId: props.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
router.refresh();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: json.data,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { format, fromUnixTime } from "date-fns";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
CalendarIcon,
|
||||
CalendarX2Icon,
|
||||
ClockIcon,
|
||||
InfinityIcon,
|
||||
PlusIcon,
|
||||
TicketCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
type Coupon = {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: "forever" | "once" | "repeating";
|
||||
duration_in_months: number | null;
|
||||
};
|
||||
|
||||
export type CouponData = {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number | null;
|
||||
coupon: Coupon;
|
||||
};
|
||||
|
||||
type ApplyCouponFn = (promoCode: string) => Promise<{
|
||||
status: number;
|
||||
data: null | CouponData;
|
||||
}>;
|
||||
|
||||
export type CouponsUIProps = {
|
||||
activeCoupons: CouponData[];
|
||||
status: "pending" | "error" | "success";
|
||||
accountCouponId: string | undefined;
|
||||
isPaymentSetup: boolean;
|
||||
applyCoupon: ApplyCouponFn;
|
||||
deleteCoupon: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function CouponsUI(props: CouponsUIProps) {
|
||||
const formatUnixTime = (timestamp: number) => {
|
||||
const date = fromUnixTime(timestamp);
|
||||
return format(date, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
const renderDuration = (coupon: Coupon) => {
|
||||
if (coupon.duration === "forever") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<InfinityIcon className="size-4 text-muted-foreground" />
|
||||
<span>Forever</span>
|
||||
</div>
|
||||
);
|
||||
} else if (coupon.duration === "once") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4 text-muted-foreground" />
|
||||
<span>One-time discount</span>
|
||||
</div>
|
||||
);
|
||||
} else if (coupon.duration === "repeating") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="size-4 text-muted-foreground" />
|
||||
<span>
|
||||
Repeats every {coupon.duration_in_months} month
|
||||
{coupon.duration_in_months !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const accountCoupon = props.activeCoupons.find(
|
||||
(coupon) => coupon.id === props.accountCouponId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b bg-card px-6 py-4">
|
||||
<h3 className="font-semibold text-xl tracking-tight">Coupons</h3>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<PlusIcon className="size-4" />
|
||||
Apply Coupon
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<ApplyCouponModalContent
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
applyCoupon={props.applyCoupon}
|
||||
accountCoupon={accountCoupon}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{props.status === "success" && props.activeCoupons.length > 0 ? (
|
||||
<TableContainer className="rounded-t-none border-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Starts On</TableHead>
|
||||
<TableHead>Ends On</TableHead>
|
||||
<TableHead>Duration </TableHead>
|
||||
{accountCoupon && (
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.activeCoupons.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{/* Name */}
|
||||
<TableCell className="font-medium">
|
||||
{item.coupon.name}
|
||||
</TableCell>
|
||||
|
||||
{/* Start Date */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
{formatUnixTime(item.start)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* End Date */}
|
||||
<TableCell>
|
||||
{item.end !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
{formatUnixTime(item.end)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarX2Icon className="size-4 text-muted-foreground" />
|
||||
<span className="text-foreground">Never </span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Duration */}
|
||||
<TableCell>{renderDuration(item.coupon)}</TableCell>
|
||||
|
||||
{accountCoupon && (
|
||||
<TableCell>
|
||||
<ToolTipLabel
|
||||
label={
|
||||
item.id === props.accountCouponId
|
||||
? "Remove coupon"
|
||||
: "This coupon cannot be removed"
|
||||
}
|
||||
>
|
||||
<DeleteCouponButton
|
||||
deleteCoupon={props.deleteCoupon}
|
||||
disabled={item.id !== props.accountCouponId}
|
||||
/>
|
||||
</ToolTipLabel>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<p className="flex min-h-[150px] items-center justify-center text-muted-foreground">
|
||||
{props.status === "pending" ? (
|
||||
<Spinner className="size-8" />
|
||||
) : props.status === "error" ? (
|
||||
<span className="text-destructive-text">
|
||||
Failed to load coupons
|
||||
</span>
|
||||
) : (
|
||||
<span>No coupons</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteCouponButton(props: {
|
||||
deleteCoupon: () => Promise<void>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const deleteCoupon = useMutation({
|
||||
mutationFn: props.deleteCoupon,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
const promise = deleteCoupon.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Coupon removed successfully",
|
||||
error: "Failed to remove coupon",
|
||||
});
|
||||
}}
|
||||
aria-label="Remove coupon"
|
||||
>
|
||||
{deleteCoupon.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Trash2Icon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const applyCouponFormSchema = z.object({
|
||||
promoCode: z.string().min(1, "Coupon code is required"),
|
||||
});
|
||||
|
||||
function ApplyCouponModalContent(props: {
|
||||
isPaymentSetup: boolean;
|
||||
applyCoupon: ApplyCouponFn;
|
||||
accountCoupon: CouponData | undefined;
|
||||
}) {
|
||||
const applyCoupon = useMutation({
|
||||
mutationFn: props.applyCoupon,
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof applyCouponFormSchema>>({
|
||||
resolver: zodResolver(applyCouponFormSchema),
|
||||
defaultValues: {
|
||||
promoCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof applyCouponFormSchema>) {
|
||||
try {
|
||||
const res = await applyCoupon.mutateAsync(values.promoCode);
|
||||
switch (res.status) {
|
||||
case 200: {
|
||||
toast.success("Coupon applied successfully");
|
||||
|
||||
break;
|
||||
}
|
||||
case 400: {
|
||||
toast.error("Coupon code is invalid");
|
||||
break;
|
||||
}
|
||||
case 401: {
|
||||
toast.error("You are not authorized to apply coupons", {
|
||||
description: "Login to dashboard and try again",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 409: {
|
||||
toast.error("Coupon already applied");
|
||||
break;
|
||||
}
|
||||
case 429: {
|
||||
toast.error("Too many coupons applied in a short period", {
|
||||
description: "Please try again after some time",
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
toast.error("Failed to apply coupon");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to apply coupon");
|
||||
}
|
||||
}
|
||||
|
||||
const couponEnabled = props.isPaymentSetup && !props.accountCoupon;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Apply Coupon</DialogTitle>
|
||||
{couponEnabled && (
|
||||
<DialogDescription>
|
||||
Enter coupon code to apply discounts or free trials
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{couponEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="promoCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Coupon Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className=""
|
||||
disabled={!props.isPaymentSetup}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!props.isPaymentSetup && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<AlertTitle>Payment method required</AlertTitle>
|
||||
<AlertDescription>
|
||||
A valid payment method must be added to apply a coupon.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{props.isPaymentSetup && props.accountCoupon && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon className="size-5" />
|
||||
<AlertTitle>Coupon already applied</AlertTitle>
|
||||
<AlertDescription>
|
||||
Remove coupon {`"${props.accountCoupon.coupon.name}"`} to apply
|
||||
a new coupon
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end border-t bg-card p-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={applyCoupon.isPending || !couponEnabled}
|
||||
className="gap-2"
|
||||
>
|
||||
{applyCoupon.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<TicketCheckIcon className="size-4" />
|
||||
)}
|
||||
Redeem
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard";
|
||||
import { CouponSection } from "./CouponCard";
|
||||
import { CreditsInfoCard } from "./PlanCard";
|
||||
import { BillingPricing } from "./Pricing";
|
||||
import { Coupons } from "./SubscriptionCoupons/Coupons";
|
||||
|
||||
// TODO - move this in app router folder in other pr
|
||||
|
||||
@@ -68,7 +68,7 @@ export const Billing: React.FC<BillingProps> = ({
|
||||
/>
|
||||
|
||||
<CreditsInfoCard twAccount={twAccount} />
|
||||
<CouponSection teamId={team.id} isPaymentSetup={validPayment} />
|
||||
<Coupons teamId={team.id} isPaymentSetup={validPayment} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,10 @@ import { z } from "zod";
|
||||
import { projectDomainsSchema, projectNameSchema } from "../validations";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
(srv) =>
|
||||
srv.name !== "relayer" &&
|
||||
srv.name !== "chainsaw" &&
|
||||
srv.name !== "engineCloud", // TODO enable once API server is out
|
||||
);
|
||||
|
||||
export type CreateProjectPrefillOptions = {
|
||||
|
||||
@@ -125,4 +125,4 @@ export type ApiKeyPayConfigValidationSchema = z.infer<
|
||||
>;
|
||||
|
||||
// FIXME: Remove
|
||||
export const HIDDEN_SERVICES = ["relayer", "chainsaw"];
|
||||
export const HIDDEN_SERVICES = ["relayer", "chainsaw", "engineCloud"];
|
||||
|
||||
@@ -112,24 +112,31 @@ export function AccountAbstractionSettingsPage(
|
||||
|
||||
const policy = props.bundlerService;
|
||||
|
||||
const transformedQueryData = useMemo(
|
||||
() => ({
|
||||
const transformedQueryData = useMemo(() => {
|
||||
const allowedContractAddresses = policy.allowedContractAddresses?.filter(
|
||||
(x) => x !== "",
|
||||
);
|
||||
|
||||
const allowedWallets = policy.allowedWallets?.filter((x) => x !== "");
|
||||
const blockedWallets = policy.blockedWallets?.filter((x) => x !== "");
|
||||
|
||||
// there is a bug in API server that makes `allowedChainIds` an array with `0` if we set it to null
|
||||
const allowedChainIds = policy.allowedChainIds?.filter((x) => x !== 0);
|
||||
|
||||
return {
|
||||
allowedChainIds:
|
||||
policy.allowedChainIds && policy.allowedChainIds?.length > 0
|
||||
? policy.allowedChainIds
|
||||
: null,
|
||||
allowedChainIds && allowedChainIds?.length > 0 ? allowedChainIds : null,
|
||||
allowedContractAddresses:
|
||||
policy.allowedContractAddresses &&
|
||||
policy.allowedContractAddresses?.length > 0
|
||||
? joinWithComma(policy.allowedContractAddresses)
|
||||
allowedContractAddresses && allowedContractAddresses.length > 0
|
||||
? joinWithComma(allowedContractAddresses)
|
||||
: null,
|
||||
allowedWallets:
|
||||
policy.allowedWallets && policy.allowedWallets?.length > 0
|
||||
? joinWithComma(policy.allowedWallets)
|
||||
allowedWallets && allowedWallets?.length > 0
|
||||
? joinWithComma(allowedWallets)
|
||||
: null,
|
||||
blockedWallets:
|
||||
policy.blockedWallets && policy.blockedWallets?.length > 0
|
||||
? joinWithComma(policy.blockedWallets)
|
||||
blockedWallets && blockedWallets?.length > 0
|
||||
? joinWithComma(blockedWallets)
|
||||
: null,
|
||||
bypassWallets:
|
||||
policy.bypassWallets && policy.bypassWallets?.length > 0
|
||||
@@ -148,14 +155,13 @@ export function AccountAbstractionSettingsPage(
|
||||
},
|
||||
globalLimit: policy.limits?.global ?? null,
|
||||
allowedOrBlockedWallets:
|
||||
policy.allowedWallets && policy.allowedWallets?.length > 0
|
||||
allowedWallets && allowedWallets?.length > 0
|
||||
? "allowed"
|
||||
: policy.blockedWallets && policy.blockedWallets?.length > 0
|
||||
: blockedWallets && blockedWallets?.length > 0
|
||||
? "blocked"
|
||||
: null,
|
||||
}),
|
||||
[policy],
|
||||
);
|
||||
};
|
||||
}, [policy]);
|
||||
|
||||
const form = useForm<z.infer<typeof aaSettingsFormSchema>>({
|
||||
resolver: zodResolver(aaSettingsFormSchema),
|
||||
@@ -212,11 +218,12 @@ export function AccountAbstractionSettingsPage(
|
||||
|
||||
const parsedValues: Omit<ProjectBundlerService, "name" | "actions"> =
|
||||
{
|
||||
allowedContractAddresses:
|
||||
values.allowedContractAddresses !== null
|
||||
? toArrFromList(values.allowedContractAddresses)
|
||||
: null,
|
||||
allowedChainIds: values.allowedChainIds,
|
||||
allowedContractAddresses: values.allowedContractAddresses
|
||||
? toArrFromList(values.allowedContractAddresses)
|
||||
: null,
|
||||
|
||||
// don't set null - `updateProject` API adds chainId 0 to the list if its null and makes it `[0]`
|
||||
allowedChainIds: values.allowedChainIds || [],
|
||||
allowedWallets:
|
||||
values.allowedOrBlockedWallets === "allowed" &&
|
||||
values.allowedWallets !== null
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
|
||||
/* Others */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 0 0% 4%;
|
||||
--sidebar-primary: 221 83% 54%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 0 0% 93%;
|
||||
--sidebar-accent-foreground: 0 0% 9%;
|
||||
--sidebar-border: 0 0% 85%;
|
||||
--sidebar-ring: 0 0% 80%;
|
||||
}
|
||||
|
||||
.dark,
|
||||
@@ -78,6 +88,16 @@
|
||||
--active-border: 0 0% 22%;
|
||||
--ring: 0 0% 30%;
|
||||
--input: 0 0% 15%;
|
||||
|
||||
/* sidebar */
|
||||
--sidebar-background: 0 0% 0%;
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-primary: 221 83% 54%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 0 0% 11%;
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 0 0% 15%;
|
||||
--sidebar-ring: 0 0% 30%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { WalletNFT } from "./types";
|
||||
|
||||
/**
|
||||
* Return the chain slug if the chain is supported by Simplehash, otherwise `undefined`
|
||||
* Chain slug because we need it to fetch the owned NFTs using another endpoint
|
||||
*/
|
||||
export async function isSimpleHashSupported(
|
||||
chainId: number,
|
||||
): Promise<string | undefined> {
|
||||
if (!process.env.SIMPLEHASH_API_KEY) {
|
||||
throw new Error("No Simplehash API Key");
|
||||
}
|
||||
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-KEY": process.env.SIMPLEHASH_API_KEY,
|
||||
},
|
||||
};
|
||||
const response: Array<{
|
||||
chain: string;
|
||||
eip155_network_id: number;
|
||||
is_testnet: boolean;
|
||||
}> = await fetch("https://api.simplehash.com/api/v0/chains", options)
|
||||
.then((r) => r.json())
|
||||
.catch(() => []);
|
||||
|
||||
const found = response.find((chain) => chain.eip155_network_id === chainId);
|
||||
return found?.chain;
|
||||
}
|
||||
|
||||
export function useSimplehashSupport(chainId: number) {
|
||||
return useQuery({
|
||||
queryKey: ["simplehash-supported", chainId],
|
||||
queryFn: () => isSimpleHashSupported(chainId),
|
||||
enabled: !!chainId,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateSimpleHashUrl({
|
||||
chainSlug,
|
||||
owner,
|
||||
}: { chainSlug: string; owner: string }) {
|
||||
const url = new URL("https://api.simplehash.com/api/v0/nfts/owners");
|
||||
url.searchParams.append("wallet_addresses", owner);
|
||||
url.searchParams.append("chains", chainSlug);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function transformSimpleHashResponseToNFT(
|
||||
simpleHashResponse: SimpleHashResponse,
|
||||
walletAddress: string,
|
||||
): Promise<WalletNFT[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
simpleHashResponse.nfts.map(async (simpleHashNft) => {
|
||||
try {
|
||||
return {
|
||||
id: simpleHashNft.token_id,
|
||||
contractAddress: simpleHashNft.contract_address,
|
||||
tokenId: simpleHashNft.token_id,
|
||||
metadata: {
|
||||
name: simpleHashNft.name,
|
||||
description: simpleHashNft.description,
|
||||
image: simpleHashNft.extra_metadata.image_original_url,
|
||||
external_url: simpleHashNft.external_url,
|
||||
attributes: simpleHashNft.extra_metadata.attributes,
|
||||
properties: simpleHashNft.extra_metadata.properties,
|
||||
id: simpleHashNft.token_id,
|
||||
uri: simpleHashNft.extra_metadata.metadata_original_url,
|
||||
animation_url:
|
||||
simpleHashNft.extra_metadata.animation_original_url,
|
||||
background_color: simpleHashNft.background_color,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
} as any,
|
||||
owner: walletAddress,
|
||||
supply: simpleHashNft.token_count.toString(),
|
||||
type: simpleHashNft.contract.type,
|
||||
tokenURI: simpleHashNft.extra_metadata.metadata_original_url,
|
||||
} as WalletNFT;
|
||||
} catch {
|
||||
return undefined as unknown as WalletNFT;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
interface PreviewImages {
|
||||
image_small_url: string;
|
||||
image_medium_url: string;
|
||||
image_large_url: string;
|
||||
image_opengraph_url: string;
|
||||
blurhash: string;
|
||||
predominant_color: string;
|
||||
}
|
||||
|
||||
interface ImageProperties {
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
interface Owner {
|
||||
owner_address: string;
|
||||
quantity: number;
|
||||
first_acquired_date: string;
|
||||
last_acquired_date: string;
|
||||
}
|
||||
|
||||
interface Contract {
|
||||
type: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
deployed_by: string;
|
||||
deployed_via_contract: string;
|
||||
}
|
||||
|
||||
interface MarketplacePage {
|
||||
marketplace_id: string;
|
||||
marketplace_name: string;
|
||||
marketplace_collection_id: string;
|
||||
nft_url: string;
|
||||
collection_url: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
collection_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
banner_image_url: string | null;
|
||||
category: string | null;
|
||||
is_nsfw: boolean | null;
|
||||
external_url: string | null;
|
||||
twitter_username: string | null;
|
||||
discord_url: string | null;
|
||||
instagram_username: string | null;
|
||||
medium_username: string | null;
|
||||
telegram_url: string | null;
|
||||
marketplace_pages: MarketplacePage[];
|
||||
metaplex_mint: string | null;
|
||||
metaplex_first_verified_creator: string | null;
|
||||
floor_prices: unknown[];
|
||||
top_bids: unknown[];
|
||||
distinct_owner_count: number;
|
||||
distinct_nft_count: number;
|
||||
total_quantity: number;
|
||||
chains: string[];
|
||||
top_contracts: string[];
|
||||
}
|
||||
|
||||
interface FirstCreated {
|
||||
minted_to: string;
|
||||
quantity: number;
|
||||
timestamp: string;
|
||||
block_number: number;
|
||||
transaction: string;
|
||||
transaction_initiator: string;
|
||||
}
|
||||
|
||||
interface Rarity {
|
||||
rank: number;
|
||||
score: number;
|
||||
unique_attributes: number;
|
||||
}
|
||||
|
||||
interface ExtraMetadataAttribute {
|
||||
trait_type: string;
|
||||
value: string;
|
||||
display_type: string | null;
|
||||
}
|
||||
|
||||
interface ExtraMetadataProperties {
|
||||
number: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ExtraMetadata {
|
||||
attributes: ExtraMetadataAttribute[];
|
||||
properties: ExtraMetadataProperties;
|
||||
image_original_url: string;
|
||||
animation_original_url: string | null;
|
||||
metadata_original_url: string;
|
||||
}
|
||||
|
||||
interface NFT {
|
||||
nft_id: string;
|
||||
chain: string;
|
||||
contract_address: string;
|
||||
token_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
previews: PreviewImages;
|
||||
image_url: string;
|
||||
image_properties: ImageProperties;
|
||||
video_url: string | null;
|
||||
video_properties: unknown;
|
||||
audio_url: string | null;
|
||||
audio_properties: unknown;
|
||||
model_url: string | null;
|
||||
model_properties: unknown;
|
||||
other_url: string | null;
|
||||
other_properties: unknown;
|
||||
background_color: string | null;
|
||||
external_url: string | null;
|
||||
created_date: string;
|
||||
status: string;
|
||||
token_count: number;
|
||||
owner_count: number;
|
||||
owners: Owner[];
|
||||
contract: Contract;
|
||||
collection: Collection;
|
||||
last_sale: unknown;
|
||||
first_created: FirstCreated;
|
||||
rarity: Rarity;
|
||||
royalty: unknown[];
|
||||
extra_metadata: ExtraMetadata;
|
||||
}
|
||||
|
||||
interface SimpleHashResponse {
|
||||
nfts: NFT[];
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "thirdweb/chains";
|
||||
|
||||
// Cannot use BigInt for the values here because it will result in error: "fail to serialize bigint"
|
||||
// when the data is being sent from server to client (when we fetch the owned NFTs from simplehash/alchemy/moralis)
|
||||
// when the data is being sent from server to client (when we fetch the owned NFTs from insight/alchemy/moralis)
|
||||
export type WalletNFT = Omit<NFT, "id" | "supply"> & {
|
||||
id: string;
|
||||
contractAddress: string;
|
||||
|
||||
@@ -71,6 +71,16 @@ module.exports = {
|
||||
link: {
|
||||
foreground: "hsl(var(--link-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.10",
|
||||
"lucide-react": "0.482.0",
|
||||
"next": "15.2.2",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "9.22.0",
|
||||
"eslint-config-next": "15.2.2",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.2"
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "6.0.10",
|
||||
"lucide-react": "0.482.0",
|
||||
"next": "15.2.2",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "15.2.2",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,21 @@
|
||||
import type React from "react";
|
||||
import { APIHeader } from "../../../../components/blocks/APIHeader";
|
||||
|
||||
export default function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<APIHeader
|
||||
title="Universal Bridge API"
|
||||
description={
|
||||
<>HTTP API to bridge, swap and onramp to and from any currency</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/pay/overview"
|
||||
heroLink="/ub.png"
|
||||
/>
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import Link from "next/link";
|
||||
import { getBridgePaths } from "./utils";
|
||||
|
||||
export default async function Page() {
|
||||
try {
|
||||
const paths = await getBridgePaths();
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<h2 className="mb-2 font-semibold text-2xl tracking-tight">
|
||||
Universal Bridge REST API
|
||||
</h2>
|
||||
<p className="mb-5 text-muted-foreground">
|
||||
Directly interact with the Universal Bridge API from your backend,
|
||||
using standard REST api.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<BlueprintSection
|
||||
title="Available endpoints"
|
||||
blueprints={paths.map(([pathName, pathObj]) => {
|
||||
if (!pathObj) {
|
||||
throw new Error(`Path not found: ${pathName}`);
|
||||
}
|
||||
return {
|
||||
name: pathName,
|
||||
description: pathObj.get?.description || "",
|
||||
link: `/connect/pay/backend/reference?route=${pathName}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return <div>Error fetching API spec</div>;
|
||||
}
|
||||
}
|
||||
|
||||
function BlueprintSection(props: {
|
||||
title: string;
|
||||
blueprints: { name: string; description: string; link: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex items-center gap-2 border-b bg-accent/20 px-6 py-4">
|
||||
<h2 className="font-semibold text-lg tracking-tight">{props.title}</h2>
|
||||
</div>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{props.blueprints.map((item) => (
|
||||
<TableRow
|
||||
key={item.link}
|
||||
className="group hover:bg-accent/50"
|
||||
linkBox
|
||||
>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-3">
|
||||
<Link
|
||||
href={item.link}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-semibold text-md">{item.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { redirect } from "next/navigation";
|
||||
import { THIRDWEB_CLIENT } from "../../../../../lib/client";
|
||||
import { isProd } from "../../../../../lib/env";
|
||||
import { BlueprintPlayground } from "../../../../insight/[blueprint_slug]/blueprint-playground.client";
|
||||
import { getBridgePaths } from "../utils";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
route: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.searchParams;
|
||||
|
||||
// invalid url
|
||||
if (!params.route) {
|
||||
redirect("/connect/pay/backend");
|
||||
}
|
||||
|
||||
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
|
||||
const domain = `https://bridge.${thirdwebDomain}.com`;
|
||||
|
||||
const paths = await getBridgePaths();
|
||||
const pathMetadata = paths.find(([path]) => path === params.route)?.[1]?.get;
|
||||
|
||||
// invalid url
|
||||
if (!pathMetadata) {
|
||||
redirect("/connect/pay/backend");
|
||||
}
|
||||
|
||||
const title = pathMetadata.summary || "";
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs />
|
||||
<h1 className="mt-3 mb-6 font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
<BlueprintPlayground
|
||||
key={params.route}
|
||||
metadata={pathMetadata}
|
||||
backLink={"/connect/pay/backend"}
|
||||
clientId={THIRDWEB_CLIENT.clientId}
|
||||
path={params.route}
|
||||
supportedChainIds={[]}
|
||||
domain={domain}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumbs() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/connect/pay/backend">
|
||||
Universal Bridge API
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OpenAPIV3 } from "openapi-types";
|
||||
import { isProd } from "../../../../lib/env";
|
||||
|
||||
export async function getBridgePaths() {
|
||||
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
|
||||
const res = await fetch(`https://bridge.${thirdwebDomain}.com/openapi.json`);
|
||||
const openapiJson = (await res.json()) as OpenAPIV3.Document;
|
||||
return Object.entries(openapiJson.paths).filter(
|
||||
([, pathObj]) =>
|
||||
pathObj?.get?.deprecated === undefined ||
|
||||
pathObj?.get?.deprecated === false,
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ function BuyMerch() {
|
||||
Commerce
|
||||
</h2>
|
||||
<p className="max-w-[600px]">
|
||||
Take paymets from Fiat or Crypto directly to your seller wallet.
|
||||
Take payments from Fiat or Crypto directly to your seller wallet.
|
||||
<br />
|
||||
Get notified for every sale through webhooks, which lets you trigger
|
||||
any action you want like shipping physical goods, activating services
|
||||
|
||||
@@ -105,8 +105,8 @@ export function LeftSection(props: {
|
||||
<CustomRadioGroup
|
||||
id="pay-mode"
|
||||
options={[
|
||||
{ value: "fund_wallet", label: "Fund Wallet" },
|
||||
{ value: "direct_payment", label: "Direct Payment" },
|
||||
{ value: "fund_wallet", label: "Buy Crypto" },
|
||||
{ value: "direct_payment", label: "Purchase" },
|
||||
{ value: "transaction", label: "Transaction" },
|
||||
]}
|
||||
onValueChange={(value) => {
|
||||
|
||||
@@ -74,7 +74,7 @@ export function RightSection(props: {
|
||||
? "Transaction"
|
||||
: props.options.payOptions.mode === "direct_payment"
|
||||
? "Purchase"
|
||||
: "Fund Wallet"),
|
||||
: "Buy Crypto"),
|
||||
image:
|
||||
props.options.payOptions.image ||
|
||||
`https://placehold.co/600x400/${
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "Fund wallets | thirdweb Universal Bridge",
|
||||
title: "Buy Crypto | thirdweb Universal Bridge",
|
||||
description:
|
||||
"The easiest way for users to fund their wallets. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.",
|
||||
};
|
||||
@@ -41,7 +41,7 @@ function StyledPayEmbed() {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Fund Wallet
|
||||
Buy Crypto
|
||||
</h2>
|
||||
<p className="max-w-[600px]">
|
||||
Inline component that allows users to buy any currency.
|
||||
|
||||
@@ -158,7 +158,6 @@ export function RightSection(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-[300px] grow justify-center rounded-lg",
|
||||
previewTab === "modal" && "scale-75 lg:scale-100",
|
||||
previewTab !== "code" && "items-center",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "Sign In, Account Abstraction and SIWE Auth | thirdweb ConnectEmbed",
|
||||
description:
|
||||
"Let users sign up with their email, phone number, social media accounts or directly with a wallet. Seemlessly integrate account abstraction and SIWE auth.",
|
||||
"Let users sign up with their email, phone number, social media accounts or directly with a wallet. Seamlessly integrate account abstraction and SIWE auth.",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: "Sign In, Account Abstraction and SIWE Auth | thirdweb Connect",
|
||||
description:
|
||||
"Let users sign up with their email, phone number, social media accounts or directly with a wallet. Seemlessly integrate account abstraction and SIWE auth.",
|
||||
"Let users sign up with their email, phone number, social media accounts or directly with a wallet. Seamlessly integrate account abstraction and SIWE auth.",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
|
||||
+5
-4
@@ -35,7 +35,6 @@ import {
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { isProd } from "../../../lib/env";
|
||||
import type { BlueprintParameter, BlueprintPathMetadata } from "../utils";
|
||||
|
||||
export function BlueprintPlayground(props: {
|
||||
@@ -44,6 +43,7 @@ export function BlueprintPlayground(props: {
|
||||
clientId: string;
|
||||
path: string;
|
||||
supportedChainIds: number[];
|
||||
domain: string;
|
||||
}) {
|
||||
const [abortController, setAbortController] =
|
||||
useState<AbortController | null>(null);
|
||||
@@ -56,6 +56,9 @@ export function BlueprintPlayground(props: {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"x-client-id": props.clientId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
@@ -78,8 +81,6 @@ export function BlueprintPlayground(props: {
|
||||
},
|
||||
});
|
||||
|
||||
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
|
||||
|
||||
return (
|
||||
<BlueprintPlaygroundUI
|
||||
backLink={props.backLink}
|
||||
@@ -98,7 +99,7 @@ export function BlueprintPlayground(props: {
|
||||
abortController.abort();
|
||||
}
|
||||
}}
|
||||
domain={`https://insight.${thirdwebDomain}.com`}
|
||||
domain={props.domain}
|
||||
path={props.path}
|
||||
supportedChainIds={props.supportedChainIds}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { redirect } from "next/navigation";
|
||||
import { THIRDWEB_CLIENT } from "../../../lib/client";
|
||||
import { isProd } from "../../../lib/env";
|
||||
import { fetchBlueprintSpec } from "../utils";
|
||||
import { BlueprintPlayground } from "./blueprint-playground.client";
|
||||
|
||||
@@ -26,6 +27,9 @@ export default async function Page(props: {
|
||||
redirect("/insight");
|
||||
}
|
||||
|
||||
const thirdwebDomain = !isProd ? "thirdweb-dev" : "thirdweb";
|
||||
const domain = `https://insight.${thirdwebDomain}.com`;
|
||||
|
||||
const [blueprintSpec] = await Promise.all([
|
||||
fetchBlueprintSpec({
|
||||
blueprintId: params.blueprint_slug,
|
||||
@@ -58,6 +62,7 @@ export default async function Page(props: {
|
||||
clientId={THIRDWEB_CLIENT.clientId}
|
||||
path={searchParams.path}
|
||||
supportedChainIds={supportedChainIds}
|
||||
domain={domain}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,28 +60,6 @@ export const staticSidebarLinks: SidebarLink[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Universal Bridge",
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
name: "UI Component",
|
||||
href: "/connect/pay",
|
||||
},
|
||||
{
|
||||
name: "Fund Wallet",
|
||||
href: "/connect/pay/fund-wallet",
|
||||
},
|
||||
{
|
||||
name: "Commerce",
|
||||
href: "/connect/pay/commerce",
|
||||
},
|
||||
{
|
||||
name: "Transactions",
|
||||
href: "/connect/pay/transactions",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Auth",
|
||||
href: "/connect/auth",
|
||||
@@ -124,6 +102,34 @@ export const staticSidebarLinks: SidebarLink[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const universalBridgeSidebarLinks: SidebarLink = {
|
||||
name: "Universal Bridge",
|
||||
isCollapsible: false,
|
||||
expanded: false,
|
||||
links: [
|
||||
{
|
||||
name: "UI Component",
|
||||
href: "/connect/pay",
|
||||
},
|
||||
{
|
||||
name: "Buy Crypto",
|
||||
href: "/connect/pay/fund-wallet",
|
||||
},
|
||||
{
|
||||
name: "Commerce",
|
||||
href: "/connect/pay/commerce",
|
||||
},
|
||||
{
|
||||
name: "Transactions",
|
||||
href: "/connect/pay/transactions",
|
||||
},
|
||||
{
|
||||
name: "Backend API",
|
||||
href: "/connect/pay/backend",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const engineSidebarLinks: SidebarLink = {
|
||||
name: "Engine",
|
||||
isCollapsible: false,
|
||||
@@ -167,13 +173,14 @@ export async function getSidebarLinks() {
|
||||
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
...staticSidebarLinks,
|
||||
universalBridgeSidebarLinks,
|
||||
engineSidebarLinks,
|
||||
{
|
||||
name: "Insight",
|
||||
isCollapsible: false,
|
||||
expanded: false,
|
||||
links: insightLinks,
|
||||
},
|
||||
engineSidebarLinks,
|
||||
];
|
||||
|
||||
return sidebarLinks;
|
||||
|
||||
@@ -19,6 +19,7 @@ export const THIRDWEB_CLIENT = createThirdwebClient(
|
||||
process.env.THIRDWEB_SECRET_KEY
|
||||
? {
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY,
|
||||
clientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID as string,
|
||||
config: {
|
||||
storage: isDev
|
||||
? {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@dirtycajunrice/klee": "^1.0.6",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "15.2.2",
|
||||
"@next/mdx": "15.2.3",
|
||||
"@radix-ui/react-dialog": "1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
@@ -35,8 +35,8 @@
|
||||
"flexsearch": "^0.7.43",
|
||||
"github-slugger": "^2.0.0",
|
||||
"he": "^1.2.0",
|
||||
"lucide-react": "0.482.0",
|
||||
"next": "15.2.2",
|
||||
"lucide-react": "0.483.0",
|
||||
"next": "15.2.3",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"node-html-markdown": "^1.3.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
@@ -55,7 +55,7 @@
|
||||
"typedoc-better-json": "0.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "15.2.2",
|
||||
"@next/eslint-plugin-next": "15.2.3",
|
||||
"@types/flexsearch": "^0.7.6",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/mdx": "^2.0.13",
|
||||
|
||||
@@ -155,6 +155,8 @@ function extractPageLLMContent(
|
||||
|
||||
const htmlToMarkdown = new NodeHtmlMarkdown({
|
||||
keepDataImages: false,
|
||||
ignore: ["button"],
|
||||
maxConsecutiveNewlines: 2,
|
||||
});
|
||||
|
||||
let linksContent = "";
|
||||
@@ -167,10 +169,7 @@ function extractPageLLMContent(
|
||||
let description = "";
|
||||
for (const p of paragraphs) {
|
||||
// skip noindex or no-llm paragraphs
|
||||
if (
|
||||
p.getAttribute("data-noindex") === "true" ||
|
||||
p.getAttribute("data-no-llm") === "true"
|
||||
) {
|
||||
if (p.closest("[data-noindex]") || p.closest("[data-no-llm]")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ function extractPageLLMContent(
|
||||
}
|
||||
}
|
||||
|
||||
linksContent += `* [${pageTitle}](${pageUrl}): ${description}`;
|
||||
linksContent += `* [${pageTitle}](${pageUrl}): ${description || `Reference for ${pageTitle}`}`;
|
||||
|
||||
// Remove noindex and no-llm elements
|
||||
const contentElements = main.querySelectorAll("*");
|
||||
@@ -204,12 +203,18 @@ function extractPageLLMContent(
|
||||
// prefix all the relative links with the `https://portal.thirdweb.com`
|
||||
const links = main.querySelectorAll("a");
|
||||
for (const link of links) {
|
||||
const [path, hash] = link.getAttribute("href")?.split("#") || [];
|
||||
if (path?.startsWith("/")) {
|
||||
link.setAttribute(
|
||||
"href",
|
||||
`https://portal.thirdweb.com${path}${hash ? `#${hash}` : ""}`,
|
||||
);
|
||||
const href = link.getAttribute("href");
|
||||
if (href?.startsWith("/")) {
|
||||
link.setAttribute("href", `https://portal.thirdweb.com${href}`);
|
||||
}
|
||||
}
|
||||
|
||||
// prefix all relative image links with the `https://portal.thirdweb.com`
|
||||
const images = main.querySelectorAll("img");
|
||||
for (const image of images) {
|
||||
const src = image.getAttribute("src");
|
||||
if (src?.startsWith("/")) {
|
||||
image.setAttribute("src", `https://portal.thirdweb.com${src}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const metadata = createMetadata({
|
||||
description: "How to batch transactions with smart accounts",
|
||||
});
|
||||
|
||||
Batching transactions allows sending multiple transactions in a single user operation. This can be useful to save on fees, reduce number of user confimations or to ensure that multiple transactions are executed atomically.
|
||||
Batching transactions allows sending multiple transactions in a single user operation. This can be useful to save on fees, reduce number of user confirmations or to ensure that multiple transactions are executed atomically.
|
||||
|
||||
A typical example is to do an approval and a transfer in a single userOperation. This way, the transfer will only happen if the approval is successful.
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Get started with Connect in your preferred language.
|
||||
- **Connect to 500+ different wallet providers** with support for every EVM network
|
||||
- **Log in and authenticate your users** with customizable and secure [email, phone, passkeys and social login](https://portal.thirdweb.com/connect/in-app-wallet/overview) flows.
|
||||
- **Sponsor transactions** to seamlessly onboard non-native or new crypto users with [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview).
|
||||
- **Perform wallet actions** like connecting and disconnecting wallets, viewing balance, displaying ENS names, and execute transactions with our [perfomant, reliable and type safe API](https://portal.thirdweb.com/connect/blockchain-api)
|
||||
- **Perform wallet actions** like connecting and disconnecting wallets, viewing balance, displaying ENS names, and execute transactions with our [performant, reliable and type safe API](https://portal.thirdweb.com/connect/blockchain-api)
|
||||
- **Easily integrate with thirdweb's contracts** to enable users to interact with your application.
|
||||
- **Facilitate payments** by letting user top up their wallets or do onchain purchases with a credit card with [Pay](https://portal.thirdweb.com/connect/pay/overview).
|
||||
|
||||
|
||||
@@ -16,11 +16,7 @@ thirdweb does not charge any fees for buy with fiat. Any fees on onramping are b
|
||||
</Details>
|
||||
|
||||
<Details summary="What are the fees for Buy With Crypto?">
|
||||
For buying with crypto, thirdweb charges 0.3% and the application developer may charge up to 0.7% on each transaction
|
||||
</Details>
|
||||
|
||||
<Details summary="How does fee sharing work for Buy With Crypto?">
|
||||
thirdweb shares 70% of all fees collected through Buy With Crypto with our developers. Fee sharing is not currently available for Buy With Fiat transactions.
|
||||
For buying with crypto or swap fees, thirdweb charges a 0.3% protocol fee. Developers may monetize their application by charging additional fees set through the dashboard.
|
||||
</Details>
|
||||
|
||||
<Details summary="What does the term “network fees” refer to?">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user