Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ae2991ad7 | ||
|
|
bab4ff5e14 | ||
|
|
621c1de62e | ||
|
|
8bb2b0a227 |
@@ -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>
|
||||
);
|
||||
}
|
||||
+44
-47
@@ -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) =>
|
||||
|
||||
+43
-49
@@ -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) =>
|
||||
|
||||
+51
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+113
-40
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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}
|
||||
/>
|
||||
|
||||
+21
-15
@@ -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
-12
@@ -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
-12
@@ -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} />;
|
||||
|
||||
+14
-14
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+5
-19
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+139
@@ -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;
|
||||
}
|
||||
+71
@@ -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>
|
||||
|
||||
+14
-21
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-25
@@ -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>
|
||||
);
|
||||
}
|
||||
+18
-29
@@ -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[]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @thirdweb-dev/wagmi-adapter
|
||||
|
||||
## 0.2.125
|
||||
|
||||
## 0.2.124
|
||||
|
||||
## 0.2.123
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user