[Dashboard] Add x402 chain analytics chart and empty state (#8398)

This commit is contained in:
Joaquim Verges
2025-11-11 17:32:03 -08:00
committed by GitHub
parent eb1a9c3c3e
commit fa640ad64b
6 changed files with 369 additions and 27 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
2. Formatting & Linting
- Biome governs formatting and linting; its rules live in biome.json.
- Run pnpm biome check --apply before committing.
- Run `pnpm fix` & `pnpm lint` before committing, make sure there are no linting errors.
- Avoid editor‑specific configs; rely on the shared settings.
- make sure everything builds after each file change by running `pnpm build`
@@ -136,7 +136,7 @@ export function reportContractDeployed(properties: {
}
```
- **Client-side only**: never import `posthog-js` in server components.
- **Client-side only**: never import `posthog-js` in server components.
- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event.
⸻
+2 -2
View File
@@ -102,7 +102,7 @@ export interface X402SettlementsOverall {
totalValueUSD: number;
}
interface X402SettlementsByChainId {
export interface X402SettlementsByChainId {
date: string;
chainId: string;
totalRequests: number;
@@ -151,5 +151,5 @@ export type X402SettlementStats =
| X402SettlementsByAsset;
export interface X402QueryParams extends AnalyticsQueryParams {
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset";
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset" | "receiver";
}
@@ -0,0 +1,160 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { useAllChainsData } from "@/hooks/chains/allChains";
import type { X402SettlementsByChainId } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByChainChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxChainsToDisplay = 10;
const isVolumeMetric = metric === "volume";
const chainsStore = useAllChainsData();
const { data, chainsToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const chainToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, chainId, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
// Convert chainId to chain name
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId.toString();
dateRecord[chainName] = (dateRecord[chainName] || 0) + value;
chainToCountMap.set(
chainName,
(chainToCountMap.get(chainName) || 0) + value,
);
}
// Sort chains by count (highest count first) - remove the ones with 0 count
const sortedChainsByCount = Array.from(chainToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const chainsToDisplayArray = sortedChainsByCount
.slice(0, maxChainsToDisplay)
.map(([chain]) => chain);
const chainsToDisplaySet = new Set(chainsToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the chain that is not in chainsToDisplay with "Other"
// Add total key that is the sum of all chains
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !chainsToDisplaySet.has(key),
);
for (const chain of keysToMove) {
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[chain] || 0);
delete dateRecord[chain];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < chainsToDisplayArray.length; i++) {
const chain = chainsToDisplayArray[i];
if (chain) {
chartConfig[chain] = {
label: chain,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" chains
if (sortedChainsByCount.length > maxChainsToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
chainsToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
chainsToDisplay: chainsToDisplayArray,
};
}, [rawData, isVolumeMetric, chainsStore]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Chain" : "Payments by Chain";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -5,9 +5,11 @@ import {
type Range,
} from "@/components/analytics/date-range-selector";
import type {
X402SettlementsByChainId,
X402SettlementsByPayer,
X402SettlementsByResource,
} from "@/types/analytics";
import { X402SettlementsByChainChartCard } from "./X402SettlementsByChainChartCard";
import { X402SettlementsByPayerChartCard } from "./X402SettlementsByPayerChartCard";
import { X402SettlementsByResourceChartCard } from "./X402SettlementsByResourceChartCard";
@@ -168,3 +170,80 @@ export function X402SettlementsByPayerChart(
</ResponsiveSuspense>
);
}
// Payments by Chain Chart
type X402SettlementsByChainChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByChainChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByChainChartProps) {
return (
<X402SettlementsByChainChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByChainChartProps = Omit<
X402SettlementsByChainChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "chainId",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByChainChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByChainId[]}
/>
);
}
export function X402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByChainChartUI {...props} isPending={true} stats={[]} />
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByChainChart {...props} />
</ResponsiveSuspense>
);
}
@@ -0,0 +1,63 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function X402EmptyState(props: { walletAddress?: string }) {
return (
<WaitingForIntegrationCard
codeTabs={[
{
code: (
<CodeServer
className="bg-background"
code={jsCode(props.walletAddress)}
lang="ts"
/>
),
label: "JavaScript",
},
]}
ctas={[
{
href: "https://portal.thirdweb.com/x402",
label: "View Docs",
},
]}
title="Start Monetizing your API"
/>
);
}
const jsCode = (walletAddress?: string) => `\
import { createThirdwebClient } from "thirdweb";
import { facilitator, settlePayment } from "thirdweb/x402";
import { arbitrumSepolia } from "thirdweb/chains";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "${walletAddress || "0xYourWalletAddress"}",
});
export async function GET(request: Request) {
// process the payment
const result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
method: "GET",
paymentData: request.headers.get("x-payment"),
network: arbitrumSepolia,
price: "$0.01",
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// Payment successful, continue to app logic
return Response.json({ data: "premium content" });
} else {
return Response.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
}
`;
@@ -1,17 +1,22 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type { X402SettlementsOverall } from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import {
X402SettlementsByChainChart,
X402SettlementsByPayerChart,
X402SettlementsByResourceChart,
} from "./analytics";
import { ChartMetricSwitcher } from "./analytics/ChartsSection";
import { X402Summary } from "./analytics/Summary";
import { X402EmptyState } from "./components/X402EmptyState";
import { QuickStartSection } from "./QuickstartSection.client";
export const dynamic = "force-dynamic";
@@ -49,35 +54,70 @@ export default async function Page(props: {
redirect(`/team/${params.team_slug}`);
}
// Check if there are any payments to determine if we should show empty state
const overallStats = await getX402Settlements(
{
from: range.from,
period: "all",
projectId: project.id,
teamId: project.teamId,
to: range.to,
groupBy: "overall",
},
authToken,
).catch(() => []);
const totalPayments = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalRequests,
0,
);
// Get project wallet for prefilling the code snippet
const projectWallet = await getProjectWallet(project);
const metric = (searchParams.metric as "payments" | "volume") || "volume";
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<X402Summary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<ChartMetricSwitcher />
<X402SettlementsByResourceChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByPayerChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
{totalPayments === 0 ? (
<X402EmptyState walletAddress={projectWallet?.address} />
) : (
<>
<X402Summary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<ChartMetricSwitcher />
<X402SettlementsByResourceChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByChainChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByPayerChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
</>
)}
<div className="pt-4">
<QuickStartSection />
</div>