From 53b761b4c178d6ff1fe38570d860a22c08cf3538 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Tue, 6 May 2025 13:03:54 -0700 Subject: [PATCH 1/5] fix: checkout error boundary --- apps/dashboard/src/app/checkout/error.tsx | 28 ++++++++++++++++++++++ apps/dashboard/src/app/checkout/layout.tsx | 16 +++++++++++-- apps/dashboard/src/app/checkout/page.tsx | 28 +++++++--------------- 3 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 apps/dashboard/src/app/checkout/error.tsx diff --git a/apps/dashboard/src/app/checkout/error.tsx b/apps/dashboard/src/app/checkout/error.tsx new file mode 100644 index 00000000000..0f38e50e422 --- /dev/null +++ b/apps/dashboard/src/app/checkout/error.tsx @@ -0,0 +1,28 @@ +"use client"; // Error boundaries must be Client Components + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useEffect } from "react"; + +export default function ErrorPage({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( + + + + Something went wrong + + + + {error.message} + + + ); +} diff --git a/apps/dashboard/src/app/checkout/layout.tsx b/apps/dashboard/src/app/checkout/layout.tsx index c21155f94c0..22386b2ce56 100644 --- a/apps/dashboard/src/app/checkout/layout.tsx +++ b/apps/dashboard/src/app/checkout/layout.tsx @@ -1,3 +1,4 @@ +import "../../global.css"; import { cn } from "@/lib/utils"; import { ThemeProvider } from "next-themes"; import { Inter } from "next/font/google"; @@ -23,11 +24,22 @@ export default function CheckoutLayout({ > - {children} +
+
+ {children} +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + +
diff --git a/apps/dashboard/src/app/checkout/page.tsx b/apps/dashboard/src/app/checkout/page.tsx index ebdde336e67..706c3a1a426 100644 --- a/apps/dashboard/src/app/checkout/page.tsx +++ b/apps/dashboard/src/app/checkout/page.tsx @@ -1,4 +1,3 @@ -import "../../global.css"; import { getThirdwebClient } from "@/constants/thirdweb.server"; import type { Metadata } from "next"; import { createThirdwebClient, defineChain, getContract } from "thirdweb"; @@ -73,24 +72,13 @@ export default async function RoutesPage({ }; return ( -
-
- -
- - {/* eslint-disable-next-line @next/next/no-img-element */} - -
+ ); } From 8048fe31ed995476c64ef07e7c6c0621770a4f93 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Thu, 8 May 2025 12:30:53 -0700 Subject: [PATCH 2/5] feature: migrate ub analytics --- apps/dashboard/package.json | 1 + apps/dashboard/src/@/api/analytics.ts | 83 ++++- .../src/components/analytics/area-chart.tsx | 1 + .../pay/PayAnalytics/PayAnalytics.tsx | 122 +++---- .../components/PayCustomersTable.tsx | 250 ++++--------- .../components/PayNewCustomers.tsx | 337 +++++++----------- .../components/PaymentHistory.tsx | 1 + .../components/PaymentsSuccessRate.tsx | 238 +++++-------- .../pay/PayAnalytics/components/Payouts.tsx | 251 +++++-------- .../components/TotalPayVolume.tsx | 282 +++++---------- .../components/TotalVolumePieChart.tsx | 243 +++++-------- apps/dashboard/src/types/analytics.ts | 22 ++ pnpm-lock.yaml | 26 +- 13 files changed, 727 insertions(+), 1130 deletions(-) diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 00641d23f9c..0db4ae7d2bb 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -25,6 +25,7 @@ "@chakra-ui/react": "^2.8.2", "@chakra-ui/styled-system": "^2.9.2", "@chakra-ui/theme-tools": "^2.1.2", + "@date-fns/tz": "^1.2.0", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.0", "@hookform/resolvers": "^3.9.1", diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index 5e07ee4e23d..c8f3b584bcd 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, @@ -30,8 +32,8 @@ async function fetchAnalytics( // create a new URL object for the analytics server const ANALYTICS_SERVICE_URL = new URL( - process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com", - ); + "https://analytics-service-dev-ldna.zeet-nftlabs.zeet.app", + ); // Production analytics URL (yes I know it says dev) ANALYTICS_SERVICE_URL.pathname = pathname; for (const param of searchParams?.split("&") || []) { const [key, value] = param.split("="); @@ -44,19 +46,22 @@ async function fetchAnalytics( ); } // client id DEBUG OVERRIDE - // ANALYTICS_SERVICE_URL.searchParams.delete("clientId"); - // ANALYTICS_SERVICE_URL.searchParams.delete("accountId"); - // ANALYTICS_SERVICE_URL.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(ANALYTICS_SERVICE_URL, { ...init, headers: { "content-type": "application/json", ...init?.headers, - authorization: `Bearer ${token}`, }, }); } @@ -367,3 +372,63 @@ 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(); + console.log(json); + + return json.data as UniversalBridgeWalletStats[]; +} diff --git a/apps/dashboard/src/components/analytics/area-chart.tsx b/apps/dashboard/src/components/analytics/area-chart.tsx index 1ec0675ada9..bf899ab7be6 100644 --- a/apps/dashboard/src/components/analytics/area-chart.tsx +++ b/apps/dashboard/src/components/analytics/area-chart.tsx @@ -1,3 +1,4 @@ +"use client"; import { cn } from "@/lib/utils"; import { useEffect, useId, useState } from "react"; import { diff --git a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx index 56a8b570daa..fcfc07f397a 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx @@ -1,107 +1,102 @@ -"use client"; - -import { useState } from "react"; -import { - DateRangeSelector, - type Range, - getLastNDaysRange, -} from "../../analytics/date-range-selector"; -import { PayCustomersTable } from "./components/PayCustomersTable"; -import { PayNewCustomers } from "./components/PayNewCustomers"; -import { PaymentHistory } from "./components/PaymentHistory"; -import { PaymentsSuccessRate } from "./components/PaymentsSuccessRate"; +import { getLastNDaysRange } from "../../analytics/date-range-selector"; import { Payouts } from "./components/Payouts"; import { TotalPayVolume } from "./components/TotalPayVolume"; import { TotalVolumePieChart } from "./components/TotalVolumePieChart"; +import { PaymentsSuccessRate } from "./components/PaymentsSuccessRate"; +import { PayNewCustomers } from "./components/PayNewCustomers"; +import { PayCustomersTable } from "./components/PayCustomersTable"; +import { + getUniversalBridgeUsage, + getUniversalBridgeWalletUsage, +} from "@/api/analytics"; +import { useMemo } from "react"; -export function PayAnalytics(props: { - /** - * @deprecated - remove after migration - */ - clientId: string; +export async function PayAnalytics(props: { // switching to projectId for lookup, but have to send both during migration projectId: string; teamId: string; }) { - const clientId = props.clientId; const projectId = props.projectId; const teamId = props.teamId; - const [range, setRange] = useState(() => - getLastNDaysRange("last-120"), - ); - + const range = getLastNDaysRange("last-120"); const numberOfDays = Math.round( (range.to.getTime() - range.from.getTime()) / (1000 * 60 * 60 * 24), ); + const [period, dateFormat]: [ + "day" | "week" | "month", + { + month: "short" | "long"; + day?: "numeric" | "2-digit"; + }, + ] = useMemo(() => { + if (numberOfDays > 90) { + return ["month", { month: "short" }]; + } + if (numberOfDays > 30) { + return ["week", { month: "short", day: "numeric" }]; + } + return ["day", { month: "short", day: "numeric" }]; + }, [numberOfDays]); + + const volumeData = await getUniversalBridgeUsage({ + teamId: teamId, + projectId: projectId, + from: range.from, + to: range.to, + period, + }).catch((error) => { + console.error(error); + return []; + }); + const walletData = await getUniversalBridgeWalletUsage({ + teamId: teamId, + projectId: projectId, + from: range.from, + to: range.to, + period, + }).catch((error) => { + console.error(error); + return []; + }); + console.log(walletData); return (
- + {/* {}} /> */}
x.status === "completed") || []} />
x.status === "completed") || []} + dateFormat={dateFormat} />
x.status === "completed") || []} + dateFormat={dateFormat} /> - +
- +
- +
- + {/* + */}
); diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx index 7f80d81daff..354eab5a702 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, TableData, TableHeading, TableHeadingRow, + CardHeading, } 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 }; } + +export 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..625f2b3ff92 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx @@ -1,3 +1,4 @@ +"use client"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; import { WalletAddress } from "@/components/blocks/wallet-address"; import { PaginationButtons } from "@/components/pagination-buttons"; diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx index 56a3f3bb53d..3be85c87acd 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 type { UniversalBridgeStats } from "types/analytics"; +import { useMemo } from "react"; +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]); + 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 ? ( + + ) : ( + } + /> + )} -
+
- + -
+
- + +
); } 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..4136b35abc5 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx @@ -1,179 +1,107 @@ +"use client"; import { SkeletonContainer } from "@/components/ui/skeleton"; -import { format } from "date-fns"; -import { useEffect, useState } from "react"; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; +import { useMemo } from "react"; 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 type { UniversalBridgeStats } from "types/analytics"; +import { ChangeBadge, NoDataOverlay, chartHeight, CardHeading } 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 && props.data.length > 0 + ? toUSD(totalPayoutsUSD) + : undefined + } + skeletonData="$20" + render={(value) => { + return ( +

+ {value} +

+ ); }} /> - )} -
-
- {props.query.isPending ? ( - - ) : ( + {!isEmpty && ( + { + return ; + }} + /> + )} +
+ +
- + { const payload = x.payload?.[0]?.payload as @@ -203,7 +131,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..f3dd4eba991 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, useState, useMemo } 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, 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,57 @@ 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 && ( + + )} + +
); diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx index 0933c321a92..b0fd73a1b53 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 { toUSD } from "../../../../utils/number"; -import { usePayVolume } from "../hooks/usePayVolume"; -import { FailedToLoad, chartHeight } from "./common"; +import { chartHeight } from "./common"; +import type { UniversalBridgeStats } from "types/analytics"; 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 || 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) + : undefined + } + skeletonData="$100" + render={(totalAmount) => { + return ( +

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

+ ); + }} + /> +
-
- {/* Right */} -
-
- {volumeData.map((v) => ( - +
+ {volumeData.map((v) => ( + 0 + ? toUSD(isEmpty ? 0 : v.amount) : undefined - } - /> - ))} + } + /> + ))} +
-
+ ); } 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 e72d51c3c0e..9d7127c66f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: '@chakra-ui/theme-tools': specifier: ^2.1.2 version: 2.2.6(@chakra-ui/styled-system@2.12.0(react@19.1.0))(react@19.1.0) + '@date-fns/tz': + specifier: ^1.2.0 + version: 1.2.0 '@emotion/react': specifier: 11.14.0 version: 11.14.0(@types/react@19.1.2)(react@19.1.0) @@ -1155,7 +1158,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) @@ -1215,7 +1218,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) @@ -2555,6 +2558,9 @@ packages: '@craftzdog/react-native-buffer@6.0.5': resolution: {integrity: sha512-Av+YqfwA9e7jhgI9GFE/gTpwl/H+dRRLmZyJPOpKTy107j9Oj7oXlm3/YiMNz+C/CEGqcKAOqnXDLs4OL6AAFw==} + '@date-fns/tz@1.2.0': + resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@dirtycajunrice/klee@1.0.6': resolution: {integrity: sha512-4Xj/2+VANvcJqcBEPrYm2mA5yryOkDwnrc98kkpBk/hmEFXTD8aPaeX6dpeO5ajQSgDdACWhKWbuYPBf+XGr5Q==} peerDependencies: @@ -17761,7 +17767,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) @@ -17821,6 +17827,8 @@ snapshots: - react - react-native + '@date-fns/tz@1.2.0': {} + '@dirtycajunrice/klee@1.0.6(react@19.1.0)': dependencies: react: 19.1.0 @@ -23903,7 +23911,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 @@ -26867,7 +26875,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)) @@ -26897,7 +26905,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 @@ -26937,7 +26945,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: @@ -26970,7 +26978,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 @@ -26988,7 +26996,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 From 95f3fff0f3629a14b0f32a698408a85f1654f4bb Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Thu, 8 May 2025 22:51:52 -0700 Subject: [PATCH 3/5] feat: adds payments history --- apps/dashboard/src/@/api/analytics.ts | 25 +- .../src/@/api/universal-bridge/developer.ts | 83 +++++ .../connect/universal-bridge/page.tsx | 47 ++- .../pay/PayAnalytics/PayAnalytics.tsx | 119 +++---- .../components/PayAnalyticsFilter.tsx | 51 +++ .../components/PayCustomersTable.tsx | 2 +- .../components/PaymentHistory.tsx | 328 ++++++++---------- .../components/PaymentsSuccessRate.tsx | 4 +- .../pay/PayAnalytics/components/Payouts.tsx | 6 +- .../components/TotalPayVolume.tsx | 2 +- .../components/TotalVolumePieChart.tsx | 2 +- apps/dashboard/src/lib/time.ts | 13 + 12 files changed, 397 insertions(+), 285 deletions(-) create mode 100644 apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index c8f3b584bcd..05e8b89b282 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -32,8 +32,8 @@ async function fetchAnalytics( // create a new URL object for the analytics server const ANALYTICS_SERVICE_URL = new URL( - "https://analytics-service-dev-ldna.zeet-nftlabs.zeet.app", - ); // Production analytics URL (yes I know it says dev) + process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com", + ); ANALYTICS_SERVICE_URL.pathname = pathname; for (const param of searchParams?.split("&") || []) { const [key, value] = param.split("="); @@ -46,16 +46,16 @@ async function fetchAnalytics( ); } // client id DEBUG OVERRIDE - 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", - ); + // 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(ANALYTICS_SERVICE_URL, { ...init, @@ -428,7 +428,6 @@ export async function getUniversalBridgeWalletUsage(args: { } const json = await res.json(); - console.log(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 9931c6e2011..6d43bbdb23e 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -150,3 +150,86 @@ 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(); + console.log("json", json); + return json as PaymentsResponse; +} 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/pay/PayAnalytics/PayAnalytics.tsx b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx index fcfc07f397a..059a8608be2 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx @@ -1,49 +1,40 @@ -import { getLastNDaysRange } from "../../analytics/date-range-selector"; -import { Payouts } from "./components/Payouts"; -import { TotalPayVolume } from "./components/TotalPayVolume"; -import { TotalVolumePieChart } from "./components/TotalVolumePieChart"; -import { PaymentsSuccessRate } from "./components/PaymentsSuccessRate"; -import { PayNewCustomers } from "./components/PayNewCustomers"; -import { PayCustomersTable } from "./components/PayCustomersTable"; import { getUniversalBridgeUsage, getUniversalBridgeWalletUsage, } from "@/api/analytics"; -import { useMemo } from "react"; +import type { Range } from "../../analytics/date-range-selector"; +import { PayCustomersTable } from "./components/PayCustomersTable"; +import { PayNewCustomers } from "./components/PayNewCustomers"; +import { PaymentHistory } from "./components/PaymentHistory"; +import { PaymentsSuccessRate } from "./components/PaymentsSuccessRate"; +import { Payouts } from "./components/Payouts"; +import { TotalPayVolume } from "./components/TotalPayVolume"; +import { TotalVolumePieChart } from "./components/TotalVolumePieChart"; 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 projectId = props.projectId; - const teamId = props.teamId; - const range = getLastNDaysRange("last-120"); - const numberOfDays = Math.round( - (range.to.getTime() - range.from.getTime()) / (1000 * 60 * 60 * 24), - ); - const [period, dateFormat]: [ - "day" | "week" | "month", - { - month: "short" | "long"; - day?: "numeric" | "2-digit"; - }, - ] = useMemo(() => { - if (numberOfDays > 90) { - return ["month", { month: "short" }]; - } - if (numberOfDays > 30) { - return ["week", { month: "short", day: "numeric" }]; - } - return ["day", { month: "short", day: "numeric" }]; - }, [numberOfDays]); + const { projectId, teamId, range, interval } = props; + + 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, + period: interval, }).catch((error) => { console.error(error); return []; @@ -53,61 +44,47 @@ export async function PayAnalytics(props: { projectId: projectId, from: range.from, to: range.to, - period, + period: interval, }).catch((error) => { console.error(error); return []; }); - console.log(walletData); return ( -
-
- {/* {}} /> */} -
-
- -
- x.status === "completed") || []} - /> -
- + +
+ x.status === "completed") || []} - dateFormat={dateFormat} /> - - -
- - x.status === "completed") || []} - dateFormat={dateFormat} - /> - - - -
+ 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 354eab5a702..39828174123 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx @@ -7,10 +7,10 @@ import { useMemo } from "react"; import type { UniversalBridgeWalletStats } from "types/analytics"; import { toUSD } from "../../../../utils/number"; import { + CardHeading, TableData, TableHeading, TableHeadingRow, - CardHeading, } from "./common"; type PayTopCustomersData = Array<{ diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx index 625f2b3ff92..a8da5fc27b4 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx @@ -1,4 +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"; @@ -6,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 */} @@ -217,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} @@ -244,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")}

