diff --git a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx index 7291a8e45fc..f89ef6ec9d3 100644 --- a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx @@ -32,6 +32,7 @@ type ThirdwebBarChartProps = { description?: string; titleClassName?: string; }; + customHeader?: React.ReactNode; // chart config config: TConfig; data: Array & { time: number | string | Date }>; @@ -41,7 +42,9 @@ type ThirdwebBarChartProps = { chartClassName?: string; isPending: boolean; toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode; + toolTipValueFormatter?: (value: unknown) => React.ReactNode; hideLabel?: boolean; + emptyChartState?: React.ReactElement; }; export function ThirdwebBarChart( @@ -65,12 +68,14 @@ export function ThirdwebBarChart( )} + {props.customHeader && props.customHeader} + {props.isPending ? ( ) : props.data.length === 0 ? ( - + {props.emptyChartState} ) : ( @@ -88,6 +93,7 @@ export function ThirdwebBarChart( props.hideLabel !== undefined ? props.hideLabel : true } labelFormatter={props.toolTipLabelFormatter} + valueFormatter={props.toolTipValueFormatter} /> } /> @@ -96,25 +102,15 @@ export function ThirdwebBarChart( content={} /> )} - {configKeys.map((key, idx) => ( + {configKeys.map((key) => ( diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.stories.tsx new file mode 100644 index 00000000000..4f65c2a448e --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { EcosystemWalletStats } from "types/analytics"; +import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard"; + +const meta = { + title: "Ecosystem/Analytics/EcosystemWalletUsersChartCard", + component: EcosystemWalletUsersChartCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const authMethods = [ + "Email", + "Google", + "Apple", + "Discord", + "Twitter", + "GitHub", + "Facebook", + "Twitch", + "LinkedIn", + "TikTok", + "Coinbase", + "MetaMask", +]; + +function ecosystemWalletStatsStub( + length: number, + startDate = new Date(2024, 11, 1), +): EcosystemWalletStats[] { + const stats: EcosystemWalletStats[] = []; + + for (let i = 0; i < length; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + const formattedDate = date.toISOString().split("T")[0] || ""; + + // each day, we pick between 1 and 4 auth methods + const authMethodsToPick = Math.floor(Math.random() * 4) + 1; + + for (let j = 0; j < authMethodsToPick; j++) { + const authMethod = + authMethods[Math.floor(Math.random() * authMethods.length)]; + stats.push({ + date: formattedDate, + authenticationMethod: authMethod || "MetaMask", + uniqueWalletsConnected: Math.floor(Math.random() * 1000) + 1, + }); + } + } + + return stats; +} + +// Empty data state +export const EmptyData: Story = { + args: { + ecosystemWalletStats: [], + isPending: false, + }, +}; + +// Loading state +export const Loading: Story = { + args: { + ecosystemWalletStats: [], + isPending: true, + }, +}; + +// 30 days of data +export const ThirtyDaysData: Story = { + args: { + ecosystemWalletStats: ecosystemWalletStatsStub(30), + isPending: false, + }, +}; + +// 60 days of data +export const SixtyDaysData: Story = { + args: { + ecosystemWalletStats: ecosystemWalletStatsStub(60), + isPending: false, + }, +}; + +// 120 days of data +export const OneHundredTwentyDaysData: Story = { + args: { + ecosystemWalletStats: ecosystemWalletStatsStub(120), + isPending: false, + }, +}; + +// Data with lots of authentication methods to test the "Others" category +export const ManyAuthMethods: Story = { + args: { + ecosystemWalletStats: (() => { + // Generate data with 15 different auth methods to test "Others" category + const basicData = ecosystemWalletStatsStub(30); + + return basicData.map((item, index) => ({ + ...item, + authenticationMethod: + authMethods[index % authMethods.length] || "MetaMask", + })); + })(), + isPending: false, + }, +}; + +// Zero values test +export const ZeroValues: Story = { + args: { + ecosystemWalletStats: ecosystemWalletStatsStub(30).map((stat) => ({ + ...stat, + uniqueWalletsConnected: 0, + })), + isPending: false, + }, +}; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx index b2659843626..e5e4972f0a6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx @@ -1,26 +1,15 @@ "use client"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { - EmptyChartState, - LoadingChartState, -} from "components/analytics/empty-chart-state"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import type { ChartConfig } from "@/components/ui/chart"; import { ReactIcon } from "components/icons/brand-icons/ReactIcon"; import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon"; import { UnityIcon } from "components/icons/brand-icons/UnityIcon"; import { DocLink } from "components/shared/DocLink"; -import { format } from "date-fns"; -import { formatTickerNumber } from "lib/format-utils"; +import { formatDate } from "date-fns"; import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import type { EcosystemWalletStats } from "types/analytics"; +import { formatTickerNumber } from "../../../../../../../../../../lib/format-utils"; type ChartData = Record & { time: string; // human readable date @@ -47,7 +36,7 @@ export function EcosystemWalletUsersChartCard(props: { // if no data for current day - create new entry if (!chartData) { _chartDataMap.set(stat.date, { - time: format(new Date(stat.date), "MMM dd"), + time: stat.date, [authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected, } as ChartData); } else if (chartData) { @@ -114,128 +103,85 @@ export function EcosystemWalletUsersChartCard(props: { ); return ( -
-

