Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/src/@/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface X402SettlementsOverall {
totalValueUSD: number;
}

interface X402SettlementsByChainId {
export interface X402SettlementsByChainId {
date: string;
chainId: string;
totalRequests: number;
Expand Down Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
@@ -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"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";

Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add import "server-only" directive.

Server components performing data rendering should include the import "server-only" directive to prevent accidental client bundling.

As per coding guidelines

Apply this diff:

+import "server-only";
 import { CodeServer } from "@/components/ui/code/code.server";
 import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx
around lines 1 to 3, this server component is missing the required server-only
directive; add the statement import "server-only" as the very first line of the
file (before any other imports) so the component is explicitly marked
server-only and will not be accidentally client-bundled.

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,
});
}
}
`;
Comment on lines +30 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add explicit return type to helper function.

The jsCode helper should have an explicit return type annotation.

As per coding guidelines

Apply this diff:

-const jsCode = (walletAddress?: string) => `\
+const jsCode = (walletAddress?: string): string => `\
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
});
}
}
`;
const jsCode = (walletAddress?: string): 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,
});
}
}
`;
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx
around lines 30 to 63, the helper jsCode is missing an explicit return type;
change its signature to include an explicit string return type (e.g., annotate
the function as returning : string) so the function declaration becomes a typed
arrow function returning a string literal, keeping the implementation unchanged.

Loading
Loading