@@ -282,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 3be85c87acd..e46b666a978 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx @@ -10,8 +10,8 @@ import { SkeletonContainer } from "@/components/ui/skeleton"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useState } from "react"; -import type { UniversalBridgeStats } from "types/analytics"; import { useMemo } from "react"; +import type { UniversalBridgeStats } from "types/analytics"; import { CardHeading } from "./common"; type PayVolumeType = "all" | "crypto" | "fiat"; @@ -113,7 +113,7 @@ export function PaymentsSuccessRate(props: { function Bar(props: { rate: number }) { return ( -
+
Date: Fri, 9 May 2025 13:31:46 -0700 Subject: [PATCH 4/5] fix: empty states --- .../src/@/api/universal-bridge/developer.ts | 1 - .../components/PaymentsSuccessRate.tsx | 6 +++--- .../pay/PayAnalytics/components/Payouts.tsx | 10 ++++++---- .../pay/PayAnalytics/components/TotalPayVolume.tsx | 5 +++-- .../components/TotalVolumePieChart.tsx | 14 +++++++------- .../pay/PayAnalytics/components/common.tsx | 2 -- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/src/@/api/universal-bridge/developer.ts b/apps/dashboard/src/@/api/universal-bridge/developer.ts index 6d43bbdb23e..1eafa483b5f 100644 --- a/apps/dashboard/src/@/api/universal-bridge/developer.ts +++ b/apps/dashboard/src/@/api/universal-bridge/developer.ts @@ -230,6 +230,5 @@ export async function getPayments(props: { } const json = await res.json(); - console.log("json", json); return json as PaymentsResponse; } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx index e46b666a978..2407893bacd 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx @@ -21,7 +21,7 @@ export function PaymentsSuccessRate(props: { }) { const [type, setType] = useState("all"); const isEmpty = useMemo(() => { - return props.data.length === 0; + return props.data.length === 0 || props.data.every((x) => x.count === 0); }, [props.data]); const graphData = useMemo(() => { let succeeded = 0; @@ -77,9 +77,9 @@ export function PaymentsSuccessRate(props: {
-
+
- {isEmpty ? ( + {isEmpty || graphData.total === 0 ? ( ) : ( 0 - ? toUSD(totalPayoutsUSD) - : undefined + !props.data + ? undefined + : props.data.length > 0 + ? toUSD(totalPayoutsUSD) + : "NA" } skeletonData="$20" render={(value) => { @@ -101,7 +103,7 @@ export function Payouts(props: {
- + { const payload = x.payload?.[0]?.payload as diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx index 1c174151c0b..cb0ef0d36e3 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx @@ -9,7 +9,7 @@ import { import { useId, useMemo, useState } from "react"; import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts"; import type { UniversalBridgeStats } from "types/analytics"; -import { CardHeading, chartHeight } from "./common"; +import { CardHeading, NoDataOverlay, chartHeight } from "./common"; type GraphData = { date: string; @@ -127,7 +127,7 @@ export function TotalPayVolume(props: {
- + @@ -177,6 +177,7 @@ export function TotalPayVolume(props: { )} + {isEmpty && }
); diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx index 49e00e602c5..ef8b2e03394 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx @@ -17,7 +17,7 @@ export function TotalVolumePieChart(props: { }) { const data = props.data; const isEmpty = - !data || data.length === 0 || data.every((x) => x.amountUsdCents === 0); + data.length === 0 || data.every((x) => x.amountUsdCents === 0); const skeletonData: VolData[] = [ { name: "Crypto", @@ -87,9 +87,11 @@ export function TotalVolumePieChart(props: { 0 - ? toUSD(cryptoTotalUSD + fiatTotalUSD) - : undefined + !data + ? undefined + : data.length > 0 + ? toUSD(cryptoTotalUSD + fiatTotalUSD) + : "NA" } skeletonData="$100" render={(totalAmount) => { @@ -117,9 +119,7 @@ export function TotalVolumePieChart(props: { color={v.color} label={v.name} amount={ - data && data.length > 0 - ? toUSD(isEmpty ? 0 : v.amount) - : undefined + !data ? undefined : data.length > 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 50250168695..c6f54c973e9 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx @@ -2,8 +2,6 @@ import { Badge } from "@/components/ui/badge"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import {} from "@/components/ui/select"; - export function NoDataOverlay() { return (
From 484fc3d42e63405545764dd7a57cd29402776b3d Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Fri, 9 May 2025 13:50:05 -0700 Subject: [PATCH 5/5] lint --- apps/dashboard/src/@/actions/proxies.ts | 1 - apps/dashboard/src/@/constants/public-envs.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index 04b70fbbe63..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"; 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 || "";