diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index 64e6c131536..32df80874ca 100644 --- a/apps/dashboard/src/@/actions/proxies.ts +++ b/apps/dashboard/src/@/actions/proxies.ts @@ -3,7 +3,6 @@ import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken"; import { NEXT_PUBLIC_ENGINE_CLOUD_URL, - NEXT_PUBLIC_PAY_URL, NEXT_PUBLIC_THIRDWEB_API_HOST, } from "../constants/public-envs"; import { ANALYTICS_SERVICE_URL } from "../constants/server-envs"; @@ -88,15 +87,6 @@ export async function engineCloudProxy(params: ProxyActionParams) { return proxy(NEXT_PUBLIC_ENGINE_CLOUD_URL, params); } -export async function payServerProxy(params: ProxyActionParams) { - return proxy( - NEXT_PUBLIC_PAY_URL - ? `https://${NEXT_PUBLIC_PAY_URL}` - : "https://pay.thirdweb-dev.com", - params, - ); -} - export async function analyticsServerProxy(params: ProxyActionParams) { return proxy(ANALYTICS_SERVICE_URL, params); } diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index ae60b00c57e..f46176b5d54 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -6,6 +6,8 @@ import type { InAppWalletStats, RpcMethodStats, TransactionStats, + UniversalBridgeStats, + UniversalBridgeWalletStats, UserOpStats, WalletStats, WalletUserStats, @@ -46,11 +48,15 @@ async function fetchAnalytics( ); } // client id DEBUG OVERRIDE - // analyticsServiceUrl.searchParams.delete("clientId"); - // analyticsServiceUrl.searchParams.delete("accountId"); - // analyticsServiceUrl.searchParams.append( - // "clientId", - // "...", + // ANALYTICS_SERVICE_URL.searchParams.delete("projectId"); + // ANALYTICS_SERVICE_URL.searchParams.delete("teamId"); + // ANALYTICS_SERVICE_URL.searchParams.append( + // "teamId", + // "team_clmb33q9w00gn1x0u2ri8z0k0", + // ); + // ANALYTICS_SERVICE_URL.searchParams.append( + // "projectId", + // "prj_clyqwud5y00u1na7nzxnzlz7o", // ); return fetch(analyticsServiceUrl, { @@ -58,7 +64,6 @@ async function fetchAnalytics( headers: { "content-type": "application/json", ...init?.headers, - authorization: `Bearer ${token}`, }, }); } @@ -369,3 +374,62 @@ export async function getEcosystemWalletUsage(args: { return json.data as EcosystemWalletStats[]; } + +export async function getUniversalBridgeUsage(args: { + teamId: string; + projectId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}) { + const searchParams = buildSearchParams(args); + const res = await fetchAnalytics(`v2/universal?${searchParams.toString()}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return null; + } + + const json = await res.json(); + + return json.data as UniversalBridgeStats[]; +} + +export async function getUniversalBridgeWalletUsage(args: { + teamId: string; + projectId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}) { + const searchParams = buildSearchParams(args); + const res = await fetchAnalytics( + `v2/universal/wallets?${searchParams.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (res?.status !== 200) { + const reason = await res?.text(); + console.error( + `Failed to fetch universal bridge wallet stats: ${res?.status} - ${res.statusText} - ${reason}`, + ); + return null; + } + + const json = await res.json(); + + return json.data as UniversalBridgeWalletStats[]; +} diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts index 58f3960bcf0..a1eda5b528b 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -151,3 +151,85 @@ export async function updateFee(props: { return; } + +export type PaymentsResponse = { + data: Payment[]; + meta: { + totalCount: number; + }; +}; +export type Payment = { + id: string; + blockNumber?: bigint; + transactionId: string; + clientId: string; + sender: string; + receiver: string; + developerFeeRecipient: string; + developerFeeBps: number; + transactions: Array<{ + chainId: number; + transactionHash: string; + }>; + status: "PENDING" | "COMPLETED" | "FAILED" | "NOT_FOUND"; + type: "buy" | "sell" | "transfer"; + originAmount: bigint; + destinationAmount: bigint; + purchaseData: unknown; + originToken: { + address: string; + symbol: string; + decimals: number; + chainId: number; + }; + destinationToken: { + address: string; + symbol: string; + decimals: number; + chainId: number; + }; + createdAt: string; +}; + +export async function getPayments(props: { + clientId: string; + limit?: number; + offset?: number; +}) { + const authToken = await getAuthToken(); + + // Build URL with query parameters if provided + let url = `${UB_BASE_URL}/v1/developer/payments`; + const queryParams = new URLSearchParams(); + + if (props.limit) { + queryParams.append("limit", props.limit.toString()); + } + + if (props.offset) { + queryParams.append("offset", props.offset.toString()); + } + + // Append query params to URL if any exist + const queryString = queryParams.toString(); + if (queryString) { + url = `${url}?${queryString}`; + } + + const res = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-client-id-override": props.clientId, + Authorization: `Bearer ${authToken}`, + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json as PaymentsResponse; +} diff --git a/apps/dashboard/src/@/constants/public-envs.ts b/apps/dashboard/src/@/constants/public-envs.ts index 4651b0e9f06..789b5737f46 100644 --- a/apps/dashboard/src/@/constants/public-envs.ts +++ b/apps/dashboard/src/@/constants/public-envs.ts @@ -32,7 +32,5 @@ export const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET = export const NEXT_PUBLIC_NEBULA_URL = process.env.NEXT_PUBLIC_NEBULA_URL || ""; -export const NEXT_PUBLIC_PAY_URL = process.env.NEXT_PUBLIC_PAY_URL || ""; - export const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL || ""; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/universal-bridge/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/universal-bridge/page.tsx index 3d254d84eb8..6f3e31a4c4e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/universal-bridge/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/connect/universal-bridge/page.tsx @@ -1,12 +1,24 @@ import { getProject } from "@/api/projects"; import { PayAnalytics } from "components/pay/PayAnalytics/PayAnalytics"; import { redirect } from "next/navigation"; +import { + ResponsiveSearchParamsProvider, + ResponsiveSuspense, +} from "responsive-rsc"; +import { Spinner } from "../../../../../../../@/components/ui/Spinner/Spinner"; +import { PayAnalyticsFilter } from "../../../../../../../components/pay/PayAnalytics/components/PayAnalyticsFilter"; +import { getUniversalBridgeFiltersFromSearchParams } from "../../../../../../../lib/time"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string; }>; + searchParams: { + from?: string | undefined | string[]; + to?: string | undefined | string[]; + interval?: string | undefined | string[]; + }; }) { const params = await props.params; const project = await getProject(params.team_slug, params.project_slug); @@ -15,11 +27,36 @@ export default async function Page(props: { redirect(`/team/${params.team_slug}`); } + const searchParams = await props.searchParams; + const { range, interval } = getUniversalBridgeFiltersFromSearchParams({ + from: searchParams.from, + to: searchParams.to, + interval: searchParams.interval, + }); + return ( - + +
+
+ +
+ + +
+ } + > + + + +
); } diff --git a/apps/dashboard/src/components/analytics/area-chart.tsx b/apps/dashboard/src/components/analytics/area-chart.tsx deleted file mode 100644 index 1ec0675ada9..00000000000 --- a/apps/dashboard/src/components/analytics/area-chart.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { cn } from "@/lib/utils"; -import { useEffect, useId, useState } from "react"; -import { - Area, - AreaChart as RechartsAreaChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { CustomToolTip } from "./custom-tooltip"; - -type GenericDataType = Record; - -type IndexType = "date"; - -interface AreaChartProps< - TData extends GenericDataType, - TIndexKey extends keyof TData, -> { - data: TData[]; - index: { - id: TIndexKey; - label?: string; - type?: IndexType; - format?: (index: TData[TIndexKey]) => string; - }; - - categories: Array<{ - id: keyof TData; - label?: string; - color?: string; - format?: (value: number) => string; - }>; - showXAxis?: boolean; - showYAxis?: boolean; - startEndOnly?: boolean; - className?: string; - isAnimationActive?: boolean; -} - -const AreaChart = < - TData extends GenericDataType, - TIndexKey extends keyof TData, ->({ - data, - index, - categories, - showXAxis, - showYAxis, - startEndOnly, - className, -}: AreaChartProps) => { - const id = useId(); - - if (!data.length) { - return null; - } - - const indexType = index.type || "date"; - const firstData = data[0]; - const lastData = data[data.length - 1]; - const firstDataAtIndexId = firstData?.[index.id]; - const lastDataAtIndexId = lastData?.[index.id]; - - return ( -
- - - - {categories.map((cat) => ( - - - - - ))} - - - {categories.map((cat) => ( - - ))} - { - const payloadKey = payload?.[0]?.dataKey; - const category = categories.find((cat) => cat.id === payloadKey); - return ( - - ); - }} - cursor={{ - stroke: "#3385FF", - fill: "#3385FF", - opacity: 0.3, - strokeDasharray: 2, - strokeWidth: 1.5, - }} - /> - - - index.format - ? index.format(payload) - : indexType === "date" - ? new Date(payload).toLocaleDateString(undefined, { - day: "numeric", - month: "short", - year: "numeric", - }) - : payload - } - className="font-sans text-xs" - stroke="hsl(var(--muted-foreground))" - tickLine={false} - axisLine={{ stroke: "hsl(var(--border))" }} - interval="preserveStartEnd" - minTickGap={5} - domain={["dataMin", "dataMax"]} - type="number" - tick={{ transform: "translate(0, 6)" }} - ticks={ - startEndOnly && firstDataAtIndexId && lastDataAtIndexId - ? [firstDataAtIndexId, lastDataAtIndexId] - : undefined - } - /> - - { - const category = categories[0]; - return category?.format - ? category.format(payload) - : payload.toString(); - }} - className="font-sans text-xs" - domain={([dataMin, dataMax]) => [ - // start from 0 unless dataMin is below 0 in which case start from dataMin - 10% - Math.min(0, dataMin - Math.round(dataMin * 0.1)), - // add 10% to the top - dataMax + Math.round(dataMax * 0.1), - ]} - tick={{ transform: "translate(-3, 0)" }} - type="number" - stroke="hsl(var(--muted-foreground))" - tickLine={false} - axisLine={{ stroke: "hsl(var(--border))" }} - interval="preserveStartEnd" - /> - - -
- ); -}; - -function randomNumber(min = 0, max = 100) { - return Math.random() * (max - min) + min; -} - -function generateFakeData() { - const data = []; - for (let i = 0; i < 7; i++) { - data.push({ - key: i, - value: randomNumber(i * 10, i * 10 + 30), - }); - } - return data; -} - -export const AreaChartLoadingState = (props: { height?: string }) => { - const [loadingData, setLoadingData] = useState(() => generateFakeData()); - - // legitimate use case - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - const interval = setInterval(() => { - setLoadingData(generateFakeData()); - }, 2500); - return () => clearInterval(interval); - }, []); - return ( -
-
-

Loading Chart

-
- -
- ); -}; diff --git a/apps/dashboard/src/components/analytics/custom-tooltip.tsx b/apps/dashboard/src/components/analytics/custom-tooltip.tsx deleted file mode 100644 index 34d63949f09..00000000000 --- a/apps/dashboard/src/components/analytics/custom-tooltip.tsx +++ /dev/null @@ -1,51 +0,0 @@ -type CustomToolTipProps = { - valueLabel: string; - active?: boolean; - // biome-ignore lint/suspicious/noExplicitAny: FIXME - payload?: any; - // biome-ignore lint/suspicious/noExplicitAny: FIXME - valueFormatter?: (value: any) => string; -}; - -const formattingOptions: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", -}; - -export const CustomToolTip: React.FC = ({ - active, - payload, - valueLabel, - valueFormatter, -}) => { - if (active && payload && payload.length) { - return ( -
- {payload[0]?.payload?.time && ( -
-
Date
-

- {new Date(payload[0].payload.time).toLocaleDateString( - undefined, - formattingOptions, - )} -

-
- )} -
-
- {valueLabel} -
-

- {valueFormatter - ? valueFormatter(payload[0].value) - : payload[0].value.toLocaleString()} -

-
-
- ); - } - - return null; -}; diff --git a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx index 56a8b570daa..059a8608be2 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx @@ -1,11 +1,8 @@ -"use client"; - -import { useState } from "react"; import { - DateRangeSelector, - type Range, - getLastNDaysRange, -} from "../../analytics/date-range-selector"; + getUniversalBridgeUsage, + getUniversalBridgeWalletUsage, +} from "@/api/analytics"; +import type { Range } from "../../analytics/date-range-selector"; import { PayCustomersTable } from "./components/PayCustomersTable"; import { PayNewCustomers } from "./components/PayNewCustomers"; import { PaymentHistory } from "./components/PaymentHistory"; @@ -14,104 +11,80 @@ import { Payouts } from "./components/Payouts"; import { TotalPayVolume } from "./components/TotalPayVolume"; import { TotalVolumePieChart } from "./components/TotalVolumePieChart"; -export function PayAnalytics(props: { - /** - * @deprecated - remove after migration - */ +export async function PayAnalytics(props: { clientId: string; // switching to projectId for lookup, but have to send both during migration projectId: string; teamId: string; + range: Range; + interval: "day" | "week"; }) { - const clientId = props.clientId; - const projectId = props.projectId; - const teamId = props.teamId; - const [range, setRange] = useState(() => - getLastNDaysRange("last-120"), - ); + const { projectId, teamId, range, interval } = props; - const numberOfDays = Math.round( - (range.to.getTime() - range.from.getTime()) / (1000 * 60 * 60 * 24), - ); + const dateFormat = + interval === "day" + ? { month: "short" as const, day: "numeric" as const } + : { + month: "short" as const, + day: "numeric" as const, + }; + + const volumeData = await getUniversalBridgeUsage({ + teamId: teamId, + projectId: projectId, + from: range.from, + to: range.to, + period: interval, + }).catch((error) => { + console.error(error); + return []; + }); + const walletData = await getUniversalBridgeWalletUsage({ + teamId: teamId, + projectId: projectId, + from: range.from, + to: range.to, + period: interval, + }).catch((error) => { + console.error(error); + return []; + }); return ( -
-
- -
-
- -
- -
- + +
+ x.status === "completed") || []} /> - - -
- - - - - -
+ x.status === "completed") || []} + dateFormat={dateFormat} + /> + - -
- -
- -
- +
- x.status === "completed") || []} + dateFormat={dateFormat} /> + + +
+ + +
+ +
+ +
+ + +
); } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx new file mode 100644 index 00000000000..9d6d2e59655 --- /dev/null +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { normalizeTimeISOString } from "@/lib/time"; +import { DateRangeSelector } from "components/analytics/date-range-selector"; +import { IntervalSelector } from "components/analytics/interval-selector"; +import { getUniversalBridgeFiltersFromSearchParams } from "lib/time"; +import { + useResponsiveSearchParams, + useSetResponsiveSearchParams, +} from "responsive-rsc"; + +export function PayAnalyticsFilter() { + const responsiveSearchParams = useResponsiveSearchParams(); + const setResponsiveSearchParams = useSetResponsiveSearchParams(); + + const { range, interval } = getUniversalBridgeFiltersFromSearchParams({ + from: responsiveSearchParams.from, + to: responsiveSearchParams.to, + interval: responsiveSearchParams.interval, + }); + + return ( +
+ { + setResponsiveSearchParams((v) => { + return { + ...v, + from: normalizeTimeISOString(newRange.from), + to: normalizeTimeISOString(newRange.to), + }; + }); + }} + /> + + { + setResponsiveSearchParams((v) => { + return { + ...v, + interval: newInterval, + }; + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx index 7f80d81daff..87b0f0f031f 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx @@ -1,214 +1,82 @@ +"use client"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; import { WalletAddress } from "@/components/blocks/wallet-address"; import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { SkeletonContainer } from "@/components/ui/skeleton"; -import { useState } from "react"; +import { useMemo } from "react"; +import type { UniversalBridgeWalletStats } from "types/analytics"; import { toUSD } from "../../../../utils/number"; import { - type PayTopCustomersData, - usePayCustomers, -} from "../hooks/usePayCustomers"; -import { - FailedToLoad, + CardHeading, TableData, TableHeading, TableHeadingRow, } from "./common"; -type UIData = { - customers: Array<{ - walletAddress: string; - totalSpendUSDCents: number; - }>; - showLoadMore: boolean; -}; - -type ProcessedQuery = { - data?: UIData; - isError?: boolean; - isPending?: boolean; - isEmpty?: boolean; -}; - -function processQuery( - topCustomersQuery: ReturnType, -): ProcessedQuery { - if (topCustomersQuery.isPending) { - return { isPending: true }; - } - - if (topCustomersQuery.isError) { - return { isError: true }; - } - - if (!topCustomersQuery.data) { - return { isEmpty: true }; - } - - let customers = topCustomersQuery.data.pages.flatMap( - (x) => x.pageData.customers, - ); - - customers = customers?.filter((x) => x.totalSpendUSDCents > 0); - - if (customers.length === 0) { - return { isEmpty: true }; - } - - return { - data: { - customers, - showLoadMore: !!topCustomersQuery.hasNextPage, - }, - }; -} +type PayTopCustomersData = Array<{ + walletAddress: string; + totalSpendUSDCents: number; +}>; export function PayCustomersTable(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; + data: UniversalBridgeWalletStats[]; }) { - const [type, setType] = useState<"top-customers" | "new-customers">( - "top-customers", - ); - - const topCustomersQuery = usePayCustomers({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - to: props.to, - pageSize: 100, - type, - }); - - const uiQuery = processQuery(topCustomersQuery); - - const customersData = uiQuery.data?.customers; + const tableData = useMemo(() => { + return getTopCustomers(props.data); + }, [props.data]); + const isEmpty = useMemo(() => tableData.length === 0, [tableData]); return (
{/* header */}
- + Top Customers - {customersData && ( + {tableData && ( { - return getCSVData(customersData); + return getCSVData(tableData); }} /> )}
- {!uiQuery.isError ? ( - <> -
- { - topCustomersQuery.fetchNextPage(); - }} - /> - - ) : ( - - )} +
+ + + + + Wallet Address + Total spend + + + + {tableData.map((customer, i) => { + return ( + + ); + })} + +
+ {isEmpty && ( +
+ No data available +
+ )} +
); } -function RenderData(props: { query: ProcessedQuery; loadMore: () => void }) { - return ( - - - - - Wallet Address - Total spend - - - - {props.query.isPending ? ( - <> - {new Array(5).fill(0).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: ok - - ))} - - ) : ( - <> - {props.query.data?.customers.map((customer, i) => { - return ( - - ); - })} - - )} - -
- {props.query.isEmpty && ( -
- No data available -
- )} - {props.query.data?.showLoadMore && ( -
- -
- )} -
- ); -} - function CustomerTableRow(props: { customer?: { walletAddress: string; @@ -246,7 +114,7 @@ function CustomerTableRow(props: { ); } -function getCSVData(data: PayTopCustomersData["customers"]) { +function getCSVData(data: PayTopCustomersData) { const header = ["Wallet Address", "Total spend"]; const rows = data.map((customer) => [ customer.walletAddress, @@ -255,3 +123,25 @@ function getCSVData(data: PayTopCustomersData["customers"]) { return { header, rows }; } + +function getTopCustomers(data: UniversalBridgeWalletStats[]) { + const customers = new Set(); + for (const item of data) { + if (!customers.has(item.walletAddress) && item.amountUsdCents > 0) { + customers.add(item.walletAddress); + } + } + const customersData = []; + for (const customer of customers) { + const totalSpend = data + .filter((x) => x.walletAddress === customer) + .reduce((acc, curr) => acc + curr.amountUsdCents, 0); + customersData.push({ + walletAddress: customer, + totalSpendUSDCents: totalSpend, + }); + } + return customersData.sort( + (a, b) => b.totalSpendUSDCents - a.totalSpendUSDCents, + ); +} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx index f0cbf235abf..32da883bdb5 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx @@ -1,233 +1,158 @@ +"use client"; import { SkeletonContainer } from "@/components/ui/skeleton"; -import { format } from "date-fns"; -import { useEffect, useId, useState } from "react"; +import { useId, useMemo } from "react"; import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; -import { AreaChartLoadingState } from "../../../analytics/area-chart"; -import { usePayNewCustomers } from "../hooks/usePayNewCustomers"; -import { - CardHeading, - ChangeBadge, - FailedToLoad, - IntervalSelector, - NoDataOverlay, - chartHeight, -} from "./common"; +import type { UniversalBridgeWalletStats } from "types/analytics"; +import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common"; type GraphDataItem = { date: string; value: number; }; -type ProcessedQuery = { - data?: { - graphData: GraphDataItem[]; - totalNewCustomers: number; - percentChange: number; - }; - isError?: boolean; - isPending?: boolean; - isEmpty?: boolean; -}; - -function processQuery( - newCustomersQuery: ReturnType, -): ProcessedQuery { - if (newCustomersQuery.isPending) { - return { isPending: true }; - } - - if (newCustomersQuery.isError) { - return { isError: true }; - } - - if (!newCustomersQuery.data) { - return { isEmpty: true }; - } - - if (newCustomersQuery.data.intervalResults.length === 0) { - return { isEmpty: true }; - } - - const newCustomersData: GraphDataItem[] = - newCustomersQuery.data.intervalResults.map((x) => { - return { - date: format(new Date(x.interval), "LLL dd"), - value: x.distinctCustomers, - }; - }); - - const totalNewCustomers = newCustomersQuery.data.aggregate.distinctCustomers; - - return { - data: { - graphData: newCustomersData, - totalNewCustomers, - percentChange: - newCustomersQuery.data.aggregate.bpsIncreaseFromPriorRange / 100, - }, - }; -} - export function PayNewCustomers(props: { + data: UniversalBridgeWalletStats[]; + dateFormat?: { + month: "short" | "long"; + day?: "numeric" | "2-digit"; + }; +}) { /** - * @deprecated - remove after migration + * For each date, compute the total number of wallets that have never existed before in the time series */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - numberOfDays: number; -}) { - const [intervalType, setIntervalType] = useState<"day" | "week">( - props.numberOfDays > 30 ? "week" : "day", - ); - - // if prop changes, update intervalType - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setIntervalType(props.numberOfDays > 30 ? "week" : "day"); - }, [props.numberOfDays]); - - const uiQuery = processQuery( - usePayNewCustomers({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - to: props.to, - intervalType, - }), + const { graphData, trend } = useMemo(() => { + const dates = new Set(); + for (const item of props.data) { + if (!dates.has(item.date)) { + dates.add(item.date); + } + } + + const seenUsers = new Set(); + const newUsersData = []; + for (const date of dates) { + const items = props.data.filter((x) => x.date === date); + const newUsers = items.reduce((acc, user) => { + if (!seenUsers.has(user.walletAddress) && user.amountUsdCents > 0) { + seenUsers.add(user.walletAddress); + return acc + 1; + } + return acc; + }, 0); + newUsersData.push({ + date: new Date(date).toLocaleDateString("en-US", { + ...props.dateFormat, + timeZone: "UTC", + }), + value: newUsers, + }); + } + const lastPeriod = newUsersData[newUsersData.length - 2]; + const currentPeriod = newUsersData[newUsersData.length - 1]; + const trend = + lastPeriod && currentPeriod && lastPeriod.value > 0 + ? (currentPeriod.value - lastPeriod.value) / lastPeriod.value + : 0; + return { graphData: newUsersData, trend }; + }, [props.data, props.dateFormat]); + const isEmpty = useMemo( + () => graphData.length === 0 || graphData.every((x) => x.value === 0), + [graphData], ); - return ( -
- {/* header */} -
- New Customers - -
- -
-
- - {/* Chart */} - {!uiQuery.isError ? ( - - ) : ( - - )} -
- ); -} - -function RenderData(props: { - query: ProcessedQuery; - intervalType: "day" | "week"; - setIntervalType: (intervalType: "day" | "week") => void; -}) { const uniqueId = useId(); - const chartColor = props.query.isEmpty + const chartColor = isEmpty ? "hsl(var(--muted-foreground))" : "hsl(var(--chart-1))"; return ( -
-
- { - return ( -

{v}

- ); - }} - /> - - {!props.query.isEmpty && ( - { - return ; - }} - /> - )} -
- -
- {props.query.isPending ? ( - - ) : ( - - - - - - - - - - { - const payload = x.payload?.[0]?.payload as - | GraphDataItem - | undefined; - return ( -
-

- {payload?.date} -

-

- Customers: {payload?.value} -

-
- ); +
+
+
+ New Customers +
+ acc + curr.value, 0) + } + skeletonData={100} + render={(v) => { + return ( +

{v}

+ ); + }} + /> + + {!isEmpty && ( + { + return ; }} /> - + )} +
+
+
- {props.query.data && ( - - )} - - - )} +
+ + + + + + + + + + { + const payload = x.payload?.[0]?.payload as + | GraphDataItem + | undefined; + return ( +
+

+ {payload?.date} +

+

+ Customers: {payload?.value} +

+
+ ); + }} + /> + + + {graphData && ( + + )} +
+
- {props.query.isEmpty && } + {isEmpty && }
-
+ ); } const emptyGraphData: GraphDataItem[] = [5, 9, 7, 15, 7, 20].map( diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx index d4496446012..a8da5fc27b4 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx @@ -1,3 +1,9 @@ +"use client"; +import { + type Payment, + type PaymentsResponse, + getPayments, +} from "@/api/universal-bridge/developer"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; import { WalletAddress } from "@/components/blocks/wallet-address"; import { PaginationButtons } from "@/components/pagination-buttons"; @@ -5,209 +11,138 @@ import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; -import { useState } from "react"; -import { - type PayPurchasesData, - getPayPurchases, - usePayPurchases, -} from "../hooks/usePayPurchases"; +import { useMemo, useState } from "react"; +import { toTokens } from "thirdweb"; import { CardHeading, - FailedToLoad, TableData, TableHeading, TableHeadingRow, } from "./common"; -type UIData = { - purchases: PayPurchasesData["purchases"]; - pages: number; -}; - -const pageSize = 10; - -type ProcessedQuery = { - data?: UIData; - isPending?: boolean; - isError?: boolean; - isEmpty?: boolean; -}; - -function processQuery( - purchasesQuery: ReturnType, -): ProcessedQuery { - if (purchasesQuery.isPending) { - return { isPending: true }; - } - if (purchasesQuery.isError) { - return { isError: true }; - } - if (!purchasesQuery.data) { - return { isEmpty: true }; - } - - const purchases = purchasesQuery.data.purchases; - const totalCount = purchasesQuery.data.count; - - if (purchases.length === 0) { - return { isEmpty: true }; - } - - return { - data: { - purchases, - pages: Math.ceil(totalCount / pageSize), - }, - }; -} +const pageSize = 50; export function PaymentHistory(props: { - /** - * @deprecated - remove after migration - */ clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; }) { const [page, setPage] = useState(1); - - const purchasesQuery = usePayPurchases({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - to: props.to, - start: (page - 1) * pageSize, - count: pageSize, + const { data: payPurchaseData, isLoading } = useQuery< + PaymentsResponse, + Error + >({ + queryKey: ["payments", props.clientId, page], + queryFn: async () => { + const res = await getPayments({ + clientId: props.clientId, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + return res; + }, }); - - const uiQuery = processQuery(purchasesQuery); + const isEmpty = useMemo( + () => !payPurchaseData?.data.length, + [payPurchaseData], + ); return (
Transaction History - {!uiQuery.isError && ( - { - const purchaseData = await getPayPurchases({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - count: 10000, - from: props.from, - start: 0, - to: props.to, - }); - - return getCSVData(purchaseData.purchases); - }} - /> - )} + { + return getCSVData(payPurchaseData?.data || []); + }} + />
- {!uiQuery.isError ? ( - - ) : ( - - )} -
- ); -} - -function RenderData(props: { - query: ProcessedQuery; - isLoadingMore: boolean; - activePage: number; - setPage: (page: number) => void; -}) { - return ( -
- - - - - Bought - Paid - Type - Status - Recipient - Date - - - - {!props.query.isEmpty && - (props.query.data && !props.isLoadingMore ? ( - <> - {props.query.data.purchases.map((purchase) => { - return ( - - ); - })} - - ) : ( - new Array(pageSize).fill(0).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: ok - - )) - ))} - -
+
+ + + + + Paid + Bought + Type + Status + Recipient + Date + + + + {(!isEmpty || isLoading) && + (payPurchaseData && !isLoading ? ( + <> + {payPurchaseData.data.map((purchase) => { + return ; + })} + + ) : ( + new Array(pageSize).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + )) + ))} + +
- {props.query.isEmpty ? ( -
- No data available -
- ) : props.query.data ? ( -
- -
- ) : null} -
+ {isEmpty && !isLoading ? ( +
+ No data available +
+ ) : payPurchaseData ? ( +
+ +
+ ) : null} + +
); } -function TableRow(props: { purchase: PayPurchasesData["purchases"][0] }) { +function TableRow(props: { purchase: Payment }) { const { purchase } = props; + const originAmount = toTokens( + purchase.originAmount, + purchase.originToken.decimals, + ); + const destinationAmount = toTokens( + purchase.destinationAmount, + purchase.destinationToken.decimals, + ); + const type = (() => { + if (purchase.originToken.chainId !== purchase.destinationToken.chainId) { + return "Bridge"; + } + if (purchase.originToken.address !== purchase.destinationToken.address) { + return "Swap"; + } + return "Transfer"; + })(); return ( - {/* Bought */} - {`${formatTokenAmount(purchase.toAmount)} ${purchase.toToken.symbol}`} - {/* Paid */} + {`${formatTokenAmount(originAmount)} ${purchase.originToken.symbol}`} + + {/* Bought */} - {purchase.purchaseType === "SWAP" - ? `${formatTokenAmount(purchase.fromAmount)} ${purchase.fromToken.symbol}` - : `${formatTokenAmount(`${purchase.fromAmountUSDCents / 100}`)} ${purchase.fromCurrencySymbol}`} + {`${formatTokenAmount(destinationAmount)} ${purchase.destinationToken.symbol}`} {/* Type */} @@ -216,12 +151,12 @@ function TableRow(props: { purchase: PayPurchasesData["purchases"][0] }) { variant="secondary" className={cn( "uppercase", - purchase.purchaseType === "ONRAMP" + type === "Transfer" ? "bg-fuchsia-200 text-fuchsia-800 dark:bg-fuchsia-950 dark:text-fuchsia-200" : "bg-indigo-200 text-indigo-800 dark:bg-indigo-950 dark:text-indigo-200", )} > - {purchase.purchaseType === "ONRAMP" ? "Fiat" : "Crypto"} + {type} @@ -243,13 +178,13 @@ function TableRow(props: { purchase: PayPurchasesData["purchases"][0] }) { {/* Address */} - + {/* Date */}

- {format(new Date(purchase.updatedAt), "LLL dd, y h:mm a")} + {format(new Date(purchase.createdAt), "LLL dd, y h:mm a")}

@@ -281,25 +216,43 @@ function SkeletonTableRow() { ); } -function getCSVData(data: PayPurchasesData["purchases"]) { +function getCSVData(data: Payment[]) { const header = ["Type", "Bought", "Paid", "Status", "Recipient", "Date"]; - const rows: string[][] = data.map((purchase) => [ - // bought - `${formatTokenAmount(purchase.toAmount)} ${purchase.toToken.symbol}`, - // paid - purchase.purchaseType === "SWAP" - ? `${formatTokenAmount(purchase.fromAmount)} ${purchase.fromToken.symbol}` - : `${formatTokenAmount(`${purchase.fromAmountUSDCents / 100}`)} ${purchase.fromCurrencySymbol}`, - // type - purchase.purchaseType === "ONRAMP" ? "Fiat" : "Crypto", - // status - purchase.status, - // recipient - purchase.fromAddress, - // date - format(new Date(purchase.updatedAt), "LLL dd y h:mm a"), - ]); + const rows: string[][] = data.map((purchase) => { + const toAmount = toTokens( + purchase.destinationAmount, + purchase.destinationToken.decimals, + ); + const fromAmount = toTokens( + purchase.originAmount, + purchase.originToken.decimals, + ); + const type = (() => { + if (purchase.originToken.chainId !== purchase.destinationToken.chainId) { + return "BRIDGE"; + } + if (purchase.originToken.address !== purchase.destinationToken.address) { + return "SWAP"; + } + return "TRANSFER"; + })(); + + return [ + // bought + `${formatTokenAmount(toAmount)} ${purchase.destinationToken.symbol}`, + // paid + `${formatTokenAmount(fromAmount)} ${purchase.originToken.symbol}`, + // type + type, + // status + purchase.status, + // sender + purchase.sender, + // date + format(new Date(purchase.createdAt), "LLL dd y h:mm a"), + ]; + }); return { header, rows }; } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx index 56a3f3bb53d..2407893bacd 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx @@ -1,3 +1,4 @@ +"use client"; import { Select, SelectContent, @@ -9,183 +10,110 @@ import { SkeletonContainer } from "@/components/ui/skeleton"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useState } from "react"; -import { toUSD } from "../../../../utils/number"; -import { usePayVolume } from "../hooks/usePayVolume"; -import { CardHeading, FailedToLoad } from "./common"; +import { useMemo } from "react"; +import type { UniversalBridgeStats } from "types/analytics"; +import { CardHeading } from "./common"; type PayVolumeType = "all" | "crypto" | "fiat"; -type UIData = { - succeeded: number; - failed: number; - rate: number; - total: number; -}; - -type ProcessedQuery = { - data?: UIData; - isError?: boolean; - isPending?: boolean; - isEmpty?: boolean; -}; - -function processQuery( - volumeQuery: ReturnType, - type: PayVolumeType, -): ProcessedQuery { - if (volumeQuery.isPending) { - return { isPending: true }; - } - - if (volumeQuery.isError) { - return { isError: true }; - } - - const aggregated = volumeQuery.data?.aggregate; - if (!aggregated) { - return { - isEmpty: true, - }; - } - - let succeeded = 0; - let failed = 0; - - switch (type) { - case "all": { - succeeded = aggregated.sum.succeeded.count; - failed = aggregated.sum.failed.count; - break; - } - - case "crypto": { - succeeded = aggregated.buyWithCrypto.succeeded.count; - failed = aggregated.buyWithCrypto.failed.count; - break; - } - - case "fiat": { - succeeded = aggregated.buyWithFiat.succeeded.count; - failed = aggregated.buyWithFiat.failed.count; - break; +export function PaymentsSuccessRate(props: { + data: UniversalBridgeStats[]; +}) { + const [type, setType] = useState("all"); + const isEmpty = useMemo(() => { + return props.data.length === 0 || props.data.every((x) => x.count === 0); + }, [props.data]); + const graphData = useMemo(() => { + let succeeded = 0; + let failed = 0; + for (const item of props.data.filter( + (x) => + type === "all" || + (type === "crypto" && x.type === "onchain") || + (type === "fiat" && x.type === "onramp"), + )) { + if (item.status === "completed") { + succeeded += item.count; + } else { + failed += item.count; + } } - - default: { - throw new Error("Invalid tab"); + const total = succeeded + failed; + if (total === 0) { + return { + succeeded: 0, + failed: 0, + rate: 0, + total: 0, + }; } - } - - const total = succeeded + failed; - - if (total === 0) { + const rate = (succeeded / (succeeded + failed)) * 100; return { - isEmpty: true, + succeeded, + failed, + rate, + total, }; - } - - const rate = (succeeded / (succeeded + failed)) * 100; - const data = { succeeded, failed, rate, total }; - - return { data }; -} - -export function PaymentsSuccessRate(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; -}) { - const [type, setType] = useState("all"); - - const uiQuery = processQuery( - usePayVolume({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - to: props.to, - intervalType: "day", - }), - type, - ); + }, [props.data, type]); return (
Payments - {!uiQuery.isPending && ( - - )} +
-
- {!uiQuery.isError ? : } -
-
- ); -} - -function RenderData(props: { query: ProcessedQuery }) { - return ( -
-
- {props.query.isEmpty ? ( - - ) : ( - } - /> - )} +
+
+ {isEmpty || graphData.total === 0 ? ( + + ) : ( + } + /> + )} -
+
- + -
+
- + +
); } function Bar(props: { rate: number }) { return ( -
+
{ return

{v}

; }} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx index 0f5b4437a19..50e0d00ca4e 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx @@ -1,179 +1,109 @@ +"use client"; import { SkeletonContainer } from "@/components/ui/skeleton"; -import { format } from "date-fns"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import type { UniversalBridgeStats } from "types/analytics"; import { toUSD } from "../../../../utils/number"; -import { AreaChartLoadingState } from "../../../analytics/area-chart"; -import { usePayVolume } from "../hooks/usePayVolume"; -import { - CardHeading, - ChangeBadge, - FailedToLoad, - IntervalSelector, - NoDataOverlay, - chartHeight, -} from "./common"; +import { CardHeading, ChangeBadge, NoDataOverlay, chartHeight } from "./common"; type GraphData = { date: string; value: number; }; -type ProcessedQuery = { - data?: { - totalPayoutsUSD: number; - graphData: GraphData[]; - percentChange: number; - }; - isError?: boolean; - isEmpty?: boolean; - isPending?: boolean; -}; - -function processQuery(query: ReturnType): ProcessedQuery { - if (query.isPending) { - return { isPending: true }; - } - - if (query.isError) { - return { isError: true }; - } - if (!query.data) { - return { isEmpty: true }; - } - - if (query.data.intervalResults.length === 0) { - return { isEmpty: true }; - } - - const graphData: GraphData[] = query.data.intervalResults.map((result) => ({ - date: format(new Date(result.interval), "LLL dd"), - value: result.payouts.amountUSDCents / 100, - })); - - const totalPayoutsUSD = query.data.aggregate.payouts.amountUSDCents / 100; - - const percentChange = - query.data.aggregate.payouts.bpsIncreaseFromPriorRange / 100; - - return { - data: { - graphData, - percentChange, - totalPayoutsUSD, - }, - }; -} - export function Payouts(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - numberOfDays: number; + data: UniversalBridgeStats[]; + dateFormat?: { + month: "short" | "long"; + day?: "numeric" | "2-digit"; + }; }) { - const [intervalType, setIntervalType] = useState<"day" | "week">( - props.numberOfDays > 30 ? "week" : "day", - ); - - // if prop changes, update intervalType - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setIntervalType(props.numberOfDays > 30 ? "week" : "day"); - }, [props.numberOfDays]); - - const uiQuery = processQuery( - usePayVolume({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - to: props.to, - intervalType, - }), - ); + const isEmpty = + !props.data || + props.data.length === 0 || + props.data.every((x) => x.developerFeeUsdCents === 0); + + const barColor = isEmpty ? "hsl(var(--accent))" : "hsl(var(--chart-1))"; + const { graphData, totalPayoutsUSD, trend } = useMemo(() => { + const dates = new Set(); + for (const item of props.data) { + if (!dates.has(item.date)) { + dates.add(item.date); + } + } + + const cleanedData = []; + let totalPayouts = 0; + for (const date of dates) { + const items = props.data.filter((x) => x.date === date); + const total = items.reduce( + (acc, curr) => acc + curr.developerFeeUsdCents, + 0, + ); + totalPayouts += total; + cleanedData.push({ + date: new Date(date).toLocaleDateString("en-US", { + ...props.dateFormat, + timeZone: "UTC", + }), + value: total / 100, + }); + } + const lastPeriod = cleanedData[cleanedData.length - 2]; + const currentPeriod = cleanedData[cleanedData.length - 1]; + const trend = + lastPeriod && currentPeriod && lastPeriod.value > 0 + ? (currentPeriod.value - lastPeriod.value) / lastPeriod.value + : 0; + return { + graphData: cleanedData, + totalPayoutsUSD: totalPayouts / 100, + trend, + }; + }, [props.data, props.dateFormat]); return (
{/* header */}
Payouts - - {uiQuery.data && ( - - )} +
- - {!uiQuery.isError ? ( - - ) : ( - - )} -
- ); -} - -function RenderData(props: { - query: ProcessedQuery; - intervalType: "day" | "week"; - setIntervalType: (intervalType: "day" | "week") => void; -}) { - const barColor = props.query.isEmpty - ? "hsl(var(--accent))" - : "hsl(var(--chart-1))"; - return ( -
-
- { - return ( -

{value}

- ); - }} - /> - - {!props.query.isEmpty && ( +
+
{ - return ; + loadedData={ + !props.data + ? undefined + : props.data.length > 0 + ? toUSD(totalPayoutsUSD) + : "NA" + } + skeletonData="$20" + render={(value) => { + return ( +

+ {value} +

+ ); }} /> - )} -
-
- {props.query.isPending ? ( - - ) : ( + {!isEmpty && ( + { + return ; + }} + /> + )} +
+ +
- + { const payload = x.payload?.[0]?.payload as @@ -203,7 +133,7 @@ function RenderData(props: { className="stroke-background" /> - {props.query.data && ( + {graphData && ( - )} - {props.query.isEmpty && } -
- - {props.query.data && ( -
- + {isEmpty && }
- )} -
+
+ ); } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx index 4f5eaafd660..cb0ef0d36e3 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx @@ -1,3 +1,4 @@ +"use client"; import { Select, SelectContent, @@ -5,155 +6,77 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { format } from "date-fns"; -import { useEffect, useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; -import { AreaChartLoadingState } from "../../../analytics/area-chart"; -import { type PayVolumeData, usePayVolume } from "../hooks/usePayVolume"; -import { - CardHeading, - FailedToLoad, - IntervalSelector, - NoDataOverlay, - chartHeight, -} from "./common"; +import type { UniversalBridgeStats } from "types/analytics"; +import { CardHeading, NoDataOverlay, chartHeight } from "./common"; type GraphData = { date: string; value: number; }; -type ProcessedQuery = { - data?: PayVolumeData; - isError?: boolean; - isEmpty?: boolean; - isPending?: boolean; -}; - -function processQuery( - volumeQuery: ReturnType, -): ProcessedQuery { - if (volumeQuery.isPending) { - return { isPending: true }; - } - - if (volumeQuery.isError) { - return { isError: true }; - } - if (!volumeQuery.data) { - return { isEmpty: true }; - } - - if (volumeQuery.data.intervalResults.length === 0) { - return { isEmpty: true }; - } - - return { - data: volumeQuery.data, - }; -} - export function TotalPayVolume(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - numberOfDays: number; -}) { - const [intervalType, setIntervalType] = useState<"day" | "week">( - props.numberOfDays > 30 ? "week" : "day", - ); - - // if prop changes, update intervalType - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - setIntervalType(props.numberOfDays > 30 ? "week" : "day"); - }, [props.numberOfDays]); - - const volumeQuery = processQuery( - usePayVolume({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - intervalType, - to: props.to, - }), - ); - - return ( -
- {!volumeQuery.isError ? ( - - ) : ( - - )} -
- ); -} - -function RenderData(props: { - query: ProcessedQuery; - intervalType: "day" | "week"; - setIntervalType: (intervalType: "day" | "week") => void; + data: UniversalBridgeStats[]; + dateFormat?: { + month: "short" | "long"; + day?: "numeric" | "2-digit"; + }; }) { const uniqueId = useId(); const [successType, setSuccessType] = useState<"success" | "fail">("success"); const [type, setType] = useState<"all" | "crypto" | "fiat">("all"); - const graphData: GraphData[] | undefined = - props.query.data?.intervalResults.map((x) => { - const date = format(new Date(x.interval), "LLL dd"); - + const graphData: GraphData[] | undefined = useMemo(() => { + let data = (() => { switch (type) { case "crypto": { - return { - date, - value: - x.buyWithCrypto[ - successType === "success" ? "succeeded" : "failed" - ].amountUSDCents / 100, - }; + return props.data?.filter((x) => x.type === "onchain"); } - case "fiat": { - return { - date, - value: - x.buyWithFiat[successType === "success" ? "succeeded" : "failed"] - .amountUSDCents / 100, - }; + return props.data?.filter((x) => x.type === "onramp"); } - case "all": { - return { - date, - value: - x.sum[successType === "success" ? "succeeded" : "failed"] - .amountUSDCents / 100, - }; + return props.data; } - default: { throw new Error("Invalid tab"); } } - }); + })(); + + data = (() => { + if (successType === "fail") { + return data.filter((x) => x.status === "failed"); + } + return data.filter((x) => x.status === "completed"); + })(); - const chartColor = props.query.isEmpty + const dates = new Set(); + for (const item of data) { + if (!dates.has(item.date)) { + dates.add(item.date); + } + } + + const cleanedData = []; + for (const date of dates) { + const items = data.filter((x) => x.date === date); + const total = items.reduce((acc, curr) => acc + curr.amountUsdCents, 0); + cleanedData.push({ + date: new Date(date).toLocaleDateString("en-US", { + ...props.dateFormat, + timeZone: "UTC", + }), + value: total / 100, + }); + } + return cleanedData; + }, [props.data, type, successType, props.dateFormat]); + + const isEmpty = + graphData.length === 0 || graphData.every((x) => x.value === 0); + const chartColor = isEmpty ? "hsl(var(--muted-foreground))" : successType === "success" ? "hsl(var(--chart-1))" @@ -164,7 +87,7 @@ function RenderData(props: {
Volume - {props.query.data && ( + {props.data && (
- -
)}
@@ -208,63 +126,58 @@ function RenderData(props: {
- {props.query.isPending ? ( - - ) : ( - - - - - - - - - - {graphData && ( - { - const payload = x.payload?.[0]?.payload as - | GraphData - | undefined; - return ( -
-

- {payload?.date} -

-

- ${payload?.value.toLocaleString()} -

-
- ); - }} - /> - )} - - + + + + + + + + + {graphData && ( + { + const payload = x.payload?.[0]?.payload as + | GraphData + | undefined; + return ( +
+

+ {payload?.date} +

+

+ ${payload?.value.toLocaleString()} +

+
+ ); + }} /> + )} + + - {graphData && ( - - )} -
-
- )} - - {props.query.isEmpty && } + {graphData && ( + + )} + + + {isEmpty && }
); diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx index 0933c321a92..ef8b2e03394 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx @@ -1,9 +1,10 @@ +"use client"; import { SkeletonContainer } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { Cell, Pie, PieChart } from "recharts"; +import type { UniversalBridgeStats } from "types/analytics"; import { toUSD } from "../../../../utils/number"; -import { usePayVolume } from "../hooks/usePayVolume"; -import { FailedToLoad, chartHeight } from "./common"; +import { chartHeight } from "./common"; type VolData = { name: string; @@ -11,193 +12,121 @@ type VolData = { color: string; }; -type ProcessedQuery = { - data?: { - totalAmount: number; - cryptoTotalUSD: number; - fiatTotalUSD: number; - }; - isError?: boolean; - isEmpty?: boolean; - isPending?: boolean; -}; - -function processQuery( - volumeQuery: ReturnType, -): ProcessedQuery { - if (volumeQuery.isPending) { - return { isPending: true }; - } - - if (volumeQuery.isError) { - return { isError: true }; - } - if (!volumeQuery.data) { - return { isEmpty: true }; - } - - if (volumeQuery.data.aggregate.sum.succeeded.amountUSDCents === 0) { - return { isEmpty: true }; - } - - const cryptoTotalUSD = Math.ceil( - volumeQuery.data.aggregate.buyWithCrypto.succeeded.amountUSDCents / 100, - ); - const fiatTotalUSD = Math.ceil( - volumeQuery.data.aggregate.buyWithFiat.succeeded.amountUSDCents / 100, - ); - - const totalAmount = cryptoTotalUSD + fiatTotalUSD; - - return { - data: { - totalAmount, - cryptoTotalUSD, - fiatTotalUSD, - }, - }; -} - export function TotalVolumePieChart(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; + data: UniversalBridgeStats[]; }) { - const uiQuery = processQuery( - usePayVolume({ - /** - * @deprecated - remove after migration - */ - clientId: props.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: props.projectId, - teamId: props.teamId, - from: props.from, - intervalType: "day", - to: props.to, - }), - ); - - return ( -
- {!uiQuery.isError ? : } -
- ); -} - -function RenderData(props: { query: ProcessedQuery }) { - const queryData = props.query.data; - + const data = props.data; + const isEmpty = + data.length === 0 || data.every((x) => x.amountUsdCents === 0); const skeletonData: VolData[] = [ { name: "Crypto", amount: 50, - color: props.query.isEmpty ? "hsl(var(--accent))" : "hsl(var(--muted))", + color: !isEmpty ? "hsl(var(--accent))" : "hsl(var(--muted))", }, { name: "Fiat", amount: 50, - color: props.query.isEmpty ? "hsl(var(--accent))" : "hsl(var(--muted))", + color: !isEmpty ? "hsl(var(--accent))" : "hsl(var(--muted))", }, ]; - const volumeData: VolData[] = queryData + const cryptoTotalUSD = data + .filter((x) => x.type === "onchain") + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + const fiatTotalUSD = data + .filter((x) => x.type === "onramp") + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + + const volumeData: VolData[] = !isEmpty ? [ { name: "Crypto", - amount: queryData.cryptoTotalUSD, + amount: cryptoTotalUSD, color: "hsl(var(--chart-1))", }, { name: "Fiat", - amount: queryData.fiatTotalUSD, + amount: fiatTotalUSD, color: "hsl(var(--chart-2))", }, ] : skeletonData; return ( -
- {/* Left */} -
- - - {volumeData.map((entry, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: ok - - ))} - - - -
-
-

Total Volume

- - { - return ( -

6 ? "text-3xl" : "text-4xl", - )} - > - {totalAmount} -

- ); +
+
+ {/* Left */} +
+ + + activeIndex={0} + data={volumeData} + dataKey="amount" + cx="50%" + cy="50%" + innerRadius="80%" + outerRadius="100%" + stroke="none" + cornerRadius={100} + paddingAngle={5} + > + {volumeData.map((entry, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} + + + +
+
+

Total Volume

+ + 0 + ? toUSD(cryptoTotalUSD + fiatTotalUSD) + : "NA" + } + skeletonData="$100" + render={(totalAmount) => { + return ( +

6 ? "text-3xl" : "text-4xl", + )} + > + {totalAmount} +

+ ); + }} + /> +
-
- {/* Right */} -
-
- {volumeData.map((v) => ( - - ))} + {/* Right */} +
+
+ {volumeData.map((v) => ( + 0 ? toUSD(v.amount) : "NA" + } + /> + ))} +
-
+
); } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx index a1de25c8536..c6f54c973e9 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx @@ -1,25 +1,6 @@ import { Badge } from "@/components/ui/badge"; import { ToolTipLabel } from "@/components/ui/tooltip"; -import { ArrowDownIcon, ArrowUpIcon, OctagonXIcon } from "lucide-react"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -export function FailedToLoad() { - return ( -
-
- -

Unable to load

-
-
- ); -} +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; export function NoDataOverlay() { return ( @@ -76,26 +57,4 @@ export function TableHeading(props: { children: React.ReactNode }) { ); } -export function IntervalSelector(props: { - intervalType: "day" | "week"; - setIntervalType: (intervalType: "day" | "week") => void; -}) { - return ( - - ); -} - export const chartHeight = 220; diff --git a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayCustomers.ts b/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayCustomers.ts deleted file mode 100644 index b910042891d..00000000000 --- a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayCustomers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { payServerProxy } from "@/actions/proxies"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useActiveAccount } from "thirdweb/react"; - -export type PayTopCustomersData = { - count: number; - customers: Array<{ - walletAddress: string; - totalSpendUSDCents: number; - }>; -}; - -type Response = { - result: { - data: PayTopCustomersData; - }; -}; - -export function usePayCustomers(options: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - pageSize: number; - type: "top-customers" | "new-customers"; -}) { - const address = useActiveAccount()?.address; - return useInfiniteQuery({ - queryKey: ["usePayCustomers", address, options], - queryFn: async ({ pageParam }) => { - const endpoint = - options.type === "new-customers" - ? "/stats/new-customers/v1" - : "/stats/customers/v1"; - - const start = options.pageSize * pageParam; - - const res = await payServerProxy({ - method: "GET", - pathname: endpoint, - searchParams: { - skip: `${start}`, - take: `${options.pageSize}`, - /** - * @deprecated - remove after migration - */ - clientId: options.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: options.projectId, - fromDate: `${options.from.getTime()}`, - toDate: `${options.to.getTime()}`, - teamId: options.teamId, - }, - }); - - if (!res.ok) { - throw new Error("Failed to fetch pay volume"); - } - - const resJSON = res.data as Response; - const pageData = resJSON.result.data; - - const itemsRequested = options.pageSize * (pageParam + 1); - const totalItems = pageData.count; - - let nextPageIndex: number | null = null; - if (itemsRequested < totalItems) { - nextPageIndex = pageParam + 1; - } - - return { - pageData: resJSON.result.data, - nextPageIndex, - }; - }, - initialPageParam: 0, - getNextPageParam: (lastPage) => { - return lastPage.nextPageIndex; - }, - }); -} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayNewCustomers.ts b/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayNewCustomers.ts deleted file mode 100644 index 1346918a7d2..00000000000 --- a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayNewCustomers.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { payServerProxy } from "@/actions/proxies"; -import { useQuery } from "@tanstack/react-query"; -import { useActiveAccount } from "thirdweb/react"; - -type PayNewCustomersData = { - intervalType: "day" | "week"; - intervalResults: Array<{ - /** - * Date formatted in ISO 8601 format - */ - interval: string; - distinctCustomers: number; - }>; - aggregate: { - // totals in the [fromDate, toDate] range - distinctCustomers: number; - bpsIncreaseFromPriorRange: number; - }; -}; - -type Response = { - result: { - data: PayNewCustomersData; - }; -}; - -export function usePayNewCustomers(options: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - intervalType: "day" | "week"; -}) { - const address = useActiveAccount()?.address; - return useQuery({ - queryKey: ["usePayNewCustomers", address, options], - queryFn: async () => { - const res = await payServerProxy({ - pathname: "/stats/aggregate/customers/v1", - searchParams: { - intervalType: options.intervalType, - /** - * @deprecated - remove after migration - */ - clientId: options.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: options.projectId, - teamId: options.teamId, - fromDate: `${options.from.getTime()}`, - toDate: `${options.to.getTime()}`, - }, - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!res.ok) { - throw new Error("Failed to fetch new customers"); - } - - const resJSON = res.data as Response; - - return resJSON.result.data; - }, - }); -} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayPurchases.ts b/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayPurchases.ts deleted file mode 100644 index 93fd914c261..00000000000 --- a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayPurchases.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { payServerProxy } from "@/actions/proxies"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { useActiveAccount } from "thirdweb/react"; - -export type PayPurchasesData = { - count: number; - purchases: Array< - { - createdAt: string; - updatedAt: string; - status: "COMPLETED" | "FAILED" | "PENDING"; - fromAddress: string; - estimatedFeesUSDCents: number; - fromAmountUSDCents: number; - toAmountUSDCents: number; - toAmountWei: string; - toAmount: string; - purchaseId: string; - - toToken: { - chainId: number; - decimals: number; - symbol: string; - name: string; - tokenAddress: string; - }; - } & ( - | { - purchaseType: "ONRAMP"; - fromCurrencyDecimals: number; - fromCurrencySymbol: string; - fromAmountUnits: string; - } - | { - purchaseType: "SWAP"; - fromAmountWei: string; - fromAmount: string; - fromToken: { - chainId: number; - decimals: number; - symbol: string; - name: string; - tokenAddress: string; - }; - } - ) - >; -}; - -type Response = { - result: { - data: PayPurchasesData; - }; -}; - -type PayPurchaseOptions = { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - start: number; - count: number; -}; - -export function usePayPurchases(options: PayPurchaseOptions) { - const address = useActiveAccount()?.address; - return useQuery({ - queryKey: ["usePayPurchases", address, options], - queryFn: async () => getPayPurchases(options), - // keep the previous data while fetching new data - placeholderData: keepPreviousData, - }); -} - -export async function getPayPurchases(options: PayPurchaseOptions) { - const res = await payServerProxy({ - pathname: "/stats/purchases/v1", - searchParams: { - skip: `${options.start}`, - take: `${options.count}`, - /** - * @deprecated - remove after migration - */ - clientId: options.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: options.projectId, - teamId: options.teamId, - fromDate: `${options.from.getTime()}`, - toDate: `${options.to.getTime()}`, - }, - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!res.ok) { - throw new Error("Failed to fetch pay volume"); - } - - const resJSON = res.data as Response; - - return resJSON.result.data; -} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayVolume.ts b/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayVolume.ts deleted file mode 100644 index a09d970ed66..00000000000 --- a/apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayVolume.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { payServerProxy } from "@/actions/proxies"; -import { useQuery } from "@tanstack/react-query"; -import { useActiveAccount } from "thirdweb/react"; - -type AggregatedData = { - succeeded: { - amountUSDCents: number; - count: number; - bpsIncreaseFromPriorRange: number; - }; - failed: { - amountUSDCents: number; - count: number; - }; -}; - -type IntervalResultNode = { - failed: { - amountUSDCents: number; - count: number; - }; - succeeded: { - amountUSDCents: number; - count: number; - }; -}; - -export type PayVolumeData = { - intervalType: "day" | "week"; - intervalResults: Array<{ - interval: string; - buyWithCrypto: IntervalResultNode; - buyWithFiat: IntervalResultNode; - sum: IntervalResultNode; - payouts: { - amountUSDCents: number; - count: number; - }; - }>; - aggregate: { - buyWithCrypto: AggregatedData; - buyWithFiat: AggregatedData; - sum: AggregatedData; - payouts: { - amountUSDCents: number; - count: number; - bpsIncreaseFromPriorRange: number; - }; - }; -}; - -type Response = { - result: { - data: PayVolumeData; - }; -}; - -export function usePayVolume(options: { - /** - * @deprecated - remove after migration - */ - clientId: string; - // switching to projectId for lookup, but have to send both during migration - projectId: string; - teamId: string; - from: Date; - to: Date; - intervalType: "day" | "week"; -}) { - const address = useActiveAccount()?.address; - return useQuery({ - queryKey: ["usePayVolume", address, options], - queryFn: async () => { - const res = await payServerProxy({ - pathname: "/stats/aggregate/volume/v1", - searchParams: { - intervalType: options.intervalType, - /** - * @deprecated - remove after migration - */ - clientId: options.clientId, - // switching to projectId for lookup, but have to send both during migration - projectId: options.projectId, - teamId: options.teamId, - fromDate: `${options.from.getTime()}`, - toDate: `${options.to.getTime()}`, - }, - headers: { - "Content-Type": "application/json", - }, - method: "GET", - }); - - if (!res.ok) { - throw new Error("Failed to fetch pay volume"); - } - - const json = res.data as Response; - - return json.result.data; - }, - retry: false, - }); -} diff --git a/apps/dashboard/src/lib/time.ts b/apps/dashboard/src/lib/time.ts index 02b913b6dbd..900b9e99204 100644 --- a/apps/dashboard/src/lib/time.ts +++ b/apps/dashboard/src/lib/time.ts @@ -12,3 +12,16 @@ export function getNebulaFiltersFromSearchParams(params: { defaultRange: "last-30", }); } + +export function getUniversalBridgeFiltersFromSearchParams(params: { + from: string | undefined | string[]; + to: string | undefined | string[]; + interval: string | undefined | string[]; +}) { + return getFiltersFromSearchParams({ + from: params.from, + to: params.to, + interval: params.interval, + defaultRange: "last-120", + }); +} diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts index 30c68677dff..ba149a8a296 100644 --- a/apps/dashboard/src/types/analytics.ts +++ b/apps/dashboard/src/types/analytics.ts @@ -1,3 +1,5 @@ +import type { Address } from "thirdweb"; + export interface WalletStats { date: string; uniqueWalletsConnected: number; @@ -43,6 +45,26 @@ export interface RpcMethodStats { count: number; } +export interface UniversalBridgeStats { + date: string; + chainId: number; + status: "completed" | "failed"; + type: "onchain" | "onramp"; + count: number; + amountUsdCents: number; + developerFeeUsdCents: number; +} + +export interface UniversalBridgeWalletStats { + date: string; + chainId: number; + walletAddress: Address; + type: "onchain" | "onramp"; + count: number; + amountUsdCents: number; + developerFeeUsdCents: number; +} + export interface AnalyticsQueryParams { teamId: string; projectId?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aba92de35ad..68d3a01d46b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,7 +219,7 @@ importers: version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nextjs-toploader: specifier: ^1.6.12 - version: 1.6.12(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 1.6.12(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.4.3 version: 2.4.3(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) @@ -415,7 +415,7 @@ importers: version: 5.50.5(@types/node@22.14.1)(typescript@5.8.3) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + version: 4.2.3(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) postcss: specifier: 8.5.3 version: 8.5.3 @@ -1183,7 +1183,7 @@ importers: version: 3.2.6(react@19.1.0)(storybook@8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@5.0.10)) '@codspeed/vitest-plugin': specifier: 4.0.1 - version: 4.0.1(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.1.2)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.29.3)(msw@2.7.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) + version: 4.0.1(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vitest@3.1.2) '@coinbase/wallet-mobile-sdk': specifier: 1.1.2 version: 1.1.2(expo@52.0.46(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.10.0)(react-native@0.78.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@19.1.2)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0)(utf-8-validate@5.0.10))(react-native@0.78.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@19.1.2)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0) @@ -1243,7 +1243,7 @@ importers: version: 4.4.1(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) '@vitest/coverage-v8': specifier: 3.1.2 - version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.1.2)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.29.3)(msw@2.7.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)) + version: 3.1.2(vitest@3.1.2) '@vitest/ui': specifier: 3.1.2 version: 3.1.2(vitest@3.1.2) @@ -18431,7 +18431,7 @@ snapshots: transitivePeerDependencies: - debug - '@codspeed/vitest-plugin@4.0.1(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.1.2)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.29.3)(msw@2.7.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))': + '@codspeed/vitest-plugin@4.0.1(vite@6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vitest@3.1.2)': dependencies: '@codspeed/core': 4.0.1 vite: 6.3.4(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.3)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1) @@ -24935,7 +24935,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.1.2)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.29.3)(msw@2.7.5(@types/node@22.14.1)(typescript@5.8.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))': + '@vitest/coverage-v8@3.1.2(vitest@3.1.2)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -28035,7 +28035,7 @@ snapshots: eslint: 9.24.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-react-hooks: 5.2.0(eslint@9.24.0(jiti@2.4.2)) @@ -28065,7 +28065,7 @@ snapshots: tinyglobby: 0.2.13 unrs-resolver: 1.6.3 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.24.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -28105,7 +28105,7 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -28138,7 +28138,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -28156,7 +28156,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.14.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.24.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -32132,6 +32132,14 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + next-sitemap@4.2.3(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): + dependencies: + '@corex/deepmerge': 4.0.43 + '@next/env': 13.5.8 + fast-glob: 3.3.3 + minimist: 1.2.8 + next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-sitemap@4.2.3(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): dependencies: '@corex/deepmerge': 4.0.43 @@ -32172,6 +32180,14 @@ snapshots: - '@babel/core' - babel-plugin-macros + nextjs-toploader@1.6.12(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nprogress: 0.2.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + nextjs-toploader@1.6.12(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)