- Unique Users -

-

- The total number of active users in your ecosystem for each period. -

- -
- { - // Shows the number of each type of wallet connected on all dates - const header = ["Date", ...uniqueAuthMethods]; - const rows = chartData.map((data) => { - const { time, ...rest } = data; - return [ - time, - ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), - ]; - }); - return { header, rows }; - }} - /> -
- - {/* Chart */} - - {props.isPending ? ( - - ) : chartData.length === 0 || - uniqueAuthMethods.every((authMethod) => - chartData.every((data) => data[authMethod] === 0), - ) ? ( - -
- - Connect users to your app with social logins - -
- - - - -
-
-
- ) : ( - - - - - - - Object.entries(data) - .filter(([key]) => key !== "time") - .map(([, value]) => value) - .reduce((acc, current) => Number(acc) + Number(current), 0) - } - tickLine={false} - axisLine={false} - tickFormatter={(value) => formatTickerNumber(value)} + +

+ Unique Users +

+

+ The total number of active users in your ecosystem for each period. +

+ +
+ { + // Shows the number of each type of wallet connected on all dates + const header = ["Date", ...uniqueAuthMethods]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} /> +
+
+ } + config={chartConfig} + data={chartData} + isPending={props.isPending} + emptyChartState={} + showLegend + hideLabel={false} + variant="stacked" + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + toolTipValueFormatter={(value) => formatTickerNumber(Number(value))} + /> + ); +} - formatTickerNumber(Number(value))} - /> - } - /> - } /> - {uniqueAuthMethods.map((authMethod) => { - return ( - - ); - })} -
- )} -
+function EcosystemWalletUsersEmptyChartState() { + return ( +
+ + Connect users to your app with social logins + +
+ + + + +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx index c2f2d07065e..cd0c845684c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/analytics/page.tsx @@ -1,4 +1,3 @@ -import type { Range } from "components/analytics/date-range-selector"; import { redirect } from "next/navigation"; import { getTeamBySlug } from "../../../../../../../../../@/api/team"; import { getAuthToken } from "../../../../../../../../api/lib/getAuthToken"; @@ -12,7 +11,8 @@ export default async function Page(props: { }>; searchParams: Promise<{ interval?: "day" | "week"; - range?: Range; + from?: string; + to?: string; }>; }) { const [params, searchParams] = await Promise.all([ @@ -45,7 +45,15 @@ export default async function Page(props: { ecosystemSlug={ecosystem.slug} teamId={team.id} interval={searchParams.interval || "week"} - range={searchParams.range} + range={ + searchParams.from && searchParams.to + ? { + from: new Date(searchParams.from), + to: new Date(searchParams.to), + type: "custom", + } + : undefined + } /> ); } diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx index f13efde09ec..6d47315c400 100644 --- a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx @@ -1,17 +1,7 @@ "use client"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { - EmptyChartState, - LoadingChartState, -} from "components/analytics/empty-chart-state"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import type { ChartConfig } from "@/components/ui/chart"; import { ReactIcon } from "components/icons/brand-icons/ReactIcon"; import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon"; import { UnityIcon } from "components/icons/brand-icons/UnityIcon"; @@ -19,9 +9,7 @@ import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon"; import { DocLink } from "components/shared/DocLink"; import { formatDate } from "date-fns"; import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import type { InAppWalletStats } from "types/analytics"; -import { formatTickerNumber } from "../../../lib/format-utils"; type ChartData = Record & { time: string; // human readable date @@ -114,129 +102,87 @@ export function InAppWalletUsersChartCardUI(props: { chartData.every((data) => data.sponsoredUsd === 0); return ( -
-

- {props.title} -

-

{props.description}

- - { - // Shows the number of each type of wallet connected on all dates - const header = ["Date", ...uniqueAuthMethods]; - const rows = chartData.map((data) => { - const { time, ...rest } = data; - return [ - time, - ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), - ]; - }); - return { header, rows }; - }} - /> - - {/* Chart */} - - {props.isPending ? ( - - ) : chartData.length === 0 || - chartData.every((data) => data.sponsoredUsd === 0) ? ( - -
- - Connect users to your app with social logins - -
- - - - - -
-
-
- ) : ( - +

+ {props.title} +

+

+ {props.description} +

+ + { + // Shows the number of each type of wallet connected on all dates + const header = ["Date", ...uniqueAuthMethods]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; }} - > - - - formatDate(new Date(value), "MMM d")} - /> - - - Object.entries(data) - .filter(([key]) => key !== "time") - .map(([, value]) => value) - .reduce((acc, current) => Number(acc) + Number(current), 0) - } - tickLine={false} - axisLine={false} - tickFormatter={(value) => formatTickerNumber(value)} - tickMargin={10} - /> + /> +
+ } + data={chartData} + isPending={props.isPending} + config={chartConfig} + chartClassName="aspect-[1.5] lg:aspect-[3.5]" + emptyChartState={} + variant="stacked" + showLegend + hideLabel={false} + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + /> + ); +} - formatTickerNumber(Number(value))} - /> - } - /> - } /> - {uniqueAuthMethods.map((authMethod) => { - return ( - - ); - })} - - )} - +function InAppWalletUsersEmptyChartState() { + return ( +
+ + Connect users to your app with social logins + +
+ + + + + +
); } diff --git a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard.tsx b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard.tsx index f71b39c4a55..b3e7426e4ed 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard.tsx +++ b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/SponsoredTransactionsChartCard.tsx @@ -1,28 +1,17 @@ "use client"; import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; -import { - type ChartConfig, - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import { - EmptyChartState, - LoadingChartState, -} from "components/analytics/empty-chart-state"; +import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; +import type { ChartConfig } from "@/components/ui/chart"; import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon"; import { ReactIcon } from "components/icons/brand-icons/ReactIcon"; import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon"; import { UnityIcon } from "components/icons/brand-icons/UnityIcon"; import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon"; import { DocLink } from "components/shared/DocLink"; -import { format } from "date-fns"; +import { formatDate } from "date-fns"; import { useAllChainsData } from "hooks/chains/allChains"; import { useMemo } from "react"; -import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import type { UserOpStats } from "types/analytics"; import { formatTickerNumber } from "../../../lib/format-utils"; @@ -48,22 +37,20 @@ export function SponsoredTransactionsChartCard(props: { const { chainId } = stat; const chain = chainsStore.idToChain.get(Number(chainId)); + const chainName = chain?.name || chainId || "Unknown"; // if no data for current day - create new entry if (!chartData) { _chartDataMap.set(stat.date, { - time: format(new Date(stat.date), "MMM dd"), - [chain?.name || chainId || "Unknown"]: stat.successful, + time: stat.date, + [chainName]: stat.successful, } as ChartData); } else { - chartData[chain?.name || chainId || "Unknown"] = - (chartData[chain?.name || chainId || "Unknown"] || 0) + - stat.successful; + chartData[chainName] = (chartData[chainName] || 0) + stat.successful; } chainIdToVolumeMap.set( - chain?.name || chainId || "Unknown", - stat.successful + - (chainIdToVolumeMap.get(chain?.name || chainId || "Unknown") || 0), + chainName, + stat.successful + (chainIdToVolumeMap.get(chainName) || 0), ); } @@ -84,9 +71,9 @@ export function SponsoredTransactionsChartCard(props: { } } - chainsToShow.forEach((walletType, i) => { - _chartConfig[walletType] = { - label: chainsToShow[i], + chainsToShow.forEach((chainName, i) => { + _chartConfig[chainName] = { + label: chainName, color: `hsl(var(--chart-${(i % 10) + 1}))`, }; }); @@ -111,91 +98,58 @@ export function SponsoredTransactionsChartCard(props: { chartData.every((data) => data.transactions === 0); return ( -
-

- Sponsored Transactions -

-

- Total number of sponsored transactions -

- -
- { - const header = ["Date", ...uniqueChainIds]; - const rows = chartData.map((data) => { - const { time, ...rest } = data; - return [ - time, - ...uniqueChainIds.map((w) => (rest[w] || 0).toString()), - ]; - }); - return { header, rows }; - }} - /> -
- - {/* Chart */} - - {props.isPending ? ( - - ) : chartData.length === 0 || - chartData.every((data) => data.transactions === 0) ? ( - - - - ) : ( - - - - - - formatTickerNumber(Number(value))} - /> - } + +

