Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d39fb6ee3 | ||
|
|
1e092a7bff | ||
|
|
80ee3d6670 | ||
|
|
a7fd4e51a3 | ||
|
|
61583cf6f3 | ||
|
|
cf02af06a4 | ||
|
|
6001f2487e | ||
|
|
3f21445369 | ||
|
|
9e8fd89ec0 | ||
|
|
95a87530e3 | ||
|
|
5faaf1be48 | ||
|
|
4847ada819 | ||
|
|
08eea95f9b | ||
|
|
737155ba21 | ||
|
|
73ae5817f2 | ||
|
|
6a0704a19b | ||
|
|
2ef8f2a371 | ||
|
|
d6373b2c67 | ||
|
|
7eaf86a607 | ||
|
|
5788cbdbad | ||
|
|
d9a39cde33 | ||
|
|
62c8e7c0c4 | ||
|
|
f618c5308c | ||
|
|
04943d730b | ||
|
|
2606da6d87 | ||
|
|
9cf137660a | ||
|
|
58ab8c2ee7 | ||
|
|
447a519ddc | ||
|
|
a9b7633d05 | ||
|
|
38e089a5f3 | ||
|
|
ef2f86cff0 | ||
|
|
8fdef82147 | ||
|
|
cbb15692a7 | ||
|
|
1d23e5cd9c | ||
|
|
a17d893811 | ||
|
|
0dcde077f5 | ||
|
|
05a041fac3 | ||
|
|
3d140eed97 | ||
|
|
a5f005fff0 | ||
|
|
fb70cab6d4 | ||
|
|
cd9ea69c13 | ||
|
|
dc892bd136 | ||
|
|
3d7c009d72 | ||
|
|
6dbed0d4fc | ||
|
|
adee1903d5 | ||
|
|
a1d785b8ed |
@@ -60,7 +60,7 @@ jobs:
|
||||
uses: ./.github/composite-actions/install
|
||||
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@c016c38f26f2c4a6eb3662679143614a254263fd # v2.3.0
|
||||
uses: biomejs/setup-biome@f382a98e582959e6aaac8e5f8b17b31749018780 # v2.5.0
|
||||
with:
|
||||
version: latest
|
||||
|
||||
|
||||
@@ -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,13 @@ 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",
|
||||
// -- brand kit --
|
||||
"/brand-kit",
|
||||
];
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"eslint-plugin-storybook": "0.11.6",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"knip": "5.46.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -270,9 +270,10 @@ type ActiveStatus = {
|
||||
ecosystemWallet: boolean;
|
||||
};
|
||||
|
||||
export async function isProjectActive(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<ActiveStatus> {
|
||||
export async function isProjectActive(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}): Promise<ActiveStatus> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/active-usage?${searchParams.toString()}`,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
|
||||
import { CustomSidebar, type SidebarLink } from "./Sidebar";
|
||||
import { AppFooter } from "./app-footer";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
@@ -54,7 +55,6 @@ export function FullWidthSidebarLayout(props: {
|
||||
footerSidebarLinks?: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
@@ -65,7 +65,7 @@ export function FullWidthSidebarLayout(props: {
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar collapsible="icon" side="left">
|
||||
<Sidebar collapsible="icon" side="left" className="pt-2">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
@@ -95,10 +95,10 @@ export function FullWidthSidebarLayout(props: {
|
||||
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">
|
||||
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
{props.footer}
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ 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">
|
||||
<div className="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>}
|
||||
|
||||
@@ -12,17 +12,23 @@ import Link from "next/link";
|
||||
|
||||
type AppFooterProps = {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function AppFooter(props: AppFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"w-full border-border border-t bg-card py-6 md:py-8",
|
||||
"w-full border-border border-t py-6 md:py-8",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="container flex flex-col gap-4 md:gap-6">
|
||||
<div
|
||||
className={cn(
|
||||
"container flex flex-col gap-4 md:gap-6",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
|
||||
@@ -32,6 +32,7 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
config: TConfig;
|
||||
data: Array<Record<keyof TConfig, number> & { time: number | string | Date }>;
|
||||
@@ -41,7 +42,9 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
chartClassName?: string;
|
||||
isPending: boolean;
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
emptyChartState?: React.ReactElement;
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -65,12 +68,14 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
{props.customHeader && props.customHeader}
|
||||
|
||||
<CardContent className={cn(!props.header && "pt-6")}>
|
||||
<ChartContainer config={props.config} className={props.chartClassName}>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState />
|
||||
<EmptyChartState>{props.emptyChartState}</EmptyChartState>
|
||||
) : (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
@@ -88,6 +93,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props.hideLabel !== undefined ? props.hideLabel : true
|
||||
}
|
||||
labelFormatter={props.toolTipLabelFormatter}
|
||||
valueFormatter={props.toolTipValueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -96,25 +102,15 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
content={<ChartLegendContent className="pt-5" />}
|
||||
/>
|
||||
)}
|
||||
{configKeys.map((key, idx) => (
|
||||
{configKeys.map((key) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
// if stacked then they should all be the same stackId
|
||||
// if grouped then they should all be unique stackId (so the key works great)
|
||||
stackId={variant === "stacked" ? "a" : key}
|
||||
fill={`var(--color-${key})`}
|
||||
// if stacked then we need to figure out the radius based on the index in the array
|
||||
// if grouped then we can just use the same radius for all
|
||||
radius={
|
||||
variant === "stacked"
|
||||
? idx === 0
|
||||
? [0, 0, 4, 4]
|
||||
: idx === configKeys.length - 1
|
||||
? [4, 4, 0, 0]
|
||||
: [0, 0, 0, 0]
|
||||
: [4, 4, 4, 4]
|
||||
}
|
||||
fill={props.config[key]?.color}
|
||||
radius={[4, 4, 4, 4]}
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function TabLinks(props: {
|
||||
className?: string;
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
}) {
|
||||
const { containerRef, lineRef, activeTabRef } =
|
||||
useUnderline<HTMLAnchorElement>();
|
||||
@@ -30,7 +31,7 @@ export function TabLinks(props: {
|
||||
<div className="absolute right-0 bottom-0 left-0 h-[1px] bg-border" />
|
||||
|
||||
<ScrollShadow
|
||||
scrollableClassName="pb-[8px] relative"
|
||||
scrollableClassName={cn("pb-[8px] relative", props.scrollableClassName)}
|
||||
shadowColor={props.shadowColor}
|
||||
>
|
||||
<div
|
||||
@@ -159,9 +160,11 @@ function useUnderline<El extends HTMLElement>() {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const lineEl = lineRef.current;
|
||||
const rect = activeTabEl.getBoundingClientRect();
|
||||
const containerPaddingLeft =
|
||||
containerRect.left - containerRef.current.offsetLeft;
|
||||
lineEl.style.width = `${rect.width}px`;
|
||||
lineEl.style.transform = `translateX(${
|
||||
rect.left - containerRect.left
|
||||
rect.left - containerPaddingLeft
|
||||
}px)`;
|
||||
setTimeout(() => {
|
||||
lineEl.style.transition = "transform 0.3s, width 0.3s";
|
||||
@@ -205,6 +208,7 @@ export function TabPathLinks(props: {
|
||||
className?: string;
|
||||
tabContainerClassName?: string;
|
||||
shadowColor?: string;
|
||||
scrollableClassName?: string;
|
||||
}) {
|
||||
const pathname = usePathname() || "";
|
||||
const { links, ...restProps } = props;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const SearchInput: React.FC = () => {
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by token address or chain ID"
|
||||
placeholder="Search with anything!"
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
|
||||
+72
-8
@@ -7,6 +7,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Address } from "thirdweb";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "../server/routelist-card";
|
||||
@@ -34,28 +35,83 @@ async function getRoutesToRender(params: SearchParams) {
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
originTextQuery?: string;
|
||||
destinationTextQuery?: string;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originTextQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (params.query) {
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationTextQuery = params.query;
|
||||
}
|
||||
}
|
||||
// Temporary, will update this after the /routes endpoint
|
||||
filters.limit = 50_000;
|
||||
|
||||
const routes = await getRoutes(filters);
|
||||
let routes = await getRoutes({ limit: 100_000 });
|
||||
|
||||
const totalCount = routes.length;
|
||||
|
||||
if (filters.originChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.originToken.chainId === filters.originChainId,
|
||||
);
|
||||
}
|
||||
if (filters.originTokenAddress) {
|
||||
const originTokenAddress = filters.originTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.originToken.address) ===
|
||||
checksumAddress(originTokenAddress),
|
||||
);
|
||||
}
|
||||
if (filters.destinationChainId) {
|
||||
routes = routes.filter(
|
||||
(route) => route.destinationToken.chainId === filters.destinationChainId,
|
||||
);
|
||||
}
|
||||
if (filters.destinationTokenAddress) {
|
||||
const destinationTokenAddress = filters.destinationTokenAddress;
|
||||
routes = routes.filter(
|
||||
(route) =>
|
||||
checksumAddress(route.destinationToken.address) ===
|
||||
checksumAddress(destinationTokenAddress),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.originTextQuery) {
|
||||
const originTextQuery = filters.originTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.originToken.name.toLowerCase().includes(originTextQuery) ||
|
||||
route.originToken.symbol.toLowerCase().includes(originTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.destinationTextQuery) {
|
||||
const destinationTextQuery = filters.destinationTextQuery.toLowerCase();
|
||||
routes = routes.filter((route) => {
|
||||
return (
|
||||
route.destinationToken.name
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery) ||
|
||||
route.destinationToken.symbol
|
||||
.toLowerCase()
|
||||
.includes(destinationTextQuery)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
routesToRender: routes,
|
||||
totalCount,
|
||||
@@ -154,13 +210,21 @@ export async function RoutesData(props: {
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">{filteredCount}</span>{" "}
|
||||
<span className="text-accent-foreground">
|
||||
{filteredCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">{totalCount}</span>)
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">{totalCount}</span> routes.
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,43 +2,18 @@ import "server-only";
|
||||
|
||||
import { BRIDGE_URL, DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/env";
|
||||
import { getAuthToken } from "app/api/lib/getAuthToken";
|
||||
import type { Address } from "thirdweb";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
} = {}) {
|
||||
const url = new URL(`${BRIDGE_URL}/v1/routes`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originChainId) {
|
||||
url.searchParams.set("originChainId", originChainId.toString());
|
||||
}
|
||||
if (originTokenAddress) {
|
||||
url.searchParams.set("originTokenAddress", originTokenAddress);
|
||||
}
|
||||
if (destinationChainId) {
|
||||
url.searchParams.set("destinationChainId", destinationChainId.toString());
|
||||
}
|
||||
if (destinationTokenAddress) {
|
||||
url.searchParams.set("destinationTokenAddress", destinationTokenAddress);
|
||||
}
|
||||
// It's faster to filter client side, doesn't seem to be a big performance boost to paginate/filter server side
|
||||
const token = await getAuthToken();
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: token
|
||||
|
||||
@@ -14,7 +14,7 @@ export const products = [
|
||||
id: "contracts",
|
||||
icon: ContractIcon,
|
||||
description: "Pre-built contracts & deployment tools",
|
||||
link: "https://thirdweb.com/contracts",
|
||||
link: "https://thirdweb.com/explore",
|
||||
},
|
||||
{
|
||||
name: "Connect SDK",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DeployedContractsPageHeader(props: {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b">
|
||||
<ImportModal
|
||||
isOpen={importModalOpen}
|
||||
onClose={() => {
|
||||
@@ -23,9 +23,11 @@ export function DeployedContractsPageHeader(props: {
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 pb-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="container flex max-w-7xl 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>
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Contracts
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-3 [&>*]:grow">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowUpRightIcon, DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ImportModal } from "../../../../components/contract-components/import-contract/modal";
|
||||
|
||||
export function DeployViaCLIOrImportCard(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 lg:p-6">
|
||||
<ImportModal
|
||||
isOpen={importModalOpen}
|
||||
onClose={() => {
|
||||
setImportModalOpen(false);
|
||||
}}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
|
||||
<h2 className="mb-0.5 font-semibold text-lg">
|
||||
Already have a smart contract?
|
||||
</h2>
|
||||
<p className="max-w-2xl text-muted-foreground text-sm lg:text-base">
|
||||
Import an already deployed contract or deploy a contract from source
|
||||
code to easily manage permissions, upload assets, and interact with
|
||||
contract functions
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3 lg:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background lg:px-10"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/contracts/deploy/overview"
|
||||
target="_blank"
|
||||
>
|
||||
Deploy via CLI
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background"
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Import Contract
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ClientOnly } from "components/ClientOnly/ClientOnly";
|
||||
import { Suspense } from "react";
|
||||
import { ContractTable } from "../../../../components/contract-components/tables/contract-table";
|
||||
import { DeployedContractsPageHeader } from "../DeployedContractsPageHeader";
|
||||
import { GetStartedWithContractsDeploy } from "./GetStartedWithContractsDeploy";
|
||||
import { DeployViaCLIOrImportCard } from "./DeployViaCLIOrImportCard";
|
||||
import { getSortedDeployedContracts } from "./getSortedDeployedContracts";
|
||||
|
||||
export function DeployedContractsPage(props: {
|
||||
@@ -17,10 +17,16 @@ export function DeployedContractsPage(props: {
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="h-6" />
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DeployedContractsPageAsync {...props} />
|
||||
</Suspense>
|
||||
<div className="h-8" />
|
||||
<DeployViaCLIOrImportCard
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,15 +43,6 @@ async function DeployedContractsPageAsync(props: {
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
if (deployedContracts.length === 0) {
|
||||
return (
|
||||
<GetStartedWithContractsDeploy
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<Loading />}>
|
||||
<ContractTable
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { ImportModal } from "components/contract-components/import-contract/modal";
|
||||
import { StepsCard } from "components/dashboard/StepsCard";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function GetStartedWithContractsDeploy(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Build, deploy or import a contract",
|
||||
description:
|
||||
"Choose between deploying your own contract or import an existing one.",
|
||||
children: (
|
||||
<DeployOptions teamId={props.teamId} projectId={props.projectId} />
|
||||
),
|
||||
completed: false, // because we only show this component if the user does not have any contracts
|
||||
},
|
||||
],
|
||||
[props.teamId, props.projectId],
|
||||
);
|
||||
|
||||
return (
|
||||
<StepsCard
|
||||
title="Get started with deploying contracts"
|
||||
description="This guide will help you to start deploying contracts on-chain in just a few minutes."
|
||||
steps={steps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ContentItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type TabId = "explore" | "import" | "build" | "deploy";
|
||||
|
||||
const DeployOptions = (props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const router = useDashboardRouter();
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const content: Record<TabId, ContentItem> = useMemo(
|
||||
() => ({
|
||||
explore: {
|
||||
title: "Ready-to-deploy",
|
||||
description:
|
||||
"Pick from our library of ready-to-deploy contracts and deploy to any EVM chain in just 1-click.",
|
||||
href: "/explore",
|
||||
},
|
||||
import: {
|
||||
title: "Import",
|
||||
description:
|
||||
"Import an already deployed contract to build apps on top of contract using thirdweb tools.",
|
||||
onClick: () => setShowImportModal(true),
|
||||
},
|
||||
build: {
|
||||
title: "Build your own",
|
||||
description:
|
||||
"Get started with the Solidity SDK to create custom contracts specific to your use case.",
|
||||
href: "/build",
|
||||
},
|
||||
deploy: {
|
||||
title: "Deploy from source",
|
||||
description:
|
||||
"Deploy your contract by using our interactive CLI. (Supports Hardhat, Forge, Truffle, and more)",
|
||||
href: "https://portal.thirdweb.com/cli",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>("explore");
|
||||
const activeTabContent = content[activeTab];
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ImportModal
|
||||
isOpen={showImportModal}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
}}
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
|
||||
<TabButtons
|
||||
tabs={Object.entries(content).map(([key, value]) => ({
|
||||
key,
|
||||
name: value.title,
|
||||
isActive: activeTab === key,
|
||||
onClick: () => setActiveTab(key as TabId),
|
||||
}))}
|
||||
tabClassName="font-medium"
|
||||
tabContainerClassName="gap-1"
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-3 flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border bg-card p-4 hover:border-active-border"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
activeTabContent.onClick?.();
|
||||
if (activeTabContent.href) {
|
||||
router.push(activeTabContent.href);
|
||||
}
|
||||
|
||||
trackEvent({
|
||||
category: "your_contracts",
|
||||
action: "click",
|
||||
label: "deploy_options",
|
||||
type: activeTab,
|
||||
href: null,
|
||||
isExternal: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-start font-semibold text-lg">
|
||||
{activeTabContent.title}
|
||||
</h4>
|
||||
<p className="text-start text-muted-foreground text-sm">
|
||||
{activeTabContent.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import NextTopLoader from "nextjs-toploader";
|
||||
import { Suspense } from "react";
|
||||
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({
|
||||
@@ -63,8 +65,10 @@ export default function RootLayout({
|
||||
customDomain="https://pl.thirdweb.com"
|
||||
selfHosted
|
||||
/>
|
||||
<PosthogHeadSetup />
|
||||
</head>
|
||||
<PostHogProvider>
|
||||
<PHProvider>
|
||||
<PostHogPageView />
|
||||
<body
|
||||
className={cn(
|
||||
"bg-background font-sans antialiased",
|
||||
@@ -91,7 +95,7 @@ export default function RootLayout({
|
||||
showSpinner={false}
|
||||
/>
|
||||
</body>
|
||||
</PostHogProvider>
|
||||
</PHProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
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";
|
||||
@@ -39,9 +39,6 @@ export default async function TeamLayout(props: {
|
||||
})),
|
||||
);
|
||||
|
||||
const isOnNebulaWaitList = (await getTeamNebulaWaitList(team.slug))
|
||||
?.onWaitlist;
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<AnnouncementBanner />
|
||||
@@ -74,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",
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { EcosystemWalletStats } from "types/analytics";
|
||||
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
|
||||
|
||||
const meta = {
|
||||
title: "Ecosystem/Analytics/EcosystemWalletUsersChartCard",
|
||||
component: EcosystemWalletUsersChartCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="container max-w-7xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof EcosystemWalletUsersChartCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const authMethods = [
|
||||
"Email",
|
||||
"Google",
|
||||
"Apple",
|
||||
"Discord",
|
||||
"Twitter",
|
||||
"GitHub",
|
||||
"Facebook",
|
||||
"Twitch",
|
||||
"LinkedIn",
|
||||
"TikTok",
|
||||
"Coinbase",
|
||||
"MetaMask",
|
||||
];
|
||||
|
||||
function ecosystemWalletStatsStub(
|
||||
length: number,
|
||||
startDate = new Date(2024, 11, 1),
|
||||
): EcosystemWalletStats[] {
|
||||
const stats: EcosystemWalletStats[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
const formattedDate = date.toISOString().split("T")[0] || "";
|
||||
|
||||
// each day, we pick between 1 and 4 auth methods
|
||||
const authMethodsToPick = Math.floor(Math.random() * 4) + 1;
|
||||
|
||||
for (let j = 0; j < authMethodsToPick; j++) {
|
||||
const authMethod =
|
||||
authMethods[Math.floor(Math.random() * authMethods.length)];
|
||||
stats.push({
|
||||
date: formattedDate,
|
||||
authenticationMethod: authMethod || "MetaMask",
|
||||
uniqueWalletsConnected: Math.floor(Math.random() * 1000) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Empty data state
|
||||
export const EmptyData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: [],
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Loading state
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: [],
|
||||
isPending: true,
|
||||
},
|
||||
};
|
||||
|
||||
// 30 days of data
|
||||
export const ThirtyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(30),
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 60 days of data
|
||||
export const SixtyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(60),
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 120 days of data
|
||||
export const OneHundredTwentyDaysData: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(120),
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Data with lots of authentication methods to test the "Others" category
|
||||
export const ManyAuthMethods: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: (() => {
|
||||
// Generate data with 15 different auth methods to test "Others" category
|
||||
const basicData = ecosystemWalletStatsStub(30);
|
||||
|
||||
return basicData.map((item, index) => ({
|
||||
...item,
|
||||
authenticationMethod:
|
||||
authMethods[index % authMethods.length] || "MetaMask",
|
||||
}));
|
||||
})(),
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Zero values test
|
||||
export const ZeroValues: Story = {
|
||||
args: {
|
||||
ecosystemWalletStats: ecosystemWalletStatsStub(30).map((stat) => ({
|
||||
...stat,
|
||||
uniqueWalletsConnected: 0,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
+81
-135
@@ -1,26 +1,15 @@
|
||||
"use client";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { formatTickerNumber } from "lib/format-utils";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { EcosystemWalletStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../../../../../../../../lib/format-utils";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
@@ -47,7 +36,7 @@ export function EcosystemWalletUsersChartCard(props: {
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: format(new Date(stat.date), "MMM dd"),
|
||||
time: stat.date,
|
||||
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
|
||||
} as ChartData);
|
||||
} else if (chartData) {
|
||||
@@ -114,128 +103,85 @@ export function EcosystemWalletUsersChartCard(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">
|
||||
Unique Users
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The total number of active users in your ecosystem for each period.
|
||||
</p>
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Unique Users
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The total number of active users in your ecosystem for each period.
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Connect Wallets"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueAuthMethods];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Connect Wallets"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueAuthMethods];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
config={chartConfig}
|
||||
data={chartData}
|
||||
isPending={props.isPending}
|
||||
emptyChartState={<EcosystemWalletUsersEmptyChartState />}
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
variant="stacked"
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EcosystemWalletUsersEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Connect users to your app with social logins
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[250px] w-full md:h-[350px]"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
uniqueAuthMethods.every((authMethod) =>
|
||||
chartData.every((data) => data[authMethod] === 0),
|
||||
) ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Connect users to your app with social logins
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) =>
|
||||
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={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{uniqueAuthMethods.map((authMethod) => {
|
||||
return (
|
||||
<Bar
|
||||
key={authMethod}
|
||||
dataKey={authMethod}
|
||||
fill={chartConfig[authMethod]?.color}
|
||||
radius={4}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,4 +1,4 @@
|
||||
import { Stat } from "components/analytics/stat";
|
||||
import { StatCard } from "components/analytics/stat";
|
||||
import { ActivityIcon, UserIcon } from "lucide-react";
|
||||
import type { EcosystemWalletStats } from "types/analytics";
|
||||
|
||||
@@ -28,15 +28,17 @@ export function EcosystemWalletsSummary(props: {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<Stat
|
||||
<StatCard
|
||||
label="Total Users"
|
||||
value={allTimeStats?.uniqueWalletsConnected || 0}
|
||||
icon={ActivityIcon}
|
||||
isPending={false}
|
||||
/>
|
||||
<Stat
|
||||
<StatCard
|
||||
label="Monthly Active Users"
|
||||
value={monthlyStats?.uniqueWalletsConnected || 0}
|
||||
icon={UserIcon}
|
||||
isPending={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+11
-3
@@ -1,4 +1,3 @@
|
||||
import type { Range } from "components/analytics/date-range-selector";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTeamBySlug } from "../../../../../../../../../@/api/team";
|
||||
import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken";
|
||||
@@ -12,7 +11,8 @@ export default async function Page(props: {
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
interval?: "day" | "week";
|
||||
range?: Range;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
@@ -45,7 +45,15 @@ export default async function Page(props: {
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
teamId={team.id}
|
||||
interval={searchParams.interval || "week"}
|
||||
range={searchParams.range}
|
||||
range={
|
||||
searchParams.from && searchParams.to
|
||||
? {
|
||||
from: new Date(searchParams.from),
|
||||
to: new Date(searchParams.to),
|
||||
type: "custom",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const [authToken, team] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
if (!team || !authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/~/nebula`);
|
||||
}
|
||||
|
||||
const hasNebulaAccess = team.enabledScopes.includes("nebula");
|
||||
|
||||
if (hasNebulaAccess) {
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NebulaWaitListPage team={team} />;
|
||||
}
|
||||
+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 && (
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
|
||||
export function ClientIDSection(props: {
|
||||
clientId: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3>Client ID</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies your application
|
||||
</p>
|
||||
|
||||
<CopyTextButton
|
||||
textToCopy={props.clientId}
|
||||
className="!h-auto w-full max-w-[400px] justify-between truncate bg-background px-3 py-3 font-mono"
|
||||
textToShow={props.clientId}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Client ID"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -29,7 +29,7 @@ export function IntegrateAPIKeyCodeTabs(props: {
|
||||
isActive: tab === key,
|
||||
}))}
|
||||
/>
|
||||
<div className="h-3" />
|
||||
<div className="h-2" />
|
||||
{props.tabs[tab]}
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-16
@@ -1,5 +1,4 @@
|
||||
import type { Project } from "@/api/projects";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
@@ -19,6 +18,7 @@ import { TypeScriptIcon } from "../../../../../../components/icons/brand-icons/T
|
||||
import { UnityIcon } from "../../../../../../components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "../../../../../../components/icons/brand-icons/UnrealIcon";
|
||||
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
|
||||
import { ClientIDSection } from "./ClientIDSection";
|
||||
import { IntegrateAPIKeyCodeTabs } from "./IntegrateAPIKeyCodeTabs";
|
||||
import { SecretKeySection } from "./SecretKeySection";
|
||||
|
||||
@@ -59,21 +59,7 @@ function IntegrateAPIKeySection({
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-6 ">
|
||||
<div>
|
||||
<h3>Client ID</h3>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
Identifies your application.
|
||||
</p>
|
||||
|
||||
<CopyTextButton
|
||||
textToCopy={clientId}
|
||||
className="!h-auto w-full max-w-[350px] justify-between truncate bg-background px-3 py-3 font-mono"
|
||||
textToShow={clientId}
|
||||
copyIconPosition="right"
|
||||
tooltip="Copy Client ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClientIDSection clientId={clientId} />
|
||||
{secretKeyMasked && (
|
||||
<SecretKeySection
|
||||
secretKeyMasked={secretKeyMasked}
|
||||
|
||||
+13
-3
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import { FullWidthSidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import { AppFooter } from "@/components/blocks/app-footer";
|
||||
import {
|
||||
BookTextIcon,
|
||||
BoxIcon,
|
||||
@@ -12,12 +11,14 @@ import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/
|
||||
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 } = props;
|
||||
const { layoutPath, children, showNebula } = props;
|
||||
|
||||
const tracking = (label: string) => ({
|
||||
category: "project-sidebar",
|
||||
@@ -27,7 +28,6 @@ export function ProjectSidebarLayout(props: {
|
||||
|
||||
return (
|
||||
<FullWidthSidebarLayout
|
||||
footer={<AppFooter />}
|
||||
contentSidebarLinks={[
|
||||
{
|
||||
href: layoutPath,
|
||||
@@ -60,6 +60,16 @@ export function ProjectSidebarLayout(props: {
|
||||
icon: ContractIcon,
|
||||
tracking: tracking("contracts"),
|
||||
},
|
||||
...(showNebula
|
||||
? [
|
||||
{
|
||||
href: `${layoutPath}/nebula`,
|
||||
label: "Nebula",
|
||||
icon: NebulaIcon,
|
||||
tracking: tracking("nebula"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `${layoutPath}/insight`,
|
||||
label: "Insight",
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function WaitingForIntegrationCard(props: {
|
||||
title: string;
|
||||
codeTabs: {
|
||||
code: React.ReactNode;
|
||||
label: string;
|
||||
}[];
|
||||
ctas: {
|
||||
label: string;
|
||||
href: string;
|
||||
trackingLabel: string;
|
||||
category: string;
|
||||
}[];
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [selectedTab, setSelectedTab] = useState(props.codeTabs[0]?.label);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<h2 className="font-semibold text-xl tracking-tight">{props.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-6 lg:p-6">
|
||||
{props.children}
|
||||
{/* Code */}
|
||||
<div>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={props.codeTabs.map((tab) => ({
|
||||
name: tab.label,
|
||||
onClick: () => setSelectedTab(tab.label),
|
||||
isActive: tab.label === selectedTab,
|
||||
}))}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
{props.codeTabs.find((tab) => tab.label === selectedTab)?.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t p-4 lg:flex-row lg:items-center lg:justify-between lg:p-6">
|
||||
<div className="flex gap-3">
|
||||
{props.ctas.map((cta) => (
|
||||
<Button asChild key={cta.label} variant="outline" size="sm">
|
||||
<TrackedLinkTW
|
||||
href={cta.href}
|
||||
key={cta.label}
|
||||
target="_blank"
|
||||
className="gap-2"
|
||||
category={cta.category}
|
||||
label={cta.trackingLabel}
|
||||
>
|
||||
{cta.label}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</TrackedLinkTW>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
|
||||
<span className="!pointer-events-auto relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
Waiting for integration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
|
||||
type FooterSectionProps = {
|
||||
title: string;
|
||||
links: {
|
||||
href: string;
|
||||
label: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type FooterCardProps = {
|
||||
left: FooterSectionProps;
|
||||
center: FooterSectionProps;
|
||||
right: FooterSectionProps;
|
||||
trackingCategory: string;
|
||||
};
|
||||
|
||||
export function FooterLinksSection(props: FooterCardProps) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 xl:gap-0 xl:divide-x xl:divide-border">
|
||||
<div className="xl:pr-6">
|
||||
<FooterSection
|
||||
{...props.left}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:px-6">
|
||||
<FooterSection
|
||||
{...props.center}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:pl-6">
|
||||
<FooterSection
|
||||
{...props.right}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterSection(
|
||||
props: FooterSectionProps & { trackingCategory: string },
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2.5 font-medium">{props.title}</h3>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{props.links.map((link) => (
|
||||
<FooterLink
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
trackingCategory={props.trackingCategory}
|
||||
label={link.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({
|
||||
href,
|
||||
label,
|
||||
trackingCategory,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
return (
|
||||
<TrackedLinkTW
|
||||
href={href}
|
||||
category={trackingCategory}
|
||||
label={label.replace(/ /g, "-").toLowerCase()}
|
||||
target="_blank"
|
||||
className="flex items-start gap-2 text-balance text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
<LinkIcon className="mt-1 size-3 shrink-0 opacity-70" />
|
||||
{label}
|
||||
</TrackedLinkTW>
|
||||
);
|
||||
}
|
||||
+72
-104
@@ -1,108 +1,76 @@
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { FooterLinksSection } from "../../components/footer/FooterLinksSection";
|
||||
|
||||
type LinkInfo = { label: string; href: string; trackingLabel: string };
|
||||
|
||||
export function AAFooterSection(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
export function AAFooter() {
|
||||
return (
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<FooterCard
|
||||
title="Docs"
|
||||
links={docLinks}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
|
||||
<FooterCard
|
||||
title="Guides"
|
||||
links={guideLinks}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
|
||||
<FooterCard
|
||||
title="Templates"
|
||||
links={templateLinks}
|
||||
trackingCategory={props.trackingCategory}
|
||||
/>
|
||||
</section>
|
||||
<FooterLinksSection
|
||||
trackingCategory="account-abstraction"
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
href: "https://portal.thirdweb.com/connect/account-abstraction/overview",
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started",
|
||||
label: "Typescript",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/account-abstraction/get-started",
|
||||
label: "React",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/account-abstraction/get-started",
|
||||
label: "React Native",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction",
|
||||
label: ".NET",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/unity/v5/wallets/account-abstraction",
|
||||
label: "Unity",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet",
|
||||
label: "Unreal Engine",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
label: "Web3 onboarding with in-app wallets and AA",
|
||||
href: "https://www.youtube.com/watch?v=HP8wSH7gnQA",
|
||||
},
|
||||
{
|
||||
label: "Multi-chain experience with account abstraction",
|
||||
href: "https://www.youtube.com/watch?v=2wUtq-drxAk",
|
||||
},
|
||||
{
|
||||
label: "Add AA to a web3 unity game",
|
||||
href: "https://www.youtube.com/watch?v=04G4DLhqwgI",
|
||||
},
|
||||
],
|
||||
}}
|
||||
center={{
|
||||
title: "Demos",
|
||||
links: [
|
||||
{
|
||||
label: "Connect Smart Accounts",
|
||||
href: "https://playground.thirdweb.com/connect/account-abstraction/connect",
|
||||
},
|
||||
{
|
||||
label: "Sponsor Gas Fees",
|
||||
href: "https://playground.thirdweb.com/connect/account-abstraction/sponsor",
|
||||
},
|
||||
{
|
||||
label: "Native Account Abstraction",
|
||||
href: "https://playground.thirdweb.com/connect/account-abstraction/native-aa",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterCard(props: {
|
||||
title: string;
|
||||
links: LinkInfo[];
|
||||
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">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.links.map((link) => (
|
||||
<TrackedLinkTW
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
category={props.trackingCategory}
|
||||
label={link.trackingLabel}
|
||||
target="_blank"
|
||||
className="text-muted-foreground text-sm hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</TrackedLinkTW>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const docLinks: LinkInfo[] = [
|
||||
{
|
||||
label: "Full Docs",
|
||||
href: "https://portal.thirdweb.com/wallets/smart-wallet",
|
||||
trackingLabel: "full-docs",
|
||||
},
|
||||
{
|
||||
label: "Account Abstraction with React SDK",
|
||||
href: "https://portal.thirdweb.com/wallets/smart-wallet/guides/react",
|
||||
trackingLabel: "smart-wallet-react",
|
||||
},
|
||||
{
|
||||
label: "Account Abstraction with TypeScript SDK",
|
||||
href: "https://portal.thirdweb.com/wallets/smart-wallet/guides/typescript",
|
||||
trackingLabel: "smart-wallet-typescript",
|
||||
},
|
||||
];
|
||||
|
||||
const guideLinks: LinkInfo[] = [
|
||||
{
|
||||
label: "Deploy a Smart Account (ERC-4337)",
|
||||
href: "https://blog.thirdweb.com/guides/how-to-use-erc4337-smart-wallets/",
|
||||
trackingLabel: "deploy-smart-wallet",
|
||||
},
|
||||
{
|
||||
label:
|
||||
"How to Extend the Base Account Abstraction Contracts Using the Solidity SDK",
|
||||
href: "https://blog.thirdweb.com/guides/custom-smart-wallet-contracts/",
|
||||
trackingLabel: "extend-base-smart-wallet",
|
||||
},
|
||||
{
|
||||
label: "Batch Transactions with Account Abstraction",
|
||||
href: "https://blog.thirdweb.com/guides/how-to-batch-transactions-with-the-thirdweb-sdk/",
|
||||
trackingLabel: "batch-txns",
|
||||
},
|
||||
];
|
||||
|
||||
const templateLinks: LinkInfo[] = [
|
||||
{
|
||||
label: "Node.js template",
|
||||
trackingLabel: "node-template",
|
||||
href: "https://github.com/thirdweb-example/smart-wallet-script",
|
||||
},
|
||||
{
|
||||
label: "React template",
|
||||
trackingLabel: "react-template",
|
||||
href: "https://github.com/thirdweb-example/smart-wallet-react",
|
||||
},
|
||||
];
|
||||
|
||||
+125
-76
@@ -1,100 +1,149 @@
|
||||
"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 { AAFooterSection } from "./AAFooterSection";
|
||||
import { isOpChainId } from "./isOpChain";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { AAFooter } from "./AAFooterSection";
|
||||
|
||||
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">
|
||||
Account Abstraction
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Easily integrate Account abstraction (ERC-4337) compliant smart accounts
|
||||
into your apps.{" "}
|
||||
<TrackedUnderlineLink
|
||||
target="_blank"
|
||||
label="docs-wallets"
|
||||
category={TRACKING_CATEGORY}
|
||||
href="https://portal.thirdweb.com/wallets/smart-wallet"
|
||||
>
|
||||
View Documentation
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
<div className="flex grow flex-col">
|
||||
{/* top */}
|
||||
<div className="pt-4 lg:pt-6">
|
||||
{/* header */}
|
||||
<div className="container flex max-w-7xl flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Account Abstraction
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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"
|
||||
>
|
||||
Learn more about Account Abstraction
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
</div>
|
||||
{props.hasSmartWalletsWithoutBilling && <SmartWalletsBillingAlert />}
|
||||
<GasCreditAlert
|
||||
teamSlug={props.teamSlug}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{/* Nav */}
|
||||
<TabPathLinks
|
||||
scrollableClassName="container max-w-7xl"
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: `${smartWalletsLayoutSlug}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Account Factories",
|
||||
path: `${smartWalletsLayoutSlug}/factories`,
|
||||
},
|
||||
{
|
||||
name: "Sponsorship Rules",
|
||||
path: `${smartWalletsLayoutSlug}/settings`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
<div className="container flex max-w-7xl grow flex-col gap-6">
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
<div className="h-20" />
|
||||
|
||||
<div>
|
||||
<AccountAbstractionSummary
|
||||
aggregateUserOpUsageQuery={props.userOpStats}
|
||||
/>
|
||||
|
||||
<div className="h-12" />
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: `${smartWalletsLayoutSlug}`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Account Factories",
|
||||
path: `${smartWalletsLayoutSlug}/factories`,
|
||||
},
|
||||
{
|
||||
name: "Sponsorship Rules",
|
||||
path: `${smartWalletsLayoutSlug}/settings`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{props.children}
|
||||
{/* footer */}
|
||||
<div className="border-border border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<AAFooter />
|
||||
</div>
|
||||
</div>
|
||||
</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 className="h-14" />
|
||||
<AAFooterSection trackingCategory={TRACKING_CATEGORY} />
|
||||
</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>
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
+25
-8
@@ -1,5 +1,6 @@
|
||||
import { getUserOpUsage } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import {
|
||||
type Range,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
import { AccountAbstractionAnalytics } from "components/smart-wallets/AccountAbstractionAnalytics";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { AccountAbstractionSummary } from "../../../../../../components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
|
||||
import { searchParamLoader } from "./search-params";
|
||||
|
||||
@@ -31,7 +33,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}`);
|
||||
@@ -61,12 +70,20 @@ export default async function Page(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<AccountAbstractionAnalytics
|
||||
userOpStats={userOpStats}
|
||||
client={getThirdwebClient(authToken)}
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
<div>
|
||||
<AccountAbstractionSummary
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
/>
|
||||
|
||||
<div className="h-10" />
|
||||
<AccountAbstractionAnalytics
|
||||
userOpStats={userOpStats}
|
||||
client={getThirdwebClient(authToken)}
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+75
-171
@@ -1,176 +1,80 @@
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { SupportedPlatformLink } from "../../../../../../../components/wallets/SupportedPlatformLink";
|
||||
import { FooterLinksSection } from "../../../components/footer/FooterLinksSection";
|
||||
|
||||
export function InAppWalletFooterSection(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
export function InAppWalletsFooter() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<ViewDocs trackingCategory={props.trackingCategory} />
|
||||
<Templates trackingCategory={props.trackingCategory} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewDocs(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
const TRACKING_CATEGORY = props.trackingCategory;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">View Docs</h3>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SupportedPlatformLink
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
platform="React"
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
|
||||
/>
|
||||
|
||||
<SupportedPlatformLink
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
platform="Unity"
|
||||
href="https://portal.thirdweb.com/unity/wallets/providers/embedded-wallet"
|
||||
/>
|
||||
<SupportedPlatformLink
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
platform="React Native"
|
||||
href="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
/>
|
||||
<SupportedPlatformLink
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
platform="TypeScript"
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Relevant Guides</h3>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<GuideLink
|
||||
href="https://blog.thirdweb.com/what-are-embedded-wallets/"
|
||||
label="what-is-an-embedded-wallet"
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
>
|
||||
What is an in-app wallet?
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/get-started"
|
||||
label="sdks-get-started"
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
>
|
||||
Get started with In-App Wallets
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/how-to/connect-users"
|
||||
label="how-to-connect-your-users"
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
>
|
||||
Using In-App Wallets with Connect
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/how-to/build-your-own-ui"
|
||||
label="how-to-build-your-own-ui"
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
>
|
||||
How to Build Your Own UI
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/custom-auth/custom-auth-server"
|
||||
label="how-to-custom-auth-server"
|
||||
trackingCategory={TRACKING_CATEGORY}
|
||||
>
|
||||
Create a custom auth server
|
||||
</GuideLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideLink(props: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
return (
|
||||
<TrackedLinkTW
|
||||
category={props.trackingCategory}
|
||||
label="guide"
|
||||
trackingProps={{
|
||||
guide: props.label,
|
||||
<FooterLinksSection
|
||||
trackingCategory="in-app-wallets"
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
href: "https://portal.thirdweb.com/connect/wallet/overview",
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/typescript/v5/in-app-wallet",
|
||||
label: "Typescript",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/in-app-wallet/get-started",
|
||||
label: "React",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/in-app-wallet/get-started",
|
||||
label: "React Native",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/dotnet/wallets/providers/in-app-wallet",
|
||||
label: ".NET",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet",
|
||||
label: "Unity",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/unreal-engine/blueprints/in-app-wallet",
|
||||
label: "Unreal Engine",
|
||||
},
|
||||
],
|
||||
}}
|
||||
href={props.href}
|
||||
className="!text-muted-foreground hover:!text-foreground text-sm"
|
||||
target="_blank"
|
||||
>
|
||||
{props.children}
|
||||
</TrackedLinkTW>
|
||||
);
|
||||
}
|
||||
|
||||
function Templates(props: {
|
||||
trackingCategory: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">Relevant Templates</h3>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<GuideLink
|
||||
href="https://github.com/thirdweb-example/embedded-smart-wallet"
|
||||
label="embedded-smart-wallet"
|
||||
trackingCategory={props.trackingCategory}
|
||||
>
|
||||
In-App Wallet + Account Abstraction Starter Kit
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://github.com/thirdweb-example/catattacknft"
|
||||
label="embedded-cat-attack"
|
||||
trackingCategory={props.trackingCategory}
|
||||
>
|
||||
Cat Attack [Demo Web Game]
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://github.com/thirdweb-example/embedded-wallet-custom-ui"
|
||||
label="embedded-wallet-with-custom-ui-react"
|
||||
trackingCategory={props.trackingCategory}
|
||||
>
|
||||
In-App Wallet With Custom UI [React]
|
||||
</GuideLink>
|
||||
|
||||
<GuideLink
|
||||
href="https://github.com/thirdweb-example/embedded-wallet-custom-ui-react-native"
|
||||
label="embedded-wallet-with-custom-ui-react-native"
|
||||
trackingCategory={props.trackingCategory}
|
||||
>
|
||||
In-App Wallet With Custom UI [ReactNative]
|
||||
</GuideLink>
|
||||
</div>
|
||||
</div>
|
||||
center={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.youtube.com/watch?v=9s3Z-MvPv2I",
|
||||
label: "Build your own custom connect component",
|
||||
},
|
||||
{
|
||||
label: "Learn to create wallets inside a Unity game",
|
||||
href: "https://www.youtube.com/watch?v=oUouNueoVFU",
|
||||
},
|
||||
{
|
||||
href: "https://www.youtube.com/watch?v=xEp-fKba_cI",
|
||||
label: "Pre-generate wallets for users",
|
||||
},
|
||||
{
|
||||
label: "Sign-in with guest mode",
|
||||
href: "https://portal.thirdweb.com/connect/wallet/sign-in-methods/guest",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Demos",
|
||||
links: [
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/in-app-wallet/ecosystem",
|
||||
label: "Ecosystems",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/in-app-wallet/sponsor",
|
||||
label: "Signless Sponsored Transactions",
|
||||
},
|
||||
{
|
||||
href: "https://playground.thirdweb.com/connect/in-app-wallet",
|
||||
label: "Customized UI",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-37
@@ -1,43 +1,16 @@
|
||||
import { getInAppWalletUsage } from "@/api/analytics";
|
||||
import { TrackedUnderlineLink } from "@/components/ui/tracked-link";
|
||||
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
|
||||
import { subDays } from "date-fns";
|
||||
import { TRACKING_CATEGORY } from "../_constants";
|
||||
|
||||
export async function InAppWalletsHeader({
|
||||
teamId,
|
||||
projectId,
|
||||
}: { teamId: string; projectId: string }) {
|
||||
const allTimeStatsPromise = getInAppWalletUsage({
|
||||
teamId,
|
||||
projectId,
|
||||
from: new Date(2022, 0, 1),
|
||||
to: new Date(),
|
||||
period: "all",
|
||||
});
|
||||
|
||||
const monthlyStatsPromise = getInAppWalletUsage({
|
||||
teamId,
|
||||
projectId,
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
period: "month",
|
||||
});
|
||||
|
||||
const [allTimeStats, monthlyStats] = await Promise.all([
|
||||
allTimeStatsPromise,
|
||||
monthlyStatsPromise,
|
||||
]).catch(() => [null, null]);
|
||||
|
||||
export async function InAppWalletsHeader() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
|
||||
<div className="">
|
||||
<h1 className="mb-2 font-semibold text-2xl tracking-tighter lg:text-3xl">
|
||||
In-App Wallets
|
||||
</h1>
|
||||
<p className="mb-7 max-w-[700px] text-muted-foreground text-sm leading-relaxed">
|
||||
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
|
||||
A wallet infrastructure that enables apps to create, manage, and control
|
||||
their users wallets. Email login, social login, and bring-your-own auth
|
||||
supported.{" "}
|
||||
their users wallets. <br /> Email login, social login, and
|
||||
bring-your-own auth supported.{" "}
|
||||
<TrackedUnderlineLink
|
||||
target="_blank"
|
||||
href="https://portal.thirdweb.com/connect/in-app-wallet/overview"
|
||||
@@ -47,10 +20,6 @@ export async function InAppWalletsHeader({
|
||||
Learn more
|
||||
</TrackedUnderlineLink>
|
||||
</p>
|
||||
<InAppWalletsSummary
|
||||
allTimeStats={allTimeStats || []}
|
||||
monthlyStats={monthlyStats || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+41
-28
@@ -1,9 +1,8 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { InAppWalletFooterSection } from "./_components/footer";
|
||||
import { InAppWalletsFooter } from "./_components/footer";
|
||||
import { InAppWalletsHeader } from "./_components/header";
|
||||
import { TRACKING_CATEGORY } from "./_constants";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
@@ -22,34 +21,48 @@ export default async function Layout(props: {
|
||||
const { team_slug, project_slug } = params;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InAppWalletsHeader teamId={project.teamId} projectId={project.id} />
|
||||
<div className="h-8" />
|
||||
<div className="flex grow flex-col">
|
||||
{/* header */}
|
||||
<div className="pt-4 lg:pt-6">
|
||||
<div className="container max-w-7xl">
|
||||
<InAppWalletsHeader />
|
||||
</div>
|
||||
<div className="h-3" />
|
||||
<TabPathLinks
|
||||
scrollableClassName="container max-w-7xl"
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/users`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/settings`,
|
||||
exactMatch: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabPathLinks
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/users`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: `/team/${team_slug}/${project_slug}/connect/in-app-wallets/settings`,
|
||||
exactMatch: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* content */}
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
<div className="h-6" />
|
||||
{props.children}
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
|
||||
<div className="h-8" />
|
||||
{props.children}
|
||||
<div className="h-8" />
|
||||
<InAppWalletFooterSection trackingCategory={TRACKING_CATEGORY} />
|
||||
{/* footer */}
|
||||
<div className="border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<InAppWalletsFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export { GenericLoadingPage as default } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
+12
-7
@@ -1,7 +1,8 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import type { Range } from "components/analytics/date-range-selector";
|
||||
import { InAppWalletAnalytics } from "components/embedded-wallets/Analytics";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getProject } from "../../../../../../@/api/projects";
|
||||
import { InAppWalletsSummary } from "../../../../../../components/embedded-wallets/Analytics/Summary";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -39,11 +40,15 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<InAppWalletAnalytics
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
interval={interval}
|
||||
range={range as Range}
|
||||
/>
|
||||
<div>
|
||||
<InAppWalletsSummary teamId={project.teamId} projectId={project.id} />
|
||||
<div className="h-10" />
|
||||
<InAppWalletAnalytics
|
||||
teamId={project.teamId}
|
||||
projectId={project.id}
|
||||
interval={interval}
|
||||
range={range as Range}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
redirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/connect/in-app-wallets`,
|
||||
);
|
||||
}
|
||||
+96
-24
@@ -1,7 +1,8 @@
|
||||
import { getProject } from "@/api/projects";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { TabPathLinks } from "@/components/ui/tabs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UnderlineLink } from "../../../../../../@/components/ui/UnderlineLink";
|
||||
import { FooterLinksSection } from "../../components/footer/FooterLinksSection";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{
|
||||
@@ -20,13 +21,13 @@ export default async function Layout(props: {
|
||||
const payLayoutPath = `/team/${params.team_slug}/${params.project_slug}/connect/universal-bridge`;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex flex-col items-start justify-between gap-6 lg:flex-row">
|
||||
<div className="max-w-[700px]">
|
||||
<div className="flex grow flex-col">
|
||||
<div className="pt-4 lg:pt-6">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="mb-1 font-semibold text-2xl tracking-tight lg:mb-2 lg:text-3xl">
|
||||
Universal Bridge
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
<p className="max-w-3xl text-muted-foreground text-sm leading-relaxed">
|
||||
Universal Bridge allows your users to bridge, swap, and purchase
|
||||
cryptocurrencies and execute transactions with any fiat options or
|
||||
tokens via cross-chain routing.{" "}
|
||||
@@ -38,28 +39,99 @@ export default async function Layout(props: {
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<TabPathLinks
|
||||
scrollableClassName="container max-w-7xl"
|
||||
className="w-full"
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: payLayoutPath,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Webhooks",
|
||||
path: `${payLayoutPath}/webhooks`,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: `${payLayoutPath}/settings`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabPathLinks
|
||||
className="w-full"
|
||||
links={[
|
||||
{
|
||||
name: "Analytics",
|
||||
path: payLayoutPath,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Webhooks",
|
||||
path: `${payLayoutPath}/webhooks`,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
path: `${payLayoutPath}/settings`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-6" />
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{props.children}
|
||||
<div className="h-20" />
|
||||
<div className="border-border border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<UBFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UBFooter() {
|
||||
return (
|
||||
<FooterLinksSection
|
||||
trackingCategory="universal-bridge"
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
href: "https://portal.thirdweb.com/connect/pay/overview",
|
||||
label: "Overview",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/typescript/v5/convertCryptoToFiat",
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/react/v5/pay/fund-wallets",
|
||||
label: "React",
|
||||
},
|
||||
{
|
||||
href: "https://portal.thirdweb.com/dotnet/universal-bridge/quickstart",
|
||||
label: ".NET",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
label: "Implement cross-chain payments in any app",
|
||||
href: "https://www.youtube.com/watch?v=aBu175-VsNY",
|
||||
},
|
||||
],
|
||||
}}
|
||||
center={{
|
||||
title: "Demos",
|
||||
links: [
|
||||
{
|
||||
label: "UI Component",
|
||||
href: "https://playground.thirdweb.com/connect/pay",
|
||||
},
|
||||
{
|
||||
label: "Buy Crypto",
|
||||
href: "https://playground.thirdweb.com/connect/pay/fund-wallet",
|
||||
},
|
||||
{
|
||||
label: "Commerce",
|
||||
href: "https://playground.thirdweb.com/connect/pay/commerce",
|
||||
},
|
||||
{
|
||||
label: "Transactions",
|
||||
href: "https://playground.thirdweb.com/connect/pay/transactions",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { DeployedContractsPage } from "../../../../account/contracts/_components/DeployedContractsPage";
|
||||
import { getAuthToken } from "../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../login/loginRedirect";
|
||||
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -29,10 +30,71 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<DeployedContractsPage
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<div className="flex grow flex-col">
|
||||
<DeployedContractsPage
|
||||
teamId={team.id}
|
||||
projectId={project.id}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<div className="h-20" />
|
||||
<ContractsFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractsFooter() {
|
||||
return (
|
||||
<div className="border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<FooterLinksSection
|
||||
trackingCategory="contracts"
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
label: "Deployment Tools",
|
||||
href: "https://portal.thirdweb.com/contracts/deploy/overview",
|
||||
},
|
||||
{
|
||||
label: "Modular Contracts",
|
||||
href: "https://portal.thirdweb.com/contracts/modular-contracts/overview",
|
||||
},
|
||||
{
|
||||
label: "Pre-built Contracts",
|
||||
href: "https://portal.thirdweb.com/contracts/explore/overview",
|
||||
},
|
||||
],
|
||||
}}
|
||||
center={{
|
||||
title: "Templates",
|
||||
links: [
|
||||
{
|
||||
label: "Hardhat Starter",
|
||||
href: "https://thirdweb.com/templates/hardhat-starter",
|
||||
},
|
||||
{
|
||||
href: "https://thirdweb.com/templates/forge-starter",
|
||||
label: "Forge Starter",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
label:
|
||||
"Everything you need to know about upgradeable smart contracts",
|
||||
href: "https://www.youtube.com/watch?v=cZt-CkzxrNM",
|
||||
},
|
||||
{
|
||||
label:
|
||||
"Modular Contracts SDK: Build Core & Modules from Scratch (Advanced Guide)",
|
||||
href: "https://www.youtube.com/watch?v=ZoOk41y4f_k",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Code2Icon,
|
||||
DatabaseIcon,
|
||||
ExternalLinkIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function BlueprintCard() {
|
||||
const features = [
|
||||
{
|
||||
icon: Code2Icon,
|
||||
title: "Easy-to-Use API",
|
||||
description: "RESTful endpoints for any application",
|
||||
},
|
||||
{
|
||||
icon: DatabaseIcon,
|
||||
title: "Managed Infrastructure",
|
||||
description:
|
||||
"No need to index blockchains yourself or manage infrastructure and RPC costs.",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
title: "Lightning-Fast Queries",
|
||||
description: "Access any transaction, event or token API data",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="border-b p-4 lg:px-6 lg:py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Blueprints</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" className="gap-2 bg-background">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/insight/blueprints"
|
||||
target="_blank"
|
||||
>
|
||||
Docs <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 lg:p-6">
|
||||
<p className="mb-2 font-semibold text-xl leading-tight tracking-tight lg:mb-1">
|
||||
Simple endpoints for querying rich blockchain data
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A blueprint is an API that provides access to on-chain data in a
|
||||
user-friendly format. <br /> No need for ABIs, decoding, RPC, or web3
|
||||
knowledge required to fetch blockchain data.
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex items-start gap-3">
|
||||
<div className="rounded-full border p-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">{feature.title}</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playground link */}
|
||||
<div className="border-t p-4 lg:p-6">
|
||||
<Button className="w-full gap-2" asChild>
|
||||
<Link href="https://playground.thirdweb.com/insight" target="_blank">
|
||||
Try Insight blueprints in the playground
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
import { isProd } from "@/constants/env";
|
||||
import { ClientIDSection } from "../components/ProjectFTUX/ClientIDSection";
|
||||
import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
|
||||
|
||||
export function InsightFTUX(props: {
|
||||
clientId: string;
|
||||
}) {
|
||||
return (
|
||||
<WaitingForIntegrationCard
|
||||
title="Integrate Insight"
|
||||
codeTabs={[
|
||||
{
|
||||
label: "JavaScript",
|
||||
code: (
|
||||
<CodeServer
|
||||
code={jsCode(props.clientId)}
|
||||
className="bg-background"
|
||||
lang="ts"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Python",
|
||||
code: (
|
||||
<CodeServer
|
||||
code={pythonCode(props.clientId)}
|
||||
className="bg-background"
|
||||
lang="python"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Curl",
|
||||
code: (
|
||||
<CodeServer
|
||||
code={curlCode(props.clientId)}
|
||||
className="bg-background"
|
||||
lang="bash"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
ctas={[
|
||||
{
|
||||
label: "Try on Playground",
|
||||
href: "https://playground.thirdweb.com/insight",
|
||||
trackingLabel: "playground",
|
||||
category: "insight-ftux",
|
||||
},
|
||||
{
|
||||
label: "View Docs",
|
||||
href: "https://portal.thirdweb.com/insight",
|
||||
trackingLabel: "docs",
|
||||
category: "insight-ftux",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ClientIDSection clientId={props.clientId} />
|
||||
<div className="h-4" />
|
||||
</WaitingForIntegrationCard>
|
||||
);
|
||||
}
|
||||
|
||||
const twDomain = isProd ? "thirdweb" : "thirdweb-dev";
|
||||
|
||||
const jsCode = (clientId: string) => `\
|
||||
// Example: Get latest 10 transactions on Ethereum
|
||||
const res = await fetch("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}");
|
||||
const data = await res.json();
|
||||
`;
|
||||
|
||||
const curlCode = (clientId: string) => `\
|
||||
# Example: Get latest 10 transactions on Ethereum
|
||||
curl -X GET "https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}"
|
||||
`;
|
||||
|
||||
const pythonCode = (clientId: string) => `\
|
||||
# Example: Get latest 10 transactions on Ethereum
|
||||
import requests
|
||||
|
||||
response = requests.get("https://insight.${twDomain}.com/v1/transactions?chain=1&limit=10&clientId=${clientId}")
|
||||
data = response.json()
|
||||
`;
|
||||
@@ -1,106 +1,110 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Code2Icon,
|
||||
DatabaseIcon,
|
||||
ExternalLinkIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { isProjectActive } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
|
||||
import { BlueprintCard } from "./blueprint-card";
|
||||
import { InsightFTUX } from "./insight-ftux";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [team, project] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!team || !project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const activeResponse = await isProjectActive({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const showFTUX = !activeResponse.insight;
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<h1 className="mb-6 font-semibold text-2xl tracking-tight sm:text-3xl">
|
||||
Insight
|
||||
</h1>
|
||||
|
||||
<div className="pb-20">
|
||||
<BlueprintCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueprintCard() {
|
||||
const features = [
|
||||
{
|
||||
icon: Code2Icon,
|
||||
title: "Easy-to-Use API",
|
||||
description: "RESTful endpoints for any application",
|
||||
},
|
||||
{
|
||||
icon: DatabaseIcon,
|
||||
title: "Managed Infrastructure",
|
||||
description:
|
||||
"No need to index blockchains yourself or manage infrastructure and RPC costs.",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
title: "Lightning-Fast Queries",
|
||||
description: "Access any transaction, event or token API data",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="border-b p-4 lg:px-6 lg:py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Blueprints</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" className="gap-2 bg-background">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/insight/blueprints"
|
||||
target="_blank"
|
||||
>
|
||||
Docs <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="mb-1 font-semibold text-3xl tracking-tight">
|
||||
Insight
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
APIs to retrieve blockchain data from any EVM chain, enrich it with
|
||||
metadata, and transform it using custom logic
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 lg:p-6">
|
||||
<p className="mb-2 font-semibold text-xl leading-tight tracking-tight lg:mb-1">
|
||||
Simple endpoints for querying rich blockchain data
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A blueprint is an API that provides access to on-chain data in a
|
||||
user-friendly format. <br /> No need for ABIs, decoding, RPC, or web3
|
||||
knowledge required to fetch blockchain data.
|
||||
</p>
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex items-start gap-3">
|
||||
<div className="rounded-full border p-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">{feature.title}</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
{showFTUX ? (
|
||||
<InsightFTUX clientId={project.publishableKey} />
|
||||
) : (
|
||||
<BlueprintCard />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playground link */}
|
||||
<div className="border-t p-4 lg:p-6">
|
||||
<Button className="w-full gap-2" asChild>
|
||||
<Link href="https://playground.thirdweb.com/insight" target="_blank">
|
||||
Try Insight blueprints in the playground
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="h-20" />
|
||||
<div className="border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<InsightFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightFooter() {
|
||||
return (
|
||||
<FooterLinksSection
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
label: "Overview",
|
||||
href: "https://portal.thirdweb.com/insight",
|
||||
},
|
||||
{
|
||||
label: "API Reference",
|
||||
href: "https://insight-api.thirdweb.com/reference",
|
||||
},
|
||||
],
|
||||
}}
|
||||
center={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
label:
|
||||
"Blockchain Data on Any EVM - Quick and Easy REST APIs for Onchain Data",
|
||||
href: "https://www.youtube.com/watch?v=U2aW7YIUJVw",
|
||||
},
|
||||
{
|
||||
label: "Build a Whale Alerts Telegram Bot with Insight",
|
||||
href: "https://www.youtube.com/watch?v=HvqewXLVRig",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Demos",
|
||||
links: [
|
||||
{
|
||||
label: "API Playground",
|
||||
href: "https://playground.thirdweb.com/insight",
|
||||
},
|
||||
],
|
||||
}}
|
||||
trackingCategory="insight"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
@@ -9,7 +9,7 @@ import { TeamHeaderLoggedIn } from "../../components/TeamHeader/team-header-logg
|
||||
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 }>;
|
||||
@@ -49,6 +49,9 @@ 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 (
|
||||
@@ -64,7 +67,10 @@ export default async function TeamLayout(props: {
|
||||
accountAddress={accountAddress}
|
||||
/>
|
||||
</div>
|
||||
<ProjectSidebarLayout layoutPath={layoutPath}>
|
||||
<ProjectSidebarLayout
|
||||
layoutPath={layoutPath}
|
||||
showNebula={!!isOnNebulaWaitList}
|
||||
>
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
|
||||
+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="container flex max-w-7xl flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
|
||||
<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="container max-w-7xl 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
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ export function NebulaWaitListPageUI(props: {
|
||||
{/* Header */}
|
||||
{!props.hideHeader && (
|
||||
<div className="border-b py-10">
|
||||
<div className="container flex items-center justify-between">
|
||||
<div className="container flex max-w-7xl 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="container flex max-w-7xl 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>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FooterLinksSection } from "../components/footer/FooterLinksSection";
|
||||
|
||||
export default function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="flex grow flex-col">{props.children}</div>
|
||||
<div className="h-20" />
|
||||
<div className="border-t">
|
||||
<div className="container max-w-7xl">
|
||||
<NebulaFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NebulaFooter() {
|
||||
return (
|
||||
<FooterLinksSection
|
||||
trackingCategory="nebula"
|
||||
left={{
|
||||
title: "Documentation",
|
||||
links: [
|
||||
{
|
||||
label: "API Reference",
|
||||
href: "https://portal.thirdweb.com/nebula/get-started",
|
||||
},
|
||||
{
|
||||
label: "Python SDK",
|
||||
href: "https://portal.thirdweb.com/nebula/tools/python-sdk/installation",
|
||||
},
|
||||
{
|
||||
label: "MCP Server",
|
||||
href: "https://portal.thirdweb.com/nebula/mcp-server/get-started",
|
||||
},
|
||||
],
|
||||
}}
|
||||
center={{
|
||||
title: "Tutorials",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.youtube.com/watch?v=xrm0emiRGMk",
|
||||
label: "Build a Telegram Mini App ERC20 deployer, powered by AI",
|
||||
},
|
||||
{
|
||||
label: "Build an AI Powered Blockchain Explorer",
|
||||
href: "https://www.youtube.com/watch?v=vBooJytkXa4",
|
||||
},
|
||||
],
|
||||
}}
|
||||
right={{
|
||||
title: "Templates",
|
||||
links: [
|
||||
{
|
||||
label: "Bouncer Eliza Agent",
|
||||
href: "https://github.com/thirdweb-example/bouncer-eliza-agent",
|
||||
},
|
||||
{
|
||||
label: "Block Explorer",
|
||||
href: "https://github.com/thirdweb-example/nebula-block-explorer",
|
||||
},
|
||||
{
|
||||
label: "ERC20 Telegram Mini-app Token Deployer",
|
||||
href: "https://github.com/thirdweb-example/erc20-token-deployer",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CodeServer } from "@/components/ui/code/code.server";
|
||||
import { SecretKeySection } from "../components/ProjectFTUX/SecretKeySection";
|
||||
import { WaitingForIntegrationCard } from "../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
|
||||
|
||||
export function NebulaFTUX(props: {
|
||||
secretKeyMasked: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<WaitingForIntegrationCard
|
||||
title="Integrate Nebula"
|
||||
codeTabs={[
|
||||
{
|
||||
label: "JavaScript",
|
||||
code: (
|
||||
<CodeServer code={jsCode} className="bg-background" lang="ts" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Python",
|
||||
code: (
|
||||
<CodeServer
|
||||
code={pythonCode}
|
||||
className="bg-background"
|
||||
lang="python"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Curl",
|
||||
code: (
|
||||
<CodeServer code={curlCode} className="bg-background" lang="bash" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
ctas={[
|
||||
{
|
||||
label: "Try on Playground",
|
||||
href: "https://nebula.thirdweb.com/",
|
||||
trackingLabel: "playground",
|
||||
category: "nebula-ftux",
|
||||
},
|
||||
{
|
||||
label: "View Docs",
|
||||
href: "https://portal.thirdweb.com/nebula",
|
||||
trackingLabel: "docs",
|
||||
category: "nebula-ftux",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SecretKeySection
|
||||
secretKeyMasked={props.secretKeyMasked}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
<div className="h-4" />
|
||||
</WaitingForIntegrationCard>
|
||||
);
|
||||
}
|
||||
|
||||
const jsCode = `\
|
||||
// Example: Send message to Nebula
|
||||
// Replace PROJECT_SECRET_KEY with your project's full secret key
|
||||
|
||||
const res = await fetch("https://nebula-api.thirdweb.com/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-secret-key": "PROJECT_SECRET_KEY",
|
||||
},
|
||||
body: {
|
||||
message: "Hello",
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
`;
|
||||
|
||||
const curlCode = `\
|
||||
# Example: Send message to Nebula
|
||||
# Replace PROJECT_SECRET_KEY with your project's full secret key
|
||||
|
||||
curl -X POST https://nebula-api.thirdweb.com/chat \
|
||||
-H "x-secret-key:PROJECT_SECRET_KEY" \
|
||||
-d '{
|
||||
"message": "Hello",
|
||||
"stream": false,
|
||||
}'
|
||||
`;
|
||||
|
||||
const pythonCode = `\
|
||||
# Example: Send message to Nebula
|
||||
# Replace PROJECT_SECRET_KEY with your project's full secret key
|
||||
|
||||
import requests
|
||||
|
||||
response = requests.post("https://nebula-api.thirdweb.com/chat", headers={
|
||||
"x-secret-key": "PROJECT_SECRET_KEY"
|
||||
}, json={
|
||||
"message": "Hello",
|
||||
"stream": False,
|
||||
})
|
||||
|
||||
data = response.json()
|
||||
`;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isProjectActive } from "@/api/analytics";
|
||||
import { getProject } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
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";
|
||||
import { NebulaFTUX } from "./nebula-ftux";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const [authToken, team, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
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");
|
||||
|
||||
const activeResponse = await isProjectActive({
|
||||
teamId: team.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const showFTUX = !activeResponse.nebula;
|
||||
|
||||
if (hasNebulaAccess) {
|
||||
if (showFTUX) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mt-6 max-w-7xl">
|
||||
<NebulaFTUX
|
||||
secretKeyMasked={project.secretKeys[0]?.masked || ""}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NebulaAnalyticsPage
|
||||
teamId={team.id}
|
||||
authToken={authToken}
|
||||
searchParams={searchParams}
|
||||
projectId={project.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NebulaWaitListPage team={team} />;
|
||||
}
|
||||
@@ -76,26 +76,29 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
// is any analytics data active?
|
||||
const isActive = Object.values(activeStatus).some((v) => !!v);
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="w-full">
|
||||
<Header
|
||||
title={project.name}
|
||||
interval={interval}
|
||||
range={range}
|
||||
showRangeSelector={isActive}
|
||||
/>
|
||||
<div className="w-full border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<Header
|
||||
title={project.name}
|
||||
interval={interval}
|
||||
range={range}
|
||||
showRangeSelector={isActive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{!isActive ? (
|
||||
<div className="pt-6">
|
||||
<div className="container max-w-7xl">
|
||||
<ProjectFTUX project={project} teamSlug={params.team_slug} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex grow flex-col pt-6">
|
||||
<div className="container flex max-w-7xl grow flex-col">
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<ProjectAnalytics
|
||||
project={project}
|
||||
@@ -106,6 +109,8 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@ export default async function Layout(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Project Settings
|
||||
</h1>
|
||||
<div className="py-6">{props.children}</div>
|
||||
<div className="border-b py-10">
|
||||
<div className="container max-w-7xl">
|
||||
<h1 className="font-semibold text-2xl tracking-tight lg:text-3xl">
|
||||
Project Settings
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container max-w-7xl pt-6 pb-20">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
export const Stat: React.FC<{
|
||||
import { Skeleton } from "../../@/components/ui/skeleton";
|
||||
|
||||
export const StatCard: React.FC<{
|
||||
label: string;
|
||||
value?: number;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
}> = ({ label, value, formatter, icon: Icon }) => {
|
||||
isPending: boolean;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
|
||||
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">
|
||||
{value !== undefined && formatter
|
||||
? formatter(value)
|
||||
: value?.toLocaleString()}
|
||||
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
{isPending ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChainIcon } from "components/icons/ChainIcon";
|
||||
import { NetworkSelectDropdown } from "components/selects/NetworkSelectDropdown";
|
||||
import { EllipsisVerticalIcon, XIcon } from "lucide-react";
|
||||
import { EllipsisVerticalIcon, ExternalLinkIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -202,8 +203,16 @@ export function ContractTableUI(props: {
|
||||
</Table>
|
||||
|
||||
{contracts.length === 0 && (
|
||||
<div className="flex h-[200px] items-center justify-center text-muted-foreground">
|
||||
No Contracts
|
||||
<div className="flex h-[350px] items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="mb-3">No contracts added to project</p>
|
||||
<Button variant="outline" asChild className="bg-background">
|
||||
<Link href="/explore" target="_blank">
|
||||
Discover Contracts
|
||||
<ExternalLinkIcon className="ml-2 size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
+10
-1
@@ -50,6 +50,15 @@ function Component() {
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="120 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
inAppWalletStats={createInAppWalletStatsStub(120)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
inAppWalletStats={createInAppWalletStatsStub(10)}
|
||||
@@ -117,7 +126,7 @@ function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
|
||||
while (d !== 0) {
|
||||
const uniqueWallets = Math.floor(Math.random() * 100);
|
||||
stubbedData.push({
|
||||
date: new Date(2024, 1, d).toLocaleString(),
|
||||
date: new Date(2024, 11, d).toLocaleString(),
|
||||
uniqueWalletsConnected: uniqueWallets,
|
||||
authenticationMethod: pickRandomAuthMethod(),
|
||||
});
|
||||
|
||||
+82
-135
@@ -1,27 +1,15 @@
|
||||
"use client";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { InAppWalletStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../lib/format-utils";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
@@ -49,7 +37,7 @@ export function InAppWalletUsersChartCardUI(props: {
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData && stat.uniqueWalletsConnected > 0) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: format(new Date(stat.date), "MMM dd"),
|
||||
time: stat.date,
|
||||
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
|
||||
} as ChartData);
|
||||
} else if (chartData) {
|
||||
@@ -114,128 +102,87 @@ export function InAppWalletUsersChartCardUI(props: {
|
||||
chartData.every((data) => data.sponsoredUsd === 0);
|
||||
|
||||
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">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground">{props.description}</p>
|
||||
<ThirdwebBarChart
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<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"
|
||||
fileName="Connect Wallets"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueAuthMethods];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[250px] w-full md:h-[350px]"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
chartData.every((data) => data.sponsoredUsd === 0) ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Connect users to your app with social logins
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
fileName="Connect Wallets"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueAuthMethods];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
data={chartData}
|
||||
isPending={props.isPending}
|
||||
config={chartConfig}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
emptyChartState={<InAppWalletUsersEmptyChartState />}
|
||||
variant="stacked"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) =>
|
||||
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)}
|
||||
tickMargin={10}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{uniqueAuthMethods.map((authMethod) => {
|
||||
return (
|
||||
<Bar
|
||||
key={authMethod}
|
||||
dataKey={authMethod}
|
||||
fill={chartConfig[authMethod]?.color}
|
||||
radius={4}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
function InAppWalletUsersEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
Connect users to your app with social logins
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Stat } from "components/analytics/stat";
|
||||
import { getInAppWalletUsage } from "@/api/analytics";
|
||||
import { StatCard } from "components/analytics/stat";
|
||||
import { subDays } from "date-fns";
|
||||
import { ActivityIcon, UserIcon } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import type { InAppWalletStats } from "types/analytics";
|
||||
|
||||
export function InAppWalletsSummary(props: {
|
||||
function InAppWalletsSummaryInner(props: {
|
||||
allTimeStats: InAppWalletStats[] | undefined;
|
||||
monthlyStats: InAppWalletStats[] | undefined;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const allTimeStats = props.allTimeStats?.reduce(
|
||||
(acc, curr) => {
|
||||
@@ -27,17 +31,76 @@ export function InAppWalletsSummary(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<Stat
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
label="Total Users"
|
||||
value={allTimeStats?.uniqueWalletsConnected || 0}
|
||||
icon={ActivityIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<Stat
|
||||
<StatCard
|
||||
label="Monthly Active Users"
|
||||
value={monthlyStats?.uniqueWalletsConnected || 0}
|
||||
icon={UserIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncInAppWalletsSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { teamId, projectId } = props;
|
||||
const allTimeStatsPromise = getInAppWalletUsage({
|
||||
teamId,
|
||||
projectId,
|
||||
from: new Date(2022, 0, 1),
|
||||
to: new Date(),
|
||||
period: "all",
|
||||
});
|
||||
|
||||
const monthlyStatsPromise = getInAppWalletUsage({
|
||||
teamId,
|
||||
projectId,
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
period: "month",
|
||||
});
|
||||
|
||||
const [allTimeStats, monthlyStats] = await Promise.all([
|
||||
allTimeStatsPromise,
|
||||
monthlyStatsPromise,
|
||||
]).catch(() => [null, null]);
|
||||
|
||||
return (
|
||||
<InAppWalletsSummaryInner
|
||||
allTimeStats={allTimeStats || undefined}
|
||||
monthlyStats={monthlyStats || undefined}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InAppWalletsSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<InAppWalletsSummaryInner
|
||||
allTimeStats={undefined}
|
||||
monthlyStats={undefined}
|
||||
isPending={true}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncInAppWalletsSummary
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,34 @@
|
||||
import { getInAppWalletUsage } from "@/api/analytics";
|
||||
import {
|
||||
type Range,
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { getInAppWalletUsage } from "../../../@/api/analytics";
|
||||
import { Suspense } from "react";
|
||||
import type { InAppWalletStats } from "../../../types/analytics";
|
||||
import { RangeSelector } from "../../analytics/range-selector";
|
||||
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
|
||||
|
||||
export async function InAppWalletAnalytics({
|
||||
teamId,
|
||||
projectId,
|
||||
interval,
|
||||
range = getLastNDaysRange("last-120"),
|
||||
}: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
type InAppWalletAnalyticsProps = {
|
||||
interval: "day" | "week";
|
||||
range?: Range;
|
||||
}) {
|
||||
const stats = await getInAppWalletUsage({
|
||||
teamId,
|
||||
projectId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: interval,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
range: Range;
|
||||
stats: InAppWalletStats[];
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
function InAppWalletAnalyticsInner({
|
||||
interval,
|
||||
range,
|
||||
stats,
|
||||
isPending,
|
||||
}: InAppWalletAnalyticsProps) {
|
||||
return (
|
||||
<div>
|
||||
<RangeSelector range={range} interval={interval} />
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<InAppWalletUsersChartCardUI
|
||||
inAppWalletStats={stats || []}
|
||||
isPending={false}
|
||||
isPending={isPending}
|
||||
title="Unique Users"
|
||||
description="The total number of active in-app wallet users on your project."
|
||||
/>
|
||||
@@ -45,3 +36,48 @@ export async function InAppWalletAnalytics({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type AsyncInAppWalletAnalyticsProps = Omit<
|
||||
InAppWalletAnalyticsProps,
|
||||
"stats" | "isPending"
|
||||
> & {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
async function AsyncInAppWalletAnalytics(
|
||||
props: AsyncInAppWalletAnalyticsProps,
|
||||
) {
|
||||
const range = props.range ?? getLastNDaysRange("last-120");
|
||||
|
||||
const stats = await getInAppWalletUsage({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
period: props.interval,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<InAppWalletAnalyticsInner
|
||||
{...props}
|
||||
stats={stats}
|
||||
range={range}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<InAppWalletAnalyticsInner {...props} stats={[]} isPending={true} />
|
||||
}
|
||||
>
|
||||
<AsyncInAppWalletAnalytics {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+49
-6
@@ -1,18 +1,22 @@
|
||||
import { Stat } from "components/analytics/stat";
|
||||
import { getAggregateUserOpUsage } from "@/api/analytics";
|
||||
import { StatCard } from "components/analytics/stat";
|
||||
import { ActivityIcon, CoinsIcon } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
|
||||
export function AccountAbstractionSummary(props: {
|
||||
aggregateUserOpUsageQuery?: UserOpStats;
|
||||
function AccountAbstractionSummaryInner(props: {
|
||||
aggregateUserOpUsageQuery: UserOpStats | undefined;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<Stat
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
label="Mainnet Sponsored Transactions"
|
||||
value={props.aggregateUserOpUsageQuery?.successful || 0}
|
||||
icon={ActivityIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
<Stat
|
||||
<StatCard
|
||||
label="Mainnet Gas Sponsored"
|
||||
value={props.aggregateUserOpUsageQuery?.sponsoredUsd || 0}
|
||||
formatter={(value: number) =>
|
||||
@@ -22,7 +26,46 @@ export function AccountAbstractionSummary(props: {
|
||||
}).format(value)
|
||||
}
|
||||
icon={CoinsIcon}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const aggregateUserOpStats = await getAggregateUserOpUsage({
|
||||
teamId: props.teamId,
|
||||
projectId: props.projectId,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccountAbstractionSummaryInner
|
||||
aggregateUserOpUsageQuery={aggregateUserOpStats}
|
||||
isPending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<AccountAbstractionSummaryInner
|
||||
aggregateUserOpUsageQuery={undefined}
|
||||
isPending={true}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncAccountAbstractionSummary
|
||||
teamId={props.teamId}
|
||||
projectId={props.projectId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
+57
-115
@@ -1,28 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../lib/format-utils";
|
||||
|
||||
@@ -48,22 +37,20 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
const { chainId } = stat;
|
||||
const chain = chainsStore.idToChain.get(Number(chainId));
|
||||
|
||||
const chainName = chain?.name || chainId || "Unknown";
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: format(new Date(stat.date), "MMM dd"),
|
||||
[chain?.name || chainId || "Unknown"]: stat.successful,
|
||||
time: stat.date,
|
||||
[chainName]: stat.successful,
|
||||
} as ChartData);
|
||||
} else {
|
||||
chartData[chain?.name || chainId || "Unknown"] =
|
||||
(chartData[chain?.name || chainId || "Unknown"] || 0) +
|
||||
stat.successful;
|
||||
chartData[chainName] = (chartData[chainName] || 0) + stat.successful;
|
||||
}
|
||||
|
||||
chainIdToVolumeMap.set(
|
||||
chain?.name || chainId || "Unknown",
|
||||
stat.successful +
|
||||
(chainIdToVolumeMap.get(chain?.name || chainId || "Unknown") || 0),
|
||||
chainName,
|
||||
stat.successful + (chainIdToVolumeMap.get(chainName) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,9 +71,9 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
}
|
||||
}
|
||||
|
||||
chainsToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
label: chainsToShow[i],
|
||||
chainsToShow.forEach((chainName, i) => {
|
||||
_chartConfig[chainName] = {
|
||||
label: chainName,
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
};
|
||||
});
|
||||
@@ -111,104 +98,59 @@ export function SponsoredTransactionsChartCard(props: {
|
||||
chartData.every((data) => data.transactions === 0);
|
||||
|
||||
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">
|
||||
Sponsored Transactions
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Total number of sponsored transactions.
|
||||
</p>
|
||||
<ThirdwebBarChart
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Sponsored Transactions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Sponsored Transactions"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
chartData.every((data) => data.transactions === 0) ? (
|
||||
<EmptyChartState>
|
||||
<EmptyAccountAbstractionChartContent />
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) => {
|
||||
return Object.entries(data)
|
||||
.filter(([key]) => key !== "time")
|
||||
.map(([, value]) => value)
|
||||
.reduce((acc, current) => Number(acc) + Number(current), 0);
|
||||
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Sponsored Transactions"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatTickerNumber(value)}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{uniqueChainIds.map((chainId) => {
|
||||
return (
|
||||
<Bar
|
||||
key={chainId}
|
||||
dataKey={chainId}
|
||||
fill={chartConfig[chainId]?.color}
|
||||
radius={4}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
config={chartConfig}
|
||||
data={chartData}
|
||||
isPending={props.isPending}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
emptyChartState={<EmptyAccountAbstractionChartContent />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyAccountAbstractionChartContent() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<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">
|
||||
|
||||
+7
@@ -42,6 +42,13 @@ function Component() {
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="120 days">
|
||||
<TotalSponsoredChartCard
|
||||
userOpStats={createUserOpStatsStub(120)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<TotalSponsoredChartCard
|
||||
userOpStats={createUserOpStatsStub(10)}
|
||||
|
||||
+97
-148
@@ -1,27 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { formatDate } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { UserOpStats } from "types/analytics";
|
||||
import { useAllChainsData } from "../../../hooks/chains/allChains";
|
||||
import { toUSD } from "../../../utils/number";
|
||||
@@ -51,7 +40,7 @@ export function TotalSponsoredChartCard(props: {
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: format(new Date(stat.date), "MMM dd"),
|
||||
time: stat.date,
|
||||
[chain?.name || chainId || "Unknown"]: stat.sponsoredUsd,
|
||||
} as ChartData);
|
||||
} else {
|
||||
@@ -98,7 +87,9 @@ export function TotalSponsoredChartCard(props: {
|
||||
};
|
||||
|
||||
return {
|
||||
chartData: Array.from(_chartDataMap.values()),
|
||||
chartData: Array.from(_chartDataMap.values()).sort((a, b) => {
|
||||
return new Date(a.time).getTime() - new Date(b.time).getTime();
|
||||
}),
|
||||
chartConfig: _chartConfig,
|
||||
};
|
||||
}, [userOpStats, chainsStore]);
|
||||
@@ -110,141 +101,99 @@ export function TotalSponsoredChartCard(props: {
|
||||
chartData.every((data) => data.sponsoredUsd === 0);
|
||||
|
||||
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">
|
||||
Gas Sponsored
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The total amount of gas sponsored in USD.
|
||||
</p>
|
||||
<ThirdwebBarChart
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Gas Sponsored
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Gas Sponsored"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Gas Sponsored"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueChainIds];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
config={chartConfig}
|
||||
data={chartData}
|
||||
isPending={props.isPending}
|
||||
emptyChartState={<TotalSponsoredChartCardEmptyChartState />}
|
||||
variant="stacked"
|
||||
showLegend
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return formatDate(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
toolTipValueFormatter={(value) => {
|
||||
if (typeof value !== "number") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return toUSD(value);
|
||||
}}
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalSponsoredChartCardEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">Sponsor gas for your users</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
|
||||
label=".NET"
|
||||
icon={DotNetIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[250px] w-full md:h-[350px]"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ||
|
||||
chartData.every((data) => data.sponsoredUsd === 0) ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">Sponsor gas for your users</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
|
||||
label=".NET"
|
||||
icon={DotNetIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
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={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => {
|
||||
// typeguard
|
||||
if (typeof value !== "number") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return toUSD(value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{uniqueChainIds.map((chainId) => {
|
||||
return (
|
||||
<Bar
|
||||
key={chainId}
|
||||
dataKey={chainId}
|
||||
fill={chartConfig[chainId]?.color}
|
||||
radius={4}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
|
||||
const sponsoredUsd = Math.random() * 100;
|
||||
|
||||
stubbedData.push({
|
||||
date: new Date(2024, 1, d).toLocaleString(),
|
||||
date: new Date(2024, 11, d).toLocaleString(),
|
||||
successful,
|
||||
failed,
|
||||
sponsoredUsd,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
|
||||
export function SupportedPlatformLink(props: {
|
||||
platform: "React" | "React Native" | "Unity" | "TypeScript";
|
||||
href: string;
|
||||
trackingCategory: string;
|
||||
className?: string;
|
||||
}) {
|
||||
let icon = ReactIcon;
|
||||
if (props.platform === "Unity") {
|
||||
icon = UnityIcon;
|
||||
} else if (props.platform === "TypeScript") {
|
||||
icon = TypeScriptIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackedLinkTW
|
||||
category={props.trackingCategory}
|
||||
label={`platform-${props.platform.replace(" ", "-").toLowerCase()}`}
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-sm hover:text-foreground",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{icon({ className: "size-4" })}
|
||||
{props.platform}
|
||||
</TrackedLinkTW>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { isProd } from "@/constants/env";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const NEXT_PUBLIC_POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
|
||||
|
||||
export function PHProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (NEXT_PUBLIC_POSTHOG_API_KEY && isProd) {
|
||||
posthog.init(NEXT_PUBLIC_POSTHOG_API_KEY, {
|
||||
api_host: "https://a.thirdweb.com",
|
||||
capture_pageview: false,
|
||||
debug: false,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const posthogHost = "https://a.thirdweb.com";
|
||||
|
||||
export function PosthogHeadSetup() {
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href={posthogHost} />
|
||||
<link rel="dns-prefetch" href={posthogHost} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
|
||||
function PostHogPageViewInner() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const posthog = usePostHog();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (pathname && posthog) {
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams.toString()) {
|
||||
url = `${url}?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture("$pageview", {
|
||||
$current_url: url,
|
||||
});
|
||||
}
|
||||
}, [pathname, searchParams, posthog]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PostHogPageView() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageViewInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const ContentSecurityPolicy = `
|
||||
style-src 'self' 'unsafe-inline';
|
||||
font-src 'self';
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
"lint": "eslint ./src",
|
||||
"prefix": "biome check ./src --fix",
|
||||
"fix": "eslint ./src --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"update-insight-blueprints": "bun scripts/updateInsightBlueprints.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abstract-foundation/agw-client": "^1.4.2",
|
||||
"@abstract-foundation/agw-client": "^1.6.2",
|
||||
"@abstract-foundation/agw-react": "^1.5.4",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
@@ -41,6 +42,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"openapi-types": "^12.1.3",
|
||||
"posthog-js": "1.67.1",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fetchAllBlueprints } from "./utils";
|
||||
|
||||
const blueprints = await fetchAllBlueprints();
|
||||
|
||||
const fileContent = `\
|
||||
// This file is auto-generated by the update-insight-blueprints script
|
||||
import type { MinimalBlueprintSpec } from "./utils";
|
||||
|
||||
export const insightBlueprints: MinimalBlueprintSpec[] = ${JSON.stringify(blueprints, null, 2)}`;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, "../src/app/insight/insightBlueprints.ts"),
|
||||
fileContent,
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Updated insightBlueprints.ts - Please format this file and commit it",
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type BlueprintListItem,
|
||||
type MinimalBlueprintSpec,
|
||||
fetchBlueprintSpec,
|
||||
} from "../src/app/insight/utils";
|
||||
|
||||
async function fetchBlueprintList() {
|
||||
const res = await fetch("https://insight.thirdweb.com/v1/blueprints");
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to fetch blueprints: ${text}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { data: BlueprintListItem[] };
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export const fetchAllBlueprints = async (): Promise<MinimalBlueprintSpec[]> => {
|
||||
try {
|
||||
// fetch list
|
||||
const blueprintSpecs = await fetchBlueprintList();
|
||||
|
||||
// fetch all blueprints
|
||||
const blueprints = await Promise.all(
|
||||
blueprintSpecs.map((spec) =>
|
||||
fetchBlueprintSpec({
|
||||
blueprintId: spec.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return blueprints.map((blueprint) => {
|
||||
const paths = Object.keys(blueprint.openapiJson.paths);
|
||||
return {
|
||||
id: blueprint.id,
|
||||
name: blueprint.name,
|
||||
paths: paths.map((pathName) => {
|
||||
const pathObj = blueprint.openapiJson.paths[pathName];
|
||||
if (!pathObj) {
|
||||
throw new Error(`Path not found: ${pathName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: pathObj.get?.summary || "Unknown",
|
||||
path: pathName,
|
||||
};
|
||||
}),
|
||||
} satisfies MinimalBlueprintSpec;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -28,7 +28,7 @@ export default function Page() {
|
||||
full wallet programmability.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
|
||||
heroLink="/account-abstraction.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Page() {
|
||||
full wallet programmability.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
|
||||
heroLink="/account-abstraction.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Page() {
|
||||
full wallet programmability.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
docsLink="https://portal.thirdweb.com/connect/account-abstraction/overview?utm_source=playground"
|
||||
heroLink="/account-abstraction.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Page() {
|
||||
them to create an additional account.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/typescript/v5/auth"
|
||||
docsLink="https://portal.thirdweb.com/typescript/v5/auth?utm_source=playground"
|
||||
heroLink="/auth.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Page() {
|
||||
contract on any EVM chain through our RPC Edge endpoints.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/typescript/v5"
|
||||
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
|
||||
heroLink="/blockchain-api.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Page(props: { children: React.ReactNode }) {
|
||||
and smart account integration.
|
||||
</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/in-app-wallet/overview"
|
||||
docsLink="https://portal.thirdweb.com/connect/in-app-wallet/overview?utm_source=playground"
|
||||
heroLink="/in-app-wallet.png"
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Layout(props: {
|
||||
description={
|
||||
<>HTTP API to bridge, swap and onramp to and from any currency</>
|
||||
}
|
||||
docsLink="https://portal.thirdweb.com/connect/pay/overview"
|
||||
docsLink="https://portal.thirdweb.com/connect/pay/overview?utm_source=playground"
|
||||
heroLink="/ub.png"
|
||||
/>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user