-
Notifications
You must be signed in to change notification settings - Fork 400
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
CORE-[656] Update Dashboard Team layout
- Loading branch information
Showing
20 changed files
with
380 additions
and
378 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
335 changes: 16 additions & 319 deletions
335
apps/dashboard/src/app/team/[team_slug]/(team)/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,336 +1,33 @@ | ||
import { | ||
getInAppWalletUsage, | ||
getUserOpUsage, | ||
getWalletConnections, | ||
getWalletUsers, | ||
} from "@/api/analytics"; | ||
import { redirect } from "next/navigation"; | ||
|
||
import type { | ||
InAppWalletStats, | ||
WalletStats, | ||
WalletUserStats, | ||
} from "types/analytics"; | ||
|
||
import { | ||
type DurationId, | ||
type Range, | ||
getLastNDaysRange, | ||
} from "components/analytics/date-range-selector"; | ||
|
||
import { type WalletId, getWalletInfo } from "thirdweb/wallets"; | ||
import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader"; | ||
import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard"; | ||
import { EmptyState } from "../../components/Analytics/EmptyState"; | ||
import { PieChartCard } from "../../components/Analytics/PieChartCard"; | ||
|
||
import { getProjects } from "@/api/projects"; | ||
import { getTeamBySlug } from "@/api/team"; | ||
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage"; | ||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; | ||
import { getValidAccount } from "app/account/settings/getAccount"; | ||
import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard"; | ||
import { Changelog, type ChangelogItem } from "components/dashboard/Changelog"; | ||
import { Suspense } from "react"; | ||
import { TotalSponsoredChartCardUI } from "./_components/TotalSponsoredCard"; | ||
|
||
// revalidate every 5 minutes | ||
export const revalidate = 300; | ||
|
||
type SearchParams = { | ||
usersChart?: string; | ||
from?: string; | ||
to?: string; | ||
type?: string; | ||
interval?: string; | ||
}; | ||
import { Changelog } from "components/dashboard/Changelog"; | ||
import { redirect } from "next/navigation"; | ||
import { TeamProjectsPage } from "./~/projects/TeamProjectsPage"; | ||
|
||
export default async function TeamOverviewPage(props: { | ||
export default async function Page(props: { | ||
params: Promise<{ team_slug: string }>; | ||
searchParams: Promise<SearchParams>; | ||
}) { | ||
const changelog = await getChangelog(); | ||
const [params, searchParams] = await Promise.all([ | ||
props.params, | ||
props.searchParams, | ||
]); | ||
|
||
const account = await getValidAccount(`/team/${params.team_slug}`); | ||
const params = await props.params; | ||
const team = await getTeamBySlug(params.team_slug); | ||
|
||
if (!team) { | ||
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 projects = await getProjects(params.team_slug); | ||
|
||
return ( | ||
<div className="flex grow flex-col"> | ||
<div className="border-b"> | ||
<AnalyticsHeader | ||
title="Team Overview" | ||
interval={interval} | ||
range={range} | ||
/> | ||
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row"> | ||
<div className="flex grow flex-col"> | ||
<h1 className="mb-4 font-semibold text-xl tracking-tight">Projects</h1> | ||
<TeamProjectsPage projects={projects} team={team} /> | ||
</div> | ||
<div className="flex grow flex-col justify-between gap-10 md:container md:pt-8 md:pb-16 xl:flex-row"> | ||
<Suspense fallback={<GenericLoadingPage />}> | ||
<OverviewPageContent | ||
account={account} | ||
range={range} | ||
interval={interval} | ||
searchParams={searchParams} | ||
/> | ||
</Suspense> | ||
<div className="shrink-0 max-md:container max-xl:hidden lg:w-[320px]"> | ||
<h2 className="mb-4 font-semibold text-lg tracking-tight"> | ||
Latest changes | ||
</h2> | ||
<Changelog changelog={changelog} /> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
async function OverviewPageContent(props: { | ||
account: Account; | ||
range: Range; | ||
interval: "day" | "week"; | ||
searchParams: SearchParams; | ||
}) { | ||
const { account, range, interval, searchParams } = props; | ||
|
||
const [ | ||
walletConnections, | ||
walletUserStatsTimeSeries, | ||
inAppWalletUsage, | ||
userOpUsageTimeSeries, | ||
userOpUsage, | ||
] = await Promise.all([ | ||
// Aggregated wallet connections | ||
getWalletConnections({ | ||
accountId: account.id, | ||
from: range.from, | ||
to: range.to, | ||
period: "all", | ||
}), | ||
// Time series data for wallet users | ||
getWalletUsers({ | ||
accountId: account.id, | ||
from: range.from, | ||
to: range.to, | ||
period: interval, | ||
}), | ||
// In-app wallet usage | ||
getInAppWalletUsage({ | ||
accountId: account.id, | ||
from: range.from, | ||
to: range.to, | ||
period: "all", | ||
}), | ||
// User operations usage | ||
getUserOpUsage({ | ||
accountId: account.id, | ||
from: range.from, | ||
to: range.to, | ||
period: interval, | ||
}), | ||
getUserOpUsage({ | ||
accountId: account.id, | ||
from: range.from, | ||
to: range.to, | ||
period: "all", | ||
}), | ||
]); | ||
|
||
const isEmpty = | ||
!walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) && | ||
walletConnections.length === 0 && | ||
inAppWalletUsage.length === 0 && | ||
userOpUsage.length === 0; | ||
|
||
if (isEmpty) { | ||
return <EmptyState />; | ||
} | ||
|
||
return ( | ||
<div className="flex grow flex-col gap-6"> | ||
{walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) ? ( | ||
<div className=""> | ||
<UsersChartCard | ||
userStats={walletUserStatsTimeSeries} | ||
searchParams={searchParams} | ||
/> | ||
</div> | ||
) : ( | ||
<EmptyStateCard | ||
metric="Connect" | ||
link="https://portal.thirdweb.com/connect/quickstart" | ||
/> | ||
)} | ||
<div className="grid gap-6 max-md:px-6 md:grid-cols-2"> | ||
{walletConnections.length > 0 ? ( | ||
<WalletDistributionCard data={walletConnections} /> | ||
) : ( | ||
<EmptyStateCard | ||
metric="Connect" | ||
link="https://portal.thirdweb.com/connect/quickstart" | ||
/> | ||
)} | ||
{inAppWalletUsage.length > 0 ? ( | ||
<AuthMethodDistributionCard data={inAppWalletUsage} /> | ||
) : ( | ||
<EmptyStateCard | ||
metric="In-App Wallets" | ||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet" | ||
/> | ||
)} | ||
<div className="shrink-0 lg:w-[320px]"> | ||
<h2 className="mb-4 font-semibold text-xl tracking-tight"> | ||
Latest changes | ||
</h2> | ||
<Changelog /> | ||
</div> | ||
{userOpUsage.length > 0 ? ( | ||
<TotalSponsoredChartCardUI | ||
searchParams={searchParams} | ||
data={userOpUsageTimeSeries} | ||
aggregatedData={userOpUsage} | ||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0" | ||
/> | ||
) : ( | ||
<EmptyStateCard | ||
metric="Sponsored Transactions" | ||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started" | ||
/> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
async function getChangelog() { | ||
const res = await fetch( | ||
"https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=title,url,published_at&filter=tag:changelog&visibility:public&limit=5", | ||
); | ||
const json = await res.json(); | ||
return json.posts as ChangelogItem[]; | ||
} | ||
|
||
type UserMetrics = { | ||
totalUsers: number; | ||
activeUsers: number; | ||
newUsers: number; | ||
returningUsers: number; | ||
}; | ||
|
||
type TimeSeriesMetrics = UserMetrics & { | ||
date: string; | ||
}; | ||
|
||
function processTimeSeriesData( | ||
userStats: WalletUserStats[], | ||
): TimeSeriesMetrics[] { | ||
const metrics: TimeSeriesMetrics[] = []; | ||
|
||
let cumulativeUsers = 0; | ||
for (const stat of userStats) { | ||
cumulativeUsers += stat.newUsers ?? 0; | ||
metrics.push({ | ||
date: stat.date, | ||
activeUsers: stat.totalUsers ?? 0, | ||
returningUsers: stat.returningUsers ?? 0, | ||
newUsers: stat.newUsers ?? 0, | ||
totalUsers: cumulativeUsers, | ||
}); | ||
} | ||
|
||
return metrics; | ||
} | ||
|
||
function UsersChartCard({ | ||
userStats, | ||
searchParams, | ||
}: { | ||
userStats: WalletUserStats[]; | ||
searchParams?: { [key: string]: string | string[] | undefined }; | ||
}) { | ||
const timeSeriesData = processTimeSeriesData(userStats); | ||
|
||
const chartConfig = { | ||
activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, | ||
totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" }, | ||
newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, | ||
returningUsers: { | ||
label: "Returning Users", | ||
color: "hsl(var(--chart-4))", | ||
}, | ||
} as const; | ||
|
||
return ( | ||
<CombinedBarChartCard | ||
className="max-md:rounded-none max-md:border-r-0 max-md:border-l-0" | ||
title="Users" | ||
chartConfig={chartConfig} | ||
activeChart={ | ||
(searchParams?.usersChart as keyof UserMetrics) ?? "activeUsers" | ||
} | ||
data={timeSeriesData} | ||
aggregateFn={(_data, key) => | ||
timeSeriesData[timeSeriesData.length - 2]?.[key] | ||
} | ||
// 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 | ||
} | ||
queryKey="usersChart" | ||
existingQueryParams={searchParams} | ||
/> | ||
); | ||
} | ||
|
||
async function WalletDistributionCard({ data }: { data: WalletStats[] }) { | ||
const formattedData = await Promise.all( | ||
data | ||
.filter((w) => w.walletType !== "smart" && w.walletType !== "smartWallet") | ||
.map(async (w) => { | ||
const wallet = await getWalletInfo(w.walletType as WalletId).catch( | ||
() => ({ name: w.walletType }), | ||
); | ||
return { | ||
walletType: w.walletType, | ||
uniqueWalletsConnected: w.uniqueWalletsConnected, | ||
totalConnections: w.totalConnections, | ||
walletName: wallet.name, | ||
}; | ||
}), | ||
); | ||
|
||
return ( | ||
<PieChartCard | ||
title="Wallets Connected" | ||
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => { | ||
return { | ||
value: uniqueWalletsConnected, | ||
label: walletName, | ||
}; | ||
})} | ||
/> | ||
); | ||
} | ||
|
||
function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) { | ||
return ( | ||
<PieChartCard | ||
title="Social Authentication" | ||
data={data.map(({ authenticationMethod, uniqueWalletsConnected }) => ({ | ||
value: uniqueWalletsConnected, | ||
label: authenticationMethod, | ||
}))} | ||
/> | ||
); | ||
} |
Oops, something went wrong.