+ Sponsored Transactions +

+

+ Total number of sponsored transactions +

+ +
+ { + const header = ["Date", ...uniqueChainIds]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueChainIds.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} /> - } /> - {uniqueChainIds.map((chainId) => { - return ( - - ); - })} - - )} - -
+
+ + } + config={chartConfig} + data={chartData} + isPending={props.isPending} + chartClassName="aspect-[1.5] lg:aspect-[3]" + showLegend + hideLabel={false} + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + toolTipValueFormatter={(value) => formatTickerNumber(Number(value))} + emptyChartState={} + /> ); } export function EmptyAccountAbstractionChartContent() { return ( -
+
Send your first sponsored transaction diff --git a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/TotalSponsoredChartCard.stories.tsx b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/TotalSponsoredChartCard.stories.tsx index ff665d0aa26..88122bde3ff 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/TotalSponsoredChartCard.stories.tsx +++ b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/TotalSponsoredChartCard.stories.tsx @@ -42,6 +42,13 @@ function Component() { /> + + + + { + return new Date(a.time).getTime() - new Date(b.time).getTime(); + }), chartConfig: _chartConfig, }; }, [userOpStats, chainsStore]); @@ -110,126 +101,99 @@ export function TotalSponsoredChartCard(props: { chartData.every((data) => data.sponsoredUsd === 0); return ( -
-

- Gas Sponsored -

-

- The total amount of gas sponsored in USD -

- -
- { - // Shows the number of each type of wallet connected on all dates - const header = ["Date", ...uniqueChainIds]; - const rows = chartData.map((data) => { - const { time, ...rest } = data; - return [ - time, - ...uniqueChainIds.map((w) => (rest[w] || 0).toString()), - ]; - }); - return { header, rows }; - }} - /> -
- - {/* Chart */} - - {props.isPending ? ( - - ) : chartData.length === 0 || - chartData.every((data) => data.sponsoredUsd === 0) ? ( - -
- Sponsor gas for your users -
- - - - - - -
-
-
- ) : ( - - - - +

+ Gas Sponsored +

+

+ The total amount of gas sponsored in USD +

+ +
+ { + // Shows the number of each type of wallet connected on all dates + const header = ["Date", ...uniqueChainIds]; + const rows = chartData.map((data) => { + const { time, ...rest } = data; + return [ + time, + ...uniqueChainIds.map((w) => (rest[w] || 0).toString()), + ]; + }); + return { header, rows }; + }} /> +
+
+ } + config={chartConfig} + data={chartData} + isPending={props.isPending} + emptyChartState={} + variant="stacked" + showLegend + hideLabel={false} + toolTipLabelFormatter={(_v, item) => { + if (Array.isArray(item)) { + const time = item[0].payload.time as number; + return formatDate(new Date(time), "MMM d, yyyy"); + } + return undefined; + }} + toolTipValueFormatter={(value) => { + if (typeof value !== "number") { + return ""; + } - { - // typeguard - if (typeof value !== "number") { - return ""; - } - - return toUSD(value); - }} - /> - } - /> - } /> - {uniqueChainIds.map((chainId) => { - return ( - - ); - })} - - )} - + return toUSD(value); + }} + chartClassName="aspect-[1.5] lg:aspect-[3.5]" + /> + ); +} + +function TotalSponsoredChartCardEmptyChartState() { + return ( +
+ Sponsor gas for your users +
+ + + + + + +
); } diff --git a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/storyUtils.ts b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/storyUtils.ts index 9cedbeec5a5..b9e39ffa617 100644 --- a/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/storyUtils.ts +++ b/apps/dashboard/src/components/smart-wallets/AccountAbstractionAnalytics/storyUtils.ts @@ -11,7 +11,7 @@ export function createUserOpStatsStub(days: number): UserOpStats[] { const sponsoredUsd = Math.random() * 100; stubbedData.push({ - date: new Date(2024, 1, d).toLocaleString(), + date: new Date(2024, 11, d).toLocaleString(), successful, failed, sponsoredUsd,