Skip to content

Commit fa640ad

Browse files
[Dashboard] Add x402 chain analytics chart and empty state (#8398)
1 parent eb1a9c3 commit fa640ad

File tree

6 files changed

+369
-27
lines changed

6 files changed

+369
-27
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
1818
2. Formatting & Linting
1919

2020
- Biome governs formatting and linting; its rules live in biome.json.
21-
- Run pnpm biome check --apply before committing.
21+
- Run `pnpm fix` & `pnpm lint` before committing, make sure there are no linting errors.
2222
- Avoid editor‑specific configs; rely on the shared settings.
2323
- make sure everything builds after each file change by running `pnpm build`
2424

@@ -136,7 +136,7 @@ export function reportContractDeployed(properties: {
136136
}
137137
```
138138

139-
- **Client-side only**: never import `posthog-js` in server components.
139+
- **Client-side only**: never import `posthog-js` in server components.
140140
- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event.
141141

142142

apps/dashboard/src/@/types/analytics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export interface X402SettlementsOverall {
102102
totalValueUSD: number;
103103
}
104104

105-
interface X402SettlementsByChainId {
105+
export interface X402SettlementsByChainId {
106106
date: string;
107107
chainId: string;
108108
totalRequests: number;
@@ -151,5 +151,5 @@ export type X402SettlementStats =
151151
| X402SettlementsByAsset;
152152

153153
export interface X402QueryParams extends AnalyticsQueryParams {
154-
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset";
154+
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset" | "receiver";
155155
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
import { format } from "date-fns";
3+
import { type ReactNode, useMemo } from "react";
4+
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
5+
import type { ChartConfig } from "@/components/ui/chart";
6+
import { useAllChainsData } from "@/hooks/chains/allChains";
7+
import type { X402SettlementsByChainId } from "@/types/analytics";
8+
import { toUSD } from "@/utils/number";
9+
10+
type ChartData = Record<string, number> & {
11+
time: string;
12+
};
13+
14+
export function X402SettlementsByChainChartCard({
15+
rawData,
16+
isPending,
17+
metric = "payments",
18+
}: {
19+
rawData: X402SettlementsByChainId[];
20+
isPending: boolean;
21+
metric?: "payments" | "volume";
22+
}) {
23+
const maxChainsToDisplay = 10;
24+
const isVolumeMetric = metric === "volume";
25+
const chainsStore = useAllChainsData();
26+
27+
const { data, chainsToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
28+
const dateToValueMap: Map<string, ChartData> = new Map();
29+
const chainToCountMap: Map<string, number> = new Map();
30+
31+
for (const dataItem of rawData) {
32+
const { date, chainId, totalRequests, totalValueUSD } = dataItem;
33+
const value = isVolumeMetric ? totalValueUSD : totalRequests;
34+
let dateRecord = dateToValueMap.get(date);
35+
36+
if (!dateRecord) {
37+
dateRecord = { time: date } as ChartData;
38+
dateToValueMap.set(date, dateRecord);
39+
}
40+
41+
// Convert chainId to chain name
42+
const chain = chainsStore.idToChain.get(Number(chainId));
43+
const chainName = chain?.name || chainId.toString();
44+
45+
dateRecord[chainName] = (dateRecord[chainName] || 0) + value;
46+
chainToCountMap.set(
47+
chainName,
48+
(chainToCountMap.get(chainName) || 0) + value,
49+
);
50+
}
51+
52+
// Sort chains by count (highest count first) - remove the ones with 0 count
53+
const sortedChainsByCount = Array.from(chainToCountMap.entries())
54+
.sort((a, b) => b[1] - a[1])
55+
.filter((x) => x[1] > 0);
56+
57+
const chainsToDisplayArray = sortedChainsByCount
58+
.slice(0, maxChainsToDisplay)
59+
.map(([chain]) => chain);
60+
const chainsToDisplaySet = new Set(chainsToDisplayArray);
61+
62+
// Loop over each entry in dateToValueMap
63+
// Replace the chain that is not in chainsToDisplay with "Other"
64+
// Add total key that is the sum of all chains
65+
for (const dateRecord of dateToValueMap.values()) {
66+
// Calculate total
67+
let totalCountOfDay = 0;
68+
for (const key of Object.keys(dateRecord)) {
69+
if (key !== "time") {
70+
totalCountOfDay += (dateRecord[key] as number) || 0;
71+
}
72+
}
73+
74+
const keysToMove = Object.keys(dateRecord).filter(
75+
(key) => key !== "time" && !chainsToDisplaySet.has(key),
76+
);
77+
78+
for (const chain of keysToMove) {
79+
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[chain] || 0);
80+
delete dateRecord[chain];
81+
}
82+
83+
dateRecord.total = totalCountOfDay;
84+
}
85+
86+
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
87+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
88+
);
89+
90+
const chartConfig: ChartConfig = {};
91+
for (let i = 0; i < chainsToDisplayArray.length; i++) {
92+
const chain = chainsToDisplayArray[i];
93+
if (chain) {
94+
chartConfig[chain] = {
95+
label: chain,
96+
color: `hsl(var(--chart-${(i % 10) + 1}))`,
97+
isCurrency: isVolumeMetric,
98+
};
99+
}
100+
}
101+
102+
// If we need to display "Other" chains
103+
if (sortedChainsByCount.length > maxChainsToDisplay) {
104+
chartConfig.Other = {
105+
label: "Other",
106+
color: "hsl(var(--muted-foreground))",
107+
isCurrency: isVolumeMetric,
108+
};
109+
chainsToDisplayArray.push("Other");
110+
}
111+
112+
return {
113+
chartConfig,
114+
data: returnValue,
115+
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
116+
chainsToDisplay: chainsToDisplayArray,
117+
};
118+
}, [rawData, isVolumeMetric, chainsStore]);
119+
120+
const emptyChartState = (
121+
<div className="flex h-[250px] items-center justify-center">
122+
<p className="text-muted-foreground text-sm">No data available</p>
123+
</div>
124+
);
125+
126+
const title = isVolumeMetric ? "Volume by Chain" : "Payments by Chain";
127+
128+
return (
129+
<ThirdwebBarChart
130+
chartClassName="aspect-auto h-[250px]"
131+
config={chartConfig}
132+
customHeader={
133+
<div className="px-6 pt-6">
134+
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
135+
{title}
136+
</h3>
137+
</div>
138+
}
139+
data={data}
140+
emptyChartState={emptyChartState}
141+
hideLabel={false}
142+
isPending={isPending}
143+
showLegend
144+
toolTipValueFormatter={(value: unknown) => {
145+
if (isVolumeMetric) {
146+
return `${toUSD(Number(value))}`;
147+
}
148+
return value as ReactNode;
149+
}}
150+
toolTipLabelFormatter={(_v, item) => {
151+
if (Array.isArray(item)) {
152+
const time = item[0].payload.time as string;
153+
return format(new Date(time), "MMM d, yyyy");
154+
}
155+
return undefined;
156+
}}
157+
variant="stacked"
158+
/>
159+
);
160+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
type Range,
66
} from "@/components/analytics/date-range-selector";
77
import type {
8+
X402SettlementsByChainId,
89
X402SettlementsByPayer,
910
X402SettlementsByResource,
1011
} from "@/types/analytics";
12+
import { X402SettlementsByChainChartCard } from "./X402SettlementsByChainChartCard";
1113
import { X402SettlementsByPayerChartCard } from "./X402SettlementsByPayerChartCard";
1214
import { X402SettlementsByResourceChartCard } from "./X402SettlementsByResourceChartCard";
1315

@@ -168,3 +170,80 @@ export function X402SettlementsByPayerChart(
168170
</ResponsiveSuspense>
169171
);
170172
}
173+
174+
// Payments by Chain Chart
175+
type X402SettlementsByChainChartProps = {
176+
interval: "day" | "week";
177+
range: Range;
178+
stats: X402SettlementsByChainId[];
179+
isPending: boolean;
180+
metric?: "payments" | "volume";
181+
};
182+
183+
function X402SettlementsByChainChartUI({
184+
stats,
185+
isPending,
186+
metric = "payments",
187+
}: X402SettlementsByChainChartProps) {
188+
return (
189+
<X402SettlementsByChainChartCard
190+
rawData={stats}
191+
isPending={isPending}
192+
metric={metric}
193+
/>
194+
);
195+
}
196+
197+
type AsyncX402SettlementsByChainChartProps = Omit<
198+
X402SettlementsByChainChartProps,
199+
"stats" | "isPending"
200+
> & {
201+
teamId: string;
202+
projectId: string;
203+
authToken: string;
204+
};
205+
206+
async function AsyncX402SettlementsByChainChart(
207+
props: AsyncX402SettlementsByChainChartProps,
208+
) {
209+
const range = props.range ?? getLastNDaysRange("last-30");
210+
211+
const stats = await getX402Settlements(
212+
{
213+
from: range.from,
214+
period: props.interval,
215+
projectId: props.projectId,
216+
teamId: props.teamId,
217+
to: range.to,
218+
groupBy: "chainId",
219+
},
220+
props.authToken,
221+
).catch((error) => {
222+
console.error(error);
223+
return [];
224+
});
225+
226+
return (
227+
<X402SettlementsByChainChartUI
228+
{...props}
229+
isPending={false}
230+
range={range}
231+
stats={stats as X402SettlementsByChainId[]}
232+
/>
233+
);
234+
}
235+
236+
export function X402SettlementsByChainChart(
237+
props: AsyncX402SettlementsByChainChartProps,
238+
) {
239+
return (
240+
<ResponsiveSuspense
241+
fallback={
242+
<X402SettlementsByChainChartUI {...props} isPending={true} stats={[]} />
243+
}
244+
searchParamsUsed={["from", "to", "interval", "metric"]}
245+
>
246+
<AsyncX402SettlementsByChainChart {...props} />
247+
</ResponsiveSuspense>
248+
);
249+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { CodeServer } from "@/components/ui/code/code.server";
2+
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
3+
4+
export function X402EmptyState(props: { walletAddress?: string }) {
5+
return (
6+
<WaitingForIntegrationCard
7+
codeTabs={[
8+
{
9+
code: (
10+
<CodeServer
11+
className="bg-background"
12+
code={jsCode(props.walletAddress)}
13+
lang="ts"
14+
/>
15+
),
16+
label: "JavaScript",
17+
},
18+
]}
19+
ctas={[
20+
{
21+
href: "https://portal.thirdweb.com/x402",
22+
label: "View Docs",
23+
},
24+
]}
25+
title="Start Monetizing your API"
26+
/>
27+
);
28+
}
29+
30+
const jsCode = (walletAddress?: string) => `\
31+
import { createThirdwebClient } from "thirdweb";
32+
import { facilitator, settlePayment } from "thirdweb/x402";
33+
import { arbitrumSepolia } from "thirdweb/chains";
34+
35+
const client = createThirdwebClient({ secretKey: "your-secret-key" });
36+
37+
const thirdwebX402Facilitator = facilitator({
38+
client,
39+
serverWalletAddress: "${walletAddress || "0xYourWalletAddress"}",
40+
});
41+
42+
export async function GET(request: Request) {
43+
// process the payment
44+
const result = await settlePayment({
45+
resourceUrl: "https://api.example.com/premium-content",
46+
method: "GET",
47+
paymentData: request.headers.get("x-payment"),
48+
network: arbitrumSepolia,
49+
price: "$0.01",
50+
facilitator: thirdwebX402Facilitator,
51+
});
52+
53+
if (result.status === 200) {
54+
// Payment successful, continue to app logic
55+
return Response.json({ data: "premium content" });
56+
} else {
57+
return Response.json(result.responseBody, {
58+
status: result.status,
59+
headers: result.responseHeaders,
60+
});
61+
}
62+
}
63+
`;

0 commit comments

Comments
 (0)