From 66798886cf20f480f8ecde5fe00c71b154dce50a Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Sat, 2 Nov 2024 01:18:06 +0000 Subject: [PATCH] [Dashboard] Feature: Adds ecosystem analytics (#5254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR enhances the `Ecosystem` analytics feature by introducing new components for wallet statistics, improving data fetching, and updating routing. It refines the structure for better maintainability and adds new functionalities for displaying wallet usage insights. ### Detailed summary - Added `EcosystemWalletStats` interface. - Introduced `EcosystemPermissionsPage` and `EcosystemAnalyticsPage` components. - Implemented `RangeSelector` for date range selection. - Updated routing to redirect to analytics. - Enhanced data fetching for wallet usage. - Refactored paths for type imports. - Improved `InAppWalletUsersChartCard` for better data visualization. - Added error handling and loading states for analytics components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../src/@3rdweb-sdk/react/hooks/useApi.ts | 2 + .../components/EcosystemAnalyticsPage.tsx | 39 +++ .../EcosystemWalletUsersChartCard.tsx | 241 ++++++++++++++++++ .../(active)/analytics/components/Summary.tsx | 43 ++++ .../[slug]/(active)/analytics/page.tsx | 42 +++ .../{ => components}/EcosystemSlugLayout.tsx | 26 +- .../{client => }/ecosystem-header.client.tsx | 28 +- .../client/AddPartnerDialogButton.tsx | 2 +- .../client}/EcosystemPermissionsPage.tsx | 8 +- .../client/add-partner-form.client.tsx | 3 +- .../client/auth-options-form.client.tsx | 2 +- .../integration-permissions-toggle.client.tsx | 2 +- .../client/update-partner-form.client.tsx | 2 +- .../client/update-partner-modal.client.tsx | 2 +- .../server/auth-options-section.tsx | 2 +- .../server/ecosystem-partners-section.tsx | 2 +- .../integration-permissions-section.tsx | 2 +- .../components/server/partners-table.tsx | 4 +- .../(active)/{ => configuration}/constants.ts | 0 .../hooks/use-add-partner.ts | 2 +- .../hooks/use-delete-partner.ts | 2 +- .../hooks/use-update-ecosystem.ts | 2 +- .../hooks/use-update-partner.ts | 2 +- .../[slug]/(active)/configuration/page.tsx | 5 + .../ecosystem/[slug]/(active)/layout.tsx | 2 +- .../ecosystem/[slug]/(active)/page.tsx | 4 +- .../[slug]/(active)/analytics/page.tsx | 45 ++++ .../[slug]/(active)/configuration/page.tsx | 5 + .../ecosystem/[slug]/(active)/layout.tsx | 2 +- .../ecosystem/[slug]/(active)/page.tsx | 10 +- .../range-selector.tsx} | 0 .../Analytics/InAppWalletUsersChartCard.tsx | 26 +- .../embedded-wallets/Analytics/index.tsx | 2 +- .../src/data/analytics/fetch-analytics.ts | 15 +- .../src/data/analytics/fetch-api-server.ts | 44 ++++ .../src/data/analytics/wallets/ecosystem.ts | 40 +++ 36 files changed, 602 insertions(+), 58 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/Summary.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/page.tsx rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => components}/EcosystemSlugLayout.tsx (56%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/{client => }/ecosystem-header.client.tsx (89%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/AddPartnerDialogButton.tsx (95%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration/components/client}/EcosystemPermissionsPage.tsx (59%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/add-partner-form.client.tsx (98%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/auth-options-form.client.tsx (99%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/integration-permissions-toggle.client.tsx (98%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/update-partner-form.client.tsx (98%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/client/update-partner-modal.client.tsx (93%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/server/auth-options-section.tsx (87%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/server/ecosystem-partners-section.tsx (94%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/server/integration-permissions-section.tsx (95%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/components/server/partners-table.tsx (97%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/constants.ts (100%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/hooks/use-add-partner.ts (97%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/hooks/use-delete-partner.ts (97%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/hooks/use-update-ecosystem.ts (97%) rename apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/{ => configuration}/hooks/use-update-partner.ts (97%) create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/page.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/analytics/page.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/configuration/page.tsx rename apps/dashboard/src/components/{embedded-wallets/Analytics/RangeSelector.tsx => analytics/range-selector.tsx} (100%) create mode 100644 apps/dashboard/src/data/analytics/fetch-api-server.ts create mode 100644 apps/dashboard/src/data/analytics/wallets/ecosystem.ts diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index 408ef187538..2d7d2a27b1d 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -244,6 +244,8 @@ export interface InAppWalletStats { uniqueWalletsConnected: number; } +export interface EcosystemWalletStats extends InAppWalletStats {} + export interface UserOpStats { date: string; successful: number; diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx new file mode 100644 index 00000000000..63a4179fa0c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage.tsx @@ -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 ( +
+ + +
+ +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx new file mode 100644 index 00000000000..b8117abf309 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemWalletUsersChartCard.tsx @@ -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 & { + 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 = new Map(); + const authMethodToVolumeMap: Map = 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 ( +
+

+ 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)} + /> + + formatTickerNumber(Number(value))} + /> + } + /> + } /> + {uniqueAuthMethods.map((authMethod) => { + return ( + + ); + })} + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/Summary.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/Summary.tsx new file mode 100644 index 00000000000..1f2b99281c1 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/Summary.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/page.tsx new file mode 100644 index 00000000000..133ac99e09b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/page.tsx @@ -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 ( + + ); +} + +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; +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/EcosystemSlugLayout.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx similarity index 56% rename from apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/EcosystemSlugLayout.tsx rename to apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx index e771165b308..597633695db 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/EcosystemSlugLayout.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx @@ -1,9 +1,10 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie"; +import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { getAddress } from "thirdweb"; -import { fetchEcosystem } from "../../utils/fetchEcosystem"; -import { EcosystemHeader } from "./components/client/ecosystem-header.client"; +import { fetchEcosystem } from "../../../utils/fetchEcosystem"; +import { EcosystemHeader } from "./ecosystem-header.client"; export async function EcosystemLayoutSlug({ children, @@ -30,11 +31,32 @@ export async function EcosystemLayoutSlug({ redirect(ecosystemLayoutPath); } + const allTimeStatsPromise = getEcosystemWalletUsage({ + ecosystemId: ecosystem.id, + from: new Date(2022, 0, 1), + to: new Date(), + period: "all", + }); + + const monthlyStatsPromise = getEcosystemWalletUsage({ + ecosystemId: ecosystem.id, + from: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + to: new Date(), + period: "month", + }); + + const [allTimeStats, monthlyStats] = await Promise.all([ + allTimeStatsPromise, + monthlyStatsPromise, + ]); + return (
{children}
diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/ecosystem-header.client.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx similarity index 89% rename from apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/ecosystem-header.client.tsx rename to apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx index c3d9c306098..f08dbb3ea8f 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/client/ecosystem-header.client.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/ecosystem-header.client.tsx @@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { TabLinks } from "@/components/ui/tabs"; import { useThirdwebClient } from "@/constants/thirdweb.client"; import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; +import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi"; import { AlertTriangleIcon, CheckIcon, @@ -24,9 +25,10 @@ import { import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useEcosystemList } from "../../../../hooks/use-ecosystem-list"; -import type { Ecosystem } from "../../../../types"; -import { useEcosystem } from "../../hooks/use-ecosystem"; +import { useEcosystemList } from "../../../hooks/use-ecosystem-list"; +import type { Ecosystem } from "../../../types"; +import { EcosystemWalletsSummary } from "../analytics/components/Summary"; +import { useEcosystem } from "../hooks/use-ecosystem"; function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) { switch (ecosystem.status) { @@ -110,6 +112,8 @@ function EcosystemSelect(props: { export function EcosystemHeader(props: { ecosystem: Ecosystem; ecosystemLayoutPath: string; + allTimeStats: EcosystemWalletStats[]; + monthlyStats: EcosystemWalletStats[]; }) { const pathname = usePathname(); const { data: fetchedEcosystem } = useEcosystem({ @@ -193,19 +197,21 @@ export function EcosystemHeader(props: { />
+ , "mutationFn">, diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-update-partner.ts b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts similarity index 97% rename from apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-update-partner.ts rename to apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts index 74f47e80886..b7040d97220 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/hooks/use-update-partner.ts +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts @@ -4,7 +4,7 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import type { Ecosystem, Partner } from "../../../types"; +import type { Ecosystem, Partner } from "../../../../types"; type UpdatePartnerParams = { partnerId: string; diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/page.tsx new file mode 100644 index 00000000000..34eae03537d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/page.tsx @@ -0,0 +1,5 @@ +import { EcosystemPermissionsPage } from "./components/client/EcosystemPermissionsPage"; + +export default function Page({ params }: { params: { slug: string } }) { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/layout.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/layout.tsx index 193d6af21af..488f2a0d7ac 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/layout.tsx @@ -1,4 +1,4 @@ -import { EcosystemLayoutSlug } from "./EcosystemSlugLayout"; +import { EcosystemLayoutSlug } from "./components/EcosystemSlugLayout"; export default async function Layout({ children, diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/page.tsx index c49a8d4465c..bfab2142548 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/page.tsx @@ -1,5 +1,5 @@ -import { EcosystemPermissionsPage } from "./EcosystemPermissionsPage"; +import { redirect } from "next/navigation"; export default function Page({ params }: { params: { slug: string } }) { - return ; + redirect(`/dashboard/connect/ecosystem/${params.slug}/analytics`); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/analytics/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/analytics/page.tsx new file mode 100644 index 00000000000..9e01c92ae4c --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/analytics/page.tsx @@ -0,0 +1,45 @@ +import type { Ecosystem } from "app/(dashboard)/dashboard/connect/ecosystem/types"; +import type { Range } from "components/analytics/date-range-selector"; +import { fetchApiServer } from "data/analytics/fetch-api-server"; +import { FetchError } from "utils/error"; +import { EcosystemAnalyticsPage } from "../../../../../../../../(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/analytics/components/EcosystemAnalyticsPage"; + +export default async function Page({ + params, + searchParams, +}: { + params: { + slug: string; + team_slug: string; + project_slug: string; + }; + searchParams: { + interval?: "day" | "week"; + range?: Range; + }; +}) { + const ecosystem = await getEcosystem(params.slug); + return ( + + ); +} + +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; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/configuration/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/configuration/page.tsx new file mode 100644 index 00000000000..bfa744ce7be --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/configuration/page.tsx @@ -0,0 +1,5 @@ +import { EcosystemPermissionsPage } from "../../../../../../../../(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/configuration/components/client/EcosystemPermissionsPage"; + +export default function Page({ params }: { params: { slug: string } }) { + return ; +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/layout.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/layout.tsx index 4909a0581e4..3949ba35a9f 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/layout.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/layout.tsx @@ -1,4 +1,4 @@ -import { EcosystemLayoutSlug } from "../../../../../../../(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/EcosystemSlugLayout"; +import { EcosystemLayoutSlug } from "../../../../../../../(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/components/EcosystemSlugLayout"; export default async function Layout(props: { params: { team_slug: string; project_slug: string; slug: string }; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/page.tsx index c353c61fb0c..ecb3f8020d6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/ecosystem/[slug]/(active)/page.tsx @@ -1,5 +1,9 @@ -import { EcosystemPermissionsPage } from "../../../../../../../(dashboard)/dashboard/connect/ecosystem/[slug]/(active)/EcosystemPermissionsPage"; +import { redirect } from "next/navigation"; -export default function Page({ params }: { params: { slug: string } }) { - return ; +export default function Page({ + params, +}: { params: { team_slug: string; project_slug: string; slug: string } }) { + redirect( + `/team/${params.team_slug}/${params.project_slug}/connect/ecosystem/${params.slug}/analytics`, + ); } diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/RangeSelector.tsx b/apps/dashboard/src/components/analytics/range-selector.tsx similarity index 100% rename from apps/dashboard/src/components/embedded-wallets/Analytics/RangeSelector.tsx rename to apps/dashboard/src/components/analytics/range-selector.tsx diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx index 79809049f6f..97bd45c286f 100644 --- a/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Analytics/InAppWalletUsersChartCard.tsx @@ -26,6 +26,7 @@ import { formatTickerNumber } from "../../../lib/format-utils"; type ChartData = Record & { time: string; // human readable date }; +const defaultLabel = "Unknown Auth"; export function InAppWalletUsersChartCard(props: { inAppWalletStats: InAppWalletStats[]; @@ -47,18 +48,19 @@ export function InAppWalletUsersChartCard(props: { if (!chartData && stat.uniqueWalletsConnected > 0) { _chartDataMap.set(stat.date, { time: format(new Date(stat.date), "MMM dd"), - [authenticationMethod || "Unknown"]: stat.uniqueWalletsConnected, + [authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected, } as ChartData); } else if (chartData) { - chartData[authenticationMethod || "Unknown"] = - (chartData[authenticationMethod || "Unknown"] || 0) + + chartData[authenticationMethod || defaultLabel] = + (chartData[authenticationMethod || defaultLabel] || 0) + stat.uniqueWalletsConnected; } authMethodToVolumeMap.set( - authenticationMethod || "Unknown", + authenticationMethod || defaultLabel, stat.uniqueWalletsConnected + - (authMethodToVolumeMap.get(authenticationMethod || "Unknown") || 0), + (authMethodToVolumeMap.get(authenticationMethod || defaultLabel) || + 0), ); } @@ -86,12 +88,14 @@ export function InAppWalletUsersChartCard(props: { }; }); - // Add Other - authMethodsToShow.push("others"); - _chartConfig.others = { - label: "Others", - color: "hsl(var(--muted-foreground))", - }; + 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( diff --git a/apps/dashboard/src/components/embedded-wallets/Analytics/index.tsx b/apps/dashboard/src/components/embedded-wallets/Analytics/index.tsx index efa8acadaed..92baeaa62da 100644 --- a/apps/dashboard/src/components/embedded-wallets/Analytics/index.tsx +++ b/apps/dashboard/src/components/embedded-wallets/Analytics/index.tsx @@ -3,8 +3,8 @@ import { getLastNDaysRange, } from "components/analytics/date-range-selector"; import { getInAppWalletUsage } from "data/analytics/wallets/in-app"; +import { RangeSelector } from "../../analytics/range-selector"; import { InAppWalletUsersChartCard } from "./InAppWalletUsersChartCard"; -import { RangeSelector } from "./RangeSelector"; export async function InAppWalletAnalytics({ clientId, diff --git a/apps/dashboard/src/data/analytics/fetch-analytics.ts b/apps/dashboard/src/data/analytics/fetch-analytics.ts index 03f78832617..533c5ec789e 100644 --- a/apps/dashboard/src/data/analytics/fetch-analytics.ts +++ b/apps/dashboard/src/data/analytics/fetch-analytics.ts @@ -1,28 +1,31 @@ import "server-only"; -export async function fetchAnalytics(input: string | URL, init?: RequestInit) { +export async function fetchAnalytics( + input: string | URL, + init?: RequestInit, +): Promise { const [pathname, searchParams] = input.toString().split("?"); if (!pathname) { throw new Error("Invalid input, no pathname provided"); } // create a new URL object for the analytics server - const API_SERVER_URL = new URL( + const ANALYTICS_SERVICE_URL = new URL( process.env.ANALYTICS_SERVICE_URL || "https://analytics.thirdweb.com", ); - API_SERVER_URL.pathname = pathname; + ANALYTICS_SERVICE_URL.pathname = pathname; for (const param of searchParams?.split("&") || []) { const [key, value] = param.split("="); if (!key || !value) { - return; + throw new Error("Invalid input, no key or value provided"); } - API_SERVER_URL.searchParams.append( + ANALYTICS_SERVICE_URL.searchParams.append( decodeURIComponent(key), decodeURIComponent(value), ); } - return await fetch(API_SERVER_URL, { + return fetch(ANALYTICS_SERVICE_URL, { ...init, headers: { "content-type": "application/json", diff --git a/apps/dashboard/src/data/analytics/fetch-api-server.ts b/apps/dashboard/src/data/analytics/fetch-api-server.ts new file mode 100644 index 00000000000..4fa4276b00b --- /dev/null +++ b/apps/dashboard/src/data/analytics/fetch-api-server.ts @@ -0,0 +1,44 @@ +import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie"; +import { cookies } from "next/headers"; +import { getAddress } from "thirdweb"; +import "server-only"; + +export async function fetchApiServer( + input: string | URL, + init?: RequestInit, +): Promise { + const activeAccount = cookies().get(COOKIE_ACTIVE_ACCOUNT)?.value; + const authToken = activeAccount + ? cookies().get(COOKIE_PREFIX_TOKEN + getAddress(activeAccount))?.value + : null; + + const [pathname, searchParams] = input.toString().split("?"); + if (!pathname) { + throw new Error("Invalid input, no pathname provided"); + } + + // create a new URL object for the analytics server + const API_SERVER_URL = new URL( + process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com", + ); + API_SERVER_URL.pathname = pathname; + for (const param of searchParams?.split("&") || []) { + const [key, value] = param.split("="); + if (!key || !value) { + throw new Error("Invalid input, no key or value provided"); + } + API_SERVER_URL.searchParams.append( + decodeURIComponent(key), + decodeURIComponent(value), + ); + } + + return fetch(API_SERVER_URL, { + ...init, + headers: { + "content-type": "application/json", + ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), + ...init?.headers, + }, + }); +} diff --git a/apps/dashboard/src/data/analytics/wallets/ecosystem.ts b/apps/dashboard/src/data/analytics/wallets/ecosystem.ts new file mode 100644 index 00000000000..561f5ce55e2 --- /dev/null +++ b/apps/dashboard/src/data/analytics/wallets/ecosystem.ts @@ -0,0 +1,40 @@ +import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi"; +import { fetchAnalytics } from "../fetch-analytics"; + +export async function getEcosystemWalletUsage(args: { + ecosystemId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}) { + const { ecosystemId, from, to, period } = args; + + const searchParams = new URLSearchParams(); + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + const res = await fetchAnalytics( + `v1/wallets/ecosystem/${ecosystemId}?${searchParams.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (res?.status !== 200) { + console.error("Failed to fetch in-app wallet stats"); + return null; + } + + const json = await res.json(); + + return json.data as EcosystemWalletStats[]; +}