[Dashboard] Add x402 chain analytics chart and empty state (#8398)
This commit is contained in:
@@ -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.
|
||||
|
||||
⸻
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
+160
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+79
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+63
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
+63
-23
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user