Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9ae2991ad7 Version Packages (#7806)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-06 19:24:10 -07:00
MananTank bab4ff5e14 Dashboard: Improved chart responsiveness, code cleanup (#7801)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing analytics components by introducing responsive time filters, updating chart components to support new props, and refactoring existing code for improved clarity and functionality.

### Detailed summary
- Removed `range-selector.tsx` and refactored `AnalyticsHeader.tsx`.
- Added `ResponsiveTimeFilters` for better time range handling.
- Updated `EcosystemWalletsSummary` and `EcosystemAnalyticsPage` to use new props.
- Refactored various chart components to accept `selectedChart` and `selectedChartQueryParam`.
- Enhanced `AsyncTransactionsChartCard` and `AsyncTotalSponsoredCard` for better data handling.
- Replaced `LoadingChartState` with responsive loading states across components.
- Consolidated logic for handling time series data in analytics cards.
- Improved type definitions for better clarity and consistency.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced responsive and modular time filter controls for analytics dashboards.
  * Added new highlights cards for team and project analytics, providing aggregated metrics in a combined bar chart format.
  * Added a new combined bar chart card for gas sponsorship data with dynamic chart selection.
  * Added components that map transaction and sponsorship data with chain metadata for improved analytics detail.

* **Refactor**
  * Replaced multiple analytics and chart components with streamlined, asynchronous, and responsive variants.
  * Standardized date range and interval filtering using a centralized utility and responsive providers.
  * Updated chart selection to use in-app state and callbacks instead of URL-based navigation.
  * Simplified analytics page layouts by replacing Suspense with responsive suspense components.
  * Externalized data processing and state management from several chart card components to improve modularity.
  * Removed deprecated components and replaced them with updated responsive and async implementations.

* **Bug Fixes**
  * Improved handling of date ranges and intervals, ensuring accurate and consistent filtering.

* **Chores**
  * Removed unused or redundant components and consolidated stories for better maintainability.
  * Cleaned up imports and type definitions across analytics and project overview pages.

* **Style**
  * Enhanced button and layout styling for chart selection and analytics cards.

* **Documentation**
  * Updated interactive stories for analytics components to improve clarity and usability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-07 01:24:29 +00:00
621c1de62e [SDK] Add buttonLabel prop to checkout widgets (#7805)
Co-authored-by: Cursor Agent <[email protected]>
2025-08-06 18:23:53 -07:00
Joaquim VergesandGitHub 8bb2b0a227 [Docs] Update pregenerate wallets API endpoint and request format (#7791) 2025-08-06 13:49:16 -07:00
47 changed files with 1390 additions and 929 deletions
@@ -18,6 +18,7 @@ export function DateRangeSelector(props: {
}) {
const { range, setRange } = props;
const daysDiff = differenceInCalendarDays(range.to, range.from);
const matchingRange =
normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime()
? durationPresets.find((preset) => preset.days === daysDiff)
@@ -85,7 +86,7 @@ export function getLastNDaysRange(id: DurationId) {
throw new Error(`Invalid duration id: ${id}`);
}
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
const todayDate = new Date(Date.now());
const value: Range = {
from: subDays(todayDate, durationInfo.days),
@@ -1,104 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import {
DateRangeSelector,
getLastNDaysRange,
} from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { useDashboardRouter } from "@/lib/DashboardRouter";
export function RangeSelector({
range,
interval,
}: {
range?: Range;
interval: "day" | "week";
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const [localRange, setRange] = useState<Range>(
range || getLastNDaysRange("last-120"),
);
const [localInterval, setInterval] = useState<"day" | "week">(interval);
useQuery({
queryFn: async () => {
if (range) {
setRange(range);
return range;
}
if (searchParams) {
const fromStringified = searchParams.get("from");
const from = fromStringified
? new Date(fromStringified)
: getLastNDaysRange("last-120").from;
const toStringified = searchParams.get("to");
const to = toStringified ? new Date(toStringified) : new Date();
const type = (searchParams.get("type") as DurationId) || "last-120";
setRange({ from, to, type });
setInterval(interval);
return { from, to, type } satisfies Range;
}
return getLastNDaysRange("last-120");
},
queryKey: ["analytics-range", searchParams?.toString(), range],
});
// prefetch for each interval and default range
useQuery({
queryFn: async () => {
const newSearchParams = new URLSearchParams(searchParams || {});
for (const interval of ["day", "week"] as const) {
newSearchParams.set("interval", interval);
for (const type of [
"last-120",
"last-60",
"last-30",
"last-7",
] as const) {
const newRange = getLastNDaysRange(type);
newSearchParams.set("from", newRange.from.toISOString());
newSearchParams.set("to", newRange.to.toISOString());
newSearchParams.set("type", newRange.type);
router.prefetch(`${pathname}?${newSearchParams.toString()}`);
}
}
return true;
},
queryKey: ["analytics-range", searchParams?.toString()],
});
return (
<div className="flex justify-end gap-3 flex-col lg:flex-row">
<DateRangeSelector
className="rounded-full"
range={localRange}
setRange={(newRange) => {
setRange(newRange);
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("from", newRange.from.toISOString());
newSearchParams.set("to", newRange.to.toISOString());
newSearchParams.set("type", newRange.type);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
<IntervalSelector
className="bg-card rounded-full"
intervalType={localInterval}
setIntervalType={(newInterval) => {
setInterval(newInterval);
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("interval", newInterval);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import {
DateRangeSelector,
type DurationId,
} from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
export function ResponsiveTimeFilters(props: { defaultRange: DurationId }) {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getFiltersFromSearchParams({
defaultRange: props.defaultRange,
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="flex justify-end gap-3 flex-col lg:flex-row">
<DateRangeSelector
className="rounded-full"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
return newParams;
});
}}
/>
<IntervalSelector
className="bg-card rounded-full"
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
const newParams = {
...v,
interval: newInterval,
};
return newParams;
});
}}
/>
</div>
);
}
@@ -1,35 +1,53 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { ChainMetadata } from "thirdweb/chains";
import { cn } from "@/lib/utils";
import type { UserOpStats } from "@/types/analytics";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
export async function TotalSponsoredChartCardUI({
data,
aggregatedData,
searchParams,
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
export function TotalSponsoredChartCardUI({
processedAggregatedData,
selectedChart,
className,
onlyMainnet,
title,
chains,
data,
description,
selectedChartQueryParam,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams?: { [key: string]: string | string[] | undefined };
selectedChart: string | undefined;
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
description?: string;
chains: ChainMetadata[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const chains = await Promise.all(
data.map(
(item) =>
// eslint-disable-next-line no-restricted-syntax
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
@@ -55,35 +73,6 @@ export async function TotalSponsoredChartCardUI({
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
if (onlyMainnet) {
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
@@ -106,15 +95,23 @@ export async function TotalSponsoredChartCardUI({
return (
<CombinedBarChartCard
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof typeof chartConfig)
: "mainnet"
}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
className={className}
data={timeSeriesData}
existingQueryParams={searchParams}
isCurrency
queryKey="totalSponsored"
title={title || "Gas Sponsored"}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -1,37 +1,53 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { ChainMetadata } from "thirdweb/chains";
import { cn } from "@/lib/utils";
import type { TransactionStats } from "@/types/analytics";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
export async function TransactionsChartCardUI({
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
export function TransactionsChartCardUI({
data,
aggregatedData,
searchParams,
className,
onlyMainnet,
title,
description,
selectedChart,
selectedChartQueryParam,
chains,
processedAggregatedData,
}: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams?: { [key: string]: string | string[] | undefined };
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
selectedChart: string | undefined;
description?: string;
chains: ChainMetadata[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const uniqueChainIds = [
...new Set(data.map((item) => item.chainId).filter(Boolean)),
];
const chains = await Promise.all(
uniqueChainIds.map((chainId) =>
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
@@ -57,35 +73,6 @@ export async function TransactionsChartCardUI({
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const processedAggregatedData = {
mainnet: aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
};
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
if (onlyMainnet) {
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
@@ -107,15 +94,22 @@ export async function TransactionsChartCardUI({
return (
<CombinedBarChartCard
activeChart={
(searchParams?.client_transactions as keyof typeof chartConfig) ??
"mainnet"
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof typeof chartConfig)
: "mainnet"
}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
className={className}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="client_transactions"
title={title || "Transactions"}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -0,0 +1,51 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import type { TransactionStats } from "@/types/analytics";
import { TransactionsChartCardUI } from "./TransactionsCard";
export async function TransactionsChartCardWithChainMapping(props: {
data: TransactionStats[];
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
selectedChart: string | undefined;
description?: string;
aggregatedData: TransactionStats[];
}) {
const uniqueChainIds = [
...new Set(props.data.map((item) => item.chainId).filter(Boolean)),
];
const chains = await Promise.all(
uniqueChainIds.map((chainId) =>
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const processedAggregatedData = {
mainnet: props.aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
testnet: props.aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
total: props.aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
};
return (
<TransactionsChartCardUI
className={props.className}
data={props.data}
onlyMainnet={props.onlyMainnet}
processedAggregatedData={processedAggregatedData}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
chains={chains}
/>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useMemo } from "react";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
import { EmptyStateContent } from "../../../../components/Analytics/EmptyStateCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
export function TeamHighlightsCard({
userStats,
volumeStats,
selectedChart,
selectedChartQueryParam,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const timeSeriesData = useMemo(
() => processTimeSeriesData(userStats, volumeStats),
[userStats, volumeStats],
);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/payments"}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
title="Team Highlights"
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
activeChart={
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof AggregatedMetrics)
: "totalVolume"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
@@ -1,9 +1,11 @@
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getClientTransactions,
@@ -14,30 +16,32 @@ import {
getWalletUsers,
} from "@/api/analytics";
import { getTeamBySlug } from "@/api/team/get-team";
import {
type DurationId,
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
} from "@/types/analytics";
import { AnalyticsHeader } from "../../../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
import { TransactionsChartCardWithChainMapping } from "../../_components/transaction-card-with-chain-mapping";
import { TeamHighlightsCard } from "./highlights-card";
type SearchParams = {
usersChart?: string;
from?: string;
to?: string;
type?: string;
interval?: string;
usersChart?: string | string[];
from?: string | string[];
to?: string | string[];
type?: string | string[];
interval?: string | string[];
client_transactions?: string | string[];
appHighlights?: string | string[];
userOpUsage?: string | string[];
};
export default async function TeamOverviewPage(props: {
@@ -55,84 +59,120 @@ export default async function TeamOverviewPage(props: {
redirect("/team");
}
const interval = (searchParams.interval as "day" | "week") ?? "week";
const rangeType = (searchParams.type as DurationId) || "last-120";
const range: Range = {
from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from),
to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to),
type: rangeType,
};
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader
interval={interval}
range={range}
showRangeSelector={true}
title="Analytics"
/>
</div>
<div className="flex grow flex-col justify-between gap-10 container max-w-7xl pt-8 pb-16">
<div className="flex grow flex-col gap-6">
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncAppHighlightsCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
<Suspense
fallback={<LoadingChartState className="h-[431px] border" />}
>
<AsyncWalletDistributionCard range={range} teamId={team.id} />
</Suspense>
<Suspense
fallback={<LoadingChartState className="h-[431px] border" />}
>
<AsyncAuthMethodDistributionCard range={range} teamId={team.id} />
</Suspense>
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex grow flex-col">
{/* header */}
<div className="border-b">
<div className="container max-w-7xl flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
Analytics
</h1>
</div>
<div className="max-sm:w-full">
<ResponsiveTimeFilters defaultRange={defaultRange} />
</div>
</div>
</div>
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTransactionsChartCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="flex grow flex-col justify-between gap-10 container max-w-7xl pt-8 pb-16">
<div className="flex grow flex-col gap-6">
<ResponsiveSuspense
searchParamsUsed={["appHighlights", "from", "to", "interval"]}
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTeamHighlightsCard
selectedChartQueryParam="appHighlights"
interval={interval}
range={range}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTotalSponsoredCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to"]}
>
<AsyncWalletDistributionCard range={range} teamId={team.id} />
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to"]}
>
<AsyncAuthMethodDistributionCard
range={range}
teamId={team.id}
/>
</ResponsiveSuspense>
</div>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={[
"from",
"to",
"interval",
"client_transactions",
]}
>
<AsyncTransactionsChartCard
selectedChartQueryParam="client_transactions"
interval={interval}
range={range}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "userOpUsage"]}
>
<AsyncTotalSponsoredCard
selectedChartQueryParam="userOpUsage"
interval={interval}
range={range}
selectedChart={
typeof searchParams.userOpUsage === "string"
? searchParams.userOpUsage
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
</div>
</div>
</div>
</div>
</ResponsiveSearchParamsProvider>
);
}
async function AsyncAppHighlightsCard(props: {
async function AsyncTeamHighlightsCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const [walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
@@ -156,13 +196,12 @@ async function AsyncAppHighlightsCard(props: {
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0)
) {
return (
<div className="">
<AppHighlightsCard
searchParams={props.searchParams}
userStats={walletUserStatsTimeSeries.value}
volumeStats={universalBridgeUsage.value}
/>
</div>
<TeamHighlightsCard
selectedChart={props.selectedChart}
userStats={walletUserStatsTimeSeries.value}
selectedChartQueryParam={props.selectedChartQueryParam}
volumeStats={universalBridgeUsage.value}
/>
);
}
@@ -220,7 +259,8 @@ async function AsyncTransactionsChartCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const [clientTransactionsTimeSeries, clientTransactions] =
await Promise.allSettled([
@@ -241,10 +281,11 @@ async function AsyncTransactionsChartCard(props: {
return clientTransactionsTimeSeries.status === "fulfilled" &&
clientTransactions.status === "fulfilled" &&
clientTransactions.value.length > 0 ? (
<TransactionsChartCardUI
<TransactionsChartCardWithChainMapping
selectedChartQueryParam={props.selectedChartQueryParam}
aggregatedData={clientTransactions.value}
data={clientTransactionsTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
/>
) : (
<EmptyStateCard
@@ -258,7 +299,8 @@ async function AsyncTotalSponsoredCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
getUserOpUsage({
@@ -278,10 +320,11 @@ async function AsyncTotalSponsoredCard(props: {
return userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
<TotalSponsoredChartCardUI
<AsyncTotalSponsoredChartCard
aggregatedData={userOpUsage.value}
data={userOpUsageTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
) : (
<EmptyStateCard
@@ -291,120 +334,6 @@ async function AsyncTotalSponsoredCard(props: {
);
}
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
function AppHighlightsCard({
userStats,
volumeStats,
searchParams,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/payments"}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
activeChart={
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="appHighlights"
title="App Highlights"
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data
@@ -446,3 +375,49 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
/>
);
}
async function AsyncTotalSponsoredChartCard(props: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
selectedChart: string | undefined;
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
description?: string;
}) {
const chains = await Promise.all(
props.data.map(
(item) =>
// eslint-disable-next-line no-restricted-syntax
item.chainId && getChainMetadata(defineChain(Number(item.chainId))),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const processedAggregatedData = {
mainnet: props.aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: props.aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: props.aggregatedData.reduce(
(acc, curr) => acc + curr.sponsoredUsd,
0,
),
};
return (
<TotalSponsoredChartCardUI
chains={chains}
processedAggregatedData={processedAggregatedData}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
data={props.data}
/>
);
}
@@ -1,10 +1,13 @@
import { Suspense } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import { getEcosystemWalletUsage } from "@/api/analytics";
import type { Partner } from "@/api/team/ecosystems";
import {
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import type { EcosystemWalletStats } from "@/types/analytics";
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
import { EcosystemWalletsSummary } from "./Summary";
@@ -14,16 +17,66 @@ export async function EcosystemAnalyticsPage({
interval,
range,
partners,
defaultRange,
}: {
ecosystemSlug: string;
teamId: string;
interval: "day" | "week";
range?: Range;
range: Range;
defaultRange: DurationId;
partners: Partner[];
}) {
if (!range) {
range = getLastNDaysRange("last-120");
}
return (
<div>
<Suspense
fallback={
<EcosystemWalletsSummary
allTimeStats={[]}
monthlyStats={[]}
isPending={true}
/>
}
>
<AsyncEcosystemWalletsSummary
ecosystemSlug={ecosystemSlug}
teamId={teamId}
/>
</Suspense>
<div className="h-6" />
<ResponsiveTimeFilters defaultRange={defaultRange} />
<div className="h-6" />
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<EcosystemWalletUsersAnalyticsUI
ecosystemWalletStats={[]}
groupBy="authenticationMethod"
isPending={true}
partners={partners}
/>
}
>
<AsyncEcosystemWalletUsersAnalytics
ecosystemSlug={ecosystemSlug}
interval={interval}
partners={partners}
range={range}
teamId={teamId}
/>
</ResponsiveSuspense>
</div>
);
}
async function AsyncEcosystemWalletsSummary(props: {
ecosystemSlug: string;
teamId: string;
}) {
const { ecosystemSlug, teamId } = props;
const allTimeStatsPromise = getEcosystemWalletUsage({
ecosystemSlug,
@@ -41,7 +94,30 @@ export async function EcosystemAnalyticsPage({
to: new Date(),
});
const statsPromise = getEcosystemWalletUsage({
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]);
return (
<EcosystemWalletsSummary
allTimeStats={allTimeStats ?? []}
monthlyStats={monthlyStats ?? []}
isPending={false}
/>
);
}
async function AsyncEcosystemWalletUsersAnalytics(props: {
ecosystemSlug: string;
teamId: string;
interval: "day" | "week";
range: Range;
partners: Partner[];
}) {
const { ecosystemSlug, teamId, interval, range, partners } = props;
const stats = await getEcosystemWalletUsage({
ecosystemSlug,
from: range.from,
period: interval,
@@ -49,39 +125,36 @@ export async function EcosystemAnalyticsPage({
to: range.to,
}).catch(() => null);
const [allTimeStats, monthlyStats, stats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
statsPromise,
]);
return (
<div>
<EcosystemWalletsSummary
allTimeStats={allTimeStats ?? []}
monthlyStats={monthlyStats ?? []}
<EcosystemWalletUsersAnalyticsUI
ecosystemWalletStats={stats || []}
groupBy="authenticationMethod"
isPending={false}
partners={partners}
/>
);
}
function EcosystemWalletUsersAnalyticsUI(props: {
ecosystemWalletStats: EcosystemWalletStats[];
isPending: boolean;
groupBy: "authenticationMethod" | "ecosystemPartnerId";
partners: Partner[];
}) {
return (
<div className="flex flex-col gap-4 lg:gap-6">
<EcosystemWalletUsersChartCard
ecosystemWalletStats={props.ecosystemWalletStats || []}
groupBy="authenticationMethod"
isPending={props.isPending}
partners={props.partners}
/>
<EcosystemWalletUsersChartCard
ecosystemWalletStats={props.ecosystemWalletStats || []}
groupBy="ecosystemPartnerId"
isPending={props.isPending}
partners={props.partners}
/>
<div className="h-6" />
<RangeSelector interval={interval} range={range} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<EcosystemWalletUsersChartCard
ecosystemWalletStats={stats || []}
groupBy="authenticationMethod"
isPending={false}
partners={partners}
/>
<EcosystemWalletUsersChartCard
ecosystemWalletStats={stats || []}
groupBy="ecosystemPartnerId"
isPending={false}
partners={partners}
/>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import type { EcosystemWalletStats } from "@/types/analytics";
export function EcosystemWalletsSummary(props: {
allTimeStats: EcosystemWalletStats[] | undefined;
monthlyStats: EcosystemWalletStats[] | undefined;
isPending: boolean;
}) {
const allTimeStats = props.allTimeStats?.reduce(
(acc, curr) => {
@@ -30,13 +31,13 @@ export function EcosystemWalletsSummary(props: {
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<StatCard
icon={ActivityIcon}
isPending={false}
isPending={props.isPending}
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
/>
<StatCard
icon={UserIcon}
isPending={false}
isPending={props.isPending}
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
/>
@@ -1,7 +1,10 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/team/ecosystems";
import { getTeamBySlug } from "@/api/team/get-team";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { getFiltersFromSearchParams } from "@/lib/time";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
@@ -47,21 +50,24 @@ export default async function Page(props: {
teamId: team.id,
});
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
return (
<EcosystemAnalyticsPage
ecosystemSlug={ecosystem.slug}
interval={searchParams.interval || "week"}
partners={partners}
range={
searchParams.from && searchParams.to
? {
from: new Date(searchParams.from),
to: new Date(searchParams.to),
type: "custom",
}
: undefined
}
teamId={team.id}
/>
<ResponsiveSearchParamsProvider value={searchParams}>
<EcosystemAnalyticsPage
ecosystemSlug={ecosystem.slug}
interval={interval}
defaultRange={defaultRange}
partners={partners}
range={range}
teamId={team.id}
/>
</ResponsiveSearchParamsProvider>
);
}
@@ -1,19 +1,8 @@
import { Suspense } from "react";
import { getEngineCloudMethodUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export function EngineCloudChartCard(props: AnalyticsQueryParams) {
return (
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<EngineCloudChartCardAsync {...props} />
</Suspense>
);
}
async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) {
export async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) {
const rawData = await getEngineCloudMethodUsage(props);
return <EngineCloudBarChartCardUI rawData={rawData} />;
}
@@ -1,19 +1,8 @@
import { Suspense } from "react";
import { getRpcMethodUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI";
export function RpcMethodBarChartCard(props: AnalyticsQueryParams) {
return (
// TODO: Add better LoadingChartState
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<RpcMethodBarChartCardAsync {...props} />
</Suspense>
);
}
async function RpcMethodBarChartCardAsync(props: AnalyticsQueryParams) {
export async function RpcMethodBarChartCardAsync(props: AnalyticsQueryParams) {
const rawData = await getRpcMethodUsage(props);
return <RpcMethodBarChartCardUI rawData={rawData} />;
@@ -3,29 +3,29 @@ import { getChainMetadata } from "thirdweb/chains";
import { fetchDashboardContractMetadata } from "@/hooks/useDashboardContractMetadata";
import type { TransactionStats } from "@/types/analytics";
import { PieChartCard } from "../../../../../components/Analytics/PieChartCard";
import { TransactionsChartCardUI } from "../../../../(team)/_components/TransactionsCard";
import { TransactionsChartCardWithChainMapping } from "../../../../(team)/_components/transaction-card-with-chain-mapping";
export function TransactionsChartsUI({
data,
aggregatedData,
searchParams,
client,
}: {
export function TransactionsChartsUI(props: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams: { [key: string]: string | string[] | undefined };
selectedChartQueryParam: string;
client: ThirdwebClient;
selectedChart: string | undefined;
}) {
return (
<>
<TransactionsChartCardUI
aggregatedData={aggregatedData}
data={data}
searchParams={searchParams}
<TransactionsChartCardWithChainMapping
aggregatedData={props.aggregatedData}
data={props.data}
selectedChartQueryParam={props.selectedChartQueryParam}
selectedChart={props.selectedChart}
/>
<div className="grid gap-6 md:grid-cols-2">
<ChainDistributionCard data={aggregatedData} />
<ContractDistributionCard client={client} data={aggregatedData} />
<ChainDistributionCard data={props.aggregatedData} />
<ContractDistributionCard
client={props.client}
data={props.aggregatedData}
/>
</div>
</>
);
@@ -1,27 +1,12 @@
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getClientTransactions } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { TransactionsChartsUI } from "./TransactionCharts";
export function TransactionsCharts(
export async function TransactionsChartCardAsync(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
client: ThirdwebClient;
},
) {
return (
// TODO: Add better LoadingChartState
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<TransactionsChartCardAsync {...props} />
</Suspense>
);
}
async function TransactionsChartCardAsync(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
},
) {
@@ -42,7 +27,8 @@ async function TransactionsChartCardAsync(
aggregatedData={aggregatedData}
client={props.client}
data={data}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
);
}
@@ -0,0 +1,139 @@
"use client";
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
export function ProjectHighlightsCard(props: {
selectedChart: string | undefined;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
teamSlug: string;
projectSlug: string;
selectedChartQueryParam: string;
}) {
const {
selectedChart,
userStats,
volumeStats,
teamSlug,
projectSlug,
selectedChartQueryParam,
} = props;
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${teamSlug}/${projectSlug}/payments/settings`}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
title="Project Highlights"
activeChart={
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof AggregatedMetrics)
: "totalVolume"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
/**
* Processes time series data to combine wallet and user statistics
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
@@ -0,0 +1,71 @@
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type TimeSeriesMetrics = {
date: string;
mainnet: number;
testnet: number;
total: number;
};
export function TotalSponsoredCardUI(props: {
selectedChart: string | undefined;
selectedChartQueryParam: string;
timeSeriesData: TimeSeriesMetrics[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const {
selectedChart,
selectedChartQueryParam,
processedAggregatedData,
timeSeriesData,
} = props;
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
return (
<CombinedBarChartCard
activeChart={(selectedChart as keyof typeof chartConfig) ?? "mainnet"}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
data={timeSeriesData}
isCurrency
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
title="Gas Sponsored"
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
@@ -1,9 +1,9 @@
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
type ChainMetadata,
@@ -21,43 +21,47 @@ import {
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject, type Project } from "@/api/project/projects";
import {
type DurationId,
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { RangeSelector } from "@/components/analytics/range-selector";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
} from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCard } from "./components/EngineCloudChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
import { TotalSponsoredCardUI } from "./overview/total-sponsored";
interface PageParams {
type PageParams = {
team_slug: string;
project_slug: string;
}
};
interface PageSearchParams {
[key: string]: string | undefined;
}
type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
interface PageProps {
type PageProps = {
params: Promise<PageParams>;
searchParams: Promise<PageSearchParams>;
}
};
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
@@ -78,13 +82,13 @@ export default async function ProjectOverviewPage(props: PageProps) {
redirect(`/team/${params.team_slug}`);
}
const interval = (searchParams.interval as "day" | "week") ?? "week";
const rangeType = (searchParams.type as DurationId) || "last-120";
const range: Range = {
from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from),
to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to),
type: rangeType,
};
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const activeStatus = await isProjectActive({
projectId: project.id,
@@ -99,27 +103,26 @@ export default async function ProjectOverviewPage(props: PageProps) {
});
return (
<div className="flex grow flex-col">
<div className="w-full border-b py-10">
<div className="container max-w-7xl">
<Header
interval={interval}
range={range}
showRangeSelector={isActive}
title={project.name}
/>
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex grow flex-col">
<div className="w-full border-b py-10">
<div className="container max-w-7xl">
<Header
defaultRange={defaultRange}
showRangeSelector={isActive}
title={project.name}
/>
</div>
</div>
</div>
<div className="h-6" />
<div className="h-6" />
{!isActive ? (
<div className="container max-w-7xl">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<GenericLoadingPage />}>
{!isActive ? (
<div className="container max-w-7xl">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex max-w-7xl grow flex-col">
<ProjectAnalytics
client={client}
interval={interval}
@@ -128,12 +131,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
range={range}
searchParams={searchParams}
/>
</Suspense>
</div>
)}
</div>
)}
<div className="h-20" />
</div>
<div className="h-20" />
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -149,117 +152,112 @@ async function ProjectAnalytics(props: {
return (
<div className="flex grow flex-col gap-6">
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
client={client}
interval={interval}
params={params}
project={project}
range={range}
searchParams={searchParams}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</Suspense>
</ResponsiveSuspense>
<div className="grid gap-6 md:grid-cols-2">
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncWalletDistributionCard project={project} range={range} />
</Suspense>
</ResponsiveSuspense>
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAuthMethodDistributionCard project={project} range={range} />
</Suspense>
</ResponsiveSuspense>
</div>
<TransactionsCharts
client={client}
from={range.from}
period={interval}
searchParams={searchParams}
teamId={project.teamId}
to={range.to}
/>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
from={range.from}
period={interval}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
teamId={project.teamId}
to={range.to}
/>
</ResponsiveSuspense>
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "totalSponsored"]}
>
<AsyncTotalSponsoredCard
interval={interval}
project={project}
range={range}
searchParams={searchParams}
selectedChart={
typeof searchParams.totalSponsored === "string"
? searchParams.totalSponsored
: undefined
}
selectedChartQueryParam="totalSponsored"
/>
</Suspense>
</ResponsiveSuspense>
<RpcMethodBarChartCard
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
<EngineCloudChartCard
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<RpcMethodBarChartCardAsync
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[377px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<EngineCloudChartCardAsync
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
</ResponsiveSuspense>
</div>
);
}
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
/**
* Processes time series data to combine wallet and user statistics
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
export async function AsyncTotalSponsoredCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
getUserOpUsage({
@@ -285,7 +283,8 @@ export async function AsyncTotalSponsoredCard(props: {
<TotalSponsoredCard
aggregatedData={userOpUsage.value}
data={userOpUsageTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
</div>
) : (
@@ -322,7 +321,8 @@ async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
}) {
@@ -350,13 +350,11 @@ async function AsyncAppHighlightsCard(props: {
)
return (
<div>
<AppHighlightsCard
chartKey={
(props.searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={props.params}
searchParams={props.searchParams}
<ProjectHighlightsCard
selectedChart={props.selectedChart}
selectedChartQueryParam="appHighlights"
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
@@ -401,75 +399,6 @@ async function AsyncWalletDistributionCard(props: {
);
}
function AppHighlightsCard({
chartKey,
userStats,
volumeStats,
params,
searchParams,
}: {
chartKey: keyof AggregatedMetrics;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
params: PageParams;
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${params.team_slug}/${params.project_slug}/payments/settings`}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
activeChart={chartKey}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="appHighlights"
title="App Highlights"
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data
@@ -515,11 +444,13 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
async function TotalSponsoredCard({
data,
aggregatedData,
searchParams,
selectedChart,
selectedChartQueryParam,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams: { [key: string]: string | string[] | undefined };
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const chains = await Promise.all(
data.map(
@@ -567,52 +498,22 @@ async function TotalSponsoredCard({
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
const chartConfig = {
mainnet: {
color: "hsl(var(--chart-1))",
label: "Mainnet Chains",
},
testnet: {
color: "hsl(var(--chart-2))",
label: "Testnet Chains",
},
total: {
color: "hsl(var(--chart-3))",
label: "All Chains",
},
};
return (
<CombinedBarChartCard
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
isCurrency
queryKey="totalSponsored"
title="Gas Sponsored"
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 3
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[data.length - 3]?.[key] as number) ?? 0) -
1
: undefined
}
<TotalSponsoredCardUI
processedAggregatedData={processedAggregatedData}
selectedChart={selectedChart}
selectedChartQueryParam={selectedChartQueryParam}
timeSeriesData={timeSeriesData}
/>
);
}
export function Header(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
defaultRange: DurationId;
}) {
const { title, interval, range, showRangeSelector } = props;
const { title, showRangeSelector, defaultRange } = props;
return (
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center">
@@ -623,7 +524,7 @@ export function Header(props: {
</div>
{showRangeSelector && (
<div className="max-sm:w-full">
<RangeSelector interval={interval} range={range} />
<ResponsiveTimeFilters defaultRange={defaultRange} />
</div>
)}
</div>
@@ -1,10 +1,9 @@
import { Suspense } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
@@ -15,27 +14,20 @@ type InAppWalletAnalyticsProps = {
isPending: boolean;
};
function InAppWalletAnalyticsInner({
interval,
range,
function InAppWalletAnalyticsUI({
stats,
isPending,
}: InAppWalletAnalyticsProps) {
return (
<div>
<RangeSelector interval={interval} range={range} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<InAppWalletUsersChartCardUI
description="The total number of active in-app wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
title="Unique Users"
/>
</div>
</div>
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active in-app wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
);
}
type AsyncInAppWalletAnalyticsProps = Omit<
InAppWalletAnalyticsProps,
"stats" | "isPending"
@@ -61,7 +53,7 @@ async function AsyncInAppWalletAnalytics(
});
return (
<InAppWalletAnalyticsInner
<InAppWalletAnalyticsUI
{...props}
isPending={false}
range={range}
@@ -72,12 +64,13 @@ async function AsyncInAppWalletAnalytics(
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
return (
<Suspense
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<InAppWalletAnalyticsInner {...props} isPending={true} stats={[]} />
<InAppWalletAnalyticsUI {...props} isPending={true} stats={[]} />
}
>
<AsyncInAppWalletAnalytics {...props} />
</Suspense>
</ResponsiveSuspense>
);
}
@@ -1,6 +1,9 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getProject } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getFiltersFromSearchParams } from "@/lib/time";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
@@ -18,20 +21,13 @@ export default async function Page(props: {
props.params,
]);
const range =
searchParams.from && searchParams.to
? {
from: new Date(searchParams.from),
to: new Date(searchParams.to),
type: searchParams.type ?? "last-120",
}
: undefined;
const interval: "day" | "week" = ["day", "week"].includes(
searchParams.interval ?? "",
)
? (searchParams.interval as "day" | "week")
: "week";
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const project = await getProject(params.team_slug, params.project_slug);
@@ -40,15 +36,19 @@ export default async function Page(props: {
}
return (
<div>
<InAppWalletsSummary projectId={project.id} teamId={project.teamId} />
<div className="h-10" />
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range as Range}
teamId={project.teamId}
/>
</div>
<ResponsiveSearchParamsProvider value={searchParams}>
<div>
<InAppWalletsSummary projectId={project.id} teamId={project.teamId} />
<div className="h-10" />
<ResponsiveTimeFilters defaultRange={defaultRange} />
<div className="h-6" />
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -1,26 +0,0 @@
import type { Range } from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
export function AnalyticsHeader(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
}) {
const { title, interval, range, showRangeSelector } = props;
return (
<div className="container max-w-7xl flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
{title}
</h1>
</div>
{showRangeSelector && (
<div className="max-sm:w-full">
<RangeSelector interval={interval} range={range} />
</div>
)}
</div>
);
}
@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { CombinedBarChartCard } from "./CombinedBarChartCard";
const meta = {
component: CombinedBarChartCard,
component: Variant,
decorators: [
(Story) => (
<div className="container max-w-6xl py-10">
@@ -11,7 +12,7 @@ const meta = {
),
],
title: "Analytics/CombinedBarChartCard",
} satisfies Meta<typeof CombinedBarChartCard>;
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -64,32 +65,20 @@ function generateTimeSeriesData(days: number) {
return data;
}
export const UserActivity: Story = {
args: {
activeChart: "dailyUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "dailyUsers",
title: "User Activity",
},
};
export const Default: Story = {};
export const MonthlyUsers: Story = {
args: {
activeChart: "monthlyUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "monthlyUsers",
title: "Monthly Users",
},
};
function Variant() {
const [activeChart, setActiveChart] = useState<
"dailyUsers" | "annualUsers" | "monthlyUsers"
>("dailyUsers");
export const AnnualUsers: Story = {
args: {
activeChart: "annualUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "annualUsers",
title: "Annual Users",
},
};
return (
<CombinedBarChartCard
title="User Activity"
activeChart={activeChart}
chartConfig={chartConfig}
data={generateTimeSeriesData(30)}
onSelect={setActiveChart}
/>
);
}
@@ -1,4 +1,4 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toUSD } from "@/utils/number";
import { BarChart } from "./BarChart";
@@ -13,8 +13,6 @@ type CombinedBarChartConfig<K extends string> = {
};
};
// 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
export function CombinedBarChartCard<
T extends string,
K extends Exclude<T, "date">,
@@ -23,7 +21,6 @@ export function CombinedBarChartCard<
chartConfig,
data,
activeChart,
queryKey,
isCurrency = false,
aggregateFn = (data, key) =>
data[data.length - 1]?.[key] as number | undefined,
@@ -33,18 +30,17 @@ export function CombinedBarChartCard<
((data[data.length - 2]?.[key] as number) ?? 0) -
1
: undefined,
existingQueryParams,
className,
onSelect,
}: {
title?: string;
chartConfig: CombinedBarChartConfig<K>;
data: { [key in T]: number | string }[];
activeChart: K;
queryKey: string;
isCurrency?: boolean;
aggregateFn?: (d: typeof data, key: K) => number | undefined;
trendFn?: (d: typeof data, key: K) => number | undefined;
existingQueryParams?: { [key: string]: string | string[] | undefined };
onSelect: (key: K) => void;
className?: string;
}) {
return (
@@ -61,18 +57,12 @@ export function CombinedBarChartCard<
{Object.keys(chartConfig).map((chart: string) => {
const key = chart as K;
return (
<Link
className="relative z-30 flex min-w-[200px] flex-1 flex-col justify-center gap-1 border-l first:border-l-0 hover:bg-accent/50"
<Button
variant="ghost"
className="rounded-none p-0 h-auto bg-transparent relative z-30 flex min-w-[200px] flex-1 flex-col items-start justify-center gap-1 border-l first:border-l-0 hover:bg-accent/50"
data-active={activeChart === chart}
href={{
query: {
...existingQueryParams,
[queryKey]: key,
},
}}
onClick={() => onSelect(key)}
key={chart}
prefetch
scroll={false}
>
<Stat
label={chartConfig[key].label}
@@ -87,7 +77,7 @@ export function CombinedBarChartCard<
className="absolute right-0 bottom-0 left-0 h-0 bg-foreground transition-all duration-300 ease-out data-[active=true]:h-[3px]"
data-active={activeChart === chart}
/>
</Link>
</Button>
);
})}
</div>
@@ -71,7 +71,7 @@ function Example() {
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.buttonLabel ? `\n\t buttonLabel="${options.payOptions.buttonLabel}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
@@ -41,6 +41,7 @@ export type BridgeComponentsPlaygroundOptions = {
title: string | undefined;
image: string | undefined;
description: string | undefined;
buttonLabel: string | undefined;
buyTokenAddress?: Address;
buyTokenAmount: string;
@@ -73,6 +73,7 @@ export function LeftSection(props: {
const modalTitleId = useId();
const modalTitleIconId = useId();
const modalDescriptionId = useId();
const buttonLabelId = useId();
const themeId = useId();
const cryptoPaymentId = useId();
const cardPaymentId = useId();
@@ -486,6 +487,26 @@ export function LeftSection(props: {
value={options.payOptions.description}
/>
</div>
{/* Button Label */}
<div className="flex flex-col gap-2">
<Label htmlFor={buttonLabelId}>Button Label</Label>
<Input
className="bg-card"
id={buttonLabelId}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...payOptions,
buttonLabel: e.target.value,
},
}))
}
placeholder="Custom button text (optional)"
value={options.payOptions.buttonLabel}
/>
</div>
</div>
</CollapsibleSection>
@@ -66,6 +66,9 @@ export function RightSection(props: {
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -91,6 +94,9 @@ export function RightSection(props: {
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -112,6 +118,9 @@ export function RightSection(props: {
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -13,6 +13,7 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
buyTokenChain: arbitrum,
description: "",
image: "",
buttonLabel: "",
paymentMethods: ["crypto", "card"],
sellerAddress: "0x0000000000000000000000000000000000000000",
title: "",
@@ -57,13 +57,31 @@ You will be asked to enter the following values
### Usage example
<Tabs defaultValue="typescript">
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">HTTP</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="dotnet">.NET</TabsTrigger>
</TabsList>
<TabsContent value="http">
```http
POST https://api.thirdweb.com/v1/wallets/user/generic-auth
Content-Type: application/json
x-client-id: <your-client-id>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "jwt",
"jwt": "<your-jwt>"
}
```
</TabsContent>
<TabsContent value="react">
```typescript
@@ -81,7 +99,7 @@ const handlePostLogin = async () => {
wallet.connect({
client,
strategy: "jwt",
jwt: "<your-jwt-token>",
jwt: "<your-jwt>",
});
return wallet;
});
@@ -107,7 +125,7 @@ const wallet = inAppWallet();
const account = await wallet.connect({
client,
strategy: "jwt",
jwt: "<your-jwt-token>",
jwt: "<your-jwt>",
});
// use the account to send transactions
@@ -122,7 +140,7 @@ using Thirdweb;
var client = ThirdwebClient.Create(clientId: "your-client-id");
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.JWT);
var address = await wallet.LoginWithCustomAuth(jwt: "<your-jwt-token>");
var address = await wallet.LoginWithCustomAuth(jwt: "<your-jwt>");
```
</TabsContent>
@@ -170,13 +188,33 @@ You can also pass a list of headers. These headers will be sent with every reque
Once you've logged in with your own auth, you can pass the user's JWT to the in-app wallet to authenticate and connect.
<Tabs defaultValue="typescript">
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">HTTP</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="dotnet">.NET</TabsTrigger>
</TabsList>
<TabsContent value="http">
```http
POST https://api.thirdweb.com/v1/wallets/user/generic-auth
Content-Type: application/json
x-client-id: <your-client-id>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "auth-payload",
"payload": "<your-auth-payload>"
}
```
</TabsContent>
<TabsContent value="react">
```typescript
@@ -214,8 +252,6 @@ const MyComponent = () => {
<TabsContent value="typescript">
In other frameworks, use your own instance of the wallet to authenticate and connect.
```typescript
import { inAppWallet } from "thirdweb/wallets";
@@ -7,7 +7,7 @@ export const metadata = createMetadata({
icon: "wallets",
},
title: "Pregenerate Wallets | thirdweb",
description: "Create wallets before users sign up with thirdweb’s wallet pregeneration. Pre-fund with tokens, NFTs, or game assets to enable smooth onboarding and asset claiming.",
description: "Create wallets before users sign up with thirdweb's wallet pregeneration. Pre-fund with tokens, NFTs, or game assets to enable smooth onboarding and asset claiming.",
});
# Pregenerate Wallets
@@ -35,21 +35,34 @@ You can distribute assets to wallets before users claim them, enabling:
To pregenerate an in-app or ecosystem wallet wallet, you can make a `POST` request to the following endpoint:
```
https://in-app-wallet.thirdweb.com/api/v1/pregenerate
```http
POST https://api.thirdweb.com/v1/wallets/user/pregenerate
Content-Type: application/json
x-secret-key: <your-secret-key>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "email",
"email": "[email protected]"
}
```
## Request Body
The request body should be a JSON object with the following parameters:
- `strategy`: The strategy for wallet generation
- `email` or `phone` or `userId`: The user identifier associated with the wallet to be generated
- `type`: The type of wallet identifier to pregenerate a wallet for:
- Email based: `email`, `google`, `facebook`, `discord`
- Phone based: `phone`
- Wallet based: `signer`
- User ID based: `custom_jwt` or `custom_auth_endpoint`
- `email` or `phone` or `userId` or `walletAddress`: The user identifier associated with the wallet to be generated
### Email based wallets
```
{ strategy: "email", email: "[email protected]" }
{ type: "email", email: "[email protected]" }
```
When the user logs in with any method associated with that email (including google, facebook, discord auth), they will get access to the same pregenerated wallet.
@@ -57,13 +70,13 @@ When the user logs in with any method associated with that email (including goog
### Phone based wallets
```
{ strategy: "phone", phone: "+1321123321" }
{ type: "phone", phone: "+1321123321" }
```
### Custom user id based wallets
```
{ strategy: "custom_auth_endpoint", userId: "some_user_id" }
{ type: "custom_auth_endpoint", userId: "some_user_id" }
```
{/* TODO: update link when custom auth documentation has been updated */}
@@ -83,13 +96,13 @@ You need to include the following headers:
Here's an example curl command to pregenerate a thirdweb wallet for the user `[email protected]`:
```bash
curl -X POST 'https://in-app-wallet.thirdweb.com/api/v1/pregenerate' \
curl -X POST 'https://api.thirdweb.com/v1/wallets/user/pregenerate' \
-H 'x-ecosystem-id: ecosystem.example-eco-123' \
-H 'x-ecosystem-partner-id: 1415d24e-c7b0-4fce-846e-740841ef2c32' \
-H 'x-secret-key: 9f8e7d6c5b4a3f2e1d0c9b8a7ffge434b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7' \
-H 'x-secret-key: <your-project-secret-key>' \
-H 'Content-Type: application/json' \
-d '{
"strategy": "email",
"type": "email",
"email": "[email protected]"
}'
```
@@ -107,9 +120,10 @@ A successful API call returns a JSON object in the following format:
```json
{
"wallet": {
"result": {
"address": "string",
"createdAt": "string",
"profile": "object[]"
}
}
```
+7
View File
@@ -1,5 +1,12 @@
# @thirdweb-dev/nebula
## 0.2.28
### Patch Changes
- Updated dependencies [[`621c1de`](https://github.com/thirdweb-dev/js/commit/621c1de62e486b8698d9bd38fabde73fef9f7de5)]:
- thirdweb@5.105.28
## 0.2.27
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.27"
"version": "0.2.28"
}
+6
View File
@@ -1,5 +1,11 @@
# thirdweb
## 5.105.28
### Patch Changes
- [#7805](https://github.com/thirdweb-dev/js/pull/7805) [`621c1de`](https://github.com/thirdweb-dev/js/commit/621c1de62e486b8698d9bd38fabde73fef9f7de5) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add ability to override button label for all payment widgets
## 5.105.27
### Patch Changes
+1 -1
View File
@@ -409,5 +409,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.27"
"version": "5.105.28"
}
@@ -42,6 +42,7 @@ export type UIOptions = Prettify<
image?: string;
};
currency?: SupportedFiatCurrency;
buttonLabel?: string;
} & (
| {
mode: "fund_wallet";
@@ -180,6 +180,11 @@ export type BuyWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -335,6 +340,7 @@ export function BuyWidget(props: BuyWidgetProps) {
},
mode: "fund_wallet",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
},
type: "success",
};
@@ -364,6 +370,8 @@ export function BuyWidget(props: BuyWidgetProps) {
title: props.title,
},
mode: "fund_wallet",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
},
type: "success",
};
@@ -186,6 +186,11 @@ export type CheckoutWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -310,6 +315,7 @@ export function CheckoutWidget(props: CheckoutWidgetProps) {
},
mode: "direct_payment",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
paymentInfo: {
amount: props.amount,
feePayer: props.feePayer === "seller" ? "receiver" : "sender",
@@ -64,7 +64,11 @@ export function DirectPayment({
);
};
const buyNow = (
const buyNow = uiOptions.buttonLabel ? (
<Text color="primaryButtonText" size="md">
{uiOptions.buttonLabel}
</Text>
) : (
<Container flex="row" gap="3xs">
<Text color="primaryButtonText" size="md">
Buy Now ·
@@ -336,13 +336,16 @@ export function FundWallet({
}}
variant="primary"
>
Buy {amount} {uiOptions.destinationToken.symbol}
{uiOptions.buttonLabel ||
`Buy ${amount} ${uiOptions.destinationToken.symbol}`}
</Button>
) : (
<ConnectButton
client={client}
connectButton={{
label: `Buy ${amount} ${uiOptions.destinationToken.symbol}`,
label:
uiOptions.buttonLabel ||
`Buy ${amount} ${uiOptions.destinationToken.symbol}`,
}}
theme={theme}
{...connectOptions}
@@ -112,7 +112,7 @@ export function TransactionPayment({
transactionDataQuery.data?.functionInfo?.functionName || "Contract Call";
const isLoading = transactionDataQuery.isLoading || chainMetadata.isLoading;
const buttonLabel = `Execute ${functionName}`;
const buttonLabel = uiOptions.buttonLabel || `Execute ${functionName}`;
if (isLoading) {
return (
@@ -189,6 +189,11 @@ export type TransactionWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -349,6 +354,7 @@ export function TransactionWidget(props: TransactionWidgetProps) {
return {
data: {
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
metadata: {
description: props.description,
image: props.image,
@@ -223,3 +223,34 @@ export const NoImageLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Purchase Now' instead of the default 'Buy Now' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Purchase Now'.",
},
},
},
};
@@ -194,3 +194,34 @@ export const LargeAmountLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: FUND_WALLET_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Add Funds Now' instead of the default 'Buy [amount] [symbol]' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: FUND_WALLET_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Add Funds Now'.",
},
},
},
};
@@ -168,3 +168,34 @@ export const ContractInteractionLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: TRANSACTION_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Execute Now' instead of the default 'Execute [functionName]' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: TRANSACTION_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Execute Now'.",
},
},
},
};
@@ -684,7 +684,7 @@ type TransactionUIOptions = Extract<UIOptions, { mode: "transaction" }>;
// UI Options for FundWallet mode
export const FUND_WALLET_UI_OPTIONS: Record<
"ethDefault" | "ethWithAmount" | "usdcDefault" | "uniLarge",
"ethDefault" | "ethWithAmount" | "usdcDefault" | "uniLarge" | "customButton",
FundWalletUIOptions
> = {
ethDefault: {
@@ -718,11 +718,26 @@ export const FUND_WALLET_UI_OPTIONS: Record<
initialAmount: "5",
mode: "fund_wallet" as const,
},
customButton: {
destinationToken: ETH,
initialAmount: "0.01",
metadata: {
description: "Test custom button label for funding",
title: "Custom Fund Wallet",
},
mode: "fund_wallet" as const,
buttonLabel: "Add Funds Now",
},
};
// UI Options for DirectPayment mode
export const DIRECT_PAYMENT_UI_OPTIONS: Record<
"digitalArt" | "concertTicket" | "subscription" | "sneakers" | "credits",
| "digitalArt"
| "concertTicket"
| "subscription"
| "sneakers"
| "credits"
| "customButton",
DirectPaymentUIOptions
> = {
concertTicket: {
@@ -794,11 +809,26 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record<
token: USDC,
},
},
customButton: {
metadata: {
description: "Test custom button label functionality",
image: PRODUCT_METADATA.digitalArt.image,
title: "Custom Button Test",
},
mode: "direct_payment" as const,
buttonLabel: "Purchase Now",
paymentInfo: {
amount: "0.05",
feePayer: "sender" as const,
sellerAddress: RECEIVER_ADDRESSES.primary,
token: ETH,
},
},
};
// UI Options for Transaction mode
export const TRANSACTION_UI_OPTIONS: Record<
"ethTransfer" | "erc20Transfer" | "contractInteraction",
"ethTransfer" | "erc20Transfer" | "contractInteraction" | "customButton",
TransactionUIOptions
> = {
contractInteraction: {
@@ -825,4 +855,13 @@ export const TRANSACTION_UI_OPTIONS: Record<
mode: "transaction" as const,
transaction: ethTransferTransaction,
},
customButton: {
metadata: {
description: "Test custom button label for transactions",
title: "Custom Transaction",
},
mode: "transaction" as const,
buttonLabel: "Execute Now",
transaction: ethTransferTransaction,
},
};
+2
View File
@@ -1,5 +1,7 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.125
## 0.2.124
## 0.2.123
+1 -1
View File
@@ -55,5 +55,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.124"
"version": "0.2.125"
}