Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd5ae1feb | ||
|
|
1caf6dd460 | ||
|
|
19204e4654 | ||
|
|
674c8d5e6c | ||
|
|
1eb0cd9314 | ||
|
|
b7dfde2d68 | ||
|
|
b50e304e0c | ||
|
|
9baa251bb1 | ||
|
|
7f36303b0b | ||
|
|
13a46b7ff6 | ||
|
|
b9d93e44ca | ||
|
|
fc9e81f99a | ||
|
|
281759f06a | ||
|
|
2d98fe2799 | ||
|
|
93bcdc557c | ||
|
|
d38deecaa1 | ||
|
|
a3ea002afd | ||
|
|
6390052f8b | ||
|
|
621f187a8a | ||
|
|
36ff327744 | ||
|
|
f68c2c62a5 | ||
|
|
5766c90fb4 | ||
|
|
8d4b6a1fea | ||
|
|
49b80fe87f | ||
|
|
5ca4d7497b | ||
|
|
0382d42a99 | ||
|
|
d35cad8655 | ||
|
|
c1275a5e38 | ||
|
|
277874594b | ||
|
|
4fc02b3618 | ||
|
|
26a91537d2 | ||
|
|
a3ff7572d6 | ||
|
|
01004e8408 | ||
|
|
f497b614cb | ||
|
|
f344fb5fbd | ||
|
|
9f265bf734 | ||
|
|
e5519eaef9 | ||
|
|
805b838fd3 | ||
|
|
ec73f1ff83 | ||
|
|
245e9a688c | ||
|
|
8bdc0c0c27 | ||
|
|
9c04b3f180 | ||
|
|
800503da68 | ||
|
|
0ff8b9ff60 | ||
|
|
304e4e5be8 | ||
|
|
fdd2557663 | ||
|
|
56a1c0a467 | ||
|
|
6e87b9205f | ||
|
|
9383ccdf4e | ||
|
|
ac326e24f1 | ||
|
|
7ebc32a0c4 | ||
|
|
5f0e25b5a8 | ||
|
|
aad3e6477e | ||
|
|
4bab6f1ae8 | ||
|
|
f9a435051d | ||
|
|
27d72e5d3e | ||
|
|
151127d668 | ||
|
|
759f41b11e | ||
|
|
5ec84bd3b6 | ||
|
|
38f4a89076 | ||
|
|
31e24f1c58 | ||
|
|
2bb6a4f9a3 | ||
|
|
e281ed185e | ||
|
|
116975286f | ||
|
|
ff8f4130e9 | ||
|
|
ffd8c402f4 | ||
|
|
d365efe997 | ||
|
|
1558b48056 | ||
|
|
3fba7e4e88 | ||
|
|
57ccadbef2 | ||
|
|
20e41ee164 | ||
|
|
f5ab57f365 | ||
|
|
01dce0e581 | ||
|
|
18425f9d74 | ||
|
|
bd2761af15 | ||
|
|
f3d2739df1 | ||
|
|
1aa4912b17 | ||
|
|
7967d7f17e | ||
|
|
1487de6808 | ||
|
|
18917b632a | ||
|
|
41919024c3 | ||
|
|
56eb1d2491 | ||
|
|
70ff608c72 | ||
|
|
7e442a4c66 | ||
|
|
a5b888576f | ||
|
|
53b6d1b4d6 | ||
|
|
9856ee4010 | ||
|
|
3c768a0ef9 | ||
|
|
a388439022 | ||
|
|
7b873d38db | ||
|
|
d33a943176 | ||
|
|
d5e0b6eb73 | ||
|
|
75e9be0358 | ||
|
|
a704c3f2b0 | ||
|
|
b0eccf365a | ||
|
|
f3525c7c01 | ||
|
|
e516b4ddc1 | ||
|
|
dffa457a39 | ||
|
|
859032dc17 | ||
|
|
e0ee69b061 | ||
|
|
b6be8d67ef | ||
|
|
6ddb15cf02 | ||
|
|
551ec680df | ||
|
|
7b9ce493c8 | ||
|
|
b27fcd3ba0 | ||
|
|
c40603f36e | ||
|
|
458246fea1 |
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
@@ -18,8 +20,9 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
"quickfix.biome": "always",
|
||||
"source.fixAll.biome": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
@@ -66,3 +66,6 @@ ANALYTICS_SERVICE_URL=""
|
||||
STRIPE_SECRET_KEY=""
|
||||
GROWTH_PLAN_SKU=""
|
||||
PAYMENT_METHOD_CONFIGURATION=""
|
||||
|
||||
# required for bridge iframe (/bridge/widget) page
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID=""
|
||||
@@ -68,4 +68,7 @@ module.exports = [
|
||||
"/token",
|
||||
"/vault",
|
||||
"/monetize/bridge",
|
||||
// ai
|
||||
"/ai-privacy-policy",
|
||||
"/ai-terms",
|
||||
];
|
||||
|
||||
@@ -18,6 +18,11 @@ const ContentSecurityPolicy = `
|
||||
block-all-mixed-content;
|
||||
`;
|
||||
|
||||
const EmbedContentSecurityPolicy = `
|
||||
${ContentSecurityPolicy}
|
||||
frame-ancestors *;
|
||||
`;
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
@@ -138,6 +143,78 @@ const baseNextConfig: NextConfig = {
|
||||
// Apply these headers to all routes in your application.
|
||||
source: "/(.*)",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/checkout-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/checkout-widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/swap-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/swap-widget/:path*",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/buy-widget",
|
||||
},
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
|
||||
},
|
||||
],
|
||||
source: "/bridge/buy-widget/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next": "15.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
@@ -53,10 +53,10 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.1",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.1.0",
|
||||
"react": "19.2.1",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "19.2.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
@@ -82,8 +82,8 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
"@next/bundle-analyzer": "15.3.8",
|
||||
"@next/eslint-plugin-next": "15.3.8",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
@@ -92,8 +92,8 @@
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -109,6 +109,18 @@ export async function getChainInfraCheckoutURL(options: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (
|
||||
"error" in json &&
|
||||
"message" in json.error &&
|
||||
typeof json.error.message === "string"
|
||||
) {
|
||||
return {
|
||||
error: json.error.message,
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
|
||||
@@ -232,13 +232,13 @@ type TokenSwapParams = {
|
||||
buyTokenAddress: string;
|
||||
sellTokenChainId: number;
|
||||
sellTokenAddress: string;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
};
|
||||
|
||||
type TokenBuyParams = {
|
||||
buyTokenChainId: number | undefined;
|
||||
buyTokenAddress: string | undefined;
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -298,7 +298,7 @@ export function reportTokenSwapSuccessful(properties: TokenSwapParams) {
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportSwapWidgetShown(properties: {
|
||||
pageType: "asset" | "bridge" | "chain";
|
||||
pageType: "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
}) {
|
||||
posthog.capture("swap widget shown", properties);
|
||||
}
|
||||
@@ -683,7 +683,7 @@ export function reportProductFeedback(properties: {
|
||||
*
|
||||
*/
|
||||
export function reportBridgePageLinkClick(params: {
|
||||
linkType: "bridge-docs" | "trending-tokens";
|
||||
linkType: "bridge-docs" | "trending-tokens" | "integrate-bridge-widget";
|
||||
}) {
|
||||
posthog.capture("bridge page link clicked", params);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
permissions: [PartnerPermission];
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
"use client";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useId, useMemo } from "react";
|
||||
import { Area, AreaChart, Bar, BarChart } from "recharts";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
import { ArrowUpRightIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type FakeCartData = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
function generateRandomData(): FakeCartData[] {
|
||||
return Array.from({ length: 30 }, () => ({
|
||||
value: Math.floor(Math.random() * 100 + 30),
|
||||
}));
|
||||
}
|
||||
|
||||
const skeletonChartConfig = {
|
||||
value: {
|
||||
color: "hsl(var(--muted)/90%)",
|
||||
label: "Value",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EmptyChartState({
|
||||
children,
|
||||
type,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
type: "bar" | "area";
|
||||
}) {
|
||||
const barChartData = useMemo(() => generateRandomData(), []);
|
||||
|
||||
export function EmptyChartState({ content }: { content?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative z-0 h-full w-full">
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
|
||||
{children ?? (
|
||||
{content || (
|
||||
<div className="flex items-center gap-3 flex-col">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<p className="text-base"> No data available </p>
|
||||
<p className="text-sm"> No data available </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkeletonBarChart data={barChartData} type={type} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +26,7 @@ export function LoadingChartState({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none flex h-full w-full items-center justify-center rounded-lg bg-muted/30",
|
||||
"pointer-events-none flex h-full w-full items-center justify-center bg-card rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -62,51 +35,42 @@ export function LoadingChartState({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBarChart(props: {
|
||||
data: FakeCartData[];
|
||||
type: "bar" | "area";
|
||||
export function EmptyChartStateGetStartedCTA(props: {
|
||||
link?: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const fillAreaSkeletonId = useId();
|
||||
return (
|
||||
<ChartContainer
|
||||
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
|
||||
config={skeletonChartConfig}
|
||||
>
|
||||
{props.type === "bar" ? (
|
||||
<BarChart
|
||||
data={props.data}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
<div className="flex items-center flex-col">
|
||||
<div className="rounded-full border p-2 mb-4">
|
||||
<XIcon className="size-4 text-foreground" />
|
||||
</div>
|
||||
<div className="mb-5 space-y-1">
|
||||
<h3 className="text-base text-foreground text-center font-medium">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.link && (
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full gap-2"
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
<Bar dataKey="value" fill="var(--color-value)" radius={8} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<AreaChart
|
||||
data={props.data}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={"hsl(var(--muted-foreground)/0.5)"}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop offset="95%" stopColor="transparent" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="value"
|
||||
fill={`url(#${fillAreaSkeletonId})`}
|
||||
radius={8}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
type="monotone"
|
||||
/>
|
||||
</AreaChart>
|
||||
<Link href={props.link.href}>
|
||||
{props.link.label} <ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
|
||||
icon: React.FC<{ className?: string }>;
|
||||
formatter?: (value: number) => string;
|
||||
isPending: boolean;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
|
||||
emptyText?: string;
|
||||
}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
|
||||
return (
|
||||
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
|
||||
<div>
|
||||
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
{isPending ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : emptyText ? (
|
||||
<span className="text-muted-foreground">{emptyText}</span>
|
||||
) : value !== undefined && formatter ? (
|
||||
formatter(value)
|
||||
) : (
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { BuyWidget, SwapWidget } from "thirdweb/react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { BridgeWidget, type SupportedFiatCurrency } from "thirdweb/react";
|
||||
import type { Wallet } from "thirdweb/wallets";
|
||||
import {
|
||||
reportAssetBuyCancelled,
|
||||
@@ -18,21 +17,21 @@ import {
|
||||
reportTokenSwapFailed,
|
||||
reportTokenSwapSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID,
|
||||
NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID,
|
||||
} from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { appMetadata } from "../../constants/connect";
|
||||
import { getConfiguredThirdwebClient } from "../../constants/thirdweb.server";
|
||||
|
||||
type PageType = "asset" | "bridge" | "chain";
|
||||
type PageType = "asset" | "bridge" | "chain" | "bridge-iframe";
|
||||
|
||||
export type BuyAndSwapEmbedProps = {
|
||||
persistTokenSelections?: boolean;
|
||||
buyTab:
|
||||
| {
|
||||
buyToken:
|
||||
@@ -64,11 +63,12 @@ export type BuyAndSwapEmbedProps = {
|
||||
| undefined;
|
||||
pageType: PageType;
|
||||
wallets?: Wallet[];
|
||||
currency?: SupportedFiatCurrency;
|
||||
showThirdwebBranding?: boolean;
|
||||
};
|
||||
|
||||
export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
const { theme } = useTheme();
|
||||
const [tab, setTab] = useState<"buy" | "swap">("swap");
|
||||
const themeObj = getSDKTheme(theme === "light" ? "light" : "dark");
|
||||
const isMounted = useRef(false);
|
||||
|
||||
@@ -92,215 +92,170 @@ export function BuyAndSwapEmbed(props: BuyAndSwapEmbedProps) {
|
||||
? NEXT_PUBLIC_BRIDGE_PAGE_CLIENT_ID
|
||||
: props.pageType === "chain"
|
||||
? NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID
|
||||
: undefined,
|
||||
: props.pageType === "bridge-iframe"
|
||||
? NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID
|
||||
: undefined,
|
||||
secretKey: undefined,
|
||||
teamId: undefined,
|
||||
});
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10 shadow-xl min-w-0">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
onClick={() => setTab("swap")}
|
||||
isActive={tab === "swap"}
|
||||
/>
|
||||
<TabButton
|
||||
label="Buy"
|
||||
onClick={() => setTab("buy")}
|
||||
isActive={tab === "buy"}
|
||||
/>
|
||||
</div>
|
||||
<BridgeWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="z-10 min-w-0 shadow-xl"
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
buy={
|
||||
props.buyTab
|
||||
? {
|
||||
amount: props.buyTab.buyToken?.amount,
|
||||
chainId: props.buyTab.buyToken?.chainId,
|
||||
tokenAddress: props.buyTab.buyToken?.tokenAddress,
|
||||
onError: (e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
|
||||
{tab === "buy" && (
|
||||
<BuyWidget
|
||||
amount={props.buyTab?.buyToken?.amount || "1"}
|
||||
chain={
|
||||
props.buyTab?.buyToken?.chainId
|
||||
? defineChain(props.buyTab.buyToken.chainId)
|
||||
: undefined
|
||||
}
|
||||
tokenAddress={
|
||||
props.buyTab?.buyToken?.tokenAddress as `0x${string}` | undefined
|
||||
}
|
||||
className="!rounded-2xl !border-none"
|
||||
title=""
|
||||
client={client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
onError={(e, quote) => {
|
||||
const errorMessage = parseError(e);
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCancel: (quote) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: ({ quote }) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
reportTokenBuyFailed({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
error: errorMessage,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuyCancelled({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuyCancelled({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSuccess={({ quote }) => {
|
||||
const buyChainId =
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationChainId
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.chainId
|
||||
: undefined;
|
||||
|
||||
if (!buyChainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportTokenBuySuccessful({
|
||||
buyTokenChainId: buyChainId,
|
||||
buyTokenAddress:
|
||||
quote?.type === "buy"
|
||||
? quote.intent.destinationTokenAddress
|
||||
: quote?.type === "onramp"
|
||||
? quote.intent.tokenAddress
|
||||
: undefined,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
|
||||
if (props.pageType === "asset") {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: buyChainId,
|
||||
contractType: undefined,
|
||||
is_testnet: false,
|
||||
});
|
||||
}
|
||||
}}
|
||||
theme={themeObj}
|
||||
paymentMethods={["card"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "swap" && (
|
||||
<SwapWidget
|
||||
client={client}
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none"
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
wallets: props.wallets,
|
||||
appMetadata: appMetadata,
|
||||
}}
|
||||
prefill={{
|
||||
buyToken: props.swapTab?.buyToken,
|
||||
sellToken: props.swapTab?.sellToken,
|
||||
}}
|
||||
onError={(error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
reportTokenSwapFailed({
|
||||
errorMessage: errorMessage,
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onSuccess={({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
onCancel={(quote) => {
|
||||
reportTokenSwapCancelled({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={props.onClick}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground px-4 bg-accent",
|
||||
props.isActive && "text-foreground border-foreground/50",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
: undefined
|
||||
}
|
||||
swap={{
|
||||
persistTokenSelections: props.persistTokenSelections,
|
||||
prefill: {
|
||||
buyToken: props.swapTab?.buyToken,
|
||||
sellToken: props.swapTab?.sellToken,
|
||||
},
|
||||
onError: (error, quote) => {
|
||||
const errorMessage = parseError(error);
|
||||
reportTokenSwapFailed({
|
||||
errorMessage: errorMessage,
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ quote }) => {
|
||||
reportTokenSwapSuccessful({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
onCancel: (quote) => {
|
||||
reportTokenSwapCancelled({
|
||||
buyTokenChainId: quote.intent.destinationChainId,
|
||||
buyTokenAddress: quote.intent.destinationTokenAddress,
|
||||
sellTokenChainId: quote.intent.originChainId,
|
||||
sellTokenAddress: quote.intent.originTokenAddress,
|
||||
pageType: props.pageType,
|
||||
});
|
||||
},
|
||||
}}
|
||||
currency={props.currency}
|
||||
showThirdwebBranding={props.showThirdwebBranding}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,9 +90,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState type="area">
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
<EmptyChartState content={props.emptyChartState} />
|
||||
) : (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
|
||||
@@ -78,9 +78,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : props.data.length === 0 ? (
|
||||
<EmptyChartState type="bar">
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
<EmptyChartState content={props.emptyChartState} />
|
||||
) : (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
export function TotalValueChartHeader(props: {
|
||||
total: number;
|
||||
title: string;
|
||||
isUsd?: boolean;
|
||||
isPending: boolean;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
@@ -18,7 +19,9 @@ export function TotalValueChartHeader(props: {
|
||||
render={(value) => {
|
||||
return (
|
||||
<p className="text-3xl font-semibold tracking-tight">
|
||||
{compactNumberFormatter.format(value)}
|
||||
{props.isUsd
|
||||
? compactUSDFormatter.format(value)
|
||||
: compactNumberFormatter.format(value)}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
@@ -48,3 +51,10 @@ const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const compactUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
? renderOption(selectedOption)
|
||||
: selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
|
||||
<ChevronDownIcon className="size-3.5 cursor-pointer text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
DollarSignIcon,
|
||||
RefreshCwIcon,
|
||||
UserIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { WalletUser } from "thirdweb/wallets";
|
||||
import { StatCard } from "@/components/analytics/stat";
|
||||
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TWTable } from "@/components/blocks/TWTable";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import {
|
||||
ToolTipLabel,
|
||||
@@ -22,6 +33,10 @@ import {
|
||||
useAllEmbeddedWallets,
|
||||
useEmbeddedWallets,
|
||||
} from "@/hooks/useEmbeddedWallets";
|
||||
import {
|
||||
useFetchAllPortfolios,
|
||||
type WalletPortfolioData,
|
||||
} from "@/hooks/useWalletPortfolio";
|
||||
import { CopyTextButton } from "../ui/CopyTextButton";
|
||||
import { AdvancedSearchInput } from "./AdvancedSearchInput";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
@@ -74,6 +89,101 @@ export function UserWalletsTable(
|
||||
| { ecosystemSlug: string; projectClientId?: never }
|
||||
),
|
||||
) {
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearchResults, setHasSearchResults] = useState(false);
|
||||
|
||||
// Portfolio state
|
||||
const [selectedChains, setSelectedChains] = useState<number[]>([1]); // Default to Ethereum
|
||||
const [portfolioMap, setPortfolioMap] = useState<
|
||||
Map<string, WalletPortfolioData>
|
||||
>(new Map());
|
||||
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
|
||||
const [fetchProgress, setFetchProgress] = useState({
|
||||
completed: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const walletsQuery = useEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
page: activePage,
|
||||
});
|
||||
const wallets = walletsQuery?.data?.users || [];
|
||||
const { mutateAsync: getAllEmbeddedWallets, isPending: isLoadingAllWallets } =
|
||||
useAllEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
});
|
||||
|
||||
const fetchPortfoliosMutation = useFetchAllPortfolios();
|
||||
|
||||
const handleFetchBalances = useCallback(async () => {
|
||||
if (selectedChains.length === 0) return;
|
||||
|
||||
try {
|
||||
// First get all wallets
|
||||
const allWallets = await getAllEmbeddedWallets({
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
const allAddresses = allWallets
|
||||
.map((w) => w.wallets[0]?.address)
|
||||
.filter((a): a is string => !!a);
|
||||
|
||||
if (allAddresses.length === 0) {
|
||||
setPortfolioLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchProgress({ completed: 0, total: allAddresses.length });
|
||||
|
||||
const results = await fetchPortfoliosMutation.mutateAsync({
|
||||
addresses: allAddresses,
|
||||
chainIds: selectedChains,
|
||||
authToken: props.authToken,
|
||||
teamId: props.teamId,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
onProgress: (completed, total) => {
|
||||
setFetchProgress({ completed, total });
|
||||
},
|
||||
});
|
||||
|
||||
setPortfolioMap(results);
|
||||
setPortfolioLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balances:", error);
|
||||
}
|
||||
}, [
|
||||
selectedChains,
|
||||
getAllEmbeddedWallets,
|
||||
props.projectClientId,
|
||||
props.ecosystemSlug,
|
||||
props.teamId,
|
||||
props.authToken,
|
||||
fetchPortfoliosMutation,
|
||||
]);
|
||||
|
||||
const isFetchingBalances =
|
||||
isLoadingAllWallets || fetchPortfoliosMutation.isPending;
|
||||
|
||||
const aggregatedStats = useMemo(() => {
|
||||
let fundedWallets = 0;
|
||||
let totalValue = 0;
|
||||
portfolioMap.forEach((data) => {
|
||||
if (data.totalUsdValue > 0) {
|
||||
fundedWallets++;
|
||||
totalValue += data.totalUsdValue;
|
||||
}
|
||||
});
|
||||
return { fundedWallets, totalValue };
|
||||
}, [portfolioMap]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
columnHelper.accessor("id", {
|
||||
@@ -129,6 +239,79 @@ export function UserWalletsTable(
|
||||
header: "Address",
|
||||
id: "address",
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
id: "total_balance",
|
||||
header: "Total Balance",
|
||||
cell: (cell) => {
|
||||
const address = cell.getValue()[0]?.address;
|
||||
if (!address) return "N/A";
|
||||
if (!portfolioLoaded) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const data = portfolioMap.get(address);
|
||||
if (!data) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(data.totalUsdValue)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("wallets", {
|
||||
id: "tokens",
|
||||
header: "Tokens",
|
||||
cell: (cell) => {
|
||||
const address = cell.getValue()[0]?.address;
|
||||
if (!address) return "N/A";
|
||||
if (!portfolioLoaded) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const data = portfolioMap.get(address);
|
||||
if (!data || data.tokens.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">None</span>;
|
||||
}
|
||||
|
||||
const topTokens = data.tokens
|
||||
.sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0))
|
||||
.slice(0, 3)
|
||||
.map((t) => t.symbol)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="text-sm">
|
||||
{topTokens}
|
||||
{data.tokens.length > 3 ? "..." : ""}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
{data.tokens.map((t) => (
|
||||
<div
|
||||
key={`${t.tokenAddress}-${t.chainId}`}
|
||||
className="flex justify-between gap-4 text-xs"
|
||||
>
|
||||
<span>{t.symbol}</span>
|
||||
<span>
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(t.usdValue || 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("linkedAccounts", {
|
||||
cell: (cell) => {
|
||||
const email = getPrimaryEmail(cell.getValue());
|
||||
@@ -201,24 +384,7 @@ export function UserWalletsTable(
|
||||
id: "login_methods",
|
||||
}),
|
||||
];
|
||||
}, [props.client]);
|
||||
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearchResults, setHasSearchResults] = useState(false);
|
||||
const walletsQuery = useEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
clientId: props.projectClientId,
|
||||
ecosystemSlug: props.ecosystemSlug,
|
||||
teamId: props.teamId,
|
||||
page: activePage,
|
||||
});
|
||||
const wallets = walletsQuery?.data?.users || [];
|
||||
const { mutateAsync: getAllEmbeddedWallets, isPending } =
|
||||
useAllEmbeddedWallets({
|
||||
authToken: props.authToken,
|
||||
});
|
||||
}, [props.client, portfolioMap, portfolioLoaded]);
|
||||
|
||||
const handleSearch = async (searchType: SearchType, query: string) => {
|
||||
setIsSearching(true);
|
||||
@@ -315,11 +481,11 @@ export function UserWalletsTable(
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
disabled={wallets.length === 0 || isLoadingAllWallets}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
{isLoadingAllWallets && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
@@ -330,6 +496,83 @@ export function UserWalletsTable(
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
{/* Chain Selector and Fetch Button */}
|
||||
<div className="flex items-center gap-3 px-4 lg:px-6 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<MultiNetworkSelector
|
||||
client={props.client}
|
||||
selectedChainIds={selectedChains}
|
||||
onChange={setSelectedChains}
|
||||
disableChainId
|
||||
hideTestnets
|
||||
popoverContentClassName="max-h-[300px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleFetchBalances()}
|
||||
disabled={
|
||||
isFetchingBalances ||
|
||||
selectedChains.length === 0 ||
|
||||
walletsQuery.isPending
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{isFetchingBalances ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
{isFetchingBalances
|
||||
? `Fetching... ${fetchProgress.total > 0 ? Math.round((fetchProgress.completed / fetchProgress.total) * 100) : 0}%`
|
||||
: "Fetch All Balances"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetchingBalances && (
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-[150px]">
|
||||
{fetchProgress.total > 0 && (
|
||||
<Progress
|
||||
value={
|
||||
(fetchProgress.completed / fetchProgress.total) * 100
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This may take a few minutes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portfolioLoaded && !isFetchingBalances && (
|
||||
<Badge variant="success" className="ml-auto">
|
||||
Balances loaded for {portfolioMap.size} wallets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="grid grid-cols-2 gap-4 px-4 lg:px-6 py-4">
|
||||
<StatCard
|
||||
label="Funded Wallets"
|
||||
value={portfolioLoaded ? aggregatedStats.fundedWallets : 0}
|
||||
icon={WalletIcon}
|
||||
isPending={isFetchingBalances}
|
||||
emptyText={!portfolioLoaded ? "—" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Value"
|
||||
value={portfolioLoaded ? aggregatedStats.totalValue : 0}
|
||||
icon={DollarSignIcon}
|
||||
formatter={(value) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(value)
|
||||
}
|
||||
isPending={isFetchingBalances}
|
||||
emptyText={!portfolioLoaded ? "—" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
|
||||
@@ -42,7 +42,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogOverlay className={dialogOverlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-lg",
|
||||
"fixed z-50 grid w-full gap-4 rounded-t-xl border border-border bg-background p-6 shadow-lg duration-300 md:max-w-lg md:rounded-xl",
|
||||
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
|
||||
"right-0 bottom-0 left-0",
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
|
||||
@@ -41,3 +41,15 @@ export const NEXT_PUBLIC_CHAIN_PAGE_CLIENT_ID =
|
||||
|
||||
export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID;
|
||||
|
||||
export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
THIRDWEB_BRIDGE_URL,
|
||||
THIRDWEB_BUNDLER_DOMAIN,
|
||||
THIRDWEB_ENGINE_CLOUD_URL,
|
||||
THIRDWEB_INAPP_WALLET_DOMAIN,
|
||||
THIRDWEB_INSIGHT_API_DOMAIN,
|
||||
THIRDWEB_PAY_DOMAIN,
|
||||
@@ -38,6 +39,7 @@ export function getConfiguredThirdwebClient(options: {
|
||||
rpc: THIRDWEB_RPC_DOMAIN,
|
||||
social: THIRDWEB_SOCIAL_API_DOMAIN,
|
||||
storage: THIRDWEB_STORAGE_DOMAIN,
|
||||
engineCloud: new URL(THIRDWEB_ENGINE_CLOUD_URL).hostname,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,6 @@ export const THIRDWEB_INSIGHT_API_DOMAIN =
|
||||
|
||||
export const THIRDWEB_BRIDGE_URL =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb-dev.com";
|
||||
|
||||
export const THIRDWEB_ENGINE_CLOUD_URL =
|
||||
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "https://engine.thirdweb-dev.com";
|
||||
|
||||
@@ -330,20 +330,16 @@ export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
|
||||
return res.data;
|
||||
|
||||
@@ -177,33 +177,63 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
}) => {
|
||||
const responses: WalletUser[] = [];
|
||||
let page = 1;
|
||||
let consecutiveFailures = 0;
|
||||
const maxConsecutiveFailures = 3;
|
||||
|
||||
while (true) {
|
||||
const res = await queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
while (consecutiveFailures < maxConsecutiveFailures) {
|
||||
try {
|
||||
const res = await queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) =>
|
||||
Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||
});
|
||||
|
||||
responses.push(...res.users);
|
||||
responses.push(...res.users);
|
||||
consecutiveFailures = 0; // Reset on success
|
||||
|
||||
if (!res.hasMore) {
|
||||
break;
|
||||
if (!res.hasMore) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} catch (error) {
|
||||
consecutiveFailures++;
|
||||
console.warn(
|
||||
`Failed to fetch page ${page}, attempt ${consecutiveFailures}/${maxConsecutiveFailures}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
if (consecutiveFailures >= maxConsecutiveFailures) {
|
||||
// If we have some data, return it instead of throwing
|
||||
if (responses.length > 0) {
|
||||
console.warn(
|
||||
`Returning partial results (${responses.length} users) after ${maxConsecutiveFailures} consecutive failures`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying the same page
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000 * consecutiveFailures),
|
||||
);
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return responses;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { GetBalanceResult } from "thirdweb/extensions/erc20";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
type WalletPortfolioToken = GetBalanceResult & {
|
||||
usdValue?: number;
|
||||
priceUsd?: number;
|
||||
};
|
||||
|
||||
export type WalletPortfolioData = {
|
||||
totalUsdValue: number;
|
||||
tokens: WalletPortfolioToken[];
|
||||
};
|
||||
|
||||
// Retry helper with exponential backoff - returns null on failure instead of throwing
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
maxRetries = 3,
|
||||
): Promise<Response | null> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Retry on rate limit (429) or server errors (5xx)
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10000); // Max 10s delay
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (_e) {
|
||||
// Network error - retry with backoff
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed - return null instead of throwing
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch tokens for a single address on a single chain
|
||||
async function fetchTokensForChain(
|
||||
address: string,
|
||||
chainId: number,
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
): Promise<WalletPortfolioToken[]> {
|
||||
try {
|
||||
const url = new URL(`${THIRDWEB_API_HOST}/v1/wallets/${address}/tokens`);
|
||||
url.searchParams.set("chainId", chainId.toString());
|
||||
url.searchParams.set("limit", "50");
|
||||
url.searchParams.set("metadata", "true");
|
||||
url.searchParams.set("includeSpam", "false");
|
||||
url.searchParams.set("includeNative", "true");
|
||||
url.searchParams.set("sortBy", "usd_value");
|
||||
url.searchParams.set("sortOrder", "desc");
|
||||
|
||||
const response = await fetchWithRetry(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-thirdweb-team-id": teamId,
|
||||
...(clientId && { "x-client-id": clientId }),
|
||||
...(ecosystemSlug && {
|
||||
"x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// If all retries failed or response not ok, return empty (balance = 0)
|
||||
if (!response || !response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawTokens = data.result?.tokens || [];
|
||||
|
||||
return rawTokens.map(
|
||||
(t: {
|
||||
balance: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
token_address: string;
|
||||
chain_id: number;
|
||||
price_data?: { usd_value?: number; price_usd?: number };
|
||||
}): WalletPortfolioToken => ({
|
||||
value: BigInt(t.balance),
|
||||
decimals: t.decimals,
|
||||
displayValue: (Number(t.balance) / 10 ** t.decimals).toString(),
|
||||
name: t.name,
|
||||
symbol: t.symbol,
|
||||
tokenAddress: t.token_address,
|
||||
chainId: t.chain_id,
|
||||
usdValue: t.price_data?.usd_value,
|
||||
priceUsd: t.price_data?.price_usd,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
// Silent fail for individual chain - continue with others
|
||||
console.warn(
|
||||
`Failed to fetch tokens for ${address} on chain ${chainId}:`,
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch tokens for a single address on selected chains
|
||||
async function fetchWalletPortfolio(
|
||||
address: string,
|
||||
chainIds: number[],
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
): Promise<WalletPortfolioData> {
|
||||
// Fetch all chains concurrently for this address
|
||||
const results = await Promise.all(
|
||||
chainIds.map((chainId) =>
|
||||
fetchTokensForChain(
|
||||
address,
|
||||
chainId,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const allTokens = results.flat();
|
||||
const totalUsdValue = allTokens.reduce(
|
||||
(acc, token) => acc + (token.usdValue || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalUsdValue,
|
||||
tokens: allTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch fetch portfolios for all addresses with progress callback
|
||||
async function fetchAllPortfolios(
|
||||
addresses: string[],
|
||||
chainIds: number[],
|
||||
authToken: string,
|
||||
teamId: string,
|
||||
clientId?: string,
|
||||
ecosystemSlug?: string,
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
): Promise<Map<string, WalletPortfolioData>> {
|
||||
const results = new Map<string, WalletPortfolioData>();
|
||||
const concurrency = 50; // Process up to 50 addresses concurrently
|
||||
let completed = 0;
|
||||
let index = 0;
|
||||
|
||||
// Worker function that processes one address at a time
|
||||
async function worker() {
|
||||
while (index < addresses.length) {
|
||||
const currentIndex = index++;
|
||||
const address = addresses[currentIndex];
|
||||
if (!address) continue;
|
||||
|
||||
try {
|
||||
const data = await fetchWalletPortfolio(
|
||||
address,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
);
|
||||
results.set(address, data);
|
||||
} catch (e) {
|
||||
// Silent fail - continue with others
|
||||
console.warn(`Failed to fetch portfolio for ${address}:`, e);
|
||||
}
|
||||
|
||||
completed++;
|
||||
onProgress?.(completed, addresses.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Start concurrent workers
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, addresses.length) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function useFetchAllPortfolios() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
addresses,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
onProgress,
|
||||
}: {
|
||||
addresses: string[];
|
||||
chainIds: number[];
|
||||
authToken: string;
|
||||
teamId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
onProgress?: (completed: number, total: number) => void;
|
||||
}) => {
|
||||
return fetchAllPortfolios(
|
||||
addresses,
|
||||
chainIds,
|
||||
authToken,
|
||||
teamId,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
onProgress,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -101,36 +101,20 @@ export interface X402SettlementsOverall {
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByChainId {
|
||||
date: string;
|
||||
export interface X402SettlementsByChainId extends X402SettlementsOverall {
|
||||
chainId: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByPayer {
|
||||
date: string;
|
||||
export interface X402SettlementsByPayer extends X402SettlementsOverall {
|
||||
payer: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
interface X402SettlementsByReceiver {
|
||||
date: string;
|
||||
interface X402SettlementsByReceiver extends X402SettlementsOverall {
|
||||
receiver: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
export interface X402SettlementsByResource {
|
||||
date: string;
|
||||
export interface X402SettlementsByResource extends X402SettlementsOverall {
|
||||
resource: string;
|
||||
totalRequests: number;
|
||||
totalValue: number;
|
||||
totalValueUSD: number;
|
||||
}
|
||||
|
||||
interface X402SettlementsByAsset {
|
||||
|
||||
@@ -13,9 +13,16 @@ export type ProductSKU =
|
||||
| "usage:in_app_wallet"
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
// dedicated relayer SKUs
|
||||
| DedicatedRelayerSKU
|
||||
| null;
|
||||
|
||||
export type ChainInfraSKU =
|
||||
| "chain:infra:rpc"
|
||||
| "chain:infra:insight"
|
||||
| "chain:infra:account_abstraction";
|
||||
|
||||
export type DedicatedRelayerSKU =
|
||||
| "product:dedicated_relayer_standard"
|
||||
| "product:dedicated_relayer_premium"
|
||||
| "product:dedicated_relayer_enterprise";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatTickerNumber = (value: number) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const usdCurrencyFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6, // prefix with $
|
||||
minimumFractionDigits: 0, // don't show decimal places if value is a whole number
|
||||
notation: "compact", // at max 2 decimal places
|
||||
roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations
|
||||
style: "currency", // shows 1.2M instead of 1,200,000, 1.2B instead of 1,200,000,000
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
style: "currency",
|
||||
});
|
||||
|
||||
export const toUSD = (value: number) => {
|
||||
|
||||
@@ -283,7 +283,7 @@ function TokenInfoSection(props: {
|
||||
<dt className="text-xs font-medium text-muted-foreground tracking-wider uppercase">
|
||||
{props.label}
|
||||
</dt>
|
||||
<dd className="text-3xl font-bold text-foreground tracking-tight">
|
||||
<dd className="text-3xl font-semibold text-foreground tracking-tight">
|
||||
{props.value}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -72,7 +72,6 @@ export const DecodedInputSet: React.FC<DecodedInputSetProps> = ({
|
||||
<div className="mt-6 flex justify-start">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={param.type === "bytes" && fields.length >= 1}
|
||||
onClick={() =>
|
||||
append({
|
||||
defaultValue: "",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { bridgeStats } from "../../../bridge/data";
|
||||
import { bridgeStats } from "../../../bridge/(general)/data";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
|
||||
@@ -17,15 +17,21 @@ export default async function CheckoutPage(props: {
|
||||
searchParams: Promise<{
|
||||
amount?: string;
|
||||
invoice_id?: string;
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
console.log("params", params);
|
||||
console.log("searchParams", searchParams);
|
||||
|
||||
switch (params.sku) {
|
||||
case "topup": {
|
||||
const amountUSD = Number.parseInt(
|
||||
(await props.searchParams).amount || "10",
|
||||
);
|
||||
const amountUSD = Number.parseInt(searchParams.amount || "10");
|
||||
if (Number.isNaN(amountUSD)) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
|
||||
}
|
||||
@@ -43,7 +49,7 @@ export default async function CheckoutPage(props: {
|
||||
break;
|
||||
}
|
||||
case "invoice": {
|
||||
const invoiceId = (await props.searchParams).invoice_id;
|
||||
const invoiceId = searchParams.invoice_id;
|
||||
if (!invoiceId) {
|
||||
return <StripeRedirectErrorPage errorMessage="Invalid invoice ID" />;
|
||||
}
|
||||
@@ -59,10 +65,12 @@ export default async function CheckoutPage(props: {
|
||||
redirect(invoice);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
const response = await getBillingCheckoutUrl({
|
||||
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,
|
||||
teamSlug: params.team_slug,
|
||||
params: searchParams,
|
||||
});
|
||||
|
||||
if (response.status === "error") {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
export async function getBillingCheckoutUrl(options: {
|
||||
teamSlug: string;
|
||||
sku: Exclude<ProductSKU, null>;
|
||||
params: {
|
||||
project_id?: string;
|
||||
chain_id?: string | string[];
|
||||
};
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -17,13 +21,26 @@ export async function getBillingCheckoutUrl(options: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const chainIds = options.params.chain_id
|
||||
? (Array.isArray(options.params.chain_id)
|
||||
? options.params.chain_id
|
||||
: [options.params.chain_id]
|
||||
)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
: undefined;
|
||||
|
||||
const body = {
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
projectId: options.params.project_id,
|
||||
chainIds: chainIds,
|
||||
};
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
redirectTo: getAbsoluteStripeRedirectUrl(),
|
||||
sku: options.sku,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -58,6 +75,18 @@ export async function getBillingCheckoutUrl(options: {
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (
|
||||
"error" in json &&
|
||||
"message" in json.error &&
|
||||
typeof json.error.message === "string"
|
||||
) {
|
||||
return {
|
||||
error: json.error.message,
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
|
||||
@@ -312,7 +312,7 @@ function CreateProjectForm(props: {
|
||||
type="submit"
|
||||
>
|
||||
{createProject.isPending && <Spinner className="size-4" />}
|
||||
Create Projecct
|
||||
Create Project
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -25,9 +24,6 @@ export function AppRouterProviders(props: {
|
||||
children: React.ReactNode;
|
||||
autoConnect: boolean;
|
||||
}) {
|
||||
// get the current pathname
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -40,9 +36,6 @@ export function AppRouterProviders(props: {
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
enableSystem={false}
|
||||
forcedTheme={
|
||||
pathname.startsWith("/get-started") ? "dark" : undefined
|
||||
}
|
||||
>
|
||||
<Toaster richColors />
|
||||
<SanctionedAddressesChecker>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useAddPartner } from "../../hooks/use-add-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -23,6 +24,8 @@ export function AddPartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: addPartner, isPending } = useAddPartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -48,10 +51,23 @@ export function AddPartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
let imageUrl: string | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addPartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -61,6 +77,7 @@ export function AddPartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
});
|
||||
};
|
||||
@@ -68,7 +85,7 @@ export function AddPartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Add"
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import { PencilIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useId, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { z } from "zod";
|
||||
import type { Partner } from "@/api/team/ecosystems";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { ImageUpload } from "@/components/ui/image-upload";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { partnerFormSchema } from "../../constants";
|
||||
import { AllowedOperationsSection } from "./allowed-operations-section";
|
||||
|
||||
@@ -118,6 +121,7 @@ export function PartnerForm({
|
||||
|
||||
const accessControlId = useId();
|
||||
const serverVerifierId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -150,6 +154,101 @@ export function PartnerForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={() => {
|
||||
const removeLogo = form.watch("removeLogo");
|
||||
const logoFile = form.watch("logo");
|
||||
const newFilePreview = logoFile
|
||||
? URL.createObjectURL(logoFile)
|
||||
: undefined;
|
||||
const existingImageUrl =
|
||||
partner?.imageUrl && !removeLogo
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: partner.imageUrl,
|
||||
})
|
||||
: undefined;
|
||||
const displayUrl = newFilePreview || existingImageUrl;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Partner Logo</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
form.setValue("logo", file, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
{displayUrl ? (
|
||||
<div className="relative inline-block">
|
||||
<Img
|
||||
alt={partner?.name ?? "Partner logo"}
|
||||
className="size-20 rounded-md border object-contain object-center"
|
||||
src={displayUrl}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Remove logo"
|
||||
className="absolute -top-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => {
|
||||
form.setValue("logo", undefined);
|
||||
form.setValue("removeLogo", true);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Change logo"
|
||||
className="absolute -bottom-2 -right-2 size-6 rounded-full bg-background p-0 hover:bg-accent"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PencilIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ImageUpload
|
||||
accept="image/png, image/jpeg, image/webp"
|
||||
className="bg-background"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0], {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("removeLogo", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional logo for this partner. Used in OTP emails sent to
|
||||
users authenticating through this partner.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
import { useDashboardStorageUpload } from "@/hooks/useDashboardStorageUpload";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useUpdatePartner } from "../../hooks/use-update-partner";
|
||||
import { PartnerForm, type PartnerFormValues } from "./partner-form.client";
|
||||
@@ -25,6 +26,8 @@ export function UpdatePartnerForm({
|
||||
const teamSlug = params.team_slug as string;
|
||||
const ecosystemSlug = params.slug as string;
|
||||
|
||||
const storageUpload = useDashboardStorageUpload({ client });
|
||||
|
||||
const { mutateAsync: updatePartner, isPending } = useUpdatePartner(
|
||||
{
|
||||
authToken,
|
||||
@@ -50,10 +53,31 @@ export function UpdatePartnerForm({
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
const isUploading = storageUpload.isPending;
|
||||
|
||||
const handleSubmit = async (
|
||||
values: PartnerFormValues,
|
||||
finalAccessControl: Partner["accessControl"] | null,
|
||||
) => {
|
||||
// Determine imageUrl based on three states:
|
||||
// 1. New file uploaded → upload and use new URI
|
||||
// 2. Explicit removal → send null to clear
|
||||
// 3. No change → preserve existing partner imageUrl
|
||||
let imageUrl: string | null | undefined;
|
||||
if (values.logo) {
|
||||
try {
|
||||
const [uri] = await storageUpload.mutateAsync([values.logo]);
|
||||
imageUrl = uri;
|
||||
} catch {
|
||||
toast.error("Failed to upload logo");
|
||||
return;
|
||||
}
|
||||
} else if (values.removeLogo) {
|
||||
imageUrl = null;
|
||||
} else {
|
||||
imageUrl = partner.imageUrl;
|
||||
}
|
||||
|
||||
updatePartner({
|
||||
accessControl: finalAccessControl,
|
||||
allowlistedBundleIds: values.bundleIds
|
||||
@@ -63,6 +87,7 @@ export function UpdatePartnerForm({
|
||||
.split(/,| /)
|
||||
.filter((d) => d.length > 0),
|
||||
ecosystem,
|
||||
imageUrl,
|
||||
name: values.name,
|
||||
partnerId: partner.id,
|
||||
});
|
||||
@@ -71,7 +96,7 @@ export function UpdatePartnerForm({
|
||||
return (
|
||||
<PartnerForm
|
||||
client={client}
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isUploading}
|
||||
onSubmit={handleSubmit}
|
||||
partner={partner}
|
||||
submitLabel="Update"
|
||||
|
||||
@@ -154,6 +154,21 @@ const allowedOperationsSchema = z.discriminatedUnion("signMethod", [
|
||||
|
||||
export const partnerFormSchema = z
|
||||
.object({
|
||||
logo: z
|
||||
.instanceof(File, {
|
||||
message: "Please select an image file",
|
||||
})
|
||||
.refine(
|
||||
(file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type),
|
||||
{
|
||||
message: "Only PNG, JPG or WEBP images are allowed",
|
||||
},
|
||||
)
|
||||
.refine((file) => file.size <= 500 * 1024, {
|
||||
message: "Logo size must be less than 500KB",
|
||||
})
|
||||
.optional(),
|
||||
removeLogo: z.boolean().default(false),
|
||||
accessControl: z
|
||||
.object({
|
||||
allowedOperations: z
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Ecosystem, Partner } from "@/api/team/ecosystems";
|
||||
type AddPartnerParams = {
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: Partner["accessControl"] | null;
|
||||
@@ -37,6 +38,7 @@ export function useAddPartner(
|
||||
accessControl: params.accessControl ?? undefined,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
// TODO - remove the requirement for permissions in API endpoint
|
||||
permissions: ["FULL_CONTROL_V1"],
|
||||
|
||||
@@ -9,6 +9,7 @@ type UpdatePartnerParams = {
|
||||
partnerId: string;
|
||||
ecosystem: Ecosystem;
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
allowlistedDomains: string[];
|
||||
allowlistedBundleIds: string[];
|
||||
accessControl?: {
|
||||
@@ -43,6 +44,7 @@ export function useUpdatePartner(
|
||||
accessControl: params.accessControl,
|
||||
allowlistedBundleIds: params.allowlistedBundleIds,
|
||||
allowlistedDomains: params.allowlistedDomains,
|
||||
imageUrl: params.imageUrl,
|
||||
name: params.name,
|
||||
}),
|
||||
|
||||
|
||||
@@ -63,15 +63,17 @@ function MetricPlaceholders({
|
||||
{metric.label}
|
||||
</span>
|
||||
<div className="h-32 w-full">
|
||||
<EmptyChartState type="area">
|
||||
{status === "active" ? (
|
||||
<Badge>Coming Soon</Badge>
|
||||
) : status === "pending" ? (
|
||||
<p className="text-xs">Activation in progress.</p>
|
||||
) : (
|
||||
<p className="text-xs">Activate service to view metrics.</p>
|
||||
)}
|
||||
</EmptyChartState>
|
||||
<EmptyChartState
|
||||
content={
|
||||
status === "active" ? (
|
||||
<Badge>Coming Soon</Badge>
|
||||
) : status === "pending" ? (
|
||||
<p className="text-xs">Activation in progress.</p>
|
||||
) : (
|
||||
<p className="text-xs">Activate service to view metrics.</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getAiUsage } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { AiTokenUsageChartCardUI } from "../ai/analytics/chart/AiTokenUsageChartCard";
|
||||
|
||||
async function AsyncAiAnalytics(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getAiUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<AiTokenUsageChartCardUI
|
||||
title="AI token volume"
|
||||
isPending={false}
|
||||
aiUsageStats={stats}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No AI usage found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIAnalyticsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<AiTokenUsageChartCardUI
|
||||
isPending={true}
|
||||
title="AI token volume"
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
aiUsageStats={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAiAnalytics
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import {
|
||||
AutoMergeBarChart,
|
||||
type StatData,
|
||||
@@ -75,7 +76,16 @@ function AllWalletConnectionsChartUI(props: {
|
||||
isPending={props.isPending}
|
||||
exportButton={undefined}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets/configure`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No wallet connections found in selected time period"
|
||||
/>
|
||||
}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getUniversalBridgeUsage } from "@/api/analytics";
|
||||
import {
|
||||
EmptyChartState,
|
||||
EmptyChartStateGetStartedCTA,
|
||||
LoadingChartState,
|
||||
} from "@/components/analytics/empty-chart-state";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import type { UniversalBridgeStats } from "@/types/analytics";
|
||||
import { TotalVolumePieChart } from "../payments/components/TotalVolumePieChart";
|
||||
|
||||
async function AsyncBridgeCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const data = await getUniversalBridgeUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<BridgeChartCardUI
|
||||
data={data}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="min-h-[40px] h-full border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncBridgeCard {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BridgeChartCardUI(props: {
|
||||
data: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const total =
|
||||
props.data.reduce((acc, curr) => acc + curr.amountUsdCents, 0) / 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-card border rounded-lg">
|
||||
<TotalValueChartHeader
|
||||
title="Bridge Volume"
|
||||
total={total}
|
||||
isUsd={true}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/bridge`}
|
||||
/>
|
||||
<div className="p-6 grow flex items-center justify-center">
|
||||
{total === 0 ? (
|
||||
<EmptyChartState
|
||||
content={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No bridge volume found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TotalVolumePieChart data={props.data} hideTotal={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Meta } from "@storybook/nextjs";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
X402SettlementsOverall,
|
||||
} from "@/types/analytics";
|
||||
import { ProjectHighlightsCard } from "./highlights-card-ui";
|
||||
|
||||
const meta = {
|
||||
component: ProjectHighlightsCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ResponsiveSearchParamsProvider value={{}}>
|
||||
<div className="container max-w-6xl py-10">
|
||||
<Story />
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
),
|
||||
],
|
||||
title: "Analytics/ProjectHighlightsCard",
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProjectHighlightsCard>;
|
||||
|
||||
export default meta;
|
||||
|
||||
function generateDateSeries(days: number) {
|
||||
const dates: string[] = [];
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
dates.push(d.toISOString());
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function inAppWalletStub(days: number): InAppWalletStats[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
// main data
|
||||
newUsers: randomValue(50, 100),
|
||||
uniqueWalletsConnected: randomValue(500, 1200),
|
||||
// others
|
||||
authenticationMethod: "email",
|
||||
date,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// a few aggregated entries that will be summed for the "Active Users" stat
|
||||
function aggregatedInAppWalletsStub(): InAppWalletStats[] {
|
||||
return Array.from({ length: 3 }).map(() => {
|
||||
return {
|
||||
// main data
|
||||
newUsers: randomValue(50, 100),
|
||||
uniqueWalletsConnected: randomValue(500, 1200),
|
||||
// others
|
||||
date: new Date().toISOString(),
|
||||
authenticationMethod: "email",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bridgeVolumeStub(days: number): UniversalBridgeStats[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
// main data
|
||||
developerFeeUsdCents: randomValue(500 * 100, 2500 * 100),
|
||||
status: "completed",
|
||||
// others
|
||||
chainId: 0,
|
||||
count: 0,
|
||||
date,
|
||||
amountUsdCents: 0,
|
||||
type: "onchain",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function randomValue(min: number, max: number) {
|
||||
return Math.max(0, Math.round(min + Math.random() * (max - min)));
|
||||
}
|
||||
|
||||
function generateX402Settlements(days: number): X402SettlementsOverall[] {
|
||||
const dates = generateDateSeries(days);
|
||||
return dates.map((date) => {
|
||||
return {
|
||||
date,
|
||||
totalRequests: randomValue(10, 100),
|
||||
totalValue: randomValue(500, 2500),
|
||||
totalValueUSD: randomValue(500, 2500),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function ThirtyDays() {
|
||||
const days = 30;
|
||||
const data = {
|
||||
aggregatedUserStats: aggregatedInAppWalletsStub(),
|
||||
userStats: inAppWalletStub(days),
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: generateX402Settlements(days),
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleDayRevenue() {
|
||||
const days = 1;
|
||||
const data = {
|
||||
aggregatedUserStats: [],
|
||||
userStats: [],
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: generateX402Settlements(days),
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleDayRevenueNoX402() {
|
||||
const days = 1;
|
||||
const data = {
|
||||
aggregatedUserStats: [],
|
||||
userStats: [],
|
||||
bridgeVolumeStats: bridgeVolumeStub(days),
|
||||
x402Settlements: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={data.aggregatedUserStats}
|
||||
userStats={data.userStats}
|
||||
volumeStats={data.bridgeVolumeStats}
|
||||
x402Settlements={data.x402Settlements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { useSetResponsiveSearchParams } from "responsive-rsc";
|
||||
import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics";
|
||||
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UniversalBridgeStats,
|
||||
X402SettlementsOverall,
|
||||
} from "@/types/analytics";
|
||||
import {
|
||||
CombinedBarChartCard,
|
||||
type CombinedBarChartConfig,
|
||||
} from "../../../../components/Analytics/CombinedBarChartCard";
|
||||
|
||||
type AggregatedMetrics = {
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
totalVolume: number;
|
||||
feesCollected: number;
|
||||
bridgeRevenue: number;
|
||||
x402Revenue: number;
|
||||
};
|
||||
|
||||
export function ProjectHighlightsCard(props: {
|
||||
selectedChart: string | undefined;
|
||||
aggregatedUserStats: InAppWalletStats[];
|
||||
userStats: InAppWalletStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
selectedChartQueryParam: string;
|
||||
x402Settlements: X402SettlementsOverall[];
|
||||
}) {
|
||||
const {
|
||||
selectedChart,
|
||||
aggregatedUserStats,
|
||||
const { aggregatedUserStats, userStats, volumeStats, x402Settlements } =
|
||||
props;
|
||||
|
||||
const [selectedChart, setSelectedChart] = useState<string | undefined>(
|
||||
"activeUsers",
|
||||
);
|
||||
|
||||
const timeSeriesData = processTimeSeriesData(
|
||||
userStats,
|
||||
volumeStats,
|
||||
teamSlug,
|
||||
projectSlug,
|
||||
selectedChartQueryParam,
|
||||
} = props;
|
||||
x402Settlements,
|
||||
);
|
||||
|
||||
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const chartConfig = {
|
||||
const chartConfig: CombinedBarChartConfig<keyof AggregatedMetrics> = {
|
||||
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
|
||||
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
|
||||
totalVolume: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
description="Onramp, swap, and bridge with thirdweb's Payments."
|
||||
link="https://portal.thirdweb.com/payments"
|
||||
metric="Payments"
|
||||
/>
|
||||
),
|
||||
newUsers: { color: "hsl(var(--chart-2))", label: "New Users" },
|
||||
bridgeRevenue: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
isCurrency: true,
|
||||
label: "Total Volume",
|
||||
label: "Bridge",
|
||||
hideAsTab: true,
|
||||
},
|
||||
x402Revenue: {
|
||||
color: "hsl(var(--chart-4))",
|
||||
isCurrency: true,
|
||||
label: "x402",
|
||||
hideAsTab: true,
|
||||
},
|
||||
feesCollected: {
|
||||
color: "hsl(var(--chart-4))",
|
||||
emptyContent: (
|
||||
<EmptyStateContent
|
||||
description="Your app hasn't collected any fees yet."
|
||||
link={`/team/${teamSlug}/${projectSlug}/bridge/configuration`}
|
||||
metric="Fees"
|
||||
/>
|
||||
),
|
||||
color: "hsl(var(--chart-3))",
|
||||
emptyContent: undefined,
|
||||
isCurrency: true,
|
||||
label: "Bridge Fee Revenue",
|
||||
label: "Revenue",
|
||||
stackedKeys: ["bridgeRevenue", "x402Revenue"],
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
return (
|
||||
<CombinedBarChartCard
|
||||
@@ -83,12 +82,7 @@ export function ProjectHighlightsCard(props: {
|
||||
chartConfig={chartConfig}
|
||||
data={timeSeriesData}
|
||||
onSelect={(key) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
[selectedChartQueryParam]: key,
|
||||
};
|
||||
});
|
||||
setSelectedChart(key);
|
||||
}}
|
||||
trendFn={(data, key) =>
|
||||
data.filter((d) => (d[key] as number) > 0).length >= 2
|
||||
@@ -103,6 +97,8 @@ export function ProjectHighlightsCard(props: {
|
||||
|
||||
type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
date: string;
|
||||
bridgeRevenue: number;
|
||||
x402Revenue: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,48 +107,51 @@ type TimeSeriesMetrics = AggregatedMetrics & {
|
||||
function processTimeSeriesData(
|
||||
userStats: InAppWalletStats[],
|
||||
volumeStats: UniversalBridgeStats[],
|
||||
x402Settlements: X402SettlementsOverall[],
|
||||
): TimeSeriesMetrics[] {
|
||||
const metrics: TimeSeriesMetrics[] = [];
|
||||
const dates = [
|
||||
...new Set([
|
||||
...userStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
|
||||
...volumeStats.map((a) => new Date(a.date).toISOString().slice(0, 10)),
|
||||
...userStats.map((a) => ignoreTimePeriod(a.date)),
|
||||
...volumeStats.map((a) => ignoreTimePeriod(a.date)),
|
||||
...x402Settlements.map((a) => ignoreTimePeriod(a.date)),
|
||||
]),
|
||||
];
|
||||
|
||||
for (const date of dates) {
|
||||
const activeUsers = userStats
|
||||
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
|
||||
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.uniqueWalletsConnected, 0);
|
||||
|
||||
const newUsers = userStats
|
||||
.filter((u) => new Date(u.date).toISOString().slice(0, 10) === date)
|
||||
.filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.newUsers, 0);
|
||||
|
||||
const volume = volumeStats
|
||||
const bridgeRevenue = volumeStats
|
||||
.filter(
|
||||
(v) =>
|
||||
new Date(v.date).toISOString().slice(0, 10) === date &&
|
||||
v.status === "completed",
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const fees = volumeStats
|
||||
.filter(
|
||||
(v) =>
|
||||
new Date(v.date).toISOString().slice(0, 10) === date &&
|
||||
ignoreTimePeriod(v.date) === ignoreTimePeriod(date) &&
|
||||
v.status === "completed",
|
||||
)
|
||||
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
|
||||
|
||||
const x402Revenue = x402Settlements
|
||||
.filter((x) => ignoreTimePeriod(x.date) === ignoreTimePeriod(date))
|
||||
.reduce((acc, curr) => acc + curr.totalValueUSD, 0);
|
||||
|
||||
metrics.push({
|
||||
activeUsers: activeUsers,
|
||||
date: date,
|
||||
feesCollected: fees,
|
||||
feesCollected: bridgeRevenue + x402Revenue,
|
||||
bridgeRevenue: bridgeRevenue,
|
||||
x402Revenue: x402Revenue,
|
||||
newUsers: newUsers,
|
||||
totalVolume: volume,
|
||||
});
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function ignoreTimePeriod(date: string) {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getX402Settlements,
|
||||
} from "@/api/analytics";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { ProjectHighlightsCard } from "./highlights-card-ui";
|
||||
import type { PageParams, PageSearchParams } from "./types";
|
||||
|
||||
export function ProjectHighlightCard(props: {
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
interval: "day" | "week";
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
searchParams: PageSearchParams;
|
||||
project: Project;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAppHighlightsCard
|
||||
authToken={props.authToken}
|
||||
client={props.client}
|
||||
interval={props.interval}
|
||||
params={props.params}
|
||||
project={props.project}
|
||||
range={props.range}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAppHighlightsCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
client: ThirdwebClient;
|
||||
params: PageParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [
|
||||
aggregatedUserStats,
|
||||
walletUserStatsTimeSeries,
|
||||
universalBridgeUsage,
|
||||
x402Settlements,
|
||||
] = await Promise.allSettled([
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getUniversalBridgeUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getX402Settlements(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
]);
|
||||
|
||||
if (
|
||||
walletUserStatsTimeSeries.status === "fulfilled" &&
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
) {
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={
|
||||
aggregatedUserStats.status === "fulfilled"
|
||||
? aggregatedUserStats.value
|
||||
: []
|
||||
}
|
||||
userStats={
|
||||
walletUserStatsTimeSeries.status === "fulfilled"
|
||||
? walletUserStatsTimeSeries.value
|
||||
: []
|
||||
}
|
||||
volumeStats={
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
? universalBridgeUsage.value
|
||||
: []
|
||||
}
|
||||
x402Settlements={
|
||||
x402Settlements.status === "fulfilled" ? x402Settlements.value : []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/wallets"
|
||||
metric="Wallets"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getInsightStatusCodeUsage } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { RequestsByStatusGraph } from "../gateway/indexer/components/RequestsByStatusGraph";
|
||||
|
||||
export function IndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RequestsByStatusGraph
|
||||
data={[]}
|
||||
isPending={true}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncIndexerRequestsChartCard
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncIndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getInsightStatusCodeUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return (
|
||||
<RequestsByStatusGraph
|
||||
data={requestsData && "data" in requestsData ? requestsData.data : []}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No indexer requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { TransactionsChartCardAsync } from "../components/Transactions";
|
||||
import { AIAnalyticsChartCard } from "./ai-card";
|
||||
import { AllWalletConnectionsChart } from "./all-wallet-connections-chart";
|
||||
import { BridgeChartCard } from "./bridge-card";
|
||||
import { ProjectHighlightCard } from "./highlights-card";
|
||||
import { IndexerRequestsChartCard } from "./indexer-card";
|
||||
import { RPCRequestsChartCard } from "./rpc-card";
|
||||
import type { PageParams, PageSearchParams } from "./types";
|
||||
import { X402RequestsChartCard } from "./x402-card";
|
||||
|
||||
export async function ProjectAnalytics(props: {
|
||||
project: Project;
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: PageSearchParams;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { project, params, range, interval, searchParams, client, authToken } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* highlights */}
|
||||
<ProjectHighlightCard
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
interval={interval}
|
||||
params={params}
|
||||
project={project}
|
||||
range={range}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* wallets */}
|
||||
<AllWalletConnectionsChart
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
projectId={project.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
|
||||
{/* bridge */}
|
||||
<BridgeChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Indexer */}
|
||||
<IndexerRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* RPC */}
|
||||
<RPCRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* x402 */}
|
||||
<X402RequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
|
||||
{/* AI */}
|
||||
<AIAnalyticsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transactions, Chains, Contracts */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
|
||||
>
|
||||
<TransactionsChartCardAsync
|
||||
client={client}
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
selectedChart={
|
||||
typeof searchParams.client_transactions === "string"
|
||||
? searchParams.client_transactions
|
||||
: undefined
|
||||
}
|
||||
selectedChartQueryParam="client_transactions"
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getRpcUsageByType } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { RPCRequestsChartUI } from "../gateway/rpc/components/RequestsGraph";
|
||||
|
||||
async function AsyncRPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getRpcUsageByType(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
const isEmpty = requestsData?.every((d) => d.count === 0);
|
||||
|
||||
return (
|
||||
<RPCRequestsChartUI
|
||||
isPending={false}
|
||||
data={isEmpty ? [] : requestsData || []}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View more details",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No RPC requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RPCRequestsChartUI
|
||||
isPending={true}
|
||||
data={[]}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncRPCRequestsChartCard
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type PageParams = {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
};
|
||||
|
||||
export type PageSearchParams = {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
type: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
client_transactions: string | undefined | string[];
|
||||
totalSponsored: string | undefined | string[];
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getX402Settlements } from "@/api/analytics";
|
||||
import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state";
|
||||
import { X402RequestsChartCardUI } from "./x402-requests-chart";
|
||||
|
||||
async function AsyncX402RequestsChart(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getX402Settlements(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
|
||||
|
||||
return (
|
||||
<X402RequestsChartCardUI
|
||||
stats={
|
||||
isAllEmpty
|
||||
? []
|
||||
: stats.map((stat) => ({
|
||||
requests: stat.totalRequests,
|
||||
time: stat.date,
|
||||
}))
|
||||
}
|
||||
isPending={false}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
emptyChartState={
|
||||
<EmptyChartStateGetStartedCTA
|
||||
link={{
|
||||
label: "View Configuration",
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/x402/configuration`,
|
||||
}}
|
||||
title="No data available"
|
||||
description="No X402 requests found in selected time period"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function X402RequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<X402RequestsChartCardUI
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncX402RequestsChart
|
||||
from={props.from}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
to={props.to}
|
||||
interval={props.interval}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export function X402RequestsChartCardUI(props: {
|
||||
isPending: boolean;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const total = useMemo(() => {
|
||||
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
|
||||
@@ -41,6 +42,7 @@ export function X402RequestsChartCardUI(props: {
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
|
||||
/>
|
||||
}
|
||||
emptyChartState={props.emptyChartState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
viewMoreLink: string | undefined;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const { aiUsageStats } = props;
|
||||
|
||||
@@ -93,7 +94,7 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
)
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<AiTokenUsageEmptyChartState />}
|
||||
emptyChartState={props.emptyChartState}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend={false}
|
||||
@@ -109,7 +110,7 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function AiTokenUsageEmptyChartState() {
|
||||
function _AiTokenUsageEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
|
||||
|
||||
@@ -20,35 +20,17 @@ export function QuickStartSection(props: {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeatureCard
|
||||
title="Buy Widget Component"
|
||||
icon={CodeIcon}
|
||||
id="swap_tokens"
|
||||
features={[
|
||||
"React component",
|
||||
"Cross-chain token swaps across 85+ blockchains",
|
||||
"Fiat onramp support to buy tokens with credit/debit cards",
|
||||
"Customizable UI",
|
||||
]}
|
||||
description={undefined}
|
||||
link={{
|
||||
href: `https://portal.thirdweb.com/bridge/swap`,
|
||||
label: "Get Started",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
title="Bridge Widget Script"
|
||||
description={undefined}
|
||||
title="Bridge Widget"
|
||||
description="Integrate cross-chain swaps and fiat onramp into your app using bridge widget"
|
||||
icon={CodeIcon}
|
||||
id="fees"
|
||||
features={[
|
||||
"Integrate with a script tag",
|
||||
"Integrate with iframe, script, or React component",
|
||||
"Cross-chain token swaps across 85+ blockchains",
|
||||
"Fiat onramp support to buy tokens with credit/debit cards",
|
||||
"Customizable UI",
|
||||
]}
|
||||
link={{
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/configuration`,
|
||||
href: `https://portal.thirdweb.com/bridge/bridge-widget`,
|
||||
label: "Get Started",
|
||||
}}
|
||||
/>
|
||||
@@ -60,7 +42,7 @@ export function QuickStartSection(props: {
|
||||
id="webhooks"
|
||||
features={["Instant events", "Transaction verification"]}
|
||||
link={{
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`,
|
||||
href: `/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`,
|
||||
label: "Setup Webhooks",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WebhookIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
@@ -43,13 +42,7 @@ export default async function Layout(props: {
|
||||
instantly
|
||||
</>
|
||||
),
|
||||
actions: {
|
||||
secondary: {
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
|
||||
label: "Webhooks",
|
||||
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
|
||||
},
|
||||
},
|
||||
actions: null,
|
||||
links: [
|
||||
{
|
||||
type: "docs",
|
||||
@@ -75,6 +68,10 @@ export default async function Layout(props: {
|
||||
name: "Configuration",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/bridge/configuration`,
|
||||
},
|
||||
{
|
||||
name: "Webhooks",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default async function Page(props: {
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/webhooks/payments`}
|
||||
href={`/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`}
|
||||
>
|
||||
Webhooks
|
||||
</Link>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
WebhookIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type PropsWithChildren, useMemo, useState } from "react";
|
||||
@@ -95,9 +96,14 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
if (!webhooksQuery.data?.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 rounded-lg border border-border bg-card p-8 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-xl">No webhooks configured</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-base mb-1">
|
||||
No webhooks configured
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create a webhook to receive notifications for bridge, swap or onramp
|
||||
events.
|
||||
</p>
|
||||
@@ -126,7 +132,7 @@ export function PayWebhooksPage(props: PayWebhooksPageProps) {
|
||||
webhook={webhook}
|
||||
clientId={props.clientId}
|
||||
teamId={props.teamId}
|
||||
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/webhooks/payments`}
|
||||
layoutPath={`/team/${props.teamSlug}/${props.projectSlug}/bridge/webhooks`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { PayWebhooksPage } from "../../payments/webhooks/components/webhooks.client";
|
||||
import { PayWebhooksPage } from "./components/webhooks.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DoorOpenIcon,
|
||||
HomeIcon,
|
||||
Settings2Icon,
|
||||
WebhookIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
FullWidthSidebarLayout,
|
||||
@@ -52,6 +51,14 @@ export function ProjectSidebarLayout(props: {
|
||||
href: `${props.layoutPath}/wallets/sponsored-gas`,
|
||||
label: "Gas Sponsorship",
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/wallets/dedicated-relayer`,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Dedicated Relayer <Badge>New</Badge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(props.showContracts
|
||||
@@ -136,14 +143,6 @@ export function ProjectSidebarLayout(props: {
|
||||
{
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/webhooks/contracts`,
|
||||
icon: WebhookIcon,
|
||||
isActive: (pathname) => {
|
||||
return pathname.startsWith(`${props.layoutPath}/webhooks`);
|
||||
},
|
||||
label: "Webhooks",
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/settings`,
|
||||
icon: Settings2Icon,
|
||||
|
||||
@@ -3,24 +3,31 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ListCollapseIcon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon,
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createThirdwebClient,
|
||||
Engine,
|
||||
getContract,
|
||||
readContract,
|
||||
type ThirdwebClient,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import type { Chain } from "thirdweb/chains";
|
||||
import { SwapWidget, useWalletBalance } from "thirdweb/react";
|
||||
import { isAddress, shortenAddress } from "thirdweb/utils";
|
||||
import { createWalletAdapter } from "thirdweb/wallets";
|
||||
import { z } from "zod";
|
||||
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
@@ -43,6 +50,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -69,6 +77,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { ProjectWalletSummary } from "@/lib/server/project-wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { updateDefaultProjectWallet } from "../../transactions/lib/vault.client";
|
||||
|
||||
type GetProjectServerWallets = (params: {
|
||||
@@ -127,6 +136,11 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
const [isSendOpen, setIsSendOpen] = useState(false);
|
||||
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
|
||||
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
|
||||
const [isSwapOpen, setIsSwapOpen] = useState(false);
|
||||
|
||||
// Persist swap credentials in memory so users don't have to re-enter them
|
||||
const [swapSecretKey, setSwapSecretKey] = useState("");
|
||||
const [swapVaultAccessToken, setSwapVaultAccessToken] = useState("");
|
||||
|
||||
// Initialize chain and token from localStorage or defaults
|
||||
const [selectedChainId, setSelectedChainId] = useState(() => {
|
||||
@@ -216,9 +230,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="p-5 flex flex-col lg:flex-row gap-5">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Address</p>
|
||||
<div className="px-5 py-4 border-b border-dashed flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground mb-0.5 tracking-tight">
|
||||
{projectWallet.label || "Project Wallet"}
|
||||
</h2>
|
||||
|
||||
<CopyTextButton
|
||||
textToShow={shortenAddress(projectWallet.address, 6)}
|
||||
textToCopy={projectWallet.address}
|
||||
@@ -229,14 +246,66 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Label</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
|
||||
{projectWallet.label || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-auto h-auto p-2 rounded-full"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 rounded-xl"
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<ArrowUpFromLineIcon className="size-4 text-muted-foreground" />
|
||||
Deposit Funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-4 text-muted-foreground" />
|
||||
Withdraw Funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSwapOpen(true)}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
Swap Tokens
|
||||
</DropdownMenuItem>
|
||||
{/* add line */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListCollapseIcon className="size-4 text-muted-foreground" />
|
||||
View Transactions
|
||||
</DropdownMenuItem>
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<Settings2Icon className="size-4 text-muted-foreground" />
|
||||
Change Project Wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
@@ -268,10 +337,10 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 border-r-0 rounded-r-none"
|
||||
client={props.client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
@@ -290,67 +359,16 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
enabled={true}
|
||||
disableAddress={true}
|
||||
showCheck={true}
|
||||
addNativeTokenIfMissing={true}
|
||||
placeholder="Native token"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50 rounded-l-none"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background hover:bg-accent/50"
|
||||
onClick={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
Withdraw
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-2 bg-background hover:bg-accent/50"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Deposit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
Transactions
|
||||
</DropdownMenuItem>
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
Change Wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendProjectWalletModal
|
||||
@@ -394,6 +412,25 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog onOpenChange={setIsSwapOpen} open={isSwapOpen}>
|
||||
<DialogContent className="gap-0 p-0 overflow-hidden max-w-md">
|
||||
<SwapProjectWalletModalContent
|
||||
client={props.client}
|
||||
chainId={selectedChainId}
|
||||
tokenAddress={selectedTokenAddress}
|
||||
walletAddress={projectWallet.address}
|
||||
chain={chain}
|
||||
isManagedVault={isManagedVault}
|
||||
publishableKey={project.publishableKey}
|
||||
secretKey={swapSecretKey}
|
||||
setSecretKey={setSwapSecretKey}
|
||||
vaultAccessToken={swapVaultAccessToken}
|
||||
setVaultAccessToken={setSwapVaultAccessToken}
|
||||
onClose={() => setIsSwapOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -573,6 +610,191 @@ function ChangeProjectWalletDialogContent(props: {
|
||||
);
|
||||
}
|
||||
|
||||
type SwapProjectWalletModalContentProps = {
|
||||
client: ThirdwebClient;
|
||||
chainId: number;
|
||||
tokenAddress: string | undefined;
|
||||
walletAddress: string;
|
||||
chain: Chain;
|
||||
isManagedVault: boolean;
|
||||
publishableKey: string;
|
||||
secretKey: string;
|
||||
setSecretKey: (value: string) => void;
|
||||
vaultAccessToken: string;
|
||||
setVaultAccessToken: (value: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function SwapProjectWalletModalContent(
|
||||
props: SwapProjectWalletModalContentProps,
|
||||
) {
|
||||
const {
|
||||
chainId,
|
||||
tokenAddress,
|
||||
walletAddress,
|
||||
chain,
|
||||
isManagedVault,
|
||||
publishableKey,
|
||||
secretKey,
|
||||
setSecretKey,
|
||||
vaultAccessToken,
|
||||
setVaultAccessToken,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const [screen, setScreen] = useState<"credentials" | "swap">("credentials");
|
||||
const { theme } = useTheme();
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
|
||||
const hasRequiredCredentials = isManagedVault
|
||||
? secretKey.trim().length > 0
|
||||
: secretKey.trim().length > 0 && vaultAccessToken.trim().length > 0;
|
||||
|
||||
const swapClient = useMemo(() => {
|
||||
if (!secretKey.trim()) {
|
||||
return null;
|
||||
}
|
||||
// use the inputted secret key to create the client used for the serverWallet
|
||||
return createThirdwebClient({
|
||||
clientId: publishableKey,
|
||||
secretKey: secretKey.trim(),
|
||||
});
|
||||
}, [secretKey, publishableKey]);
|
||||
|
||||
const activeWallet = useMemo(() => {
|
||||
if (!swapClient) {
|
||||
return undefined;
|
||||
}
|
||||
const vaultAccessTokenValue = vaultAccessToken.trim();
|
||||
return createWalletAdapter({
|
||||
adaptedAccount: Engine.serverWallet({
|
||||
client: swapClient,
|
||||
address: walletAddress,
|
||||
...(vaultAccessTokenValue
|
||||
? { vaultAccessToken: vaultAccessTokenValue }
|
||||
: {}),
|
||||
}),
|
||||
chain: chain,
|
||||
client: swapClient,
|
||||
onDisconnect: () => {},
|
||||
switchChain: () => {},
|
||||
});
|
||||
}, [swapClient, walletAddress, chain, vaultAccessToken]);
|
||||
|
||||
// Screen 1: Credentials
|
||||
if (screen === "credentials") {
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>Swap Tokens</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your credentials to swap tokens from your project wallet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-4 lg:px-6 lg:pb-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="swap-secret-key"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Project secret key
|
||||
</label>
|
||||
<Input
|
||||
id="swap-secret-key"
|
||||
type="password"
|
||||
placeholder="Enter your project secret key"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{secretKeyHelper}</p>
|
||||
</div>
|
||||
|
||||
{!isManagedVault && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="swap-vault-access-token"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Vault access token
|
||||
</label>
|
||||
<Input
|
||||
id="swap-vault-access-token"
|
||||
type="password"
|
||||
placeholder="Enter a vault access token"
|
||||
value={vaultAccessToken}
|
||||
onChange={(e) => setVaultAccessToken(e.target.value)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{vaultAccessTokenHelper}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setScreen("swap")}
|
||||
type="button"
|
||||
disabled={!hasRequiredCredentials}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Screen 2: Swap Widget
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-auto w-auto"
|
||||
onClick={() => setScreen("credentials")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<DialogTitle>Swap Tokens</DialogTitle>
|
||||
<DialogDescription>
|
||||
Swap tokens from your project wallet
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 pb-4 lg:px-6 lg:pb-6 flex justify-center">
|
||||
{activeWallet && (
|
||||
<SwapWidget
|
||||
client={props.client}
|
||||
prefill={{
|
||||
sellToken: {
|
||||
chainId: chainId,
|
||||
tokenAddress: tokenAddress,
|
||||
},
|
||||
}}
|
||||
activeWallet={activeWallet}
|
||||
theme={getSDKTheme(t)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createSendFormSchema = (secretKeyLabel: string) =>
|
||||
z.object({
|
||||
chainId: z.number({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ProjectWalletSectionUI } from "./project-wallet";
|
||||
|
||||
const meta = {
|
||||
component: ProjectWalletSectionUI,
|
||||
component: Variant,
|
||||
title: "Project/ProjectWalletSection",
|
||||
parameters: {
|
||||
nextjs: {
|
||||
@@ -35,16 +35,16 @@ projectWithManagedAccessToken.services = [
|
||||
|
||||
const projectWithoutManagedAccessToken = projectStub("foo", "bar");
|
||||
|
||||
const projectWallet1: ProjectWalletSummary = {
|
||||
const serverWallet1: ProjectWalletSummary = {
|
||||
id: "server-wallet-id",
|
||||
address: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
|
||||
label: "Project Wallet 1",
|
||||
label: "XYZ",
|
||||
};
|
||||
|
||||
const projectWallet2: ProjectWalletSummary = {
|
||||
const serverWallet2: ProjectWalletSummary = {
|
||||
id: "server-wallet-id-2",
|
||||
address: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425",
|
||||
label: "Project Wallet 2",
|
||||
label: "FooBar",
|
||||
};
|
||||
|
||||
export const NoProjectWalletSetNoManagedAccessToken: Story = {
|
||||
@@ -84,7 +84,7 @@ export const NoProjectWalletSetWithManagedAccessTokenAndServerWallets: Story = {
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -98,7 +98,7 @@ export const NoProjectWalletSetLoading: Story = {
|
||||
projectWallet: undefined,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -109,10 +109,10 @@ export const ProjectWalletSetMultipleServerWallets: Story = {
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
client: storybookThirdwebClient,
|
||||
projectWallet: projectWallet1,
|
||||
projectWallet: serverWallet1,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1, projectWallet2];
|
||||
return [serverWallet1, serverWallet2];
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -122,11 +122,23 @@ export const ProjectWalletSetSingleServerWallet: Story = {
|
||||
layout: "column",
|
||||
project: projectWithManagedAccessToken,
|
||||
teamSlug: "bar",
|
||||
projectWallet: projectWallet1,
|
||||
projectWallet: serverWallet1,
|
||||
client: storybookThirdwebClient,
|
||||
getProjectServerWallets: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [projectWallet1];
|
||||
return [serverWallet1];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function Variant(props: Parameters<typeof ProjectWalletSectionUI>[0]) {
|
||||
return (
|
||||
<div className="space-y-20">
|
||||
<ProjectWalletSectionUI {...props} />
|
||||
|
||||
<div className="max-w-[660px]">
|
||||
<ProjectWalletSectionUI {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
|
||||
import { ArrowUpRightIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronDownIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -134,11 +134,8 @@ function CreateProjectWalletSection(props: {
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-5 bg-card">
|
||||
<div className="rounded-full p-2 border bg-background inline-flex mb-4">
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium text-foreground mb-0.5">
|
||||
No Project Wallet set
|
||||
<h2 className="text-sm font-medium text-foreground mb-0.5 text-yellow-700 dark:text-yellow-500">
|
||||
No project wallet set
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set a project wallet to set the default sender in thirdweb API.
|
||||
|
||||
@@ -98,11 +98,11 @@ export default async function Page(props: {
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
href: "https://api.thirdweb.com/reference#tag/contracts",
|
||||
href: "https://api.thirdweb.com/reference#tag/gateway",
|
||||
},
|
||||
{
|
||||
type: "webhooks",
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/contracts`,
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { shortenLargeNumber } from "thirdweb/utils";
|
||||
import type { InsightStatusCodeStats } from "@/api/analytics";
|
||||
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
@@ -17,6 +16,7 @@ export function RequestsByStatusGraph(props: {
|
||||
data: InsightStatusCodeStats[];
|
||||
isPending: boolean;
|
||||
viewMoreLink: string | undefined;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const topStatusCodesToShow = 10;
|
||||
|
||||
@@ -105,7 +105,7 @@ export function RequestsByStatusGraph(props: {
|
||||
/>
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<EmptyChartState type="bar" />}
|
||||
emptyChartState={props.emptyChartState}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend
|
||||
|
||||
@@ -10,6 +10,7 @@ export function RPCRequestsChartUI(props: {
|
||||
data: RpcUsageTypeStats[];
|
||||
viewMoreLink: string | undefined;
|
||||
isPending: boolean;
|
||||
emptyChartState?: React.ReactElement | undefined;
|
||||
}) {
|
||||
const total = useMemo(() => {
|
||||
return props.data.reduce((acc, curr) => acc + curr.count, 0);
|
||||
@@ -73,6 +74,7 @@ export function RPCRequestsChartUI(props: {
|
||||
xAxis={{
|
||||
showHour: showBreakdownByHour,
|
||||
}}
|
||||
emptyChartState={props.emptyChartState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ResponsiveSearchParamsProvider,
|
||||
ResponsiveSuspense,
|
||||
} from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getAiUsage,
|
||||
getInAppWalletUsage,
|
||||
getInsightStatusCodeUsage,
|
||||
getRpcUsageByType,
|
||||
getUniversalBridgeUsage,
|
||||
getX402Settlements,
|
||||
isProjectActive,
|
||||
} from "@/api/analytics";
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
import { isProjectActive } from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject, type Project } from "@/api/project/projects";
|
||||
import type {
|
||||
DurationId,
|
||||
Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import type { DurationId } from "@/components/analytics/date-range-selector";
|
||||
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
|
||||
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
@@ -28,39 +11,16 @@ import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AiTokenUsageChartCardUI } from "./ai/analytics/chart/AiTokenUsageChartCard";
|
||||
import { ProjectAnalytics } from "./_analytics/project-analytics";
|
||||
import type { PageParams, PageSearchParams } from "./_analytics/types";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
|
||||
import { TransactionsChartCardAsync } from "./components/Transactions";
|
||||
import { RequestsByStatusGraph } from "./gateway/indexer/components/RequestsByStatusGraph";
|
||||
import { RPCRequestsChartUI } from "./gateway/rpc/components/RequestsGraph";
|
||||
import { ProjectHighlightsCard } from "./overview/highlights-card";
|
||||
import { AllWalletConnectionsChart } from "./wallets/analytics/chart/all-wallet-connections-chart";
|
||||
import { X402RequestsChartCardUI } from "./x402/analytics/x402-requests-chart";
|
||||
|
||||
type PageParams = {
|
||||
team_slug: string;
|
||||
project_slug: string;
|
||||
};
|
||||
|
||||
type PageSearchParams = {
|
||||
from: string | undefined | string[];
|
||||
to: string | undefined | string[];
|
||||
type: string | undefined | string[];
|
||||
interval: string | undefined | string[];
|
||||
appHighlights: string | undefined | string[];
|
||||
client_transactions: string | undefined | string[];
|
||||
totalSponsored: string | undefined | string[];
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<PageParams>;
|
||||
searchParams: Promise<PageSearchParams>;
|
||||
};
|
||||
|
||||
// Revalidate this page data every 30 seconds
|
||||
export const revalidate = 30;
|
||||
|
||||
export default async function ProjectOverviewPage(props: PageProps) {
|
||||
const [params, searchParams] = await Promise.all([
|
||||
props.params,
|
||||
@@ -104,25 +64,25 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<ProjectPage
|
||||
header={{
|
||||
client,
|
||||
isProjectIcon: true,
|
||||
icon: () => (
|
||||
<ProjectAvatar
|
||||
className="size-11"
|
||||
client={client}
|
||||
src={project.image ?? ""}
|
||||
/>
|
||||
),
|
||||
title: project.name,
|
||||
description: "Your project's overview and analytics",
|
||||
// explicitly NO actions on the overview page
|
||||
actions: null,
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<ProjectPage
|
||||
header={{
|
||||
client,
|
||||
isProjectIcon: true,
|
||||
icon: () => (
|
||||
<ProjectAvatar
|
||||
className="size-11"
|
||||
client={client}
|
||||
src={project.image ?? ""}
|
||||
/>
|
||||
),
|
||||
title: project.name,
|
||||
description: "Your project's overview and analytics",
|
||||
// explicitly NO actions on the overview page
|
||||
actions: null,
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<div className="flex flex-col gap-4 md:gap-10">
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
@@ -144,413 +104,24 @@ export default async function ProjectOverviewPage(props: PageProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-6">
|
||||
<ProjectFTUX
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectWalletSection={
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectWallet={projectWallet}
|
||||
client={client}
|
||||
layout="row"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPage>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function ProjectAnalytics(props: {
|
||||
project: Project;
|
||||
params: PageParams;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
searchParams: PageSearchParams;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { project, params, range, interval, searchParams, client, authToken } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* project highlights */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
|
||||
>
|
||||
<AsyncAppHighlightsCard
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
interval={interval}
|
||||
params={params}
|
||||
project={project}
|
||||
range={range}
|
||||
selectedChart={
|
||||
typeof searchParams.appHighlights === "string"
|
||||
? searchParams.appHighlights
|
||||
: undefined
|
||||
}
|
||||
selectedChartQueryParam="appHighlights"
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
{/* wallet connections */}
|
||||
<AllWalletConnectionsChart
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
projectId={project.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
|
||||
{/* indexer and RPC requests */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RequestsByStatusGraph
|
||||
data={[]}
|
||||
isPending={true}
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/indexer`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncIndexerRequestsChartCard
|
||||
</ResponsiveSearchParamsProvider>
|
||||
) : (
|
||||
<div className="pt-6">
|
||||
<ProjectFTUX
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
projectWalletSection={
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectWallet={projectWallet}
|
||||
client={client}
|
||||
layout="row"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<RPCRequestsChartUI
|
||||
isPending={true}
|
||||
data={[]}
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/rpc`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncRPCRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* x402 */}
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<X402RequestsChartCardUI
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncX402RequestsChart
|
||||
from={range.from}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
{/* AI tokens */}
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<AiTokenUsageChartCardUI
|
||||
isPending={true}
|
||||
title="AI token volume"
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/ai/analytics`}
|
||||
aiUsageStats={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAiAnalytics
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
|
||||
{/* transactions */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
|
||||
>
|
||||
<TransactionsChartCardAsync
|
||||
client={client}
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
selectedChart={
|
||||
typeof searchParams.client_transactions === "string"
|
||||
? searchParams.client_transactions
|
||||
: undefined
|
||||
}
|
||||
selectedChartQueryParam="client_transactions"
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncIndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getInsightStatusCodeUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return (
|
||||
<RequestsByStatusGraph
|
||||
data={requestsData && "data" in requestsData ? requestsData.data : []}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncRPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getRpcUsageByType(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return (
|
||||
<RPCRequestsChartUI
|
||||
isPending={false}
|
||||
data={requestsData || []}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAppHighlightsCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
interval: "day" | "week";
|
||||
selectedChartQueryParam: string;
|
||||
selectedChart: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
params: PageParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
|
||||
await Promise.allSettled([
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getUniversalBridgeUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
]);
|
||||
|
||||
if (
|
||||
walletUserStatsTimeSeries.status === "fulfilled" &&
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
) {
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={
|
||||
aggregatedUserStats.status === "fulfilled"
|
||||
? aggregatedUserStats.value
|
||||
: []
|
||||
}
|
||||
selectedChart={props.selectedChart}
|
||||
selectedChartQueryParam={props.selectedChartQueryParam}
|
||||
teamSlug={props.params.team_slug}
|
||||
projectSlug={props.params.project_slug}
|
||||
userStats={
|
||||
walletUserStatsTimeSeries.status === "fulfilled"
|
||||
? walletUserStatsTimeSeries.value
|
||||
: []
|
||||
}
|
||||
volumeStats={
|
||||
universalBridgeUsage.status === "fulfilled"
|
||||
? universalBridgeUsage.value
|
||||
: []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/wallets"
|
||||
metric="Wallets"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAiAnalytics(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getAiUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<AiTokenUsageChartCardUI
|
||||
title="AI token volume"
|
||||
isPending={false}
|
||||
aiUsageStats={stats}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncX402RequestsChart(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const stats = await getX402Settlements(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
|
||||
|
||||
return (
|
||||
<X402RequestsChartCardUI
|
||||
stats={
|
||||
isAllEmpty
|
||||
? []
|
||||
: stats.map((stat) => ({
|
||||
requests: stat.totalRequests,
|
||||
time: stat.date,
|
||||
}))
|
||||
}
|
||||
isPending={false}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AdvancedSection({
|
||||
id="webhooks"
|
||||
features={["Instant events", "Transaction verification"]}
|
||||
link={{
|
||||
href: `/team/${teamSlug}/${projectSlug}/webhooks/payments`,
|
||||
href: `/team/${teamSlug}/${projectSlug}/bridge/webhooks`,
|
||||
label: "Setup Webhooks",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -67,24 +67,9 @@ export async function PayAnalytics(props: {
|
||||
walletDataPromise,
|
||||
]);
|
||||
|
||||
const hasVolume = volumeData.some((d) => d.amountUsdCents > 0);
|
||||
const hasWallet = walletData.some((d) => d.count > 0);
|
||||
|
||||
if (!hasVolume && !hasWallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight mb-1">
|
||||
Analytics
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Track Bridge volume, customers, payouts, and success rates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 flex justify-start">
|
||||
<PayAnalyticsFilter />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,10 @@ type VolData = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
|
||||
export function TotalVolumePieChart(props: {
|
||||
data: UniversalBridgeStats[];
|
||||
hideTotal?: boolean;
|
||||
}) {
|
||||
const data = props.data;
|
||||
const isEmpty =
|
||||
data.length === 0 || data.every((x) => x.amountUsdCents === 0);
|
||||
@@ -36,20 +39,20 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
|
||||
.filter((x) => x.type === "onramp")
|
||||
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
|
||||
|
||||
const volumeData: VolData[] = !isEmpty
|
||||
? [
|
||||
{
|
||||
amount: cryptoTotalUSD,
|
||||
color: "hsl(var(--chart-1))",
|
||||
name: "Crypto",
|
||||
},
|
||||
{
|
||||
amount: fiatTotalUSD,
|
||||
color: "hsl(var(--chart-2))",
|
||||
name: "Fiat",
|
||||
},
|
||||
]
|
||||
: skeletonData;
|
||||
const volumeData: VolData[] = [
|
||||
{
|
||||
amount: cryptoTotalUSD,
|
||||
color: "hsl(var(--chart-1))",
|
||||
name: "Crypto",
|
||||
},
|
||||
{
|
||||
amount: fiatTotalUSD,
|
||||
color: "hsl(var(--chart-2))",
|
||||
name: "Fiat",
|
||||
},
|
||||
];
|
||||
|
||||
const chartVolumeData = !isEmpty ? volumeData : skeletonData;
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
@@ -62,7 +65,7 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
|
||||
cornerRadius={100}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
data={volumeData}
|
||||
data={chartVolumeData}
|
||||
dataKey="amount"
|
||||
innerRadius="80%"
|
||||
outerRadius="100%"
|
||||
@@ -72,41 +75,45 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{volumeData.map((entry, index) => (
|
||||
{chartVolumeData.map((entry, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<Cell fill={entry.color} key={index} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-10">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="font-medium text-sm"> Total Volume</p>
|
||||
{!props.hideTotal && (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-10">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="font-medium text-sm text-muted-foreground">
|
||||
Total Volume
|
||||
</p>
|
||||
|
||||
<SkeletonContainer
|
||||
loadedData={
|
||||
!data
|
||||
? undefined
|
||||
: data.length > 0
|
||||
? toUSD(cryptoTotalUSD + fiatTotalUSD)
|
||||
: "NA"
|
||||
}
|
||||
render={(totalAmount) => {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-3xl tracking-tighter",
|
||||
totalAmount.length > 6 ? "text-3xl" : "text-4xl",
|
||||
)}
|
||||
>
|
||||
{totalAmount}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData="$100"
|
||||
/>
|
||||
<SkeletonContainer
|
||||
loadedData={
|
||||
!data
|
||||
? undefined
|
||||
: data.length > 0
|
||||
? toUSD(cryptoTotalUSD + fiatTotalUSD)
|
||||
: "NA"
|
||||
}
|
||||
render={(totalAmount) => {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-2xl tracking-tighter",
|
||||
totalAmount.length > 6 ? "text-2xl" : "text-3xl",
|
||||
)}
|
||||
>
|
||||
{totalAmount}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData="$100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right */}
|
||||
<div className="items-center border-border border-t pt-5 lg:flex lg:border-none lg:pt-0 lg:pr-10">
|
||||
@@ -114,9 +121,9 @@ export function TotalVolumePieChart(props: { data: UniversalBridgeStats[] }) {
|
||||
{volumeData.map((v) => (
|
||||
<VolumeLegend
|
||||
amount={
|
||||
!data ? undefined : data.length > 0 ? toUSD(v.amount) : "NA"
|
||||
isEmpty ? "NA" : data.length > 0 ? toUSD(v.amount) : "NA"
|
||||
}
|
||||
color={v.color}
|
||||
color={isEmpty ? "hsl(var(--muted))" : v.color}
|
||||
key={v.name}
|
||||
label={v.name}
|
||||
/>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default async function Page(props: {
|
||||
),
|
||||
},
|
||||
secondary: {
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/webhooks/payments`,
|
||||
href: `/team/${params.team_slug}/${params.project_slug}/bridge/webhooks`,
|
||||
label: "Webhooks",
|
||||
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
|
||||
},
|
||||
@@ -85,7 +85,7 @@ export default async function Page(props: {
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
href: "https://api.thirdweb.com/reference#tag/payments",
|
||||
href: "https://api.thirdweb.com/reference#tag/x402",
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Img } from "@workspace/ui/components/img";
|
||||
import { ArrowDownToLineIcon, CoinsIcon, ImagesIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
@@ -63,19 +65,27 @@ export function Cards(props: {
|
||||
projectId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<CardLink
|
||||
description="Launch your own ERC-20 coin"
|
||||
images={{
|
||||
light: "/assets/tokens/coin-light.png",
|
||||
dark: "/assets/tokens/coin-dark.png",
|
||||
}}
|
||||
description="Launch an ERC-20 coin with various pricing strategies"
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/token`}
|
||||
icon={CoinsIcon}
|
||||
title="Create Coin"
|
||||
title="Coin"
|
||||
/>
|
||||
|
||||
<CardLink
|
||||
description="Launch your own NFT collection"
|
||||
images={{
|
||||
light: "/assets/tokens/nft-light.png",
|
||||
dark: "/assets/tokens/nft-dark.png",
|
||||
}}
|
||||
description="Launch an ERC-721 or ERC-1155 NFT collection"
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/tokens/create/nft`}
|
||||
icon={ImagesIcon}
|
||||
title="Create NFT Collection"
|
||||
title="NFT Collection"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -87,15 +97,20 @@ function CardLink(props: {
|
||||
href: string | undefined;
|
||||
onClick?: () => void;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
images: {
|
||||
dark: string;
|
||||
light: string;
|
||||
};
|
||||
}) {
|
||||
const { onClick } = props;
|
||||
const isClickable = !!onClick || !!props.href;
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: FIXME
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg border bg-card p-4",
|
||||
"relative flex flex-col rounded-xl border bg-card p-4",
|
||||
isClickable && "cursor-pointer hover:border-active-border ",
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -107,13 +122,23 @@ function CardLink(props: {
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
<div className="mb-4 flex">
|
||||
<div className="flex items-center justify-center rounded-full border p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
<div className="mb-4">
|
||||
<div className="flex lg:hidden">
|
||||
<div className="flex items-center justify-center rounded-full border p-2">
|
||||
<props.icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Img
|
||||
src={
|
||||
resolvedTheme === "light" ? props.images.light : props.images.dark
|
||||
}
|
||||
alt={props.title}
|
||||
className="hidden lg:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.href ? (
|
||||
<Link className="before:absolute before:inset-0" href={props.href}>
|
||||
{props.title}
|
||||
@@ -1,19 +1,14 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { ContractTable } from "@/components/contract-components/tables/contract-table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -23,8 +18,9 @@ import { TokenIcon } from "@/icons/TokenIcon";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { Cards, ImportTokenButton } from "./cards";
|
||||
|
||||
export default async function Page(props: {
|
||||
export default async function Layout(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
@@ -68,9 +64,12 @@ export default async function Page(props: {
|
||||
Create Token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="!max-w-md">
|
||||
<DialogContent className="!max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Launch your own ERC-20 coin or NFT collection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Cards
|
||||
client={client}
|
||||
@@ -110,48 +109,19 @@ export default async function Page(props: {
|
||||
},
|
||||
],
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
name: "Overview",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/tokens`,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Webhooks",
|
||||
path: `/team/${params.team_slug}/${params.project_slug}/tokens/webhooks`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<AssetsPageAsync
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectId={project.id}
|
||||
projectSlug={params.project_slug}
|
||||
teamId={team.id}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
</Suspense>
|
||||
{props.children}
|
||||
</ProjectPage>
|
||||
);
|
||||
}
|
||||
|
||||
async function AssetsPageAsync(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const deployedContracts = await getFilteredProjectContracts({
|
||||
authToken: props.authToken,
|
||||
type: "token-contracts",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<GenericLoadingPage />}>
|
||||
<ContractTable
|
||||
client={props.client}
|
||||
contracts={deployedContracts}
|
||||
pageSize={10}
|
||||
projectId={props.projectId}
|
||||
projectSlug={props.projectSlug}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
variant="asset"
|
||||
/>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getFilteredProjectContracts } from "@/api/project/getSortedDeployedContracts";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
|
||||
import { ContractTable } from "@/components/contract-components/tables/contract-table";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
|
||||
const [authToken, team, project] = await Promise.all([
|
||||
getAuthToken(),
|
||||
getTeamBySlug(params.team_slug),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/tokens`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense fallback={<GenericLoadingPage />}>
|
||||
<AssetsPageAsync
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectId={project.id}
|
||||
projectSlug={params.project_slug}
|
||||
teamId={team.id}
|
||||
teamSlug={params.team_slug}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AssetsPageAsync(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const deployedContracts = await getFilteredProjectContracts({
|
||||
authToken: props.authToken,
|
||||
type: "token-contracts",
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientOnly ssr={<GenericLoadingPage />}>
|
||||
<ContractTable
|
||||
client={props.client}
|
||||
contracts={deployedContracts}
|
||||
pageSize={10}
|
||||
projectId={props.projectId}
|
||||
projectSlug={props.projectSlug}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
variant="asset"
|
||||
/>
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WebhookFormValues } from "../utils/webhookTypes";
|
||||
import type { WebhookFormValues } from "./utils/webhookTypes";
|
||||
|
||||
interface BasicInfoStepProps {
|
||||
form: UseFormReturn<WebhookFormValues>;
|
||||
@@ -16,26 +16,26 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useAbiMultiFetch } from "../hooks/useAbiProcessing";
|
||||
import { useTestWebhook } from "../hooks/useTestWebhook";
|
||||
import BasicInfoStep from "./BasicInfoStep";
|
||||
import { FilterDetailsStep } from "./FilterDetailsStep";
|
||||
import ReviewStep from "./ReviewStep";
|
||||
import { useAbiMultiFetch } from "./useAbiProcessing";
|
||||
import { useTestWebhook } from "./useTestWebhook";
|
||||
import {
|
||||
extractEventSignatures,
|
||||
extractFunctionSignatures,
|
||||
} from "../utils/abiUtils";
|
||||
import type { WebhookPayload } from "../utils/webhookPayloadUtils";
|
||||
} from "./utils/abiUtils";
|
||||
import type { WebhookPayload } from "./utils/webhookPayloadUtils";
|
||||
import {
|
||||
buildEventWebhookPayload,
|
||||
buildTransactionWebhookPayload,
|
||||
} from "../utils/webhookPayloadUtils";
|
||||
} from "./utils/webhookPayloadUtils";
|
||||
import {
|
||||
type WebhookFormStep,
|
||||
WebhookFormSteps,
|
||||
type WebhookFormValues,
|
||||
webhookFormSchema,
|
||||
} from "../utils/webhookTypes";
|
||||
import BasicInfoStep from "./BasicInfoStep";
|
||||
import { FilterDetailsStep } from "./FilterDetailsStep";
|
||||
import ReviewStep from "./ReviewStep";
|
||||
} from "./utils/webhookTypes";
|
||||
|
||||
interface CreateWebhookModalProps {
|
||||
projectClientId: string;
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { truncateMiddle } from "../utils/abiUtils";
|
||||
import { truncateMiddle } from "./utils/abiUtils";
|
||||
import type {
|
||||
AbiData,
|
||||
EventSignature,
|
||||
FunctionSignature,
|
||||
WebhookFormValues,
|
||||
} from "../utils/webhookTypes";
|
||||
} from "./utils/webhookTypes";
|
||||
|
||||
interface FilterDetailsStepProps {
|
||||
form: UseFormReturn<WebhookFormValues>;
|
||||
@@ -3,8 +3,8 @@ import { CheckIcon, PlayIcon, XIcon } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { truncateMiddle } from "../utils/abiUtils";
|
||||
import type { WebhookFormValues } from "../utils/webhookTypes";
|
||||
import { truncateMiddle } from "./utils/abiUtils";
|
||||
import type { WebhookFormValues } from "./utils/webhookTypes";
|
||||
|
||||
interface WebhookTestResult {
|
||||
status: string;
|
||||
@@ -17,9 +17,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useTestWebhook } from "../hooks/useTestWebhook";
|
||||
import { CreateContractWebhookButton } from "./CreateWebhookModal";
|
||||
import { RelativeTime } from "./RelativeTime";
|
||||
import { useTestWebhook } from "./useTestWebhook";
|
||||
|
||||
function getEventType(filters: WebhookFilters): string {
|
||||
if (!filters || typeof filters !== "object") {
|
||||
@@ -1,8 +1,9 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getWebhooks, type WebhookResponse } from "@/api/insight/webhooks";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { CreateContractWebhookButton } from "../components/CreateWebhookModal";
|
||||
import { ContractsWebhooksTable } from "../components/WebhooksTable";
|
||||
import { CreateContractWebhookButton } from "./CreateWebhookModal";
|
||||
import { ContractsWebhooksTable } from "./WebhooksTable";
|
||||
|
||||
export async function ContractsWebhooksPageContent(props: {
|
||||
project: Project;
|
||||
@@ -50,11 +51,14 @@ export async function ContractsWebhooksPageContent(props: {
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border p-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-border py-14 px-6 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1 font-medium text-lg">No webhooks found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Create a webhook to get started.
|
||||
<div className="rounded-full p-2 border bg-background inline-flex mb-3">
|
||||
<XIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-1 font-semibold text-base">No webhooks found</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create a webhook to get started
|
||||
</p>
|
||||
</div>
|
||||
<CreateContractWebhookButton
|
||||
@@ -5,12 +5,12 @@ import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getContract, resolveAbiFromContractApi } from "thirdweb/contract";
|
||||
import { useGetV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { parseAddresses } from "../utils/webhookPayloadUtils";
|
||||
import { parseAddresses } from "./utils/webhookPayloadUtils";
|
||||
import type {
|
||||
AbiData,
|
||||
EventSignature,
|
||||
FunctionSignature,
|
||||
} from "../utils/webhookTypes";
|
||||
} from "./utils/webhookTypes";
|
||||
|
||||
export function useAbiMultiFetch({
|
||||
isOpen,
|
||||
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getSupportedWebhookChains } from "@/api/insight/webhooks";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { ContractsWebhooksPageContent } from "../contract-webhooks/contract-webhooks-page";
|
||||
import { ContractsWebhooksPageContent } from "./components/contract-webhooks-page";
|
||||
|
||||
export default async function ContractsPage(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
@@ -21,6 +21,41 @@ const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
|
||||
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
|
||||
"Management Token for Dashboard";
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff.
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: { maxAttempts?: number; baseDelayMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const { maxAttempts = 3, baseDelayMs = 1000 } = options;
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new Error("maxAttempts must be at least 1");
|
||||
}
|
||||
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) {
|
||||
throw new Error("baseDelayMs must be a non-negative number");
|
||||
}
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (attempt < maxAttempts) {
|
||||
// Exponential backoff with cap at 30s and jitter to prevent thundering herd
|
||||
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 30_000);
|
||||
const jitter = Math.floor(Math.random() * Math.min(250, delay));
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, delay + jitter),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("withRetry failed without capturing an error");
|
||||
}
|
||||
|
||||
let vc: VaultClient | null = null;
|
||||
|
||||
export async function initVaultClient() {
|
||||
@@ -47,6 +82,20 @@ export async function rotateVaultAccountAndAccessToken(props: {
|
||||
throw new Error("No rotation code found");
|
||||
}
|
||||
|
||||
// IMPORTANT: Validate secret key BEFORE rotating to prevent bricking the project.
|
||||
// If we rotate first and then secret key validation fails, the old rotation code
|
||||
// is consumed but we can't save the new one, leaving the project unrecoverable.
|
||||
if (props.projectSecretKey) {
|
||||
const projectSecretKeyHash = await hashSecretKey(props.projectSecretKey);
|
||||
const secretKeysHashed = [
|
||||
...props.project.secretKeys,
|
||||
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
|
||||
];
|
||||
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
throw new Error("Invalid project secret key");
|
||||
}
|
||||
}
|
||||
|
||||
const rotateServiceAccountRes = await rotateServiceAccount({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
@@ -69,6 +118,9 @@ export async function rotateVaultAccountAndAccessToken(props: {
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
// Skip wallet creation on rotation - preserve the existing project wallet
|
||||
skipWalletCreation: true,
|
||||
existingProjectWalletAddress: service?.projectWalletAddress ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -222,9 +274,19 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
projectSecretHash?: string;
|
||||
adminKey: string;
|
||||
rotationCode: string;
|
||||
skipWalletCreation?: boolean;
|
||||
existingProjectWalletAddress?: string;
|
||||
}) {
|
||||
const { project, projectSecretKey, vaultClient, adminKey, rotationCode } =
|
||||
props;
|
||||
const {
|
||||
project,
|
||||
projectSecretKey,
|
||||
projectSecretHash,
|
||||
vaultClient,
|
||||
adminKey,
|
||||
rotationCode,
|
||||
skipWalletCreation,
|
||||
existingProjectWalletAddress,
|
||||
} = props;
|
||||
|
||||
const [managementTokenResult, walletTokenResult] = await Promise.all([
|
||||
createManagementAccessToken({ project, adminKey, vaultClient }),
|
||||
@@ -246,12 +308,12 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const managementToken = managementTokenResult.data;
|
||||
const walletToken = walletTokenResult.data;
|
||||
|
||||
// create a default project server wallet
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
// CRITICAL: Save credentials IMMEDIATELY after creating tokens.
|
||||
// This prevents a broken state if wallet creation or other operations fail.
|
||||
// The rotationCode is consumed when rotating, so if we don't save the new one,
|
||||
// the project becomes unrecoverable.
|
||||
let encryptedAdminKey: string | null = null;
|
||||
let encryptedWalletAccessToken: string | null = null;
|
||||
|
||||
if (projectSecretKey) {
|
||||
// verify that the project secret key is valid
|
||||
@@ -259,68 +321,65 @@ async function createAndEncryptVaultAccessTokens(props: {
|
||||
const secretKeysHashed = [
|
||||
...project.secretKeys,
|
||||
// for newly rotated secret keys, we don't have the secret key in the project secret keys yet
|
||||
...(props.projectSecretHash ? [{ hash: props.projectSecretHash }] : []),
|
||||
...(projectSecretHash ? [{ hash: projectSecretHash }] : []),
|
||||
];
|
||||
if (!secretKeysHashed.some((key) => key?.hash === projectSecretKeyHash)) {
|
||||
throw new Error("Invalid project secret key");
|
||||
}
|
||||
|
||||
// encrypt admin key and wallet token with project secret key
|
||||
const [encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
[encryptedAdminKey, encryptedWalletAccessToken] = await Promise.all([
|
||||
encrypt(adminKey, projectSecretKey),
|
||||
encrypt(walletToken.accessToken, projectSecretKey),
|
||||
]);
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// no secret key, only store the management token, remove any encrypted keys
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...props.project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey: null,
|
||||
encryptedWalletAccessToken: null,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: defaultProjectServerWallet.address,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// For rotation, preserve existing wallet address. For new creation, create a default wallet.
|
||||
let projectWalletAddress: string | null | undefined =
|
||||
existingProjectWalletAddress ??
|
||||
project.services.find((s) => s.name === "engineCloud")
|
||||
?.projectWalletAddress;
|
||||
|
||||
// Only create a new wallet if we don't have one (initial setup, not rotation)
|
||||
if (!skipWalletCreation && !projectWalletAddress) {
|
||||
const defaultProjectServerWallet = await createProjectServerWallet({
|
||||
project,
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
label: getProjectWalletLabel(project.name),
|
||||
});
|
||||
projectWalletAddress = defaultProjectServerWallet.address;
|
||||
}
|
||||
|
||||
// Save credentials with retry - this is critical because if rotation succeeded
|
||||
// but this save fails, the new rotation code is lost and project becomes unrecoverable
|
||||
await withRetry(
|
||||
() =>
|
||||
updateProjectClient(
|
||||
{
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
},
|
||||
{
|
||||
services: [
|
||||
...project.services.filter(
|
||||
(service) => service.name !== "engineCloud",
|
||||
),
|
||||
{
|
||||
name: "engineCloud",
|
||||
actions: [],
|
||||
managementAccessToken: managementToken.accessToken,
|
||||
maskedAdminKey: maskSecret(adminKey),
|
||||
encryptedAdminKey,
|
||||
encryptedWalletAccessToken,
|
||||
rotationCode: rotationCode,
|
||||
projectWalletAddress: projectWalletAddress ?? null,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
{ maxAttempts: 3, baseDelayMs: 1000 },
|
||||
);
|
||||
|
||||
return {
|
||||
managementToken,
|
||||
walletToken,
|
||||
|
||||