Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2606da6d87 | ||
|
|
9cf137660a | ||
|
|
58ab8c2ee7 | ||
|
|
447a519ddc | ||
|
|
a9b7633d05 | ||
|
|
38e089a5f3 | ||
|
|
ef2f86cff0 | ||
|
|
8fdef82147 | ||
|
|
cbb15692a7 | ||
|
|
1d23e5cd9c | ||
|
|
a17d893811 | ||
|
|
0dcde077f5 | ||
|
|
05a041fac3 | ||
|
|
3d140eed97 | ||
|
|
a5f005fff0 | ||
|
|
fb70cab6d4 | ||
|
|
cd9ea69c13 | ||
|
|
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";
|
||||
|
||||
@@ -49,12 +49,11 @@ module.exports = [
|
||||
"/templates/:template_slug",
|
||||
// -- learn --
|
||||
"/learn",
|
||||
"/learn/tutorials",
|
||||
"/learn/tutorials/:tutorial_slug",
|
||||
"/learn/guides",
|
||||
"/learn/guides/:guide_slug",
|
||||
"/learn/courses",
|
||||
"/learn/glossary",
|
||||
"/learn/glossary/:glossary_slug",
|
||||
// -- faucets --
|
||||
"/faucets",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -361,6 +361,19 @@ async function redirects() {
|
||||
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// all /learn/tutorials (and sub-routes) -> /learn/guides
|
||||
{
|
||||
source: "/learn/tutorials/:path*",
|
||||
destination: "/learn/guides/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/learn/tutorials",
|
||||
destination: "/learn/guides",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
...legacyDashboardToTeamRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,22 +0,0 @@
|
||||
// app/providers.tsx
|
||||
"use client";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider as PHProvider } from "posthog-js/react";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
posthog.init(
|
||||
process.env.NEXT_PUBLIC_POSTHOG_API_KEY ||
|
||||
"phc_hKK4bo8cHZrKuAVXfXGpfNSLSJuucUnguAgt2j6dgSV",
|
||||
{
|
||||
api_host: "https://a.thirdweb.com",
|
||||
autocapture: true,
|
||||
debug: false,
|
||||
capture_pageview: true,
|
||||
disable_session_recording: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
return <PHProvider client={posthog}>{children}</PHProvider>;
|
||||
}
|
||||
@@ -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,11 +6,12 @@ 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 { PHProvider } from "../lib/posthog/Posthog";
|
||||
import { PosthogHeadSetup } from "../lib/posthog/PosthogHeadSetup";
|
||||
import { PostHogPageView } from "../lib/posthog/PosthogPageView";
|
||||
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
|
||||
import { PostHogProvider } from "./components/root-providers";
|
||||
import { AppRouterProviders } from "./providers";
|
||||
|
||||
const fontSans = Inter({
|
||||
@@ -64,15 +65,16 @@ export default function RootLayout({
|
||||
customDomain="https://pl.thirdweb.com"
|
||||
selfHosted
|
||||
/>
|
||||
<PosthogHeadSetup />
|
||||
</head>
|
||||
<PostHogProvider>
|
||||
<PHProvider>
|
||||
<PostHogPageView />
|
||||
<body
|
||||
className={cn(
|
||||
"bg-background font-sans antialiased",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
<OrganizeContractsToProjectsBanner />
|
||||
<AppRouterProviders>
|
||||
{children}
|
||||
<Suspense fallback={null}>
|
||||
@@ -93,7 +95,7 @@ export default function RootLayout({
|
||||
showSpinner={false}
|
||||
/>
|
||||
</body>
|
||||
</PostHogProvider>
|
||||
</PHProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -88,10 +88,10 @@ export async function TotalSponsoredChartCardUI({
|
||||
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-4 lg:p-6", className)}>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{title || "Total Sponsored"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground"> {description}</p>
|
||||
<p className="text-muted-foreground text-sm"> {description}</p>
|
||||
<BarChart
|
||||
isCurrency
|
||||
chartConfig={chartConfig}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeamNebulaWaitList, getTeams } from "@/api/team";
|
||||
import { 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";
|
||||
@@ -37,11 +39,9 @@ export default async function TeamLayout(props: {
|
||||
})),
|
||||
);
|
||||
|
||||
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
|
||||
?.onWaitlist;
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<AnnouncementBanner />
|
||||
<div className="bg-card">
|
||||
<TeamHeaderLoggedIn
|
||||
currentTeam={team}
|
||||
@@ -71,14 +71,6 @@ export default async function TeamLayout(props: {
|
||||
path: `/team/${params.team_slug}/~/ecosystem`,
|
||||
name: "Ecosystems",
|
||||
},
|
||||
...(isOnNebulaWaitList
|
||||
? [
|
||||
{
|
||||
path: `/team/${params.team_slug}/~/nebula`,
|
||||
name: "Nebula",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: `/team/${params.team_slug}/~/usage`,
|
||||
name: "Usage",
|
||||
@@ -92,6 +84,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]);
|
||||
|
||||
|
||||
+2
-16
@@ -5,7 +5,6 @@ import { BillingPortalButton } from "@/components/billing";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { differenceInDays, isAfter } from "date-fns";
|
||||
import { format } from "date-fns/format";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
@@ -37,7 +36,7 @@ export function PlanInfoCard(props: {
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
<div>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{validPlan} Plan
|
||||
</h3>
|
||||
@@ -56,6 +55,7 @@ export function PlanInfoCard(props: {
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{/* go to invoices page */}
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/team/${team.slug}/~/settings/invoices`}>
|
||||
View Invoices
|
||||
@@ -73,20 +73,6 @@ export function PlanInfoCard(props: {
|
||||
>
|
||||
Manage Billing
|
||||
</BillingPortalButton>
|
||||
|
||||
{isActualFreePlan && (
|
||||
<Button asChild variant="outline">
|
||||
<TrackedLinkTW
|
||||
category="account"
|
||||
href="/pricing"
|
||||
label="pricing-plans"
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View Pricing
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export function SponsoredTransactionsTableUI(
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="flex flex-col justify-between gap-3 border-b px-4 py-4 lg:flex-row lg:items-center lg:px-6">
|
||||
<h2 className="font-semibold text-lg">Sponsored Transactions</h2>
|
||||
<h2 className="font-semibold text-xl">Sponsored Transactions</h2>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
|
||||
+4
-4
@@ -23,13 +23,13 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
totalUsage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative flex min-h-[190px] flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">{name}</h3>
|
||||
<p className="text-muted-foreground"> {description}</p>
|
||||
<div className="relative flex flex-col rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">{name}</h3>
|
||||
<p className="text-muted-foreground text-sm"> {description}</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
{title && <p className="text-foreground">{title}</p>}
|
||||
|
||||
{total !== undefined && (
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"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";
|
||||
import { NebulaIcon } from "../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
|
||||
export function ProjectSidebarLayout(props: {
|
||||
layoutPath: string;
|
||||
children: React.ReactNode;
|
||||
showNebula: boolean;
|
||||
}) {
|
||||
const { layoutPath, children, showNebula } = 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"),
|
||||
},
|
||||
...(showNebula
|
||||
? [
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
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;
|
||||
}
|
||||
+3
-3
@@ -34,12 +34,12 @@ function FooterCard(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<h3 className="mb-5 font-semibold text-lg tracking-tight lg:text-xl">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="mb-4 border-border border-b pt-1 pb-4 font-semibold text-lg leading-none tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{props.links.map((link) => (
|
||||
<TrackedLinkTW
|
||||
key={link.href}
|
||||
|
||||
+73
-38
@@ -1,75 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
|
||||
import {
|
||||
TrackedLinkTW,
|
||||
TrackedUnderlineLink,
|
||||
} from "@/components/ui/tracked-link";
|
||||
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useActiveWalletChain } from "thirdweb/react";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { AAFooterSection } from "./AAFooterSection";
|
||||
import { isOpChainId } from "./isOpChain";
|
||||
|
||||
const TRACKING_CATEGORY = "smart-wallet";
|
||||
|
||||
export function AccountAbstractionLayout(props: {
|
||||
projectSlug: string;
|
||||
projectId: string;
|
||||
teamSlug: string;
|
||||
projectKey: string;
|
||||
children: React.ReactNode;
|
||||
hasSmartWalletsWithoutBilling: boolean;
|
||||
userOpStats: UserOpStats;
|
||||
}) {
|
||||
const chain = useActiveWalletChain();
|
||||
|
||||
const isOpChain = chain?.id ? isOpChainId(chain.id) : false;
|
||||
|
||||
const smartWalletsLayoutSlug = `/team/${props.teamSlug}/${props.projectSlug}/connect/account-abstraction`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Account Abstraction
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
|
||||
into your apps.{" "}
|
||||
Integrate ERC-4337 compliant smart accounts for gasless sponsorships and
|
||||
more. <br className="lg:hidden" />
|
||||
<TrackedUnderlineLink
|
||||
target="_blank"
|
||||
label="docs-wallets"
|
||||
category={TRACKING_CATEGORY}
|
||||
href="https://portal.thirdweb.com/wallets/smart-wallet"
|
||||
>
|
||||
View Documentation
|
||||
Learn more about Account Abstraction
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{props.hasSmartWalletsWithoutBilling ? (
|
||||
<SmartWalletsBillingAlert />
|
||||
) : (
|
||||
isOpChain && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<AlertTitle>
|
||||
Using the gas credits for OP chain paymaster
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Credits will automatically be applied to cover gas fees for any
|
||||
onchain activity across thirdweb services. <br />
|
||||
Eligible chains: OP Mainnet, Base, Zora, Frax, Mode.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
{props.hasSmartWalletsWithoutBilling && <SmartWalletsBillingAlert />}
|
||||
<GasCreditAlert teamSlug={props.teamSlug} projectId={props.projectId} />
|
||||
|
||||
<div>
|
||||
<AccountAbstractionSummary
|
||||
aggregateUserOpUsageQuery={props.userOpStats}
|
||||
/>
|
||||
|
||||
<div className="h-12" />
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
@@ -93,8 +72,64 @@ export function AccountAbstractionLayout(props: {
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-14" />
|
||||
|
||||
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GasCreditAlert(props: {
|
||||
teamSlug: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useLocalStorage(
|
||||
`gas-credit-${props.projectId}`,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4">
|
||||
<Button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
|
||||
aria-label="Close alert"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="mb-0.5 font-semibold">OP Gas Credit Program</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Redeem credits towards gas Sponsorship. <br className="lg:hidden" />
|
||||
<TrackedUnderlineLink
|
||||
target="_blank"
|
||||
label="superchain-landing"
|
||||
category={TRACKING_CATEGORY}
|
||||
href="https://thirdweb.com/grant/superchain"
|
||||
>
|
||||
Learn More
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Button asChild variant="outline" size="sm" className="bg-background">
|
||||
<TrackedLinkTW
|
||||
href={`/team/${props.teamSlug}/~/settings/credits`}
|
||||
target="_blank"
|
||||
className="gap-2"
|
||||
category={TRACKING_CATEGORY}
|
||||
label="claim-credits"
|
||||
>
|
||||
Claim your credits <ArrowRightIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
export function isOpChainId(chainId: number) {
|
||||
return [10, 8453, 7777777, 252, 34443].includes(chainId);
|
||||
}
|
||||
+1
-7
@@ -1,4 +1,3 @@
|
||||
import { getAggregateUserOpUsage } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import type { Metadata } from "next";
|
||||
@@ -34,18 +33,13 @@ export default async function Page(props: {
|
||||
team.billingStatus !== "validPayment" &&
|
||||
team.billingStatus !== "pastDue";
|
||||
|
||||
const userOpStats = await getAggregateUserOpUsage({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccountAbstractionLayout
|
||||
userOpStats={userOpStats}
|
||||
projectSlug={project.slug}
|
||||
teamSlug={team_slug}
|
||||
projectKey={project.publishableKey}
|
||||
hasSmartWalletsWithoutBilling={hasSmartWalletsWithoutBilling}
|
||||
projectId={project.id}
|
||||
>
|
||||
{props.children}
|
||||
</AccountAbstractionLayout>
|
||||
|
||||
+22
-3
@@ -1,5 +1,6 @@
|
||||
import { getUserOpUsage } from "@/api/analytics";
|
||||
import { getAggregateUserOpUsage, getUserOpUsage } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import {
|
||||
type Range,
|
||||
@@ -31,7 +32,14 @@ export default async function Page(props: {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
const [team, project] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
@@ -52,7 +60,7 @@ export default async function Page(props: {
|
||||
type: rangeType,
|
||||
};
|
||||
|
||||
const userOpStats = await getUserOpUsage({
|
||||
const userOpStatsPromise = getUserOpUsage({
|
||||
teamId: project.teamId,
|
||||
projectId: project.id,
|
||||
from: range.from,
|
||||
@@ -60,6 +68,16 @@ export default async function Page(props: {
|
||||
period: interval,
|
||||
});
|
||||
|
||||
const aggregateUserOpStatsPromise = getAggregateUserOpUsage({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const [userOpStats, aggregateUserOpStats] = await Promise.all([
|
||||
userOpStatsPromise,
|
||||
aggregateUserOpStatsPromise,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AccountAbstractionAnalytics
|
||||
userOpStats={userOpStats}
|
||||
@@ -67,6 +85,7 @@ export default async function Page(props: {
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
teamSlug={params.team_slug}
|
||||
aggregateUserOpStats={aggregateUserOpStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,12 +1,15 @@
|
||||
import { getProjects } from "@/api/projects";
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getTeamNebulaWaitList, 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: {
|
||||
export default async function ProjectLayout(props: {
|
||||
children: React.ReactNode;
|
||||
breadcrumbNav: React.ReactNode;
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -46,21 +49,32 @@ export default async function TeamLayout(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
|
||||
?.onWaitlist;
|
||||
|
||||
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}
|
||||
showNebula={!!isOnNebulaWaitList}
|
||||
>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
<div className="flex grow flex-col">{props.children}</div>
|
||||
</div>
|
||||
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ export type NebulaAnalyticsDataItem = {
|
||||
export const fetchNebulaAnalytics = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
from: string;
|
||||
to: string;
|
||||
@@ -20,6 +21,7 @@ export const fetchNebulaAnalytics = unstable_cache(
|
||||
const analyticsEndpoint = process.env.ANALYTICS_SERVICE_URL as string;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/nebula/usage`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
url.searchParams.set("projectId", params.projectId);
|
||||
url.searchParams.set("from", params.from);
|
||||
url.searchParams.set("to", params.to);
|
||||
url.searchParams.set("period", params.period);
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export function NebulaAnalyticsFilter() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="no-scrollbar flex items-center gap-3 overflow-auto">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
popoverAlign="end"
|
||||
|
||||
+6
-2
@@ -19,11 +19,12 @@ export function NebulaAnalyticsPage(props: {
|
||||
};
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<header className="border-b">
|
||||
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col items-start gap-4 pt-2 pb-6 md:flex-row md:items-center md:justify-between lg:pt-4 lg:pb-10">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
@@ -44,7 +45,7 @@ export function NebulaAnalyticsPage(props: {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container pt-8 pb-20">
|
||||
<div className="pt-8">
|
||||
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
|
||||
<NebulaAnalyticsFilter />
|
||||
@@ -56,6 +57,7 @@ export function NebulaAnalyticsPage(props: {
|
||||
<NebulaAnalyticDashboard
|
||||
searchParams={props.searchParams}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
@@ -67,6 +69,7 @@ export function NebulaAnalyticsPage(props: {
|
||||
async function NebulaAnalyticDashboard(props: {
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
projectId: string;
|
||||
searchParams: {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
@@ -80,6 +83,7 @@ async function NebulaAnalyticDashboard(props: {
|
||||
const res = await fetchNebulaAnalytics({
|
||||
teamId: props.teamId,
|
||||
authToken: props.authToken,
|
||||
projectId: props.projectId,
|
||||
from: normalizeTimeISOString(range.from),
|
||||
to: normalizeTimeISOString(range.to),
|
||||
// internally renamed
|
||||
|
||||
+3
-3
@@ -13,8 +13,8 @@ export function NebulaWaitListPageUI(props: {
|
||||
<div className={cn("flex grow flex-col", props.className)}>
|
||||
{/* Header */}
|
||||
{!props.hideHeader && (
|
||||
<div className="border-b py-10">
|
||||
<div className="container flex items-center justify-between">
|
||||
<div className="border-b pt-4 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight"> Nebula </h1>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/contact-us" target="_blank">
|
||||
@@ -25,7 +25,7 @@ export function NebulaWaitListPageUI(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container flex grow flex-col overflow-hidden py-32">
|
||||
<div className="flex grow flex-col overflow-hidden py-32">
|
||||
<CenteredCard
|
||||
title="You're on the waitlist"
|
||||
description="You should receive access to Nebula soon!"
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ function UnexpectedErrorPage(props: {
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="container flex grow flex-col py-10">
|
||||
<div className="flex grow flex-col py-10">
|
||||
<div className="flex min-h-[300px] grow flex-col items-center justify-center rounded-lg border">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-destructive-text">{props.message}</span>
|
||||
|
||||
+20
-7
@@ -1,12 +1,15 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../login/loginRedirect";
|
||||
import { NebulaAnalyticsPage } from "../../../[project_slug]/nebula/components/analytics/nebula-analytics-page";
|
||||
import { NebulaWaitListPage } from "../../../[project_slug]/nebula/components/nebula-waitlist-page";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
import { NebulaAnalyticsPage } from "./components/analytics/nebula-analytics-page";
|
||||
import { NebulaWaitListPage } from "./components/nebula-waitlist-page";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
from: string | undefined | string[];
|
||||
@@ -19,13 +22,22 @@ export default async function Page(props: {
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const [authToken, team] = await Promise.all([
|
||||
const [authToken, team, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!team || !authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/nebula`);
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/nebula`);
|
||||
}
|
||||
|
||||
const hasNebulaAccess = team.enabledScopes.includes("nebula");
|
||||
@@ -36,6 +48,7 @@ export default async function Page(props: {
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
projectId={project.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -5,18 +5,16 @@ export const Stat: React.FC<{
|
||||
formatter?: (value: number) => string;
|
||||
}> = ({ label, value, formatter, icon: Icon }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
|
||||
<div>
|
||||
<dd className="font-semibold text-3xl tracking-tight lg:text-5xl">
|
||||
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
{value !== undefined && formatter
|
||||
? formatter(value)
|
||||
: value?.toLocaleString()}
|
||||
</dd>
|
||||
<dt className="font-medium text-muted-foreground text-sm tracking-tight lg:text-lg">
|
||||
{label}
|
||||
</dt>
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
</div>
|
||||
<Icon className="hidden size-12 text-muted-foreground opacity-50 lg:block" />
|
||||
<Icon className="hidden size-8 text-muted-foreground opacity-50 lg:block" />
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
+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."}
|
||||
</>
|
||||
|
||||
+2
-2
@@ -115,10 +115,10 @@ export function InAppWalletUsersChartCardUI(props: {
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground">{props.description}</p>
|
||||
<p className="mb-3 text-muted-foreground text-sm">{props.description}</p>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
|
||||
@@ -27,7 +27,7 @@ export function InAppWalletsSummary(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Stat
|
||||
label="Total Users"
|
||||
value={allTimeStats?.uniqueWalletsConnected || 0}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,81 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Flex,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Text, TrackedLink } from "tw-components";
|
||||
|
||||
export const SmartWalletsBillingAlert = ({
|
||||
dismissible = false,
|
||||
}: {
|
||||
dismissible?: boolean;
|
||||
}) => {
|
||||
const [dismissed, setDismissed] = useLocalStorage(
|
||||
"dismissed-smart-wallets-billing-alert",
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
if (dismissible && dismissed) {
|
||||
return true;
|
||||
}
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
|
||||
export const SmartWalletsBillingAlert = () => {
|
||||
return (
|
||||
<Alert
|
||||
status="warning"
|
||||
borderRadius="md"
|
||||
as={Flex}
|
||||
alignItems="start"
|
||||
justifyContent="space-between"
|
||||
mb={4}
|
||||
variant="left-accent"
|
||||
bg="backgroundCardHighlight"
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<AlertIcon boxSize={4} mt={1} ml={1} />
|
||||
<Flex flexDir="column" gap={1} pl={1}>
|
||||
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Text as="span" pr={1}>
|
||||
You've enabled Account Abstraction for one of your API keys.
|
||||
<br />
|
||||
To enable AA on mainnet chains,
|
||||
</Text>{" "}
|
||||
<TrackedLink
|
||||
href="/team/~/~/settings/billing"
|
||||
category="api_keys"
|
||||
label="smart_wallets_missing_billing"
|
||||
fontWeight="medium"
|
||||
color="blue.500"
|
||||
>
|
||||
<Text as="span" color="blue.500">
|
||||
subscribe to a billing plan.
|
||||
</Text>
|
||||
</TrackedLink>
|
||||
</AlertDescription>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
aria-label="Close notice"
|
||||
icon={<XIcon className="size-4" />}
|
||||
colorScheme="blackAlpha"
|
||||
color="white"
|
||||
variant="ghost"
|
||||
opacity={0.6}
|
||||
_hover={{ opacity: 1 }}
|
||||
onClick={() => setDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
|
||||
<AlertDescription>
|
||||
To enable AA on mainnet chains,{" "}
|
||||
<TrackedUnderlineLink
|
||||
href="/team/~/~/settings/billing"
|
||||
category="api_keys"
|
||||
label="smart_wallets_missing_billing"
|
||||
>
|
||||
subscribe to a billing plan.
|
||||
</TrackedUnderlineLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"];
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ export function AccountAbstractionSummary(props: {
|
||||
aggregateUserOpUsageQuery?: UserOpStats;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Stat
|
||||
label="Mainnet Sponsored Transactions"
|
||||
value={props.aggregateUserOpUsageQuery?.successful || 0}
|
||||
|
||||
+5
-17
@@ -22,7 +22,7 @@ import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../lib/format-utils";
|
||||
|
||||
@@ -112,11 +112,11 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Sponsored Transactions
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Total number of sponsored transactions.
|
||||
Total number of sponsored transactions
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
@@ -139,7 +139,7 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
||||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
@@ -164,18 +164,6 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) => {
|
||||
return Object.entries(data)
|
||||
.filter(([key]) => key !== "time")
|
||||
.map(([, value]) => value)
|
||||
.reduce((acc, current) => Number(acc) + Number(current), 0);
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatTickerNumber(value)}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
@@ -208,7 +196,7 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
export function EmptyAccountAbstractionChartContent() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Send your first sponsored transaction
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
|
||||
+4
-19
@@ -21,7 +21,7 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import { toUSD } from "../../../utils/number";
|
||||
@@ -111,11 +111,11 @@ export function TotalSponsoredChartCard(props: {
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Gas Sponsored
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The total amount of gas sponsored in USD.
|
||||
The total amount of gas sponsored in USD
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
@@ -139,10 +139,7 @@ export function TotalSponsoredChartCard(props: {
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[250px] w-full md:h-[350px]"
|
||||
>
|
||||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
@@ -201,18 +198,6 @@ export function TotalSponsoredChartCard(props: {
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) => {
|
||||
return Object.entries(data)
|
||||
.filter(([key]) => key !== "time")
|
||||
.map(([, value]) => value)
|
||||
.reduce((acc, current) => Number(acc) + Number(current), 0);
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => toUSD(value)}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { SponsoredTransactionsTable } from "../../../app/team/[team_slug]/(team)/~/usage/overview/components/SponsoredTransactionsTable";
|
||||
import { searchParams } from "../../../app/team/[team_slug]/[project_slug]/connect/account-abstraction/search-params";
|
||||
import { AccountAbstractionSummary } from "./AccountAbstractionSummary";
|
||||
import { SponsoredTransactionsChartCard } from "./SponsoredTransactionsChartCard";
|
||||
import { TotalSponsoredChartCard } from "./TotalSponsoredChartCard";
|
||||
|
||||
@@ -21,6 +22,7 @@ export function AccountAbstractionAnalytics(props: {
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
projectId: string;
|
||||
aggregateUserOpStats: UserOpStats;
|
||||
}) {
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
|
||||
@@ -65,6 +67,12 @@ export function AccountAbstractionAnalytics(props: {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AccountAbstractionSummary
|
||||
aggregateUserOpUsageQuery={props.aggregateUserOpStats}
|
||||
/>
|
||||
|
||||
<div className="h-10" />
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useActiveAccount,
|
||||
@@ -32,48 +32,49 @@ export const PosthogIdentifierClient: React.FC<{
|
||||
client,
|
||||
});
|
||||
const wallet = useActiveWallet();
|
||||
const posthog = usePostHog();
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (wallet) {
|
||||
if (wallet && posthog && posthog.__loaded) {
|
||||
const connector = walletIdToPHName[wallet.id] || wallet.id;
|
||||
posthog.register({ connector });
|
||||
posthog.capture("wallet_connected", { connector });
|
||||
}
|
||||
}, [wallet]);
|
||||
}, [wallet, posthog]);
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (accountAddress) {
|
||||
if (accountAddress && posthog && posthog.__loaded) {
|
||||
posthog.identify(accountAddress);
|
||||
}
|
||||
}, [accountAddress]);
|
||||
}, [accountAddress, posthog]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
if (accountId && posthog && posthog.__loaded) {
|
||||
posthog.identify(accountId);
|
||||
}
|
||||
}, [accountId]);
|
||||
}, [accountId, posthog]);
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (chain?.id) {
|
||||
if (chain?.id && posthog && posthog.__loaded) {
|
||||
posthog.unregister("network");
|
||||
posthog.register({ chain_id: chain?.id, ecosystem: "evm" });
|
||||
}
|
||||
}, [chain?.id]);
|
||||
}, [chain?.id, posthog]);
|
||||
|
||||
// legitimate use-case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (balance?.data?.displayValue) {
|
||||
if (balance?.data?.displayValue && posthog && posthog.__loaded) {
|
||||
posthog.register({ balance: balance.data.displayValue });
|
||||
}
|
||||
}, [balance]);
|
||||
}, [balance, posthog]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user