diff --git a/AGENTS.md b/AGENTS.md index e5831ca0394..e75c2ed636f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. ⸻ diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index a22c0060386..7502cc6f0f3 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -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"; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByChainChartCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByChainChartCard.tsx new file mode 100644 index 00000000000..3ddb3c23fb5 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByChainChartCard.tsx @@ -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 & { + 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 = new Map(); + const chainToCountMap: Map = 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 = ( +
+

No data available

+
+ ); + + const title = isVolumeMetric ? "Volume by Chain" : "Payments by Chain"; + + return ( + +

+ {title} +

+ + } + 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" + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx index edd0a80f264..9b710c9efcd 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx @@ -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( ); } + +// 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 ( + + ); +} + +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 ( + + ); +} + +export function X402SettlementsByChainChart( + props: AsyncX402SettlementsByChainChartProps, +) { + return ( + + } + searchParamsUsed={["from", "to", "interval", "metric"]} + > + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx new file mode 100644 index 00000000000..8f1b03e1a37 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx @@ -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 ( + + ), + 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, + }); + } +} +`; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx index 079084a0a70..2b2db9191f5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx @@ -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 (
- - - - + {totalPayments === 0 ? ( + + ) : ( + <> + + + + + + + )}