From d458a0d1be30efb14cec09af010bc372774ab24f Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Tue, 11 Nov 2025 14:14:40 +1300 Subject: [PATCH] [Dashboard] Add x402 payments section and disallow robots --- apps/dashboard/src/@/api/analytics.ts | 42 +++++ apps/dashboard/src/@/types/analytics.ts | 59 ++++++ apps/dashboard/src/@/utils/number.ts | 2 +- .../[project_slug]/(sidebar)/bridge/page.tsx | 3 + .../components/ProjectSidebarLayout.tsx | 9 +- .../x402/QuickstartSection.client.tsx | 69 +++++++ .../x402/analytics/ChartsSection.tsx | 30 ++++ .../x402/analytics/MetricSwitcher.tsx | 31 ++++ .../(sidebar)/x402/analytics/Summary.tsx | 133 ++++++++++++++ .../X402SettlementsByPayerChartCard.tsx | 158 ++++++++++++++++ .../X402SettlementsByResourceChartCard.tsx | 155 ++++++++++++++++ .../(sidebar)/x402/analytics/index.tsx | 170 ++++++++++++++++++ .../(sidebar)/x402/configuration/page.tsx | 33 ++++ .../[project_slug]/(sidebar)/x402/layout.tsx | 64 +++++++ .../[project_slug]/(sidebar)/x402/page.tsx | 87 +++++++++ 15 files changed, 1042 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/QuickstartSection.client.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/ChartsSection.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/MetricSwitcher.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/Summary.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByPayerChartCard.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByResourceChartCard.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/configuration/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/layout.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index cb1f07941fb..451099ba2ea 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -16,6 +16,8 @@ import type { WalletStats, WebhookLatencyStats, WebhookSummaryStats, + X402QueryParams, + X402SettlementStats, } from "@/types/analytics"; import { getChains } from "./chain"; @@ -906,3 +908,43 @@ export function getInsightUsage( ) { return cached_getInsightUsage(normalizedParams(params), authToken); } + +const cached_getX402Settlements = unstable_cache( + async ( + params: X402QueryParams, + authToken: string, + ): Promise => { + const searchParams = buildSearchParams(params); + + if (params.groupBy) { + searchParams.append("groupBy", params.groupBy); + } + + const res = await fetchAnalytics({ + authToken, + url: `v2/x402/settlements?${searchParams.toString()}`, + init: { + method: "GET", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch x402 settlements: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return []; + } + + const json = await res.json(); + return json.data as X402SettlementStats[]; + }, + ["getX402Settlements"], + { + revalidate: 60 * 60, // 1 hour + }, +); + +export function getX402Settlements(params: X402QueryParams, authToken: string) { + return cached_getX402Settlements(normalizedParams(params), authToken); +} diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index b70030d2b53..a22c0060386 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -94,3 +94,62 @@ export interface AnalyticsQueryParams { period?: "day" | "week" | "month" | "year" | "all"; limit?: number; } + +export interface X402SettlementsOverall { + date: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +interface X402SettlementsByChainId { + date: string; + chainId: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +export interface X402SettlementsByPayer { + date: string; + payer: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +interface X402SettlementsByReceiver { + date: string; + receiver: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +export interface X402SettlementsByResource { + date: string; + resource: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +interface X402SettlementsByAsset { + date: string; + asset: string; + totalRequests: number; + totalValue: number; + totalValueUSD: number; +} + +export type X402SettlementStats = + | X402SettlementsOverall + | X402SettlementsByChainId + | X402SettlementsByPayer + | X402SettlementsByReceiver + | X402SettlementsByResource + | X402SettlementsByAsset; + +export interface X402QueryParams extends AnalyticsQueryParams { + groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset"; +} diff --git a/apps/dashboard/src/@/utils/number.ts b/apps/dashboard/src/@/utils/number.ts index 0bed2c0b962..8f22ede8969 100644 --- a/apps/dashboard/src/@/utils/number.ts +++ b/apps/dashboard/src/@/utils/number.ts @@ -1,6 +1,6 @@ const usdCurrencyFormatter = new Intl.NumberFormat("en-US", { currency: "USD", - maximumFractionDigits: 2, // prefix with $ + maximumFractionDigits: 6, // prefix with $ minimumFractionDigits: 0, // don't show decimal places if value is a whole number notation: "compact", // at max 2 decimal places roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/bridge/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/bridge/page.tsx index 9a1d6f99911..023dd33a3c4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/bridge/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/bridge/page.tsx @@ -69,6 +69,9 @@ export default async function Page(props: { icon: , }, }, + settings: { + href: `/team/${params.team_slug}/${params.project_slug}/settings/payments`, + }, links: [ { type: "docs", diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx index 2c03fa4f88d..95f346283c2 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectSidebarLayout.tsx @@ -1,4 +1,5 @@ "use client"; +import { Badge } from "@workspace/ui/components/badge"; import { BookTextIcon, BoxIcon, @@ -76,9 +77,13 @@ export function ProjectSidebarLayout(props: { group: "Monetize", links: [ { - href: `${props.layoutPath}/payments`, + href: `${props.layoutPath}/x402`, icon: PayIcon, - label: "Payments", + label: ( + + x402 New + + ), }, { href: `${props.layoutPath}/bridge`, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/QuickstartSection.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/QuickstartSection.client.tsx new file mode 100644 index 00000000000..1084f52fcdd --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/QuickstartSection.client.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { BotIcon, ServerIcon, WalletIcon } from "lucide-react"; +import { FeatureCard } from "../payments/components/FeatureCard.client"; + +export function QuickStartSection() { + return ( +
+
+

Quick Start

+

+ Choose how to integrate x402 payments into your project. +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/ChartsSection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/ChartsSection.tsx new file mode 100644 index 00000000000..e4ccfaec7cc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/ChartsSection.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import type { Metric } from "./MetricSwitcher"; +import { MetricSwitcher } from "./MetricSwitcher"; + +export function ChartMetricSwitcher() { + const router = useDashboardRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const metric = (searchParams.get("metric") as Metric) || "volume"; + + const handleMetricChange = useCallback( + (newMetric: Metric) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("metric", newMetric); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }, + [pathname, router, searchParams], + ); + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/MetricSwitcher.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/MetricSwitcher.tsx new file mode 100644 index 00000000000..b371f60e07b --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/MetricSwitcher.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export type Metric = "payments" | "volume"; + +export function MetricSwitcher(props: { + value: Metric; + onChange: (value: Metric) => void; +}) { + return ( +
+ Show: + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/Summary.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/Summary.tsx new file mode 100644 index 00000000000..0a420de6fdc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/Summary.tsx @@ -0,0 +1,133 @@ +import { CreditCardIcon, DollarSignIcon, UsersIcon } from "lucide-react"; +import { Suspense } from "react"; +import { getX402Settlements } from "@/api/analytics"; +import type { Range } from "@/components/analytics/date-range-selector"; +import { StatCard } from "@/components/analytics/stat"; +import type { + X402SettlementsByPayer, + X402SettlementsOverall, +} from "@/types/analytics"; + +function X402SummaryInner(props: { + totalPayments: number | undefined; + totalBuyers: number | undefined; + totalVolume: number | undefined; + isPending: boolean; +}) { + const formatUSD = (value: number) => { + return `$${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + })}`; + }; + + return ( +
+ + + +
+ ); +} + +async function AsyncX402Summary(props: { + teamId: string; + projectId: string; + authToken: string; + range: Range; +}) { + const { teamId, projectId, authToken, range } = props; + + const [overallStats, payerStats] = await Promise.all([ + getX402Settlements( + { + from: range.from, + period: "all", + projectId, + teamId, + to: range.to, + groupBy: "overall", + }, + authToken, + ).catch(() => []), + getX402Settlements( + { + from: range.from, + period: "all", + projectId, + teamId, + to: range.to, + groupBy: "payer", + }, + authToken, + ).catch(() => []), + ]); + + const totalPayments = (overallStats as X402SettlementsOverall[]).reduce( + (acc, curr) => acc + curr.totalRequests, + 0, + ); + + const totalVolume = (overallStats as X402SettlementsOverall[]).reduce( + (acc, curr) => acc + curr.totalValueUSD, + 0, + ); + + // Count unique payers + const uniquePayers = new Set( + (payerStats as X402SettlementsByPayer[]).map((stat) => stat.payer), + ); + const totalBuyers = uniquePayers.size; + + return ( + + ); +} + +export function X402Summary(props: { + teamId: string; + projectId: string; + authToken: string; + range: Range; +}) { + return ( + + } + > + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByPayerChartCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByPayerChartCard.tsx new file mode 100644 index 00000000000..0703969f0c3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByPayerChartCard.tsx @@ -0,0 +1,158 @@ +"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 type { X402SettlementsByPayer } from "@/types/analytics"; +import { toUSD } from "@/utils/number"; + +type ChartData = Record & { + time: string; +}; + +export function X402SettlementsByPayerChartCard({ + rawData, + isPending, + metric = "payments", +}: { + rawData: X402SettlementsByPayer[]; + isPending: boolean; + metric?: "payments" | "volume"; +}) { + const maxPayersToDisplay = 10; + const isVolumeMetric = metric === "volume"; + + const { data, payersToDisplay, chartConfig, isAllEmpty } = useMemo(() => { + const dateToValueMap: Map = new Map(); + const payerToCountMap: Map = new Map(); + + for (const dataItem of rawData) { + const { date, payer, totalRequests, totalValueUSD } = dataItem; + const value = isVolumeMetric ? totalValueUSD : totalRequests; + let dateRecord = dateToValueMap.get(date); + + if (!dateRecord) { + dateRecord = { time: date } as ChartData; + dateToValueMap.set(date, dateRecord); + } + + // Truncate payer address for display + const displayPayer = + payer.length > 10 ? `${payer.slice(0, 6)}...${payer.slice(-4)}` : payer; + + dateRecord[displayPayer] = (dateRecord[displayPayer] || 0) + value; + payerToCountMap.set( + displayPayer, + (payerToCountMap.get(displayPayer) || 0) + value, + ); + } + + // Sort payers by count (highest count first) - remove the ones with 0 count + const sortedPayersByCount = Array.from(payerToCountMap.entries()) + .sort((a, b) => b[1] - a[1]) + .filter((x) => x[1] > 0); + + const payersToDisplayArray = sortedPayersByCount + .slice(0, maxPayersToDisplay) + .map(([payer]) => payer); + const payersToDisplay = new Set(payersToDisplayArray); + + // Loop over each entry in dateToValueMap + // Replace the payer that is not in payersToDisplay with "Other" + // Add total key that is the sum of all payers + 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" && !payersToDisplay.has(key), + ); + + for (const payer of keysToMove) { + dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[payer] || 0); + delete dateRecord[payer]; + } + + 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 < payersToDisplayArray.length; i++) { + const payer = payersToDisplayArray[i]; + if (payer) { + chartConfig[payer] = { + label: payer, + color: `hsl(var(--chart-${(i % 10) + 1}))`, + isCurrency: isVolumeMetric, + }; + } + } + + // If we need to display "Other" payers + if (sortedPayersByCount.length > maxPayersToDisplay) { + chartConfig.Other = { + label: "Other", + color: "hsl(var(--muted-foreground))", + isCurrency: isVolumeMetric, + }; + payersToDisplayArray.push("Other"); + } + + return { + chartConfig, + data: returnValue, + isAllEmpty: returnValue.every((d) => (d.total || 0) === 0), + payersToDisplay: payersToDisplayArray, + }; + }, [rawData, isVolumeMetric]); + + const emptyChartState = ( +
+

No data available

+
+ ); + + const title = isVolumeMetric ? "Volume by Buyer" : "Payments by Buyer"; + + 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/X402SettlementsByResourceChartCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByResourceChartCard.tsx new file mode 100644 index 00000000000..35716d8c47d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/X402SettlementsByResourceChartCard.tsx @@ -0,0 +1,155 @@ +"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 type { X402SettlementsByResource } from "@/types/analytics"; +import { toUSD } from "@/utils/number"; + +type ChartData = Record & { + time: string; +}; + +export function X402SettlementsByResourceChartCard({ + rawData, + isPending, + metric = "payments", +}: { + rawData: X402SettlementsByResource[]; + isPending: boolean; + metric?: "payments" | "volume"; +}) { + const maxResourcesToDisplay = 10; + const isVolumeMetric = metric === "volume"; + + const { data, resourcesToDisplay, chartConfig, isAllEmpty } = useMemo(() => { + const dateToValueMap: Map = new Map(); + const resourceToCountMap: Map = new Map(); + + for (const dataItem of rawData) { + const { date, resource, totalRequests, totalValueUSD } = dataItem; + const value = isVolumeMetric ? totalValueUSD : totalRequests; + let dateRecord = dateToValueMap.get(date); + + if (!dateRecord) { + dateRecord = { time: date } as ChartData; + dateToValueMap.set(date, dateRecord); + } + + dateRecord[resource] = (dateRecord[resource] || 0) + value; + resourceToCountMap.set( + resource, + (resourceToCountMap.get(resource) || 0) + value, + ); + } + + // Sort resources by count (highest count first) - remove the ones with 0 count + const sortedResourcesByCount = Array.from(resourceToCountMap.entries()) + .sort((a, b) => b[1] - a[1]) + .filter((x) => x[1] > 0); + + const resourcesToDisplayArray = sortedResourcesByCount + .slice(0, maxResourcesToDisplay) + .map(([resource]) => resource); + const resourcesToDisplay = new Set(resourcesToDisplayArray); + + // Loop over each entry in dateToValueMap + // Replace the resource that is not in resourcesToDisplay with "Other" + // Add total key that is the sum of all resources + 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" && !resourcesToDisplay.has(key), + ); + + for (const resource of keysToMove) { + dateRecord.Other = + (dateRecord.Other || 0) + (dateRecord[resource] || 0); + delete dateRecord[resource]; + } + + 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 < resourcesToDisplayArray.length; i++) { + const resource = resourcesToDisplayArray[i]; + if (resource) { + chartConfig[resource] = { + label: resource, + color: `hsl(var(--chart-${(i % 10) + 1}))`, + isCurrency: isVolumeMetric, + }; + } + } + + // If we need to display "Other" resources + if (sortedResourcesByCount.length > maxResourcesToDisplay) { + chartConfig.Other = { + label: "Other", + color: "hsl(var(--muted-foreground))", + isCurrency: isVolumeMetric, + }; + resourcesToDisplayArray.push("Other"); + } + + return { + chartConfig, + data: returnValue, + isAllEmpty: returnValue.every((d) => (d.total || 0) === 0), + resourcesToDisplay: resourcesToDisplayArray, + }; + }, [rawData, isVolumeMetric]); + + const emptyChartState = ( +
+

No data available

+
+ ); + + const title = isVolumeMetric ? "Volume by Resource" : "Payments by Resource"; + + 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 new file mode 100644 index 00000000000..edd0a80f264 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/analytics/index.tsx @@ -0,0 +1,170 @@ +import { ResponsiveSuspense } from "responsive-rsc"; +import { getX402Settlements } from "@/api/analytics"; +import { + getLastNDaysRange, + type Range, +} from "@/components/analytics/date-range-selector"; +import type { + X402SettlementsByPayer, + X402SettlementsByResource, +} from "@/types/analytics"; +import { X402SettlementsByPayerChartCard } from "./X402SettlementsByPayerChartCard"; +import { X402SettlementsByResourceChartCard } from "./X402SettlementsByResourceChartCard"; + +// Payments by Resource Chart +type X402SettlementsByResourceChartProps = { + interval: "day" | "week"; + range: Range; + stats: X402SettlementsByResource[]; + isPending: boolean; + metric?: "payments" | "volume"; +}; + +function X402SettlementsByResourceChartUI({ + stats, + isPending, + metric = "payments", +}: X402SettlementsByResourceChartProps) { + return ( + + ); +} + +type AsyncX402SettlementsByResourceChartProps = Omit< + X402SettlementsByResourceChartProps, + "stats" | "isPending" +> & { + teamId: string; + projectId: string; + authToken: string; +}; + +async function AsyncX402SettlementsByResourceChart( + props: AsyncX402SettlementsByResourceChartProps, +) { + 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: "resource", + }, + props.authToken, + ).catch((error) => { + console.error(error); + return []; + }); + + return ( + + ); +} + +export function X402SettlementsByResourceChart( + props: AsyncX402SettlementsByResourceChartProps, +) { + return ( + + } + searchParamsUsed={["from", "to", "interval", "metric"]} + > + + + ); +} + +// Payments by Payer Chart +type X402SettlementsByPayerChartProps = { + interval: "day" | "week"; + range: Range; + stats: X402SettlementsByPayer[]; + isPending: boolean; + metric?: "payments" | "volume"; +}; + +function X402SettlementsByPayerChartUI({ + stats, + isPending, + metric = "payments", +}: X402SettlementsByPayerChartProps) { + return ( + + ); +} + +type AsyncX402SettlementsByPayerChartProps = Omit< + X402SettlementsByPayerChartProps, + "stats" | "isPending" +> & { + teamId: string; + projectId: string; + authToken: string; +}; + +async function AsyncX402SettlementsByPayerChart( + props: AsyncX402SettlementsByPayerChartProps, +) { + 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: "payer", + }, + props.authToken, + ).catch((error) => { + console.error(error); + return []; + }); + + return ( + + ); +} + +export function X402SettlementsByPayerChart( + props: AsyncX402SettlementsByPayerChartProps, +) { + return ( + + } + searchParamsUsed={["from", "to", "interval", "metric"]} + > + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/configuration/page.tsx new file mode 100644 index 00000000000..e87e9e5b966 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/configuration/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { loginRedirect } from "@/utils/redirects"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + + const authToken = await getAuthToken(); + if (!authToken) { + loginRedirect( + `/team/${params.team_slug}/${params.project_slug}/x402/configuration`, + ); + } + + const project = await getProject(params.team_slug, params.project_slug); + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + return ( +
+
+

Coming Soon

+

+ x402 payments configuration will be available soon. +

+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/layout.tsx new file mode 100644 index 00000000000..cf8ce59ddb4 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/layout.tsx @@ -0,0 +1,64 @@ +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { ProjectPage } from "@/components/blocks/project-page/project-page"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { PayIcon } from "@/icons/PayIcon"; +import { loginRedirect } from "@/utils/redirects"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const params = await props.params; + const basePath = `/team/${params.team_slug}/${params.project_slug}/x402`; + + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(params.team_slug, params.project_slug), + ]); + + if (!authToken) { + loginRedirect(basePath); + } + + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + return ( + + {props.children} + + ); +} 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 new file mode 100644 index 00000000000..079084a0a70 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/page.tsx @@ -0,0 +1,87 @@ +import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +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 { getFiltersFromSearchParams } from "@/lib/time"; +import { loginRedirect } from "@/utils/redirects"; +import { + X402SettlementsByPayerChart, + X402SettlementsByResourceChart, +} from "./analytics"; +import { ChartMetricSwitcher } from "./analytics/ChartsSection"; +import { X402Summary } from "./analytics/Summary"; +import { QuickStartSection } from "./QuickstartSection.client"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string; + to?: string; + type?: string; + interval?: string; + metric?: string; + }>; +}) { + const [searchParams, params] = await Promise.all([ + props.searchParams, + props.params, + ]); + + const authToken = await getAuthToken(); + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/${params.project_slug}/x402`); + } + + const defaultRange: DurationId = "last-30"; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange, + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + const project = await getProject(params.team_slug, params.project_slug); + if (!project) { + redirect(`/team/${params.team_slug}`); + } + + const metric = (searchParams.metric as "payments" | "volume") || "volume"; + + return ( + +
+ + + + + +
+ +
+
+
+ ); +}