Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ export interface InAppWalletStats {
uniqueWalletsConnected: number;
}

export interface EcosystemWalletStats extends InAppWalletStats {}

export interface UserOpStats {
date: string;
successful: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
type Range,
getLastNDaysRange,
} from "components/analytics/date-range-selector";
import { RangeSelector } from "components/analytics/range-selector";
import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem";
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";

export async function EcosystemAnalyticsPage({
ecosystemId,
interval,
range,
}: { ecosystemId: string; interval: "day" | "week"; range?: Range }) {
if (!range) {
range = getLastNDaysRange("last-120");
}

const stats = await getEcosystemWalletUsage({
ecosystemId,
from: range.from,
to: range.to,
period: interval,
}).catch(() => null);

return (
<div>
<RangeSelector range={range} interval={interval} />

<div className="h-6" />

<div className="flex flex-col gap-4 lg:gap-6">
<EcosystemWalletUsersChartCard
ecosystemWalletStats={stats || []}
isPending={false}
/>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"use client";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import {
EmptyChartState,
LoadingChartState,
} from "components/analytics/empty-chart-state";
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 { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";

type ChartData = Record<string, number> & {
time: string; // human readable date
};
const defaultLabel = "Unknown Auth";

export function EcosystemWalletUsersChartCard(props: {
ecosystemWalletStats: EcosystemWalletStats[];
isPending: boolean;
}) {
const { ecosystemWalletStats } = props;

const topChainsToShow = 10;

const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const authMethodToVolumeMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of ecosystemWalletStats) {
const chartData = _chartDataMap.get(stat.date);
const { authenticationMethod } = stat;

// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: format(new Date(stat.date), "MMM dd"),
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
} as ChartData);
} else if (chartData) {
chartData[authenticationMethod || defaultLabel] =
(chartData[authenticationMethod || defaultLabel] || 0) +
stat.uniqueWalletsConnected;
}

authMethodToVolumeMap.set(
authenticationMethod || defaultLabel,
stat.uniqueWalletsConnected +
(authMethodToVolumeMap.get(authenticationMethod || defaultLabel) ||
0),
);
}

const authMethodsSorted = Array.from(authMethodToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);

const authMethodsToShow = authMethodsSorted.slice(0, topChainsToShow);
const authMethodsAsOther = authMethodsSorted.slice(topChainsToShow);

// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const authMethod in data) {
if (authMethodsAsOther.includes(authMethod)) {
data.others = (data.others || 0) + (data[authMethod] || 0);
delete data[authMethod];
}
}
}

authMethodsToShow.forEach((walletType, i) => {
_chartConfig[walletType] = {
label: authMethodsToShow[i],
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});

if (authMethodsToShow.length > topChainsToShow) {
// Add Other
authMethodsToShow.push("others");
_chartConfig.others = {
label: "Others",
color: "hsl(var(--muted-foreground))",
};
}

return {
chartData: Array.from(_chartDataMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
),
chartConfig: _chartConfig,
};
}, [ecosystemWalletStats]);

const uniqueAuthMethods = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
uniqueAuthMethods.every((authMethod) =>
chartData.every((data) => data[authMethod] === 0),
);

return (
<div className="relative w-full rounded-lg border border-border bg-muted/50 p-4 md:p-6">
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
Unique Users
</h3>
<p className="mb-3 text-muted-foreground text-sm">
The total number of active users in your ecosystem for each period.
</p>

<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Connect Wallets"
disabled={disableActions}
getData={async () => {
// 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 };
}}
/>
</div>

{/* Chart */}
<ChartContainer
config={chartConfig}
className="h-[250px] w-full md:h-[350px]"
>
{props.isPending ? (
<LoadingChartState />
) : chartData.length === 0 ||
uniqueAuthMethods.every((authMethod) =>
chartData.every((data) => data[authMethod] === 0),
) ? (
<EmptyChartState>
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">
Connect users to your app with social logins
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
label="TypeScript"
icon={TypeScriptIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
label="React Native"
icon={ReactIcon}
/>
<DocLink
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
label="Unity"
icon={UnityIcon}
/>
</div>
</div>
</EmptyChartState>
) : (
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />

<XAxis
dataKey="time"
tickLine={false}
tickMargin={10}
axisLine={false}
/>

<YAxis
dataKey={(data) =>
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)}
/>

<ChartTooltip
cursor={true}
content={
<ChartTooltipContent
valueFormatter={(value) => formatTickerNumber(Number(value))}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{uniqueAuthMethods.map((authMethod) => {
return (
<Bar
key={authMethod}
dataKey={authMethod}
fill={chartConfig[authMethod]?.color}
radius={4}
stackId="a"
strokeWidth={1.5}
className="stroke-muted"
/>
);
})}
</BarChart>
)}
</ChartContainer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
import { Stat } from "components/analytics/stat";
import { ActivityIcon, UserIcon } from "lucide-react";

export function EcosystemWalletsSummary(props: {
allTimeStats: EcosystemWalletStats[] | undefined;
monthlyStats: EcosystemWalletStats[] | undefined;
}) {
const allTimeStats = props.allTimeStats?.reduce(
(acc, curr) => {
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
return acc;
},
{
uniqueWalletsConnected: 0,
},
);

const monthlyStats = props.monthlyStats?.reduce(
(acc, curr) => {
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
return acc;
},
{
uniqueWalletsConnected: 0,
},
);

return (
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<Stat
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
icon={ActivityIcon}
/>
<Stat
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
icon={UserIcon}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Range } from "components/analytics/date-range-selector";
import { fetchApiServer } from "data/analytics/fetch-api-server";
import { FetchError } from "utils/error";
import type { Ecosystem } from "../../../types";
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";

export default async function Page({
params,
searchParams,
}: {
params: { slug: string };
searchParams: {
interval?: "day" | "week";
range?: Range;
};
}) {
const ecosystem = await getEcosystem(params.slug);

return (
<EcosystemAnalyticsPage
ecosystemId={ecosystem.id}
interval={searchParams.interval || "week"}
range={searchParams.range}
/>
);
}

async function getEcosystem(ecosystemSlug: string) {
const res = await fetchApiServer(`v1/ecosystem-wallet/${ecosystemSlug}`);

if (!res.ok) {
const data = await res.json();
console.error(data);
throw new FetchError(
res,
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
);
}

const data = (await res.json()) as { result: Ecosystem };
return data.result;
}
Loading
Loading