diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index f46176b5d54..0bed8ec8eff 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -48,15 +48,15 @@ async function fetchAnalytics( ); } // client id DEBUG OVERRIDE - // ANALYTICS_SERVICE_URL.searchParams.delete("projectId"); - // ANALYTICS_SERVICE_URL.searchParams.delete("teamId"); - // ANALYTICS_SERVICE_URL.searchParams.append( + // analyticsServiceUrl.searchParams.delete("projectId"); + // analyticsServiceUrl.searchParams.delete("teamId"); + // analyticsServiceUrl.searchParams.append( // "teamId", - // "team_clmb33q9w00gn1x0u2ri8z0k0", + // "team_cm0lde33r02344w129k5hm2xz", // ); - // ANALYTICS_SERVICE_URL.searchParams.append( + // analyticsServiceUrl.searchParams.append( // "projectId", - // "prj_clyqwud5y00u1na7nzxnzlz7o", + // "prj_cm4rqwx9b002qrnsnr37wqpo6", // ); return fetch(analyticsServiceUrl, { @@ -377,7 +377,7 @@ export async function getEcosystemWalletUsage(args: { export async function getUniversalBridgeUsage(args: { teamId: string; - projectId: string; + projectId?: string; from?: Date; to?: Date; period?: "day" | "week" | "month" | "year" | "all"; @@ -395,11 +395,10 @@ export async function getUniversalBridgeUsage(args: { console.error( `Failed to fetch universal bridge stats: ${res?.status} - ${res.statusText} - ${reason}`, ); - return null; + return []; } const json = await res.json(); - return json.data as UniversalBridgeStats[]; } @@ -430,6 +429,5 @@ export async function getUniversalBridgeWalletUsage(args: { } const json = await res.json(); - return json.data as UniversalBridgeWalletStats[]; } diff --git a/apps/dashboard/src/@/components/ui/chart.tsx b/apps/dashboard/src/@/components/ui/chart.tsx index a452dbddc36..6d721e4741a 100644 --- a/apps/dashboard/src/@/components/ui/chart.tsx +++ b/apps/dashboard/src/@/components/ui/chart.tsx @@ -12,6 +12,7 @@ export type ChartConfig = { [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; + isCurrency?: boolean; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } diff --git a/apps/dashboard/src/@/lib/time.ts b/apps/dashboard/src/@/lib/time.ts index 3e151924cca..07ec2a7d527 100644 --- a/apps/dashboard/src/@/lib/time.ts +++ b/apps/dashboard/src/@/lib/time.ts @@ -39,9 +39,10 @@ export function getFiltersFromSearchParams(params: { }; const defaultInterval = - differenceInCalendarDays(range.to, range.from) > 30 - ? "week" - : ("day" as const); + params.interval ?? + (differenceInCalendarDays(range.to, range.from) > 30 + ? ("week" as const) + : ("day" as const)); return { range, @@ -50,6 +51,6 @@ export function getFiltersFromSearchParams(params: { ? ("day" as const) : params.interval === "week" ? ("week" as const) - : defaultInterval, + : (defaultInterval as "day" | "week"), }; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx index f03426b1c50..c655b14eef1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/analytics/page.tsx @@ -1,6 +1,7 @@ import { getClientTransactions, getInAppWalletUsage, + getUniversalBridgeUsage, getUserOpUsage, getWalletConnections, getWalletUsers, @@ -14,6 +15,7 @@ import { redirect } from "next/navigation"; import { type WalletId, getWalletInfo } from "thirdweb/wallets"; import type { InAppWalletStats, + UniversalBridgeStats, WalletStats, WalletUserStats, } from "types/analytics"; @@ -24,7 +26,10 @@ import { PieChartCard } from "../../../../components/Analytics/PieChartCard"; import { getTeamBySlug } from "@/api/team"; import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; -import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard"; +import { + EmptyStateCard, + EmptyStateContent, +} from "app/(app)/team/components/Analytics/EmptyStateCard"; import { Suspense } from "react"; import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard"; import { TransactionsChartCardUI } from "../../_components/TransactionsCard"; @@ -100,6 +105,7 @@ async function OverviewPageContent(props: { userOpUsage, clientTransactionsTimeSeries, clientTransactions, + universalBridgeUsage, ] = await Promise.all([ // Aggregated wallet connections getWalletConnections({ @@ -148,6 +154,13 @@ async function OverviewPageContent(props: { to: range.to, period: "all", }), + // Universal Bridge + getUniversalBridgeUsage({ + teamId: teamId, + from: range.from, + to: range.to, + period: interval, + }), ]); const isEmpty = @@ -164,8 +177,9 @@ async function OverviewPageContent(props: {
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? (
-
@@ -218,70 +232,96 @@ async function OverviewPageContent(props: { ); } -type UserMetrics = { - totalUsers: number; +type AggregatedMetrics = { activeUsers: number; newUsers: number; - returningUsers: number; + totalVolume: number; + feesCollected: number; }; -type TimeSeriesMetrics = UserMetrics & { +type TimeSeriesMetrics = AggregatedMetrics & { date: string; }; function processTimeSeriesData( userStats: WalletUserStats[], + volumeStats: UniversalBridgeStats[], ): TimeSeriesMetrics[] { const metrics: TimeSeriesMetrics[] = []; - let cumulativeUsers = 0; for (const stat of userStats) { - cumulativeUsers += stat.newUsers ?? 0; + const volume = volumeStats + .filter((v) => v.date === stat.date && v.status === "completed") + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + + const fees = volumeStats + .filter((v) => v.date === stat.date && v.status === "completed") + .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); + metrics.push({ date: stat.date, activeUsers: stat.totalUsers ?? 0, - returningUsers: stat.returningUsers ?? 0, newUsers: stat.newUsers ?? 0, - totalUsers: cumulativeUsers, + totalVolume: volume, + feesCollected: fees, }); } return metrics; } -function UsersChartCard({ +function AppHighlightsCard({ userStats, + volumeStats, searchParams, }: { userStats: WalletUserStats[]; + volumeStats: UniversalBridgeStats[]; searchParams?: { [key: string]: string | string[] | undefined }; }) { - const timeSeriesData = processTimeSeriesData(userStats); + const timeSeriesData = processTimeSeriesData(userStats, volumeStats); const chartConfig = { - activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, - totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" }, - newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, - returningUsers: { - label: "Returning Users", + totalVolume: { + label: "Total Volume", + color: "hsl(var(--chart-2))", + isCurrency: true, + emptyContent: ( + + ), + }, + feesCollected: { + label: "Fee Revenue", color: "hsl(var(--chart-4))", + isCurrency: true, + emptyContent: ( + + ), }, + activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, + newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, } as const; return ( - // If there is only one data point, use that one, otherwise use the previous - timeSeriesData.filter((d) => (d[key] as number) > 0).length >= 2 - ? timeSeriesData[timeSeriesData.length - 2]?.[key] - : timeSeriesData[timeSeriesData.length - 1]?.[key] + timeSeriesData.reduce((acc, curr) => acc + curr[key], 0) } // Get the trend from the last two COMPLETE periods trendFn={(data, key) => @@ -291,7 +331,7 @@ function UsersChartCard({ 1 : undefined } - queryKey="usersChart" + queryKey="appHighlights" existingQueryParams={searchParams} /> ); 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 ac2cec604ae..ee4ef61176c 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 @@ -14,11 +14,11 @@ export default async function Page(props: { team_slug: string; project_slug: string; }>; - searchParams: { + searchParams: Promise<{ 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); @@ -36,7 +36,7 @@ export default async function Page(props: { }); return ( - +
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/page.tsx index b5ea0a066ba..ec128cca392 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/page.tsx @@ -9,6 +9,7 @@ import { } from "components/analytics/date-range-selector"; import type { InAppWalletStats, + UniversalBridgeStats, UserOpStats, WalletStats, WalletUserStats, @@ -16,12 +17,16 @@ import type { import { getInAppWalletUsage, + getUniversalBridgeUsage, getUserOpUsage, getWalletConnections, getWalletUsers, isProjectActive, } from "@/api/analytics"; -import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard"; +import { + EmptyStateCard, + EmptyStateContent, +} from "app/(app)/team/components/Analytics/EmptyStateCard"; import { RangeSelector } from "components/analytics/range-selector"; import { Suspense } from "react"; import type { ThirdwebClient } from "thirdweb"; @@ -116,6 +121,7 @@ export default async function ProjectOverviewPage(props: PageProps) {
}> {walletUserStatsTimeSeries.status === "fulfilled" && + universalBridgeUsage.status === "fulfilled" && walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0) ? (
-
@@ -267,14 +283,14 @@ async function ProjectAnalytics(props: { ); } -type UserMetrics = { - totalUsers: number; +type AggregatedMetrics = { activeUsers: number; newUsers: number; - returningUsers: number; + totalVolume: number; + feesCollected: number; }; -type TimeSeriesMetrics = UserMetrics & { +type TimeSeriesMetrics = AggregatedMetrics & { date: string; }; @@ -283,52 +299,82 @@ type TimeSeriesMetrics = UserMetrics & { */ function processTimeSeriesData( userStats: WalletUserStats[], + volumeStats: UniversalBridgeStats[], ): TimeSeriesMetrics[] { const metrics: TimeSeriesMetrics[] = []; - let cumulativeUsers = 0; for (const stat of userStats) { - cumulativeUsers += stat.newUsers ?? 0; + const volume = volumeStats + .filter((v) => v.date === stat.date && v.status === "completed") + .reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0); + + const fees = volumeStats + .filter((v) => v.date === stat.date && v.status === "completed") + .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); + metrics.push({ date: stat.date, activeUsers: stat.totalUsers ?? 0, - returningUsers: stat.returningUsers ?? 0, newUsers: stat.newUsers ?? 0, - totalUsers: cumulativeUsers, + totalVolume: volume, + feesCollected: fees, }); } return metrics; } -function UsersChartCard({ +function AppHighlightsCard({ chartKey, userStats, + volumeStats, + params, searchParams, }: { - chartKey: keyof UserMetrics; + chartKey: keyof AggregatedMetrics; userStats: WalletUserStats[]; + volumeStats: UniversalBridgeStats[]; + params: PageParams; searchParams?: { [key: string]: string | string[] | undefined }; }) { - const timeSeriesData = processTimeSeriesData(userStats); + const timeSeriesData = processTimeSeriesData(userStats, volumeStats); const chartConfig = { - activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, - totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" }, - newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, - returningUsers: { - label: "Returning Users", + totalVolume: { + label: "Total Volume", + color: "hsl(var(--chart-2))", + isCurrency: true, + emptyContent: ( + + ), + }, + feesCollected: { + label: "Fee Revenue", color: "hsl(var(--chart-4))", + isCurrency: true, + emptyContent: ( + + ), }, + activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, + newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, } as const; return ( // If there is only one data point, use that one, otherwise use the previous diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx index 61dc75d8074..e3f5c7c2ccc 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx @@ -41,7 +41,7 @@ export function BarChart({ }} className="aspect-auto h-[250px] w-full pt-6" > - {data.length === 0 ? ( + {data.length === 0 || data.every((d) => d[activeKey] === 0) ? ( {emptyChartContent} ) : ( - isCurrency + isCurrency || chartConfig[activeKey]?.isCurrency ? toUSD(v as number) : formatTickerNumber(v as number) } diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx index c912e7349a3..6fb93d8c3e7 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx @@ -5,7 +5,12 @@ import { BarChart } from "./BarChart"; import { Stat } from "./Stat"; type CombinedBarChartConfig = { - [key in K]: { label: string; color: string }; + [key in K]: { + label: string; + color: string; + isCurrency?: boolean; + emptyContent?: React.ReactNode; + }; }; // TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client @@ -72,7 +77,7 @@ export function CombinedBarChartCard< diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/EmptyStateCard.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/EmptyStateCard.tsx index 7a0961b1c63..646f27b213d 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/EmptyStateCard.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/EmptyStateCard.tsx @@ -6,25 +6,46 @@ import Link from "next/link"; export function EmptyStateCard({ metric, link, -}: { metric: string; link?: string }) { + description, +}: { metric: string; link?: string; description?: string }) { return (
-
- -
-
No data available
-
- Your app may not be configured to use {metric}. -
- {link && ( - - )} +
); } + +export function EmptyStateContent({ + metric, + description, + link, +}: { + metric: string; + description?: string; + link?: string; +}) { + return ( +
+
+ +
+
No data available
+
+ {description ?? `Your app may not be configured to use ${metric}.`} +
+ {link && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx index 137d533ee1d..f1f9dfc3607 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayAnalyticsFilter.tsx @@ -16,7 +16,7 @@ import { const STORAGE_KEY = "thirdweb:ub-analytics-range"; type SavedRange = { - rangeType: string; + rangeType: "custom" | DurationId | undefined; interval: "day" | "week"; }; @@ -47,7 +47,10 @@ export function PayAnalyticsFilter() { from: undefined, to: undefined, interval: savedRange.interval, - defaultRange: (savedRange.rangeType || "last-30") as DurationId, + defaultRange: + savedRange.rangeType === "custom" + ? "last-30" + : savedRange.rangeType || "last-30", }); setResponsiveSearchParams((v) => ({ @@ -57,7 +60,6 @@ export function PayAnalyticsFilter() { interval: savedRange.interval, })); } catch (e) { - localStorage.removeItem(STORAGE_KEY); console.error("Failed to parse saved range:", e); } } diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx index a10a102452e..8f812fe56c9 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx @@ -47,15 +47,18 @@ export function PayNewCustomers(props: { value: newUsers, }); } - const lastPeriod = newUsersData[newUsersData.length - 2]; - const currentPeriod = newUsersData[newUsersData.length - 1]; + const lastPeriod = newUsersData[newUsersData.length - 3]; + const currentPeriod = newUsersData[newUsersData.length - 2]; // Calculate the percent change from last period to current period const trend = - lastPeriod && currentPeriod && lastPeriod.value > 0 + lastPeriod && + currentPeriod && + lastPeriod.value > 0 && + currentPeriod.value > 0 ? (currentPeriod.value - lastPeriod.value) / lastPeriod.value - : lastPeriod?.value === 0 + : lastPeriod?.value === 0 && (currentPeriod?.value || 0) > 0 ? 100 - : 0; + : undefined; return { graphData: newUsersData, trend }; }, [props.data, props.dateFormat]); const isEmpty = useMemo( @@ -87,7 +90,7 @@ export function PayNewCustomers(props: { }} /> - {!isEmpty && ( + {!isEmpty && typeof trend !== "undefined" && ( !payPurchaseData?.data.length, @@ -68,8 +69,8 @@ export function PaymentHistory(props: { - Paid - Bought + Sent + Received Type Status Recipient diff --git a/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx b/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx index a5d1c5b36cf..b2cc9cf25d2 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx @@ -49,14 +49,17 @@ export function Payouts(props: { value: total / 100, }); } - const lastPeriod = cleanedData[cleanedData.length - 2]; - const currentPeriod = cleanedData[cleanedData.length - 1]; + const lastPeriod = cleanedData[cleanedData.length - 3]; + const currentPeriod = cleanedData[cleanedData.length - 2]; const trend = - lastPeriod && currentPeriod && lastPeriod.value > 0 + lastPeriod && + currentPeriod && + lastPeriod.value > 0 && + currentPeriod.value > 0 ? (currentPeriod.value - lastPeriod.value) / lastPeriod.value - : lastPeriod?.value === 0 + : lastPeriod?.value === 0 && (currentPeriod?.value || 0) > 0 ? 100 - : 0; + : undefined; return { graphData: cleanedData, totalPayoutsUSD: totalPayouts / 100, @@ -91,7 +94,7 @@ export function Payouts(props: { }} /> - {!isEmpty && ( + {!isEmpty && typeof trend !== "undefined" && (