Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3e938b06 | ||
|
|
3ebca18f9e | ||
|
|
4c5696e414 | ||
|
|
9e43da4ec0 | ||
|
|
22cea5bb8f | ||
|
|
d0e4db26ce | ||
|
|
1056696696 | ||
|
|
48297993d8 | ||
|
|
cd9e42911e | ||
|
|
41e2ccb67d | ||
|
|
68a1dcac26 | ||
|
|
98d8f292db | ||
|
|
4f2dbccba4 | ||
|
|
908142f8fa | ||
|
|
4d065c3774 | ||
|
|
426229050c | ||
|
|
0fd55a2a13 | ||
|
|
59ad7e8b09 | ||
|
|
fda2284036 | ||
|
|
629efc13b6 | ||
|
|
512f9dd3c9 | ||
|
|
919230f0c8 | ||
|
|
3ac8cba2e8 | ||
|
|
35aaf24e8a | ||
|
|
ad2d1757e2 | ||
|
|
52aba0ffb8 | ||
|
|
9809d5cf66 | ||
|
|
8b10674481 |
@@ -1,19 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
|
||||
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
|
||||
import { normalizeTime } from "@/lib/time";
|
||||
import type {
|
||||
AIUsageStats,
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
EngineCloudStats,
|
||||
InAppWalletStats,
|
||||
TransactionStats,
|
||||
UniversalBridgeStats,
|
||||
UniversalBridgeWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletStatsWithName,
|
||||
WebhookLatencyStats,
|
||||
WebhookSummaryStats,
|
||||
X402QueryParams,
|
||||
@@ -148,13 +149,7 @@ const cached_getWalletConnections = unstable_cache(
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return (json.data as WalletStats[]).filter(
|
||||
(w) =>
|
||||
w.walletType !== "smart" &&
|
||||
w.walletType !== "smartWallet" &&
|
||||
w.walletType !== "inApp" &&
|
||||
w.walletType !== "inAppWallet",
|
||||
);
|
||||
return json.data as WalletStats[];
|
||||
},
|
||||
["getWalletConnections"],
|
||||
{
|
||||
@@ -162,11 +157,116 @@ const cached_getWalletConnections = unstable_cache(
|
||||
},
|
||||
);
|
||||
|
||||
export function getWalletConnections(
|
||||
const cache_getEOAWalletConnections = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> => {
|
||||
const result = await cached_getWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
const filteredResult = result.filter(
|
||||
(w) =>
|
||||
w.walletType !== "smart" &&
|
||||
w.walletType !== "smartWallet" &&
|
||||
w.walletType !== "inApp" &&
|
||||
w.walletType !== "inAppWallet",
|
||||
);
|
||||
|
||||
const modifiedResult = await Promise.all(
|
||||
filteredResult.map(async (w) => {
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return {
|
||||
...w,
|
||||
walletName: w.walletType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...w,
|
||||
walletName: wallet.name,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return modifiedResult;
|
||||
},
|
||||
["getEOAWalletConnections"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function getEOAWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
) {
|
||||
return cached_getWalletConnections(normalizedParams(params), authToken);
|
||||
return cache_getEOAWalletConnections(normalizedParams(params), authToken);
|
||||
}
|
||||
|
||||
const cache_getEOAAndInAppWalletConnections = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> => {
|
||||
const result = await cached_getWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
|
||||
const modifiedResult = await Promise.all(
|
||||
result.map(async (w) => {
|
||||
if (
|
||||
w.walletType === "inApp" ||
|
||||
w.walletType === "inAppWallet" ||
|
||||
w.walletType === "smart" ||
|
||||
w.walletType === "smartWallet"
|
||||
) {
|
||||
return {
|
||||
...w,
|
||||
walletName: "User wallet",
|
||||
};
|
||||
}
|
||||
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
if (!wallet) {
|
||||
return {
|
||||
...w,
|
||||
walletName: w.walletType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...w,
|
||||
walletName: wallet.name,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return modifiedResult;
|
||||
},
|
||||
["getEOAAndInAppWalletConnections"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export async function getEOAAndInAppWalletConnections(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<WalletStatsWithName[]> {
|
||||
return cache_getEOAAndInAppWalletConnections(
|
||||
normalizedParams(params),
|
||||
authToken,
|
||||
);
|
||||
}
|
||||
|
||||
const cached_getInAppWalletUsage = unstable_cache(
|
||||
@@ -691,41 +791,6 @@ export function getUniversalBridgeWalletUsage(
|
||||
);
|
||||
}
|
||||
|
||||
const cached_getEngineCloudMethodUsage = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
): Promise<EngineCloudStats[]> => {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics({
|
||||
authToken,
|
||||
url: `v2/engine-cloud/requests?${searchParams.toString()}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
},
|
||||
});
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch Engine Cloud method usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as EngineCloudStats[];
|
||||
},
|
||||
["getEngineCloudMethodUsage"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
|
||||
export function getEngineCloudMethodUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
authToken: string,
|
||||
) {
|
||||
return cached_getEngineCloudMethodUsage(normalizedParams(params), authToken);
|
||||
}
|
||||
|
||||
const _cached_getWebhookSummary = unstable_cache(
|
||||
async (
|
||||
params: AnalyticsQueryParams & { webhookId: string },
|
||||
|
||||
@@ -8,7 +8,10 @@ import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
export type Team = TeamResponse & {
|
||||
stripeCustomerId: string | null;
|
||||
isLegacyPlan: boolean;
|
||||
};
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useId, useMemo } from "react";
|
||||
import { Area, AreaChart, Bar, BarChart } from "recharts";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
@@ -33,8 +34,15 @@ export function EmptyChartState({
|
||||
|
||||
return (
|
||||
<div className="relative z-0 h-full w-full">
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center font-medium text-base text-muted-foreground">
|
||||
{children ?? "No data available"}
|
||||
<div className="absolute inset-0 z-[1] flex flex-col items-center justify-center text-base text-muted-foreground">
|
||||
{children ?? (
|
||||
<div className="flex items-center gap-3 flex-col">
|
||||
<div className="rounded-full border p-2 bg-background">
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<p className="text-base"> No data available </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkeletonBarChart data={barChartData} type={type} />
|
||||
</div>
|
||||
@@ -61,7 +69,7 @@ function SkeletonBarChart(props: {
|
||||
const fillAreaSkeletonId = useId();
|
||||
return (
|
||||
<ChartContainer
|
||||
className="pointer-events-none h-full w-full blur-[5px]"
|
||||
className="pointer-events-none h-full w-full blur-[5px] aspect-auto"
|
||||
config={skeletonChartConfig}
|
||||
>
|
||||
{props.type === "bar" ? (
|
||||
|
||||
@@ -34,7 +34,8 @@ export function ExportToCSVButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("flex items-center gap-2 border text-xs", props.className)}
|
||||
className={cn("gap-2 rounded-full", props.className)}
|
||||
size="sm"
|
||||
disabled={props.disabled || exportMutation.isPending}
|
||||
onClick={async () => {
|
||||
exportMutation.mutate();
|
||||
@@ -42,16 +43,11 @@ export function ExportToCSVButton(props: {
|
||||
variant="outline"
|
||||
>
|
||||
{exportMutation.isPending ? (
|
||||
<>
|
||||
Downloading
|
||||
<Spinner className="size-3" />
|
||||
</>
|
||||
<Spinner className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon className="size-3" />
|
||||
Export as CSV
|
||||
</>
|
||||
<DownloadIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ function Variants() {
|
||||
key={currentPlan}
|
||||
requiredPlan={requiredPlan}
|
||||
teamSlug="foo"
|
||||
isLegacyPlan={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,7 @@ type GatedSwitchProps = {
|
||||
trackingLabel?: string;
|
||||
currentPlan: Team["billingPlan"];
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
teamSlug: string;
|
||||
switchProps?: SwitchProps;
|
||||
};
|
||||
@@ -33,7 +34,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
<div className="w-full min-w-[280px]">
|
||||
<h3 className="font-medium text-base">
|
||||
<span className="capitalize">
|
||||
{getTeamPlanBadgeLabel(props.requiredPlan)}+
|
||||
{getTeamPlanBadgeLabel(props.requiredPlan, false)}+
|
||||
</span>{" "}
|
||||
plan required
|
||||
</h3>
|
||||
@@ -48,7 +49,8 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan)} plan
|
||||
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan, false)}{" "}
|
||||
plan
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -61,6 +63,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
{isUpgradeRequired && (
|
||||
<TeamPlanBadge
|
||||
plan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
postfix="+"
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
|
||||
@@ -23,28 +23,42 @@ const teamPlanToBadgeVariant: Record<
|
||||
starter: "warning",
|
||||
};
|
||||
|
||||
export function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) {
|
||||
export function getTeamPlanBadgeLabel(
|
||||
plan: Team["billingPlan"],
|
||||
isLegacyPlan: boolean,
|
||||
) {
|
||||
if (plan === "growth_legacy") {
|
||||
return "Growth - Legacy";
|
||||
}
|
||||
|
||||
if (isLegacyPlan) {
|
||||
return `${plan} - Legacy`;
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function TeamPlanBadge(props: {
|
||||
teamSlug: string;
|
||||
plan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
className?: string;
|
||||
postfix?: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
|
||||
function handleNavigateToBilling(e: React.MouseEvent | React.KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (props.isLegacyPlan) {
|
||||
router.push(`/team/${props.teamSlug}/~/billing`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.plan !== "free") {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
|
||||
}
|
||||
|
||||
@@ -57,11 +71,13 @@ export function TeamPlanBadge(props: {
|
||||
handleNavigateToBilling(e);
|
||||
}
|
||||
}}
|
||||
role={props.plan === "free" ? "button" : undefined}
|
||||
tabIndex={props.plan === "free" ? 0 : undefined}
|
||||
variant={teamPlanToBadgeVariant[props.plan]}
|
||||
role={props.plan === "free" || props.isLegacyPlan ? "button" : undefined}
|
||||
tabIndex={props.plan === "free" || props.isLegacyPlan ? 0 : undefined}
|
||||
variant={
|
||||
props.isLegacyPlan ? "warning" : teamPlanToBadgeVariant[props.plan]
|
||||
}
|
||||
>
|
||||
{`${getTeamPlanBadgeLabel(props.plan)}${props.postfix || ""}`}
|
||||
{`${getTeamPlanBadgeLabel(props.plan, props.isLegacyPlan)}${props.postfix || ""}`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
|
||||
yAxis?: boolean;
|
||||
xAxis?: {
|
||||
sameDay?: boolean;
|
||||
showHour?: boolean;
|
||||
};
|
||||
|
||||
variant?: "stacked" | "individual";
|
||||
@@ -112,7 +112,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
tickFormatter={(value) =>
|
||||
format(
|
||||
new Date(value),
|
||||
props.xAxis?.sameDay ? "MMM dd, HH:mm" : "MMM dd",
|
||||
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
tickLine={false}
|
||||
|
||||
+68
-21
@@ -1,12 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import type { InAppWalletAuth } from "thirdweb/wallets";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import type { InAppWalletStats } from "@/types/analytics";
|
||||
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
|
||||
import { AutoMergeBarChart, type StatData } from "./automerge-barchart";
|
||||
|
||||
const meta = {
|
||||
component: Component,
|
||||
title: "Charts/InAppWallets",
|
||||
title: "Charts/AutoMergeBarChart",
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
@@ -22,57 +21,106 @@ function Component() {
|
||||
"This is an example of a description about user wallet usage chart";
|
||||
return (
|
||||
<div className="container flex max-w-6xl flex-col gap-10 py-10">
|
||||
<BadgeContainer label="30 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<BadgeContainer label="10 days, view more variant">
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={"#"}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(30)}
|
||||
stats={createStatsStub(10)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="30 days">
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
stats={createStatsStub(30)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(60)}
|
||||
stats={createStatsStub(60)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="120 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(120)}
|
||||
stats={createStatsStub(120)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(10)}
|
||||
stats={createStatsStub(10)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Empty List">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={createInAppWalletStatsStub(0)}
|
||||
stats={createStatsStub(0)}
|
||||
isPending={false}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pending">
|
||||
<InAppWalletUsersChartCardUI
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
description={description}
|
||||
inAppWalletStats={[]}
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
title={title}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
exportButton={{
|
||||
fileName: "in-app-wallets",
|
||||
}}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
@@ -111,17 +159,16 @@ const pickRandomAuthMethod = () => {
|
||||
return capitalized;
|
||||
};
|
||||
|
||||
function createInAppWalletStatsStub(days: number): InAppWalletStats[] {
|
||||
const stubbedData: InAppWalletStats[] = [];
|
||||
function createStatsStub(days: number): StatData[] {
|
||||
const stubbedData: StatData[] = [];
|
||||
|
||||
let d = days;
|
||||
while (d !== 0) {
|
||||
const uniqueWallets = Math.floor(Math.random() * 100);
|
||||
stubbedData.push({
|
||||
authenticationMethod: pickRandomAuthMethod(),
|
||||
label: pickRandomAuthMethod(),
|
||||
date: new Date(2024, 11, d).toLocaleString(),
|
||||
uniqueWalletsConnected: uniqueWallets,
|
||||
newUsers: Math.floor(Math.random() * 100) + 1,
|
||||
count: uniqueWallets,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { TotalValueChartHeader } from "./chart-header";
|
||||
|
||||
export type StatData = {
|
||||
date: string;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
};
|
||||
|
||||
const defaultLabel = "Unknown";
|
||||
|
||||
// merges the multiple stats in single bar if they share the same date
|
||||
// shows the top `maxLabelsToShow` labels, merge the rest into "Others"
|
||||
|
||||
export function AutoMergeBarChart(props: {
|
||||
stats: StatData[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
emptyChartState: React.ReactElement | undefined;
|
||||
description: string | undefined;
|
||||
maxLabelsToShow: number;
|
||||
exportButton:
|
||||
| {
|
||||
fileName: string;
|
||||
}
|
||||
| undefined;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
const { stats, maxLabelsToShow } = props;
|
||||
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
const labelToCountMap: Map<string, number> = new Map();
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of stats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData && stat.count > 0) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[stat.label || defaultLabel]: stat.count,
|
||||
} as ChartData);
|
||||
} else if (chartData) {
|
||||
chartData[stat.label || defaultLabel] =
|
||||
(chartData[stat.label || defaultLabel] || 0) + stat.count;
|
||||
}
|
||||
|
||||
labelToCountMap.set(
|
||||
stat.label || defaultLabel,
|
||||
stat.count + (labelToCountMap.get(stat.label || defaultLabel) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const labelsSorted = Array.from(labelToCountMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const labelsToShow = labelsSorted.slice(0, maxLabelsToShow);
|
||||
const labelsToShowAsOther = labelsSorted.slice(maxLabelsToShow);
|
||||
|
||||
// replace chainIdsToTagAsOther chainId with "other"
|
||||
for (const data of _chartDataMap.values()) {
|
||||
for (const dataKey in data) {
|
||||
if (labelsToShowAsOther.includes(dataKey)) {
|
||||
data.others = (data.others || 0) + (data[dataKey] || 0);
|
||||
delete data[dataKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labelsToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
label: labelsToShow[i],
|
||||
};
|
||||
});
|
||||
|
||||
// Add Other
|
||||
if (labelsToShow.length >= maxLabelsToShow) {
|
||||
labelsToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
label: "Others",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chartConfig: _chartConfig,
|
||||
chartData: Array.from(_chartDataMap.values()).sort(
|
||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
),
|
||||
};
|
||||
}, [stats, maxLabelsToShow]);
|
||||
|
||||
const uniqueLabels = Object.keys(chartConfig);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return props.stats.reduce((acc, stat) => acc + stat.count, 0);
|
||||
}, [props.stats]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="h-[275px] w-full"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
props.viewMoreLink ? (
|
||||
<TotalValueChartHeader
|
||||
total={total}
|
||||
title={props.title}
|
||||
isPending={props.isPending}
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative px-6 pt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg tracking-tight leading-none">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.exportButton && props.stats.length > 0 && (
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
fileName={props.exportButton.fileName}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Time", ...uniqueLabels];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
new Date(time).toISOString(),
|
||||
...uniqueLabels.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={props.emptyChartState}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return format(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
variant="stacked"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,10 @@ type ThirdwebBarChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
hideLabel?: boolean;
|
||||
emptyChartState?: React.ReactElement;
|
||||
className?: string;
|
||||
xAxis?: {
|
||||
showHour?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
@@ -55,7 +59,7 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
props.variant || configKeys.length > 4 ? "stacked" : "grouped";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={cn("overflow-hidden", props.className)}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
@@ -83,7 +87,12 @@ export function ThirdwebBarChart<TConfig extends ChartConfig>(
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
tickFormatter={(value) => format(new Date(value), "MMM d")}
|
||||
tickFormatter={(value) =>
|
||||
format(
|
||||
new Date(value),
|
||||
props.xAxis?.showHour ? "MMM dd, HH:mm" : "MMM dd",
|
||||
)
|
||||
}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
|
||||
export function TotalValueChartHeader(props: {
|
||||
total: number;
|
||||
title: string;
|
||||
isPending: boolean;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1 p-6 pb-0 flex justify-between items-start gap-3">
|
||||
<div>
|
||||
<SkeletonContainer
|
||||
loadedData={!props.isPending ? props.total : undefined}
|
||||
skeletonData={100}
|
||||
render={(value) => {
|
||||
return (
|
||||
<p className="text-3xl font-semibold tracking-tight">
|
||||
{compactNumberFormatter.format(value)}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<h3 className="text-muted-foreground">{props.title}</h3>
|
||||
</div>
|
||||
|
||||
{props.viewMoreLink && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link href={props.viewMoreLink}>
|
||||
<span>View More</span>
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
@@ -22,6 +22,7 @@ interface UpsellWrapperProps {
|
||||
isLocked?: boolean;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
currentPlan?: Team["billingPlan"];
|
||||
isLegacyPlan?: boolean;
|
||||
featureName: string;
|
||||
featureDescription: string;
|
||||
benefits?: {
|
||||
@@ -41,6 +42,7 @@ export function UpsellWrapper({
|
||||
featureDescription,
|
||||
benefits = [],
|
||||
className,
|
||||
isLegacyPlan = false,
|
||||
}: UpsellWrapperProps) {
|
||||
if (!isLocked) {
|
||||
return <>{children}</>;
|
||||
@@ -66,6 +68,7 @@ export function UpsellWrapper({
|
||||
featureDescription={featureDescription}
|
||||
featureName={featureName}
|
||||
requiredPlan={requiredPlan}
|
||||
isLegacyPlan={isLegacyPlan}
|
||||
teamSlug={teamSlug}
|
||||
/>
|
||||
</div>
|
||||
@@ -79,6 +82,7 @@ export function UpsellContent(props: {
|
||||
featureDescription: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
currentPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
benefits?: {
|
||||
description: string;
|
||||
status: "available" | "soon";
|
||||
@@ -96,6 +100,7 @@ export function UpsellContent(props: {
|
||||
plan={props.requiredPlan}
|
||||
postfix=" Feature"
|
||||
teamSlug={props.teamSlug}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="font-bold text-2xl text-foreground md:text-3xl">
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}},
|
||||
);
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);
|
||||
|
||||
@@ -214,7 +214,7 @@ function App() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);`,
|
||||
dotnet: `using Thirdweb;
|
||||
@@ -272,7 +272,7 @@ export default function Component() {
|
||||
|
||||
// Get your contract
|
||||
var contract = await ThirdwebManager.Instance.GetContract(
|
||||
address: "{{contract_address}}",
|
||||
address: "{{contract_address}}",
|
||||
chainId: {{chainId}}
|
||||
);
|
||||
|
||||
@@ -1103,7 +1103,7 @@ function AccountFactorySection(props: {
|
||||
<AlertDescription>
|
||||
The recommended way to use account factories is to integrate the{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/transactions/sponsor"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
+74
-76
@@ -64,7 +64,7 @@ const truncateIdentifier = (value: string) => {
|
||||
return `${value.slice(0, 8)}...${value.slice(-4)}`;
|
||||
};
|
||||
|
||||
export function InAppWalletUsersPageContent(
|
||||
export function UserWalletsTable(
|
||||
props: {
|
||||
authToken: string;
|
||||
client: ThirdwebClient;
|
||||
@@ -288,83 +288,81 @@ export function InAppWalletUsersPageContent(
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
isLoading={isSearching}
|
||||
hasResults={hasSearchResults}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between p-4 lg:px-6 py-5 lg:items-center gap-5">
|
||||
<div>
|
||||
{hasSearchResults ? (
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="User wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={wallets.length === 0 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => p + 1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full bg-background border border-border">
|
||||
<UserIcon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
User Wallets
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View and manage your project's users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start lg:items-end gap-2.5 border-t lg:border-t-0 pt-5 lg:pt-0 border-dashed">
|
||||
<div className="w-full lg:w-auto lg:min-w-[320px]">
|
||||
<AdvancedSearchInput
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
isLoading={isSearching}
|
||||
hasResults={hasSearchResults}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2 bg-background rounded-full"
|
||||
disabled={wallets.length === 0 || isPending}
|
||||
onClick={downloadCSV}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
Download as .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{hasSearchResults ? (
|
||||
<SearchResults results={searchResults} client={props.client} />
|
||||
) : (
|
||||
<>
|
||||
<TWTable
|
||||
columns={columns}
|
||||
data={wallets}
|
||||
isFetched={walletsQuery.isFetched}
|
||||
isPending={walletsQuery.isPending}
|
||||
tableContainerClassName="rounded-none border-x-0 border-b-0"
|
||||
title="User wallets"
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-3 border-t bg-card p-6">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={activePage === 1 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
disabled={wallets.length === 0 || walletsQuery.isPending}
|
||||
onClick={() => setActivePage((p) => p + 1)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -30,6 +30,7 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
billingPlan: billingPlan,
|
||||
billingStatus: "validPayment",
|
||||
canCreatePublicChains: null,
|
||||
isLegacyPlan: false,
|
||||
capabilities: {
|
||||
bundler: {
|
||||
enabled: true,
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface WalletStats {
|
||||
walletType: string;
|
||||
}
|
||||
|
||||
export type WalletStatsWithName = WalletStats & {
|
||||
walletName: string;
|
||||
};
|
||||
|
||||
export interface InAppWalletStats {
|
||||
date: string;
|
||||
authenticationMethod: string;
|
||||
@@ -35,13 +39,6 @@ export interface TransactionStats {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EngineCloudStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
pathname: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface UniversalBridgeStats {
|
||||
date: string;
|
||||
chainId: number;
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export function AccountsPage(props: {
|
||||
View list of smart accounts that have been created for this
|
||||
contract.{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/transactions/sponsor"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
|
||||
import { SearchInput } from "../components/SearchInput";
|
||||
|
||||
export function AccountTeamsUI(props: {
|
||||
@@ -117,8 +116,6 @@ function TeamRow(props: {
|
||||
role: TeamAccountRole;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const plan = getValidTeamPlan(props.team);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* start */}
|
||||
@@ -133,7 +130,11 @@ function TeamRow(props: {
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-semibold text-sm">{props.team.name}</p>
|
||||
<TeamPlanBadge plan={plan} teamSlug={props.team.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={props.team.billingPlan}
|
||||
teamSlug={props.team.slug}
|
||||
isLegacyPlan={props.team.isLegacyPlan}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm capitalize">
|
||||
{props.role.toLowerCase()}
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
} from "responsive-rsc";
|
||||
import { defineChain } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
|
||||
import {
|
||||
getClientTransactions,
|
||||
getEOAWalletConnections,
|
||||
getInAppWalletUsage,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
} from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
@@ -26,7 +25,7 @@ import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletStatsWithName,
|
||||
} from "@/types/analytics";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
|
||||
@@ -240,7 +239,7 @@ async function AsyncWalletDistributionCard(props: {
|
||||
range: Range;
|
||||
authToken: string;
|
||||
}) {
|
||||
const walletConnections = await getWalletConnections(
|
||||
const walletConnections = await getEOAWalletConnections(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
@@ -373,29 +372,19 @@ async function AsyncTotalSponsoredCard(props: {
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
metric="Account Abstraction"
|
||||
metric="Gas Sponsorship"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
|
||||
const formattedData = await Promise.all(
|
||||
data.map(async (w) => {
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => ({ name: w.walletType }),
|
||||
);
|
||||
return {
|
||||
totalConnections: w.totalConnections,
|
||||
uniqueWalletsConnected: w.uniqueWalletsConnected,
|
||||
walletName: wallet.name,
|
||||
walletType: w.walletType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
async function WalletDistributionCard({
|
||||
data,
|
||||
}: {
|
||||
data: WalletStatsWithName[];
|
||||
}) {
|
||||
return (
|
||||
<PieChartCard
|
||||
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
|
||||
data={data.map(({ walletName, uniqueWalletsConnected }) => {
|
||||
return {
|
||||
label: walletName,
|
||||
value: uniqueWalletsConnected,
|
||||
|
||||
+9
-6
@@ -73,21 +73,24 @@ export function PlanInfoCardUI(props: {
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">
|
||||
{validPlan === "growth_legacy" ? "Growth" : validPlan} Plan
|
||||
</h3>
|
||||
{validPlan.includes("legacy") && (
|
||||
{props.team.isLegacyPlan && (
|
||||
<Badge variant="warning">Legacy</Badge>
|
||||
)}
|
||||
{trialEndsInFuture && <Badge variant="default">Trial</Badge>}
|
||||
</div>
|
||||
|
||||
{validPlan.includes("legacy") && (
|
||||
{props.team.isLegacyPlan && (
|
||||
<p className="text-sm text-yellow-600">
|
||||
You are on the legacy plan. You may save by upgrading to new
|
||||
plan.{" "}
|
||||
Legacy plans will be upgraded to new plans automatically on
|
||||
January 1st, 2026.
|
||||
<br />
|
||||
<UnderlineLink
|
||||
className="decoration-yellow-600/50"
|
||||
href="/pricing"
|
||||
href="https://blog.thirdweb.com/retiring-legacy-pricing-plans-on-january-1st-2026"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
Learn more about changes to legacy plans
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { fetchEcosystem } from "@/api/team/ecosystems";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
|
||||
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function EcosystemUsersPage(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<InAppWalletUsersPageContent
|
||||
<UserWalletsTable
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
ecosystemSlug={ecosystem.slug}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col grow justify-center items-center">
|
||||
<UpsellContent
|
||||
currentPlan={team.billingPlan}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
featureDescription="Create support cases for your projects"
|
||||
featureName="Support Center"
|
||||
requiredPlan="starter"
|
||||
|
||||
+50
-8
@@ -1,20 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
import { getUserOpUsage } from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProjects } from "@/api/project/projects";
|
||||
import { getTeamBySlug } from "@/api/team/get-team";
|
||||
import { getTeamSubscriptions } from "@/api/team/team-subscription";
|
||||
import { SponsoredTransactionsTable } from "@/components/sponsored-transactions-table/SponsoredTransactionsTable";
|
||||
import {
|
||||
getLastNDaysRange,
|
||||
type Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { AccountAbstractionSummary } from "../../../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { AccountAbstractionAnalytics } from "../../../../[project_slug]/(sidebar)/account-abstraction/aa-analytics";
|
||||
import { searchParamLoader } from "../../../../[project_slug]/(sidebar)/account-abstraction/search-params";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{
|
||||
team_slug: string;
|
||||
}>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
const [params, parsedSearchParams, authToken] = await Promise.all([
|
||||
props.params,
|
||||
searchParamLoader(props.searchParams),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
@@ -22,6 +31,8 @@ export default async function Page(props: {
|
||||
loginRedirect(`/team/${params.team_slug}/~/usage/account-abstraction`);
|
||||
}
|
||||
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
}
|
||||
@@ -44,20 +55,51 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const interval = parsedSearchParams.interval ?? "week";
|
||||
const rangeType = parsedSearchParams.range || "last-120";
|
||||
|
||||
const range: Range = {
|
||||
from:
|
||||
rangeType === "custom"
|
||||
? parsedSearchParams.from
|
||||
: getLastNDaysRange(rangeType).from,
|
||||
to:
|
||||
rangeType === "custom"
|
||||
? parsedSearchParams.to
|
||||
: getLastNDaysRange(rangeType).to,
|
||||
type: rangeType,
|
||||
};
|
||||
|
||||
const userOpStats = await getUserOpUsage(
|
||||
{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
teamId: team.id,
|
||||
to: range.to,
|
||||
},
|
||||
authToken,
|
||||
);
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-14">
|
||||
<SponsoredTransactionsTable
|
||||
<div className="flex flex-col gap-10">
|
||||
<AccountAbstractionSummary
|
||||
authToken={authToken}
|
||||
teamId={team.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
/>
|
||||
|
||||
<AccountAbstractionAnalytics
|
||||
client={client}
|
||||
from={usageSubscription.currentPeriodStart}
|
||||
projects={projects}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
to={usageSubscription.currentPeriodEnd}
|
||||
userOpStats={userOpStats}
|
||||
variant="team"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function Layout(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border pt-10 pb-4">
|
||||
<div className="border-border pt-10 pb-6">
|
||||
<div className="container max-w-7xl flex flex-col gap-4 lg:flex-row lg:justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Usage</h1>
|
||||
<div className="items-center gap-3 hidden lg:flex">
|
||||
@@ -47,7 +47,7 @@ export default async function Layout(props: {
|
||||
path: `${usagePath}/storage`,
|
||||
},
|
||||
{
|
||||
name: "Account Abstraction",
|
||||
name: "Gas Sponsorship",
|
||||
path: `${usagePath}/account-abstraction`,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -43,7 +43,8 @@ export default async function Page(props: {
|
||||
<div className="grow flex flex-col justify-center items-center">
|
||||
<UpsellContent
|
||||
currentPlan={team.billingPlan}
|
||||
featureDescription="View RPC, Wallet, Storage, Account Abstraction, Engine Cloud, Webhooks usage and more"
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
featureDescription="View RPC, Wallet, Storage, Gas Sponsorship, Engine Cloud, Webhooks usage and more"
|
||||
featureName="Usage"
|
||||
requiredPlan="starter"
|
||||
teamSlug={params.team_slug}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export function CountGraph(props: {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
}}
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
showHour: true,
|
||||
}}
|
||||
yAxis
|
||||
/>
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export function RateGraph(props: {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
}}
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
showHour: true,
|
||||
}}
|
||||
yAxis
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,6 @@ export default async function RPCUsage(props: {
|
||||
redirect("/team");
|
||||
}
|
||||
|
||||
const currentPlan = team.billingPlan;
|
||||
const currentRateLimit = team.capabilities.rpc.rateLimit;
|
||||
|
||||
const apiData = await getLast24HoursRPCUsage({
|
||||
@@ -102,7 +101,11 @@ export default async function RPCUsage(props: {
|
||||
<div className="font-bold text-2xl capitalize">
|
||||
{currentRateLimit.toLocaleString()} RPS
|
||||
</div>
|
||||
<TeamPlanBadge plan={currentPlan} teamSlug={team.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={team.billingPlan}
|
||||
teamSlug={team.slug}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+10
-2
@@ -34,13 +34,17 @@ function AccountAbstractionSummaryInner(props: {
|
||||
|
||||
async function AsyncAccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
const aggregateUserOpStats = await getAggregateUserOpUsage(
|
||||
{
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
from: props.from,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
);
|
||||
@@ -55,8 +59,10 @@ async function AsyncAccountAbstractionSummary(props: {
|
||||
|
||||
export function AccountAbstractionSummary(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -71,6 +77,8 @@ export function AccountAbstractionSummary(props: {
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
authToken={props.authToken}
|
||||
from={props.from}
|
||||
to={props.to}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+34
-12
@@ -15,13 +15,30 @@ import { GasMetricsChartCard } from "./AccountAbstractionAnalytics/GasMetricsCha
|
||||
import { TotalSponsoredChartCard } from "./AccountAbstractionAnalytics/TotalSponsoredChartCard";
|
||||
import { searchParams } from "./search-params";
|
||||
|
||||
export function AccountAbstractionAnalytics(props: {
|
||||
type AccountAbstractionAnalyticsProps = {
|
||||
userOpStats: UserOpStats[];
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
projectId: string;
|
||||
}) {
|
||||
} & (
|
||||
| {
|
||||
variant: "project";
|
||||
projectId: string;
|
||||
}
|
||||
| {
|
||||
variant: "team";
|
||||
projects: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
slug: string;
|
||||
}[];
|
||||
}
|
||||
);
|
||||
|
||||
export function AccountAbstractionAnalytics(
|
||||
props: AccountAbstractionAnalyticsProps,
|
||||
) {
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
|
||||
const [rangeType, setRangeType] = useQueryState(
|
||||
@@ -62,6 +79,8 @@ export function AccountAbstractionAnalytics(props: {
|
||||
to: rangeType === "custom" ? to : getLastNDaysRange(rangeType).to,
|
||||
type: rangeType,
|
||||
};
|
||||
const resolvedFrom = range.from;
|
||||
const resolvedTo = range.to;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -105,15 +124,18 @@ export function AccountAbstractionAnalytics(props: {
|
||||
userOpStats={props.userOpStats}
|
||||
/>
|
||||
|
||||
<SponsoredTransactionsTable
|
||||
client={props.client}
|
||||
from={from.toISOString()}
|
||||
projectId={props.projectId}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
to={to.toISOString()}
|
||||
variant="project"
|
||||
/>
|
||||
{resolvedFrom && resolvedTo && (
|
||||
<SponsoredTransactionsTable
|
||||
client={props.client}
|
||||
from={resolvedFrom.toISOString()}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
to={resolvedTo.toISOString()}
|
||||
{...(props.variant === "project"
|
||||
? { projectId: props.projectId, variant: "project" as const }
|
||||
: { projects: props.projects, variant: "team" as const })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
@@ -44,6 +44,7 @@ type AccountAbstractionSettingsPageProps = {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
validTeamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
@@ -565,6 +566,7 @@ export function AccountAbstractionSettingsPage(
|
||||
<GatedSwitch
|
||||
currentPlan={props.validTeamPlan}
|
||||
requiredPlan="starter"
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: field.value.enabled,
|
||||
id: "server-verifier-switch",
|
||||
|
||||
+53
-34
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { DocLink } from "@/components/blocks/DocLink";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import type { AIUsageStats } from "@/types/analytics";
|
||||
|
||||
@@ -17,7 +19,7 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
aiUsageStats: AIUsageStats[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
const { aiUsageStats } = props;
|
||||
|
||||
@@ -52,33 +54,43 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
chartData.length === 0 ||
|
||||
chartData.every((data) => data.tokens === 0);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return chartData.reduce((acc, curr) => acc + curr.tokens, 0);
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
chartClassName="h-[275px] w-full aspect-auto"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
disabled={disableActions}
|
||||
fileName="AI Token Usage"
|
||||
getData={async () => {
|
||||
const header = ["Date", "Tokens"];
|
||||
const rows = chartData.map((data) => [
|
||||
data.time,
|
||||
data.tokens.toString(),
|
||||
]);
|
||||
return { header, rows };
|
||||
}}
|
||||
props.viewMoreLink ? (
|
||||
<TotalValueChartHeader
|
||||
isPending={props.isPending}
|
||||
total={total}
|
||||
title={props.title}
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
disabled={disableActions}
|
||||
fileName="AI Token Usage"
|
||||
getData={async () => {
|
||||
const header = ["Date", "Tokens"];
|
||||
const rows = chartData.map((data) => [
|
||||
data.time,
|
||||
data.tokens.toString(),
|
||||
]);
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<AiTokenUsageEmptyChartState />}
|
||||
@@ -100,17 +112,24 @@ export function AiTokenUsageChartCardUI(props: {
|
||||
function AiTokenUsageEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center text-lg">
|
||||
<h3 className="text-base font-semibold text-foreground mb-1 text-center">
|
||||
No data available
|
||||
</h3>
|
||||
<p className="mb-6 text-center text-sm">
|
||||
Integrate thirdweb AI to interact with any EVM chain using natural
|
||||
language
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
icon={BotIcon}
|
||||
label="Get Started"
|
||||
link="https://portal.thirdweb.com/ai/chat"
|
||||
/>
|
||||
</div>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="rounded-full gap-2"
|
||||
>
|
||||
<Link href="https://portal.thirdweb.com/ai/chat" target="_blank">
|
||||
Get Started
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,9 +18,9 @@ function AiAnalyticsUI({ stats, isPending }: AiAnalyticsProps) {
|
||||
return (
|
||||
<AiTokenUsageChartCardUI
|
||||
title="Token Usage"
|
||||
description="The total number of tokens used for AI interactions on your project."
|
||||
aiUsageStats={stats || []}
|
||||
isPending={isPending}
|
||||
viewMoreLink={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { getSessionById, getSessions } from "../../api/session";
|
||||
@@ -39,7 +39,7 @@ export default async function Page(props: {
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
redirect(`/team/${params.team_slug}/${params.project_slug}/ai`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -410,7 +410,7 @@ export function ChatBar(props: {
|
||||
if (message.trim() === "" && images.length === 0) return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="primary"
|
||||
variant="default"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
-11
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
import {
|
||||
BotIcon,
|
||||
ChartLineIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FileCode2Icon,
|
||||
@@ -83,16 +82,6 @@ export function ChatSidebar(props: {
|
||||
<div className="h-5" />
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-dashed px-4 py-4">
|
||||
<Link
|
||||
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
|
||||
href={`/team/${props.team_slug}/${props.project.slug}/ai/analytics`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChartLineIcon className="size-3.5 text-muted-foreground" />
|
||||
Analytics
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
<Link
|
||||
className="flex items-center gap-1 rounded-full text-foreground text-sm hover:underline justify-between"
|
||||
href={"https://portal.thirdweb.com/ai/chat"}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import type { EngineCloudStats } from "@/types/analytics";
|
||||
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
|
||||
|
||||
const meta = {
|
||||
component: Component,
|
||||
title: "Analytics/EngineCloudBarChartCard",
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Variants: Story = {
|
||||
parameters: {
|
||||
viewport: { defaultViewport: "desktop" },
|
||||
},
|
||||
};
|
||||
|
||||
const generateTimeSeriesData = (days: number, pathnames: string[]) => {
|
||||
const data: EngineCloudStats[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
for (const pathname of pathnames) {
|
||||
data.push({
|
||||
chainId: "84532",
|
||||
// biome-ignore lint/style/noNonNullAssertion: we know this is not null
|
||||
date: dateStr!,
|
||||
pathname,
|
||||
totalRequests: Math.floor(Math.random() * 1000) + 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container max-w-6xl space-y-10 py-10">
|
||||
<BadgeContainer label="Multiple Pathnames">
|
||||
<EngineCloudBarChartCardUI
|
||||
rawData={generateTimeSeriesData(30, [
|
||||
"/v1/write/transaction",
|
||||
"/v1/sign/transaction",
|
||||
"/v1/sign/message",
|
||||
])}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
BarChart as RechartsBarChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import type { EngineCloudStats } from "@/types/analytics";
|
||||
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
|
||||
|
||||
export function EngineCloudBarChartCardUI({
|
||||
rawData,
|
||||
}: {
|
||||
rawData: EngineCloudStats[];
|
||||
}) {
|
||||
const { data, pathnames, chartConfig, isAllEmpty } = useMemo(() => {
|
||||
// Dynamically collect all unique pathnames
|
||||
const pathnameSet = new Set<string>();
|
||||
for (const item of rawData) {
|
||||
// Ignore empty pathname ''.
|
||||
if (item.pathname) {
|
||||
pathnameSet.add(item.pathname);
|
||||
}
|
||||
}
|
||||
const pathnames = Array.from(pathnameSet);
|
||||
|
||||
// Group by date, then by pathname
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
for (const { date, pathname, totalRequests } of rawData) {
|
||||
const map = dateMap.get(date) ?? {};
|
||||
map[pathname] = Number(totalRequests) || 0;
|
||||
dateMap.set(date, map);
|
||||
}
|
||||
|
||||
// Build data array for recharts
|
||||
const data = Array.from(dateMap.entries()).map(([date, value]) => {
|
||||
let total = 0;
|
||||
for (const pathname of pathnames) {
|
||||
if (!value[pathname]) value[pathname] = 0;
|
||||
total += value[pathname];
|
||||
}
|
||||
return { date, ...value, total };
|
||||
});
|
||||
|
||||
// Chart config
|
||||
const chartConfig: ChartConfig = {};
|
||||
for (const pathname of pathnames) {
|
||||
chartConfig[pathname] = { label: pathname };
|
||||
}
|
||||
|
||||
return {
|
||||
chartConfig,
|
||||
data,
|
||||
isAllEmpty: data.every((d) => d.total === 0),
|
||||
pathnames,
|
||||
};
|
||||
}, [rawData]);
|
||||
|
||||
if (data.length === 0 || isAllEmpty) {
|
||||
return (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/transactions"
|
||||
metric="Transactions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
|
||||
<CardTitle className="font-semibold text-lg">
|
||||
Engine Cloud Requests
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6 sm:pl-0">
|
||||
<ChartContainer
|
||||
className="aspect-auto h-[250px] w-full pt-6"
|
||||
config={chartConfig}
|
||||
>
|
||||
<RechartsBarChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="date"
|
||||
minTickGap={32}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(d) => format(new Date(d), "MMM d")}
|
||||
valueFormatter={(_value) => {
|
||||
const value = typeof _value === "number" ? _value : 0;
|
||||
return <span className="inline-flex gap-1.5">{value}</span>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{pathnames.map((pathname, idx) => (
|
||||
<Bar
|
||||
className="stroke-background"
|
||||
dataKey={pathname}
|
||||
fill={`hsl(var(--chart-${idx + 1}))`}
|
||||
key={pathname}
|
||||
radius={[
|
||||
idx === pathnames.length - 1 ? 4 : 0,
|
||||
idx === pathnames.length - 1 ? 4 : 0,
|
||||
idx === 0 ? 4 : 0,
|
||||
idx === 0 ? 4 : 0,
|
||||
]}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { getEngineCloudMethodUsage } from "@/api/analytics";
|
||||
import type { AnalyticsQueryParams } from "@/types/analytics";
|
||||
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
|
||||
|
||||
export async function EngineCloudChartCardAsync(props: {
|
||||
params: AnalyticsQueryParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const rawData = await getEngineCloudMethodUsage(
|
||||
props.params,
|
||||
props.authToken,
|
||||
);
|
||||
return <EngineCloudBarChartCardUI rawData={rawData} />;
|
||||
}
|
||||
+20
-3
@@ -83,9 +83,26 @@ export function ProjectSidebarLayout(props: {
|
||||
label: "Tokens",
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/ai`,
|
||||
icon: BotIcon,
|
||||
label: "AI",
|
||||
subMenu: {
|
||||
icon: BotIcon,
|
||||
label: "AI",
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: `${props.layoutPath}/ai`,
|
||||
label: "Chat",
|
||||
isActive: (pathname) => {
|
||||
return (
|
||||
pathname === `${props.layoutPath}/ai` ||
|
||||
pathname.startsWith(`${props.layoutPath}/ai/chat`)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
href: `${props.layoutPath}/ai/analytics`,
|
||||
label: "Analytics",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
subMenu: {
|
||||
|
||||
+3
-3
@@ -109,14 +109,14 @@ export function RpcMethodBarChartCardUI({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 p-0">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 p-6 pb-0">
|
||||
<CardTitle className="font-semibold text-lg">RPC Methods</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6 sm:pl-0">
|
||||
<ChartContainer
|
||||
className="aspect-auto h-[250px] w-full pt-6"
|
||||
className="aspect-auto h-[275px] w-full pt-6"
|
||||
config={chartConfig}
|
||||
>
|
||||
<RechartsBarChart
|
||||
|
||||
+14
-9
@@ -20,7 +20,7 @@ export function WaitingForIntegrationCard(props: {
|
||||
const [selectedTab, setSelectedTab] = useState(props.codeTabs[0]?.label);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="border-b px-4 py-4 lg:px-6">
|
||||
<div className="border-b px-4 py-4 lg:px-6 lg:py-5 border-dashed">
|
||||
<h2 className="font-semibold text-xl tracking-tight">{props.title}</h2>
|
||||
</div>
|
||||
|
||||
@@ -28,14 +28,19 @@ export function WaitingForIntegrationCard(props: {
|
||||
{props.children}
|
||||
{/* Code */}
|
||||
<div>
|
||||
<TabButtons
|
||||
tabs={props.codeTabs.map((tab) => ({
|
||||
isActive: tab.label === selectedTab,
|
||||
name: tab.label,
|
||||
onClick: () => setSelectedTab(tab.label),
|
||||
}))}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
{props.codeTabs.length > 1 && (
|
||||
<>
|
||||
<TabButtons
|
||||
tabs={props.codeTabs.map((tab) => ({
|
||||
isActive: tab.label === selectedTab,
|
||||
name: tab.label,
|
||||
onClick: () => setSelectedTab(tab.label),
|
||||
}))}
|
||||
/>
|
||||
<div className="h-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.codeTabs.find((tab) => tab.label === selectedTab)?.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+249
-90
@@ -10,17 +10,24 @@ import {
|
||||
ShuffleIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebClient, toWei } from "thirdweb";
|
||||
import {
|
||||
getContract,
|
||||
readContract,
|
||||
type ThirdwebClient,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import { useWalletBalance } from "thirdweb/react";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { isAddress, shortenAddress } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { sendProjectWalletTokens } from "@/actions/project-wallet/send-tokens";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import type { TokenMetadata } from "@/api/universal-bridge/types";
|
||||
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -78,15 +85,96 @@ type ProjectWalletControlsProps = {
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PREFIX = "project-wallet-selection";
|
||||
|
||||
function getStorageKey(projectId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}-${projectId}`;
|
||||
}
|
||||
|
||||
type StoredSelection = {
|
||||
chainId: number;
|
||||
tokenAddress: string | undefined;
|
||||
};
|
||||
|
||||
function readStoredSelection(projectId: string): StoredSelection | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(projectId));
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as StoredSelection;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveStoredSelection(projectId: string, selection: StoredSelection) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(projectId), JSON.stringify(selection));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
const { projectWallet, project, defaultChainId } = props;
|
||||
const [isSendOpen, setIsSendOpen] = useState(false);
|
||||
const [isReceiveOpen, setIsReceiveOpen] = useState(false);
|
||||
const [selectedChainId, setSelectedChainId] = useState(defaultChainId ?? 1);
|
||||
const [isChangeWalletOpen, setIsChangeWalletOpen] = useState(false);
|
||||
|
||||
// Initialize chain and token from localStorage or defaults
|
||||
const [selectedChainId, setSelectedChainId] = useState(() => {
|
||||
const stored = readStoredSelection(project.id);
|
||||
return stored?.chainId ?? defaultChainId ?? 1;
|
||||
});
|
||||
const [selectedTokenAddress, setSelectedTokenAddress] = useState<
|
||||
string | undefined
|
||||
>(() => {
|
||||
const stored = readStoredSelection(project.id);
|
||||
if (stored) {
|
||||
return stored.tokenAddress;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const chain = useV5DashboardChain(selectedChainId);
|
||||
|
||||
// Handle chain change - reset token to native when chain changes
|
||||
const handleChainChange = useCallback(
|
||||
(newChainId: number) => {
|
||||
setSelectedChainId((prevChainId) => {
|
||||
if (prevChainId !== newChainId) {
|
||||
// Reset token to native (undefined) when chain changes
|
||||
setSelectedTokenAddress(undefined);
|
||||
saveStoredSelection(project.id, {
|
||||
chainId: newChainId,
|
||||
tokenAddress: undefined,
|
||||
});
|
||||
}
|
||||
return newChainId;
|
||||
});
|
||||
},
|
||||
[project.id],
|
||||
);
|
||||
|
||||
// Handle token change
|
||||
const handleTokenChange = useCallback(
|
||||
(token: TokenMetadata) => {
|
||||
setSelectedTokenAddress(token.address);
|
||||
saveStoredSelection(project.id, {
|
||||
chainId: selectedChainId,
|
||||
tokenAddress: token.address,
|
||||
});
|
||||
},
|
||||
[project.id, selectedChainId],
|
||||
);
|
||||
|
||||
const engineCloudService = useMemo(
|
||||
() => project.services?.find((service) => service.name === "engineCloud"),
|
||||
[project.services],
|
||||
@@ -117,6 +205,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
address: projectWallet.address,
|
||||
chain,
|
||||
client: props.client,
|
||||
tokenAddress: selectedTokenAddress,
|
||||
});
|
||||
|
||||
const canChangeWallet =
|
||||
@@ -126,62 +215,12 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-xl border border-border bg-card relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open wallet actions"
|
||||
className="p-2 h-auto w-auto absolute right-4 top-4 z-10"
|
||||
variant="ghost"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
Send funds
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Receive funds
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
View transactions
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
Change project wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="p-5 relative flex flex-col gap-5">
|
||||
<div>
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="p-5 flex flex-col lg:flex-row gap-5">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Address</p>
|
||||
<CopyTextButton
|
||||
textToShow={projectWallet.address}
|
||||
textToShow={shortenAddress(projectWallet.address, 6)}
|
||||
textToCopy={projectWallet.address}
|
||||
tooltip="Copy wallet address"
|
||||
copyIconPosition="right"
|
||||
@@ -190,7 +229,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground mb-1">Wallet Label</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm break-all py-2 sm:py-0 text-muted-foreground">
|
||||
@@ -200,7 +239,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border-t border-dashed flex justify-between items-center">
|
||||
<div className="p-5 border-t border-dashed flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div>
|
||||
<p className="text-sm text-foreground mb-1">Balance</p>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -229,17 +268,88 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
client={props.client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
onChange={setSelectedChainId}
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChainId}
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
client={props.client}
|
||||
disableDeprecated
|
||||
disableChainId
|
||||
onChange={handleChainChange}
|
||||
placeholder="Select network"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
<TokenSelector
|
||||
selectedToken={
|
||||
selectedTokenAddress
|
||||
? { chainId: selectedChainId, address: selectedTokenAddress }
|
||||
: undefined
|
||||
}
|
||||
onChange={handleTokenChange}
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
enabled={true}
|
||||
showCheck={true}
|
||||
addNativeTokenIfMissing={true}
|
||||
placeholder="Native token"
|
||||
className="w-full sm:w-fit rounded-full bg-background hover:bg-accent/50"
|
||||
popoverContentClassName="!w-[320px] rounded-xl overflow-hidden"
|
||||
align="end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background hover:bg-accent/50"
|
||||
onClick={() => setIsSendOpen(true)}
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
Withdraw
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-2 bg-background hover:bg-accent/50"
|
||||
>
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsReceiveOpen(true)}
|
||||
>
|
||||
<WalletIcon className="size-4 text-muted-foreground" />
|
||||
Deposit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/team/${props.teamSlug}/${props.project.slug}/transactions`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
|
||||
Transactions
|
||||
</DropdownMenuItem>
|
||||
{canChangeWallet && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={() => setIsChangeWalletOpen(true)}
|
||||
>
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
Change Wallet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +363,7 @@ export function ProjectWalletDetailsSection(props: ProjectWalletControlsProps) {
|
||||
open={isSendOpen}
|
||||
publishableKey={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
tokenAddress={selectedTokenAddress}
|
||||
walletAddress={projectWallet.address}
|
||||
/>
|
||||
|
||||
@@ -467,6 +578,7 @@ const createSendFormSchema = (secretKeyLabel: string) =>
|
||||
chainId: z.number({
|
||||
required_error: "Select a network",
|
||||
}),
|
||||
tokenAddress: z.string().optional(),
|
||||
toAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -491,6 +603,7 @@ type SendProjectWalletModalProps = {
|
||||
publishableKey: string;
|
||||
teamId: string;
|
||||
chainId: number;
|
||||
tokenAddress?: string;
|
||||
label: string;
|
||||
client: ReturnType<typeof getClientThirdwebClient>;
|
||||
isManagedVault: boolean;
|
||||
@@ -528,6 +641,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
publishableKey,
|
||||
teamId,
|
||||
chainId,
|
||||
tokenAddress,
|
||||
label,
|
||||
client,
|
||||
isManagedVault,
|
||||
@@ -539,6 +653,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
defaultValues: {
|
||||
amount: "0",
|
||||
chainId,
|
||||
tokenAddress,
|
||||
secretKey: "",
|
||||
vaultAccessToken: "",
|
||||
toAddress: "",
|
||||
@@ -548,10 +663,24 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
});
|
||||
|
||||
const selectedChain = useV5DashboardChain(form.watch("chainId"));
|
||||
const selectedFormChainId = form.watch("chainId");
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: async (values: SendFormValues) => {
|
||||
const quantityWei = toWei(values.amount).toString();
|
||||
let decimals = 18;
|
||||
if (values.tokenAddress) {
|
||||
const decimalsRpc = await readContract({
|
||||
contract: getContract({
|
||||
address: values.tokenAddress,
|
||||
chain: selectedChain,
|
||||
client,
|
||||
}),
|
||||
method: "function decimals() view returns (uint8)",
|
||||
params: [],
|
||||
});
|
||||
decimals = Number(decimalsRpc);
|
||||
}
|
||||
const quantityWei = toUnits(values.amount, decimals).toString();
|
||||
const secretKeyValue = values.secretKey.trim();
|
||||
const vaultAccessTokenValue = values.vaultAccessToken.trim();
|
||||
|
||||
@@ -563,16 +692,16 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
teamId,
|
||||
walletAddress,
|
||||
secretKey: secretKeyValue,
|
||||
...(values.tokenAddress ? { tokenAddress: values.tokenAddress } : {}),
|
||||
...(vaultAccessTokenValue
|
||||
? { vaultAccessToken: vaultAccessTokenValue }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const errorMessage =
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: "Failed to send funds";
|
||||
const errorMessage = result.error
|
||||
? JSON.stringify(result.error, null, 2)
|
||||
: "Failed to send funds";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
@@ -623,6 +752,8 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
disableDeprecated
|
||||
onChange={(nextChainId) => {
|
||||
field.onChange(nextChainId);
|
||||
// Reset token to native when chain changes
|
||||
form.setValue("tokenAddress", undefined);
|
||||
}}
|
||||
placeholder="Select network"
|
||||
/>
|
||||
@@ -632,6 +763,39 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<TokenSelector
|
||||
selectedToken={
|
||||
field.value
|
||||
? {
|
||||
chainId: selectedFormChainId,
|
||||
address: field.value,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(token) => {
|
||||
field.onChange(token.address);
|
||||
}}
|
||||
chainId={selectedFormChainId}
|
||||
client={client}
|
||||
enabled={true}
|
||||
showCheck={true}
|
||||
addNativeTokenIfMissing={true}
|
||||
placeholder="Native token"
|
||||
className="w-full bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="toAddress"
|
||||
@@ -655,12 +819,6 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
<FormControl>
|
||||
<Input inputMode="decimal" min="0" step="any" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Sending native token
|
||||
{selectedChain?.nativeCurrency?.symbol
|
||||
? ` (${selectedChain.nativeCurrency.symbol})`
|
||||
: ""}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -682,7 +840,9 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{secretKeyHelper}</FormDescription>
|
||||
<FormDescription className="text-muted-foreground text-xs">
|
||||
{secretKeyHelper}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -694,21 +854,20 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
name="vaultAccessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Vault access token
|
||||
<span className="text-muted-foreground"> (optional)</span>
|
||||
</FormLabel>
|
||||
<FormLabel>Vault access token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
placeholder="Enter a vault access token (optional)"
|
||||
placeholder="Enter a vault access token"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{vaultAccessTokenHelper}</FormDescription>
|
||||
<FormDescription className="text-muted-foreground text-xs">
|
||||
{vaultAccessTokenHelper}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -716,7 +875,7 @@ function SendProjectWalletModalContent(props: SendProjectWalletModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4 lg:p-6">
|
||||
<div className="flex justify-end gap-3 border-t bg-card p-4">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
+1
-2
@@ -183,9 +183,8 @@ export async function InsightAnalytics(props: {
|
||||
) : (
|
||||
<RequestsByStatusGraph
|
||||
data={"data" in statusCodesData ? statusCodesData.data : []}
|
||||
description="The number of requests by status code over time."
|
||||
isPending={false}
|
||||
title="Requests by Status Code"
|
||||
viewMoreLink={undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+13
-11
@@ -5,6 +5,7 @@ import { shortenLargeNumber } from "thirdweb/utils";
|
||||
import type { InsightStatusCodeStats } from "@/api/analytics";
|
||||
import { EmptyChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
@@ -15,11 +16,14 @@ const defaultLabel = 200;
|
||||
export function RequestsByStatusGraph(props: {
|
||||
data: InsightStatusCodeStats[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
viewMoreLink: string | undefined;
|
||||
}) {
|
||||
const topStatusCodesToShow = 10;
|
||||
|
||||
const total = useMemo(() => {
|
||||
return props.data.reduce((acc, curr) => acc + curr.totalRequests, 0);
|
||||
}, [props.data]);
|
||||
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
@@ -90,17 +94,15 @@ export function RequestsByStatusGraph(props: {
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
chartClassName="aspect-auto h-[275px]"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
<TotalValueChartHeader
|
||||
isPending={props.isPending}
|
||||
total={total}
|
||||
title="Indexer Requests"
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<EmptyChartState type="bar" />}
|
||||
|
||||
+11
-9
@@ -4,7 +4,6 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import { shortenLargeNumber } from "thirdweb/utils";
|
||||
import type { RpcMethodStats } from "@/api/analytics";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CardHeading } from "../../../payments/components/common";
|
||||
|
||||
export function TopRPCMethodsTable(props: {
|
||||
data: RpcMethodStats[];
|
||||
@@ -41,18 +39,22 @@ export function TopRPCMethodsTable(props: {
|
||||
const isEmpty = useMemo(() => sortedData.length === 0, [sortedData]);
|
||||
|
||||
return (
|
||||
<Card className="relative flex flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* header */}
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<CardHeading>Top EVM Methods Called </CardHeading>
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="tracking-tight font-semibold text-lg">
|
||||
Top EVM Methods Called
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<TableContainer scrollableContainerClassName="h-[280px]">
|
||||
<TableContainer
|
||||
scrollableContainerClassName="max-h-[380px] min-h-[200px]"
|
||||
className="border-none"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="lg:w-[320px]">Method</TableHead>
|
||||
<TableHead>Requests</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -85,7 +87,7 @@ export function TopRPCMethodsTable(props: {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-16
@@ -1,14 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { shortenLargeNumber } from "thirdweb/utils";
|
||||
import { differenceInCalendarDays, format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import type { RpcUsageTypeStats } from "@/api/analytics";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
|
||||
export function RPCRequestsChartUI(props: {
|
||||
data: RpcUsageTypeStats[];
|
||||
viewMoreLink: string | undefined;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const total = useMemo(() => {
|
||||
return props.data.reduce((acc, curr) => acc + curr.count, 0);
|
||||
}, [props.data]);
|
||||
|
||||
const showBreakdownByHour = useMemo(() => {
|
||||
if (props.data.length === 0) return true;
|
||||
const firstData = props.data[0];
|
||||
const lastData = props.data[props.data.length - 1];
|
||||
if (!firstData || !lastData) return true;
|
||||
const firstDate = new Date(firstData.date);
|
||||
const lastDate = new Date(lastData.date);
|
||||
return Math.abs(differenceInCalendarDays(lastDate, firstDate)) < 2;
|
||||
}, [props.data]);
|
||||
|
||||
export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
|
||||
return (
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[4]"
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-auto h-[275px]"
|
||||
config={{
|
||||
requests: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
@@ -32,23 +51,33 @@ export function RequestsGraph(props: { data: RpcUsageTypeStats[] }) {
|
||||
},
|
||||
[] as { requests: number; time: string }[],
|
||||
)}
|
||||
header={{
|
||||
description: "Requests over time.",
|
||||
title: "RPC Requests",
|
||||
}}
|
||||
customHeader={
|
||||
<TotalValueChartHeader
|
||||
total={total}
|
||||
isPending={props.isPending}
|
||||
title="RPC Requests"
|
||||
viewMoreLink={props.viewMoreLink}
|
||||
/>
|
||||
}
|
||||
hideLabel={false}
|
||||
isPending={false}
|
||||
showLegend
|
||||
isPending={props.isPending}
|
||||
toolTipLabelFormatter={(label) => {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
if (showBreakdownByHour) {
|
||||
return format(label, "MMM dd, HH:mm");
|
||||
}
|
||||
return format(label, "MMM dd");
|
||||
}}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return shortenLargeNumber(value as number);
|
||||
return compactNumberFormatter.format(value as number);
|
||||
}}
|
||||
xAxis={{
|
||||
sameDay: true,
|
||||
showHour: showBreakdownByHour,
|
||||
}}
|
||||
yAxis
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
+27
-3
@@ -3,10 +3,12 @@ import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { getRpcMethodUsage, getRpcUsageByType } from "@/api/analytics";
|
||||
import type { Range } from "@/components/analytics/date-range-selector";
|
||||
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
|
||||
import { StatCard } from "@/components/analytics/stat";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RpcMethodBarChartCardAsync } from "../../../components/RpcMethodBarChartCard";
|
||||
import { TopRPCMethodsTable } from "./MethodsTable";
|
||||
import { RequestsGraph } from "./RequestsGraph";
|
||||
import { RPCRequestsChartUI } from "./RequestsGraph";
|
||||
import { RpcAnalyticsFilter } from "./RpcAnalyticsFilter";
|
||||
import { RpcFTUX } from "./RpcFtux";
|
||||
|
||||
@@ -21,7 +23,7 @@ export async function RPCAnalytics(props: {
|
||||
}) {
|
||||
const { projectId, teamId, range, interval, authToken } = props;
|
||||
|
||||
// TODO: add requests by status code, but currently not performant enough
|
||||
// TODO: add requests by status code filter, but currently not performant enough
|
||||
const allRequestsByUsageTypePromise = getRpcUsageByType(
|
||||
{
|
||||
from: range.from,
|
||||
@@ -96,13 +98,35 @@ export async function RPCAnalytics(props: {
|
||||
value={totalRequests}
|
||||
/>
|
||||
</div>
|
||||
<RequestsGraph data={usageData} />
|
||||
<RPCRequestsChartUI
|
||||
data={usageData}
|
||||
viewMoreLink={undefined}
|
||||
isPending={false}
|
||||
/>
|
||||
<TopRPCMethodsTable
|
||||
client={props.client}
|
||||
data={evmMethodsData || []}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[377px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<RpcMethodBarChartCardAsync
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: projectId,
|
||||
teamId: teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
"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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,16 +6,12 @@ import {
|
||||
} from "responsive-rsc";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
|
||||
import {
|
||||
getAiUsage,
|
||||
getInAppWalletUsage,
|
||||
getInsightStatusCodeUsage,
|
||||
getRpcUsageByType,
|
||||
getUniversalBridgeUsage,
|
||||
getUserOpUsage,
|
||||
getWalletConnections,
|
||||
getX402Settlements,
|
||||
isProjectActive,
|
||||
} from "@/api/analytics";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
@@ -31,20 +27,16 @@ import { ProjectPage } from "@/components/blocks/project-page/project-page";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
} from "@/types/analytics";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
|
||||
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
|
||||
import { AiTokenUsageChartCardUI } from "./ai/analytics/chart/AiTokenUsageChartCard";
|
||||
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
|
||||
import { ProjectWalletSection } from "./components/project-wallet/project-wallet";
|
||||
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
|
||||
import { TransactionsChartCardAsync } from "./components/Transactions";
|
||||
import { RequestsByStatusGraph } from "./gateway/indexer/components/RequestsByStatusGraph";
|
||||
import { RPCRequestsChartUI } from "./gateway/rpc/components/RequestsGraph";
|
||||
import { ProjectHighlightsCard } from "./overview/highlights-card";
|
||||
import { TotalSponsoredCardUI } from "./overview/total-sponsored";
|
||||
import { AllWalletConnectionsChart } from "./wallets/analytics/chart/all-wallet-connections-chart";
|
||||
import { X402RequestsChartCardUI } from "./x402/analytics/x402-requests-chart";
|
||||
|
||||
type PageParams = {
|
||||
team_slug: string;
|
||||
@@ -188,6 +180,7 @@ async function ProjectAnalytics(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* project highlights */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
|
||||
@@ -208,30 +201,116 @@ async function ProjectAnalytics(props: {
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
{/* wallet connections */}
|
||||
<AllWalletConnectionsChart
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
authToken={authToken}
|
||||
projectId={project.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
|
||||
{/* indexer and RPC requests */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[431px] border" />}
|
||||
fallback={
|
||||
<RequestsByStatusGraph
|
||||
data={[]}
|
||||
isPending={true}
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/indexer`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncWalletDistributionCard
|
||||
project={project}
|
||||
range={range}
|
||||
<AsyncIndexerRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[431px] border" />}
|
||||
fallback={
|
||||
<RPCRequestsChartUI
|
||||
isPending={true}
|
||||
data={[]}
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/gateway/rpc`}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAuthMethodDistributionCard
|
||||
project={project}
|
||||
range={range}
|
||||
<AsyncRPCRequestsChartCard
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* x402 */}
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<X402RequestsChartCardUI
|
||||
stats={[]}
|
||||
isPending={true}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncX402RequestsChart
|
||||
from={range.from}
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
{/* AI tokens */}
|
||||
<ResponsiveSuspense
|
||||
fallback={
|
||||
<AiTokenUsageChartCardUI
|
||||
isPending={true}
|
||||
title="AI token volume"
|
||||
viewMoreLink={`/team/${params.team_slug}/${params.project_slug}/ai/analytics`}
|
||||
aiUsageStats={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncAiAnalytics
|
||||
teamSlug={params.team_slug}
|
||||
projectSlug={params.project_slug}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
|
||||
{/* transactions */}
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
|
||||
@@ -254,130 +333,66 @@ async function ProjectAnalytics(props: {
|
||||
selectedChartQueryParam="client_transactions"
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[458px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval", "totalSponsored"]}
|
||||
>
|
||||
<AsyncTotalSponsoredCard
|
||||
interval={interval}
|
||||
project={project}
|
||||
range={range}
|
||||
selectedChart={
|
||||
typeof searchParams.totalSponsored === "string"
|
||||
? searchParams.totalSponsored
|
||||
: undefined
|
||||
}
|
||||
selectedChartQueryParam="totalSponsored"
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[377px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<RpcMethodBarChartCardAsync
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
|
||||
<ResponsiveSuspense
|
||||
fallback={<LoadingChartState className="h-[377px] border" />}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<EngineCloudChartCardAsync
|
||||
params={{
|
||||
from: range.from,
|
||||
period: interval,
|
||||
projectId: project.id,
|
||||
teamId: project.teamId,
|
||||
to: range.to,
|
||||
}}
|
||||
authToken={authToken}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function AsyncTotalSponsoredCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
async function AsyncIndexerRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
selectedChart: string | undefined;
|
||||
selectedChartQueryParam: string;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
|
||||
getUserOpUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: props.interval,
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getUserOpUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
]);
|
||||
|
||||
return userOpUsageTimeSeries.status === "fulfilled" &&
|
||||
userOpUsage.status === "fulfilled" &&
|
||||
userOpUsage.value.length > 0 ? (
|
||||
<TotalSponsoredCard
|
||||
aggregatedData={userOpUsage.value}
|
||||
data={userOpUsageTimeSeries.value}
|
||||
selectedChart={props.selectedChart}
|
||||
selectedChartQueryParam={props.selectedChartQueryParam}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
|
||||
metric="Account Abstraction"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAuthMethodDistributionCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
authToken: string;
|
||||
}) {
|
||||
const inAppWalletUsage = await getInAppWalletUsage(
|
||||
const requestsData = await getInsightStatusCodeUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
|
||||
<AuthMethodDistributionCard data={inAppWalletUsage} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
metric="Wallets"
|
||||
return (
|
||||
<RequestsByStatusGraph
|
||||
data={requestsData && "data" in requestsData ? requestsData.data : []}
|
||||
isPending={false}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/indexer`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncRPCRequestsChartCard(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const requestsData = await getRpcUsageByType(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return (
|
||||
<RPCRequestsChartUI
|
||||
isPending={false}
|
||||
data={requestsData || []}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/gateway/rpc`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -463,158 +478,79 @@ async function AsyncAppHighlightsCard(props: {
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncWalletDistributionCard(props: {
|
||||
project: Project;
|
||||
range: Range;
|
||||
async function AsyncAiAnalytics(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const walletConnections = await getWalletConnections(
|
||||
const stats = await getAiUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch(() => undefined);
|
||||
|
||||
return walletConnections && walletConnections.length > 0 ? (
|
||||
<WalletDistributionCard data={walletConnections} />
|
||||
) : (
|
||||
<EmptyStateCard
|
||||
link="https://portal.thirdweb.com/wallets/external-wallets"
|
||||
metric="External Wallets"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
|
||||
const formattedData = await Promise.all(
|
||||
data.map(async (w) => {
|
||||
const wallet = await getWalletInfo(w.walletType as WalletId).catch(
|
||||
() => ({ name: w.walletType }),
|
||||
);
|
||||
return {
|
||||
totalConnections: w.totalConnections,
|
||||
uniqueWalletsConnected: w.uniqueWalletsConnected,
|
||||
walletName: wallet.name,
|
||||
walletType: w.walletType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<PieChartCard
|
||||
data={formattedData.map(({ walletName, uniqueWalletsConnected }) => {
|
||||
return {
|
||||
label: walletName,
|
||||
value: uniqueWalletsConnected,
|
||||
};
|
||||
})}
|
||||
title="External Wallets Connected"
|
||||
<AiTokenUsageChartCardUI
|
||||
title="AI token volume"
|
||||
isPending={false}
|
||||
aiUsageStats={stats}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/ai/analytics`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
|
||||
return (
|
||||
<PieChartCard
|
||||
data={data.map(({ authenticationMethod, uniqueWalletsConnected }) => ({
|
||||
label: authenticationMethod,
|
||||
value: uniqueWalletsConnected,
|
||||
}))}
|
||||
title="Social Authentication"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function TotalSponsoredCard({
|
||||
data,
|
||||
aggregatedData,
|
||||
selectedChart,
|
||||
selectedChartQueryParam,
|
||||
}: {
|
||||
data: UserOpStats[];
|
||||
aggregatedData: UserOpStats[];
|
||||
selectedChart: string | undefined;
|
||||
selectedChartQueryParam: string;
|
||||
async function AsyncX402RequestsChart(props: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
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 stats = await getX402Settlements(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
// Process data to combine by date and chain type
|
||||
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
|
||||
for (const item of data) {
|
||||
const chain = chains.find((c) => c.chainId === Number(item.chainId));
|
||||
|
||||
const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 };
|
||||
if (chain?.testnet) {
|
||||
existing.testnet += item.sponsoredUsd;
|
||||
} else {
|
||||
existing.mainnet += item.sponsoredUsd;
|
||||
}
|
||||
dateMap.set(item.date, existing);
|
||||
}
|
||||
|
||||
// Convert to array and sort by date
|
||||
const timeSeriesData = Array.from(dateMap.entries())
|
||||
.map(([date, values]) => ({
|
||||
date,
|
||||
mainnet: values.mainnet,
|
||||
testnet: values.testnet,
|
||||
total: values.mainnet + values.testnet,
|
||||
}))
|
||||
.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 isAllEmpty = stats.every((stat) => stat.totalRequests === 0);
|
||||
|
||||
return (
|
||||
<TotalSponsoredCardUI
|
||||
processedAggregatedData={processedAggregatedData}
|
||||
selectedChart={selectedChart}
|
||||
selectedChartQueryParam={selectedChartQueryParam}
|
||||
timeSeriesData={timeSeriesData}
|
||||
<X402RequestsChartCardUI
|
||||
stats={
|
||||
isAllEmpty
|
||||
? []
|
||||
: stats.map((stat) => ({
|
||||
requests: stat.totalRequests,
|
||||
time: stat.date,
|
||||
}))
|
||||
}
|
||||
isPending={false}
|
||||
teamSlug={props.teamSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header(props: {
|
||||
title: string;
|
||||
showRangeSelector: boolean;
|
||||
defaultRange: DurationId;
|
||||
}) {
|
||||
const { title, showRangeSelector, defaultRange } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3 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">
|
||||
<ResponsiveTimeFilters defaultRange={defaultRange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ function Variants(props: { currentPlan: Team["billingPlan"] }) {
|
||||
<div className="mx-auto w-full max-w-[1140px] px-4 py-6">
|
||||
<div className="flex flex-col gap-10">
|
||||
<InAppWalletSettingsUI
|
||||
isLegacyPlan={false}
|
||||
client={storybookThirdwebClient}
|
||||
embeddedWalletService={{
|
||||
actions: [],
|
||||
|
||||
+13
@@ -45,6 +45,7 @@ type InAppWalletSettingsPageProps = {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
@@ -170,6 +171,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isUpdating={props.isUpdating}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
|
||||
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
|
||||
@@ -196,6 +198,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
requiredPlan={authRequiredPlan}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
|
||||
<div className="my-5 border-t border-dashed" />
|
||||
@@ -205,6 +208,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
requiredPlan={authRequiredPlan}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
|
||||
<div className="my-5 border-t border-dashed" />
|
||||
@@ -215,6 +219,7 @@ export const InAppWalletSettingsUI: React.FC<
|
||||
smsCountryTiers={props.smsCountryTiers}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
</Fieldset>
|
||||
</form>
|
||||
@@ -227,6 +232,7 @@ function BrandingFieldset(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
client: ThirdwebClient;
|
||||
isUpdating: boolean;
|
||||
}) {
|
||||
@@ -247,6 +253,7 @@ function BrandingFieldset(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("branding"),
|
||||
id: "branding-switch",
|
||||
@@ -391,6 +398,7 @@ function SMSCountryFields(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
requiredPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -402,6 +410,7 @@ function SMSCountryFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("smsEnabledCountryISOs").length,
|
||||
id: "sms-switch",
|
||||
@@ -446,6 +455,7 @@ function JSONWebTokenFields(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -468,6 +478,7 @@ function JSONWebTokenFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("customAuthentication"),
|
||||
id: "authentication-switch",
|
||||
@@ -533,6 +544,7 @@ function AuthEndpointFields(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const expandCustomAuthEndpointField =
|
||||
props.form.watch("customAuthEndpoint") !== undefined;
|
||||
@@ -559,6 +571,7 @@ function AuthEndpointFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: expandCustomAuthEndpointField,
|
||||
id: "auth-endpoint-switch",
|
||||
|
||||
+2
@@ -32,6 +32,7 @@ export function CreateNFTPageUI(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const [step, setStep] =
|
||||
useState<keyof typeof nftCreationPages>("collection-info");
|
||||
@@ -135,6 +136,7 @@ export function CreateNFTPageUI(props: {
|
||||
|
||||
{step === nftCreationPages["launch-nft"] && (
|
||||
<LaunchNFT
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
client={props.client}
|
||||
createNFTFunctions={props.createNFTFunctions}
|
||||
onLaunchSuccess={props.onLaunchSuccess}
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ export function CreateNFTPage(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
|
||||
+2
@@ -69,6 +69,7 @@ export function LaunchNFT(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const formValues = props.values;
|
||||
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
|
||||
@@ -716,6 +717,7 @@ export function LaunchNFT(props: {
|
||||
</div>
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan="starter"
|
||||
switchProps={{
|
||||
checked: isGasless,
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ export default async function Page(props: {
|
||||
<div className="container max-w-5xl pt-8 pb-32">
|
||||
<CreateNFTPage
|
||||
accountAddress={accountAddress}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
client={client}
|
||||
projectId={project.id}
|
||||
projectSlug={params.project_slug}
|
||||
|
||||
+2
@@ -49,6 +49,7 @@ export function CreateTokenAssetPage(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const activeAccount = useActiveAccount();
|
||||
const addContractToProject = useAddContractToProject();
|
||||
@@ -443,6 +444,7 @@ export function CreateTokenAssetPage(props: {
|
||||
return (
|
||||
<CreateTokenAssetPageUI
|
||||
accountAddress={props.accountAddress}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
client={props.client}
|
||||
createTokenFunctions={{
|
||||
ERC20Asset: {
|
||||
|
||||
+2
@@ -66,6 +66,7 @@ export function CreateTokenAssetPageUI(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
|
||||
"token-info",
|
||||
@@ -228,6 +229,7 @@ export function CreateTokenAssetPageUI(props: {
|
||||
|
||||
{step === "launch" && (
|
||||
<LaunchTokenStatus
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
client={props.client}
|
||||
createTokenFunctions={props.createTokenFunctions}
|
||||
onLaunchSuccess={props.onLaunchSuccess}
|
||||
|
||||
+3
@@ -57,6 +57,7 @@ const mockCreateTokenFunctions: CreateTokenFunctions = {
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isLegacyPlan: false,
|
||||
accountAddress: "0x1234567890123456789012345678901234567890",
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: mockCreateTokenFunctions,
|
||||
@@ -69,6 +70,7 @@ export const Default: Story = {
|
||||
|
||||
export const ErrorOnDeploy: Story = {
|
||||
args: {
|
||||
isLegacyPlan: false,
|
||||
accountAddress: "0x1234567890123456789012345678901234567890",
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: {
|
||||
@@ -90,6 +92,7 @@ export const ErrorOnDeploy: Story = {
|
||||
|
||||
export const StorageErrorOnDeploy: Story = {
|
||||
args: {
|
||||
isLegacyPlan: false,
|
||||
accountAddress: "0x1234567890123456789012345678901234567890",
|
||||
client: storybookThirdwebClient,
|
||||
createTokenFunctions: {
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ export function LaunchTokenStatus(props: {
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const formValues = props.values;
|
||||
const { createTokenFunctions } = props;
|
||||
@@ -358,6 +359,7 @@ export function LaunchTokenStatus(props: {
|
||||
</div>
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan="starter"
|
||||
switchProps={{
|
||||
checked: isGasless,
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ export default async function Page(props: {
|
||||
<div className="container max-w-5xl pt-8 pb-32">
|
||||
<CreateTokenAssetPage
|
||||
accountAddress={accountAddress}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
client={client}
|
||||
projectId={project.id}
|
||||
projectSlug={params.project_slug}
|
||||
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
"use client";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { DocLink } from "@/components/blocks/DocLink";
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import type { ChartConfig } from "@/components/ui/chart";
|
||||
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
|
||||
import type { InAppWalletStats } from "@/types/analytics";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
};
|
||||
const defaultLabel = "Unknown Auth";
|
||||
|
||||
export function InAppWalletUsersChartCardUI(props: {
|
||||
inAppWalletStats: InAppWalletStats[];
|
||||
isPending: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
const { inAppWalletStats } = 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 inAppWalletStats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
const { authenticationMethod } = stat;
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData && stat.uniqueWalletsConnected > 0) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: stat.date,
|
||||
[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] = {
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
label: authMethodsToShow[i],
|
||||
};
|
||||
});
|
||||
|
||||
if (authMethodsToShow.length > topChainsToShow) {
|
||||
// Add Other
|
||||
authMethodsToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
label: "Others",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
chartConfig: _chartConfig,
|
||||
chartData: Array.from(_chartDataMap.values()).sort(
|
||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
),
|
||||
};
|
||||
}, [inAppWalletStats]);
|
||||
|
||||
const uniqueAuthMethods = Object.keys(chartConfig);
|
||||
const disableActions =
|
||||
props.isPending ||
|
||||
chartData.length === 0 ||
|
||||
chartData.every((data) => data.sponsoredUsd === 0);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
|
||||
config={chartConfig}
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
{props.description}
|
||||
</p>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
|
||||
disabled={disableActions}
|
||||
fileName="Connect Wallets"
|
||||
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>
|
||||
}
|
||||
data={chartData}
|
||||
emptyChartState={<InAppWalletUsersEmptyChartState />}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={(_v, item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const time = item[0].payload.time as number;
|
||||
return format(new Date(time), "MMM d, yyyy");
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
variant="stacked"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InAppWalletUsersEmptyChartState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="mb-6 text-center 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
|
||||
icon={TypeScriptIcon}
|
||||
label="TypeScript"
|
||||
link="https://portal.thirdweb.com/typescript/v5/inAppWallet"
|
||||
/>
|
||||
<DocLink
|
||||
icon={ReactIcon}
|
||||
label="React"
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
/>
|
||||
<DocLink
|
||||
icon={ReactIcon}
|
||||
label="React Native"
|
||||
link="https://portal.thirdweb.com/react/v5/in-app-wallet/get-started"
|
||||
/>
|
||||
<DocLink
|
||||
icon={UnityIcon}
|
||||
label="Unity"
|
||||
link="https://portal.thirdweb.com/unity/v5/wallets/in-app-wallet"
|
||||
/>
|
||||
<DocLink
|
||||
icon={UnrealIcon}
|
||||
label="Unreal Engine"
|
||||
link="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getEOAAndInAppWalletConnections } from "@/api/analytics";
|
||||
import {
|
||||
AutoMergeBarChart,
|
||||
type StatData,
|
||||
} from "@/components/blocks/charts/automerge-barchart";
|
||||
|
||||
type AllWalletConnectionsChartProps = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export function AllWalletConnectionsChart(
|
||||
props: AllWalletConnectionsChartProps,
|
||||
) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<AllWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
|
||||
}
|
||||
>
|
||||
<AsyncAllWalletConnectionsChart {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncAllWalletConnectionsChart(
|
||||
props: AllWalletConnectionsChartProps,
|
||||
) {
|
||||
const stats = await getEOAAndInAppWalletConnections(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<AllWalletConnectionsChartUI
|
||||
{...props}
|
||||
isPending={false}
|
||||
stats={stats.map((stat) => ({
|
||||
date: stat.date,
|
||||
count: stat.uniqueWalletsConnected,
|
||||
label: stat.walletName,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AllWalletConnectionsChartUI(props: {
|
||||
stats: StatData[];
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AutoMergeBarChart
|
||||
title="Wallet connections"
|
||||
description={undefined}
|
||||
stats={props.stats || []}
|
||||
isPending={props.isPending}
|
||||
exportButton={undefined}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/wallets/user-wallets`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getEOAWalletConnections } from "@/api/analytics";
|
||||
import {
|
||||
AutoMergeBarChart,
|
||||
type StatData,
|
||||
} from "@/components/blocks/charts/automerge-barchart";
|
||||
|
||||
type EOAConnectionsChartProps = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
};
|
||||
|
||||
export function EOAConnectionsChart(props: EOAConnectionsChartProps) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<EOAConnectionsChartUI {...props} isPending={true} stats={[]} />
|
||||
}
|
||||
>
|
||||
<AsyncEOAConnectionsChart {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncEOAConnectionsChart(props: EOAConnectionsChartProps) {
|
||||
const stats = await getEOAWalletConnections(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<EOAConnectionsChartUI
|
||||
{...props}
|
||||
isPending={false}
|
||||
stats={stats.map((stat) => ({
|
||||
date: stat.date,
|
||||
count: stat.uniqueWalletsConnected,
|
||||
label: stat.walletName,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EOAConnectionsChartUI({
|
||||
stats,
|
||||
isPending,
|
||||
}: {
|
||||
stats: StatData[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
title="EOA wallet connections"
|
||||
description="Total number of unique EOA wallets connected"
|
||||
stats={stats || []}
|
||||
isPending={isPending}
|
||||
exportButton={{
|
||||
fileName: "eoa-connections",
|
||||
}}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getInAppWalletUsage } from "@/api/analytics";
|
||||
import {
|
||||
getLastNDaysRange,
|
||||
type Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import type { InAppWalletStats } from "@/types/analytics";
|
||||
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
|
||||
|
||||
type InAppWalletAnalyticsProps = {
|
||||
interval: "day" | "week";
|
||||
range: Range;
|
||||
stats: InAppWalletStats[];
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
function InAppWalletAnalyticsUI({
|
||||
stats,
|
||||
isPending,
|
||||
}: InAppWalletAnalyticsProps) {
|
||||
return (
|
||||
<InAppWalletUsersChartCardUI
|
||||
title="Unique Users"
|
||||
description="The total number of active user wallet users on your project."
|
||||
inAppWalletStats={stats || []}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AsyncInAppWalletAnalyticsProps = Omit<
|
||||
InAppWalletAnalyticsProps,
|
||||
"stats" | "isPending"
|
||||
> & {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
};
|
||||
|
||||
async function AsyncInAppWalletAnalytics(
|
||||
props: AsyncInAppWalletAnalyticsProps,
|
||||
) {
|
||||
const range = props.range ?? getLastNDaysRange("last-30");
|
||||
|
||||
const stats = await getInAppWalletUsage(
|
||||
{
|
||||
from: range.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: range.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<InAppWalletAnalyticsUI
|
||||
{...props}
|
||||
isPending={false}
|
||||
range={range}
|
||||
stats={stats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<InAppWalletAnalyticsUI {...props} isPending={true} stats={[]} />
|
||||
}
|
||||
>
|
||||
<AsyncInAppWalletAnalytics {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
import { getInAppWalletUsage } from "@/api/analytics";
|
||||
import {
|
||||
AutoMergeBarChart,
|
||||
type StatData,
|
||||
} from "@/components/blocks/charts/automerge-barchart";
|
||||
|
||||
type UserWalletConnectionsChartProps = {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
authToken: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval: "day" | "week";
|
||||
};
|
||||
|
||||
export function UserWalletConnectionsChart(
|
||||
props: UserWalletConnectionsChartProps,
|
||||
) {
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={
|
||||
<UserWalletConnectionsChartUI {...props} isPending={true} stats={[]} />
|
||||
}
|
||||
>
|
||||
<AsyncUserWalletConnectionsChart {...props} />
|
||||
</ResponsiveSuspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncUserWalletConnectionsChart(
|
||||
props: UserWalletConnectionsChartProps,
|
||||
) {
|
||||
const stats = await getInAppWalletUsage(
|
||||
{
|
||||
from: props.from,
|
||||
period: props.interval,
|
||||
projectId: props.projectId,
|
||||
teamId: props.teamId,
|
||||
to: props.to,
|
||||
},
|
||||
props.authToken,
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
return (
|
||||
<UserWalletConnectionsChartUI
|
||||
{...props}
|
||||
isPending={false}
|
||||
stats={stats.map((stat) => ({
|
||||
date: stat.date,
|
||||
count: stat.uniqueWalletsConnected,
|
||||
label: stat.authenticationMethod,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserWalletConnectionsChartUI({
|
||||
stats,
|
||||
isPending,
|
||||
}: {
|
||||
stats: StatData[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AutoMergeBarChart
|
||||
viewMoreLink={undefined}
|
||||
title="User wallet connections"
|
||||
description="Total number of unique user wallets connected"
|
||||
stats={stats || []}
|
||||
isPending={isPending}
|
||||
exportButton={{
|
||||
fileName: "user-wallet-connections",
|
||||
}}
|
||||
maxLabelsToShow={5}
|
||||
emptyChartState={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+4
@@ -57,6 +57,10 @@ export default async function Layout(props: {
|
||||
name: "Configuration",
|
||||
path: `${basePath}/configuration`,
|
||||
},
|
||||
{
|
||||
name: "Wallets",
|
||||
path: `${basePath}/wallets`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
+12
-36
@@ -4,9 +4,10 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getProjectWallet } from "@/lib/server/project-wallet";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ProjectWalletSection } from "../../components/project-wallet/project-wallet";
|
||||
import { TransactionsAnalyticsPageContent } from "../../transactions/analytics/analytics-page";
|
||||
import { ServerWalletsTable } from "../../transactions/components/server-wallets-table.client";
|
||||
import type { Wallet } from "../../transactions/server-wallets/wallet-table/types";
|
||||
import { listSolanaAccounts } from "../../transactions/solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "../../transactions/solana-wallets/wallet-table/types";
|
||||
@@ -16,9 +17,6 @@ export const dynamic = "force-dynamic";
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
from?: string | string[];
|
||||
to?: string | string[];
|
||||
interval?: string | string[];
|
||||
testTxWithWallet?: string | string[];
|
||||
testSolanaTxWithWallet?: string | string[];
|
||||
page?: string;
|
||||
@@ -33,7 +31,7 @@ export default async function Page(props: {
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/wallets`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,9 +101,6 @@ export default async function Page(props: {
|
||||
};
|
||||
}
|
||||
|
||||
const isSolanaPermissionError =
|
||||
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
|
||||
|
||||
const testTxWithWallet =
|
||||
typeof searchParams.testTxWithWallet === "string"
|
||||
? searchParams.testTxWithWallet
|
||||
@@ -122,8 +117,17 @@ export default async function Page(props: {
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
const projectWallet = await getProjectWallet(project);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<ProjectWalletSection
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
projectWallet={projectWallet}
|
||||
client={client}
|
||||
layout="column"
|
||||
/>
|
||||
<TransactionsAnalyticsPageContent
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
@@ -139,34 +143,6 @@ export default async function Page(props: {
|
||||
testTxWithWallet={testTxWithWallet}
|
||||
wallets={wallets}
|
||||
/>
|
||||
|
||||
{eoas.error ? (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={wallets}
|
||||
pageSize={pageSize}
|
||||
project={project}
|
||||
solanaCurrentPage={solanaCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
solanaPermissionError={isSolanaPermissionError}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { ServerWalletsTable } from "../../../transactions/components/server-wallets-table.client";
|
||||
import type { Wallet } from "../../../transactions/server-wallets/wallet-table/types";
|
||||
import { listSolanaAccounts } from "../../../transactions/solana-wallets/lib/vault.client";
|
||||
import type { SolanaWallet } from "../../../transactions/solana-wallets/wallet-table/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
searchParams: Promise<{
|
||||
page?: string;
|
||||
solana_page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const [params, searchParams, authToken] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
|
||||
);
|
||||
}
|
||||
|
||||
const [vaultClient, project] = await Promise.all([
|
||||
createVaultClient({
|
||||
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
|
||||
}).catch(() => undefined),
|
||||
getProject(params.team_slug, params.project_slug),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!vaultClient) {
|
||||
return <div>Error: Failed to connect to Vault</div>;
|
||||
}
|
||||
|
||||
const projectEngineCloudService = project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
|
||||
const managementAccessToken =
|
||||
projectEngineCloudService?.managementAccessToken;
|
||||
|
||||
const pageSize = 10;
|
||||
const currentPage = Number.parseInt(searchParams.page ?? "1");
|
||||
const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
|
||||
|
||||
const eoas = managementAccessToken
|
||||
? await listEoas({
|
||||
client: vaultClient,
|
||||
request: {
|
||||
auth: {
|
||||
accessToken: managementAccessToken,
|
||||
},
|
||||
options: {
|
||||
page: currentPage - 1,
|
||||
// @ts-expect-error - TODO: fix this
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
})
|
||||
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
|
||||
|
||||
const wallets = (eoas.data?.items as Wallet[] | undefined) ?? [];
|
||||
|
||||
let solanaAccounts: {
|
||||
data: { items: SolanaWallet[]; totalRecords: number };
|
||||
error: Error | null;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
if (managementAccessToken) {
|
||||
solanaAccounts = await listSolanaAccounts({
|
||||
managementAccessToken,
|
||||
page: solanaCurrentPage,
|
||||
limit: pageSize,
|
||||
projectId: project.id,
|
||||
});
|
||||
} else {
|
||||
solanaAccounts = {
|
||||
data: { items: [], totalRecords: 0 },
|
||||
error: null,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isSolanaPermissionError =
|
||||
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{eoas.error ? (
|
||||
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-destructive font-semibold mb-2">
|
||||
EVM Wallet Error
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerWalletsTable
|
||||
client={client}
|
||||
evmCurrentPage={currentPage}
|
||||
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
|
||||
evmTotalRecords={eoas.data.totalRecords}
|
||||
evmWallets={wallets}
|
||||
pageSize={pageSize}
|
||||
project={project}
|
||||
solanaCurrentPage={solanaCurrentPage}
|
||||
solanaTotalPages={Math.ceil(
|
||||
solanaAccounts.data.totalRecords / pageSize,
|
||||
)}
|
||||
solanaTotalRecords={solanaAccounts.data.totalRecords}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
teamSlug={params.team_slug}
|
||||
solanaPermissionError={isSolanaPermissionError}
|
||||
authToken={authToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-2
@@ -49,9 +49,9 @@ export default async function Page(props: {
|
||||
{!bundlerService ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
|
||||
<AlertTitle>Gas Sponsorship is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Enable Account Abstraction service in{" "}
|
||||
Enable Gas Sponsorship in{" "}
|
||||
<UnderlineLink href={`/team/${team_slug}/${project_slug}/settings`}>
|
||||
project settings
|
||||
</UnderlineLink>{" "}
|
||||
@@ -61,6 +61,7 @@ export default async function Page(props: {
|
||||
) : (
|
||||
<>
|
||||
<AccountAbstractionSettingsPage
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
bundlerService={bundlerService}
|
||||
client={client}
|
||||
project={project}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export default async function Layout(props: {
|
||||
links: [
|
||||
{
|
||||
type: "docs",
|
||||
href: "https://portal.thirdweb.com/transactions/sponsor",
|
||||
href: "https://portal.thirdweb.com/wallets/sponsor-gas",
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
+3
@@ -99,10 +99,13 @@ export default async function Page(props: {
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
authToken={authToken}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
/>
|
||||
|
||||
<AccountAbstractionAnalytics
|
||||
client={client}
|
||||
variant="project"
|
||||
projectId={project.id}
|
||||
teamId={project.teamId}
|
||||
teamSlug={params.team_slug}
|
||||
|
||||
+13
@@ -47,6 +47,7 @@ type InAppWalletSettingsPageProps = {
|
||||
teamId: string;
|
||||
teamSlug: string;
|
||||
teamPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
@@ -248,6 +249,7 @@ const InAppWalletSettingsUI: React.FC<
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
isUpdating={props.isUpdating}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
/>
|
||||
|
||||
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
|
||||
@@ -271,6 +273,7 @@ const InAppWalletSettingsUI: React.FC<
|
||||
>
|
||||
<JSONWebTokenFields
|
||||
form={form}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan={authRequiredPlan}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
@@ -280,6 +283,7 @@ const InAppWalletSettingsUI: React.FC<
|
||||
|
||||
<AuthEndpointFields
|
||||
form={form}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan={authRequiredPlan}
|
||||
teamPlan={props.teamPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
@@ -289,6 +293,7 @@ const InAppWalletSettingsUI: React.FC<
|
||||
|
||||
<SMSCountryFields
|
||||
form={form}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan={authRequiredPlan}
|
||||
smsCountryTiers={props.smsCountryTiers}
|
||||
teamPlan={props.teamPlan}
|
||||
@@ -305,6 +310,7 @@ function BrandingFieldset(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
client: ThirdwebClient;
|
||||
isUpdating: boolean;
|
||||
}) {
|
||||
@@ -324,6 +330,7 @@ function BrandingFieldset(props: {
|
||||
<div className="absolute top-8 right-6">
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("branding"),
|
||||
@@ -468,6 +475,7 @@ function SMSCountryFields(props: {
|
||||
smsCountryTiers: SMSCountryTiers;
|
||||
teamPlan: Team["billingPlan"];
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -480,6 +488,7 @@ function SMSCountryFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("smsEnabledCountryISOs").length,
|
||||
id: "sms-switch",
|
||||
@@ -521,6 +530,7 @@ function JSONWebTokenFields(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -543,6 +553,7 @@ function JSONWebTokenFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: !!props.form.watch("customAuthentication"),
|
||||
id: "authentication-switch",
|
||||
@@ -608,6 +619,7 @@ function AuthEndpointFields(props: {
|
||||
teamPlan: Team["billingPlan"];
|
||||
teamSlug: string;
|
||||
requiredPlan: Team["billingPlan"];
|
||||
isLegacyPlan: boolean;
|
||||
}) {
|
||||
const expandCustomAuthEndpointField =
|
||||
props.form.watch("customAuthEndpoint") !== undefined;
|
||||
@@ -634,6 +646,7 @@ function AuthEndpointFields(props: {
|
||||
<GatedSwitch
|
||||
currentPlan={props.teamPlan}
|
||||
requiredPlan={props.requiredPlan}
|
||||
isLegacyPlan={props.isLegacyPlan}
|
||||
switchProps={{
|
||||
checked: expandCustomAuthEndpointField,
|
||||
id: "auth-endpoint-switch",
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ export default async function Page(props: {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<InAppWalletSettingsPage
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
client={client}
|
||||
project={project}
|
||||
smsCountryTiers={smsCountryTiers}
|
||||
|
||||
+4
@@ -57,6 +57,10 @@ export default async function Layout(props: {
|
||||
name: "Configuration",
|
||||
path: `${basePath}/configuration`,
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
path: `${basePath}/users`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
+12
-14
@@ -4,12 +4,11 @@ import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import type { DurationId } from "@/components/analytics/date-range-selector";
|
||||
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
|
||||
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { InAppWalletAnalytics } from "../analytics/chart";
|
||||
import { EOAConnectionsChart } from "../analytics/chart/eoa-connections-chart";
|
||||
import { InAppWalletsSummary } from "../analytics/chart/Summary";
|
||||
import { UserWalletConnectionsChart } from "../analytics/chart/user-wallet-connections-chart";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -47,11 +46,6 @@ export default async function Page(props: {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
@@ -62,17 +56,21 @@ export default async function Page(props: {
|
||||
range={range}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
<InAppWalletAnalytics
|
||||
<UserWalletConnectionsChart
|
||||
authToken={authToken}
|
||||
interval={interval}
|
||||
projectId={project.id}
|
||||
range={range}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
<InAppWalletUsersPageContent
|
||||
|
||||
<EOAConnectionsChart
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
projectId={project.id}
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
interval={interval}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { getProject } from "@/api/project/projects";
|
||||
import { UserWalletsTable } from "@/components/in-app-wallet-users-content/user-wallets-table";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string; project_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets/users`,
|
||||
);
|
||||
}
|
||||
|
||||
const project = await getProject(params.team_slug, params.project_slug);
|
||||
if (!project) {
|
||||
redirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
const client = getClientThirdwebClient({
|
||||
jwt: authToken,
|
||||
teamId: project.teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<UserWalletsTable
|
||||
authToken={authToken}
|
||||
client={client}
|
||||
projectClientId={project.publishableKey}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
|
||||
import { TotalValueChartHeader } from "@/components/blocks/charts/chart-header";
|
||||
|
||||
export function X402RequestsChartCardUI(props: {
|
||||
stats: Array<{ requests: number; time: string }>;
|
||||
isPending: boolean;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
}) {
|
||||
const total = useMemo(() => {
|
||||
return props.stats.reduce((acc, curr) => acc + curr.requests, 0);
|
||||
}, [props.stats]);
|
||||
|
||||
return (
|
||||
<ThirdwebBarChart
|
||||
chartClassName="w-full h-[275px]"
|
||||
data={props.stats}
|
||||
config={{
|
||||
requests: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Requests",
|
||||
},
|
||||
}}
|
||||
isPending={props.isPending}
|
||||
hideLabel={false}
|
||||
toolTipLabelFormatter={(label) => {
|
||||
return format(label, "MMM dd");
|
||||
}}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return compactNumberFormatter.format(value as number);
|
||||
}}
|
||||
customHeader={
|
||||
<TotalValueChartHeader
|
||||
total={total}
|
||||
isPending={props.isPending}
|
||||
title="X402 Requests"
|
||||
viewMoreLink={`/team/${props.teamSlug}/${props.projectSlug}/x402`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
+1
-1
@@ -80,11 +80,11 @@ export default async function Page(props: {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={searchParams}>
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<ResponsiveTimeFilters defaultRange={defaultRange} />
|
||||
{totalPayments === 0 ? (
|
||||
<X402EmptyState walletAddress={projectWallet?.address} />
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveTimeFilters defaultRange={defaultRange} />
|
||||
<X402Summary
|
||||
authToken={authToken}
|
||||
projectId={project.id}
|
||||
|
||||
@@ -72,7 +72,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
<TeamVerifiedIcon domain={currentTeam.verifiedDomain} />
|
||||
</Link>
|
||||
{/* may render its own link so has to be outside of the link */}
|
||||
<TeamPlanBadge plan={teamPlan} teamSlug={currentTeam.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={teamPlan}
|
||||
teamSlug={currentTeam.slug}
|
||||
isLegacyPlan={currentTeam.isLegacyPlan}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<TeamAndProjectSelectorPopoverButton
|
||||
|
||||
@@ -117,6 +117,7 @@ export function TeamSelectionUI(props: {
|
||||
|
||||
<TeamPlanBadge
|
||||
plan={team.billingPlan}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -66,6 +66,7 @@ export function ProjectAndTeamSelectorCard(props: {
|
||||
<span className="font-medium text-sm">{team.name}</span>
|
||||
<TeamPlanBadge
|
||||
plan={team.billingPlan}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,11 @@ export function TeamSelectorCard(props: {
|
||||
>
|
||||
{team.name}
|
||||
</Link>
|
||||
<TeamPlanBadge plan={team.billingPlan} teamSlug={team.slug} />
|
||||
<TeamPlanBadge
|
||||
plan={team.billingPlan}
|
||||
teamSlug={team.slug}
|
||||
isLegacyPlan={team.isLegacyPlan}
|
||||
/>
|
||||
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground group-hover:text-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const bridgeStats = {
|
||||
supportedChains: "95+",
|
||||
supportedTokens: "10,000+",
|
||||
supportedTokens: "14,000+",
|
||||
supportedRoutes: "15M+",
|
||||
};
|
||||
|
||||
export const bridgeStatsNumbers = {
|
||||
supportedChains: 95,
|
||||
supportedTokens: 10000,
|
||||
supportedTokens: 14000,
|
||||
supportedRoutes: 15000000,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -106,7 +106,7 @@ export function X402LeftSection(props: {
|
||||
{/* Chain selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={chainId}>Chain</Label>
|
||||
<BridgeNetworkSelector
|
||||
<SingleNetworkSelector
|
||||
chainId={selectedChain}
|
||||
onChange={handleChainChange}
|
||||
placeholder="Select a chain"
|
||||
@@ -119,6 +119,7 @@ export function X402LeftSection(props: {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={tokenId}>Token</Label>
|
||||
<TokenSelector
|
||||
includeNativeToken={false}
|
||||
chainId={selectedChain}
|
||||
client={THIRDWEB_CLIENT}
|
||||
enabled={true}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { CodeIcon, LockIcon } from "lucide-react";
|
||||
import { CircleDollarSignIcon, CodeIcon, LockIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ConnectButton,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { ConnectButton, useFetchWithPayment } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { THIRDWEB_CLIENT } from "@/lib/client";
|
||||
@@ -31,55 +25,47 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
|
||||
window.history.replaceState({}, "", `${pathname}?tab=${tab}`);
|
||||
}
|
||||
|
||||
const activeWallet = useActiveWallet();
|
||||
const activeAccount = useActiveAccount();
|
||||
const { fetchWithPayment, isPending, data, error, isError } =
|
||||
useFetchWithPayment(THIRDWEB_CLIENT);
|
||||
|
||||
const paidApiCall = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeWallet) {
|
||||
throw new Error("No active wallet");
|
||||
}
|
||||
const fetchWithPay = wrapFetchWithPayment(
|
||||
fetch,
|
||||
THIRDWEB_CLIENT,
|
||||
activeWallet,
|
||||
);
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("chainId", props.options.chain.id.toString());
|
||||
searchParams.set("payTo", props.options.payTo);
|
||||
searchParams.set("amount", props.options.amount);
|
||||
searchParams.set("tokenAddress", props.options.tokenAddress);
|
||||
searchParams.set("decimals", props.options.tokenDecimals.toString());
|
||||
searchParams.set("waitUntil", props.options.waitUntil);
|
||||
|
||||
const url =
|
||||
"/api/paywall" +
|
||||
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
|
||||
const response = await fetchWithPay(url.toString());
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
const isTokenSelected =
|
||||
props.options.tokenAddress !==
|
||||
"0x0000000000000000000000000000000000000000" &&
|
||||
props.options.tokenSymbol !== "";
|
||||
|
||||
const handlePayClick = async () => {
|
||||
paidApiCall.mutate();
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("chainId", props.options.chain.id.toString());
|
||||
searchParams.set("payTo", props.options.payTo);
|
||||
searchParams.set("amount", props.options.amount);
|
||||
searchParams.set("tokenAddress", props.options.tokenAddress);
|
||||
searchParams.set("decimals", props.options.tokenDecimals.toString());
|
||||
searchParams.set("waitUntil", props.options.waitUntil);
|
||||
|
||||
const url =
|
||||
"/api/paywall" +
|
||||
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
|
||||
|
||||
await fetchWithPayment(url);
|
||||
};
|
||||
|
||||
const clientCode = `import { createThirdwebClient } from "thirdweb";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
import { useFetchWithPayment } from "thirdweb/react";
|
||||
|
||||
const client = createThirdwebClient({ clientId: "your-client-id" });
|
||||
|
||||
export default function Page() {
|
||||
const wallet = useActiveWallet();
|
||||
const { fetchWithPayment, isPending } = useFetchWithPayment(client);
|
||||
|
||||
const onClick = async () => {
|
||||
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
|
||||
const response = await fetchWithPay('/api/paid-endpoint');
|
||||
const data = await fetchWithPayment('/api/paid-endpoint');
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onClick}>Pay Now</Button>
|
||||
<Button onClick={onClick} disabled={isPending}>
|
||||
{isPending ? "Processing..." : "Pay Now"}
|
||||
</Button>
|
||||
);
|
||||
}`;
|
||||
|
||||
@@ -159,34 +145,36 @@ export async function POST(request: Request) {
|
||||
>
|
||||
<BackgroundPattern />
|
||||
|
||||
{previewTab === "ui" && (
|
||||
{previewTab === "ui" && !isTokenSelected && (
|
||||
<Card className="p-8 text-center max-w-md mx-auto my-auto">
|
||||
<CircleDollarSignIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Select Payment Token</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please select a chain and payment token from the configuration
|
||||
panel to continue.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{previewTab === "ui" && isTokenSelected && (
|
||||
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={props.options.chain}
|
||||
detailsButton={{
|
||||
displayBalanceToken:
|
||||
props.options.tokenAddress !==
|
||||
"0x0000000000000000000000000000000000000000"
|
||||
? {
|
||||
[props.options.chain.id]: props.options.tokenAddress,
|
||||
}
|
||||
: undefined,
|
||||
displayBalanceToken: {
|
||||
[props.options.chain.id]: props.options.tokenAddress,
|
||||
},
|
||||
}}
|
||||
supportedTokens={{
|
||||
[props.options.chain.id]: [
|
||||
{
|
||||
address: props.options.tokenAddress,
|
||||
symbol: props.options.tokenSymbol,
|
||||
name: props.options.tokenSymbol,
|
||||
},
|
||||
],
|
||||
}}
|
||||
supportedTokens={
|
||||
props.options.tokenAddress !==
|
||||
"0x0000000000000000000000000000000000000000"
|
||||
? {
|
||||
[props.options.chain.id]: [
|
||||
{
|
||||
address: props.options.tokenAddress,
|
||||
symbol: props.options.tokenSymbol,
|
||||
name: props.options.tokenSymbol,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -194,8 +182,7 @@ export async function POST(request: Request) {
|
||||
<span className="text-lg font-medium">Paid API Call</span>
|
||||
<Badge variant="success">
|
||||
<span className="text-xl font-bold">
|
||||
{props.options.amount}{" "}
|
||||
{props.options.tokenSymbol || "tokens"}
|
||||
{props.options.amount} {props.options.tokenSymbol}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -204,12 +191,12 @@ export async function POST(request: Request) {
|
||||
onClick={handlePayClick}
|
||||
className="w-full mb-4"
|
||||
size="lg"
|
||||
disabled={paidApiCall.isPending || !activeAccount}
|
||||
disabled={isPending}
|
||||
>
|
||||
Access Premium Content
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pay for access with {props.options.tokenSymbol || "tokens"} on{" "}
|
||||
Pay for access with {props.options.tokenSymbol} on{" "}
|
||||
{props.options.chain.name || `chain ${props.options.chain.id}`}
|
||||
</p>
|
||||
</Card>
|
||||
@@ -218,19 +205,12 @@ export async function POST(request: Request) {
|
||||
<CodeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">API Call Response</span>
|
||||
</div>
|
||||
{paidApiCall.isPending && (
|
||||
<div className="text-center">Loading...</div>
|
||||
{isPending && <div className="text-center">Loading...</div>}
|
||||
{isError && (
|
||||
<div className="text-center">Error: {error?.message}</div>
|
||||
)}
|
||||
{paidApiCall.isError && (
|
||||
<div className="text-center">
|
||||
Error: {paidApiCall.error.message}
|
||||
</div>
|
||||
)}
|
||||
{paidApiCall.data && (
|
||||
<CodeClient
|
||||
code={JSON.stringify(paidApiCall.data, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
{!!data && (
|
||||
<CodeClient code={JSON.stringify(data, null, 2)} lang="json" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ChainIcon } from "@/components/blocks/ChainIcon";
|
||||
import { SelectWithSearch } from "@/components/ui/select-with-search";
|
||||
import { useBridgeSupportedChains } from "@/hooks/chains";
|
||||
import { useAllChainsData } from "../../hooks/allChains";
|
||||
|
||||
function cleanChainName(chainName: string) {
|
||||
return chainName.replace("Mainnet", "");
|
||||
@@ -11,6 +13,132 @@ function cleanChainName(chainName: string) {
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
export function SingleNetworkSelector(props: {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
// if specified - only these chains will be shown
|
||||
chainIds?: number[];
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
disableTestnets?: boolean;
|
||||
disableDeprecated?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const allChains = useAllChainsData();
|
||||
|
||||
const chainsToShow = useMemo(() => {
|
||||
let chains = allChains || [];
|
||||
|
||||
if (props.disableTestnets) {
|
||||
chains = chains.filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
if (props.chainIds) {
|
||||
const chainIdSet = new Set(props.chainIds);
|
||||
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
}
|
||||
|
||||
if (props.disableDeprecated) {
|
||||
chains = chains.filter((chain) => chain.status !== "deprecated");
|
||||
}
|
||||
|
||||
return chains;
|
||||
}, [
|
||||
allChains,
|
||||
props.chainIds,
|
||||
props.disableTestnets,
|
||||
props.disableDeprecated,
|
||||
]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return chainsToShow.map((chain) => {
|
||||
return {
|
||||
label: chain.name,
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [chainsToShow]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = chainsToShow.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[chainsToShow],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = chainsToShow.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIcon
|
||||
className="size-5"
|
||||
ipfsSrc={chain.icon?.url}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
|
||||
{!props.disableChainId && (
|
||||
<Badge className="gap-2 max-sm:hidden" variant="outline">
|
||||
<span className="text-muted-foreground">Chain ID</span>
|
||||
{chain.chainId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[chainsToShow, props.disableChainId],
|
||||
);
|
||||
|
||||
const isLoadingChains = !allChains || allChains.length === 0;
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
align={props.align}
|
||||
className={props.className}
|
||||
closeOnSelect={true}
|
||||
disabled={isLoadingChains}
|
||||
onValueChange={(chainId) => {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={
|
||||
isLoadingChains
|
||||
? "Loading Chains..."
|
||||
: props.placeholder || "Select Chain"
|
||||
}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
searchPlaceholder="Search by Name or Chain ID"
|
||||
showCheck={false}
|
||||
side={props.side}
|
||||
value={props.chainId ? String(props.chainId) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BridgeNetworkSelector(props: {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CoinsIcon } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Img } from "@/components/ui/Img";
|
||||
@@ -21,13 +21,24 @@ export function TokenSelector(props: {
|
||||
client: ThirdwebClient;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
includeNativeToken?: boolean;
|
||||
}) {
|
||||
const tokensQuery = useTokensData({
|
||||
chainId: props.chainId,
|
||||
enabled: props.enabled,
|
||||
});
|
||||
|
||||
const tokens = tokensQuery.data || [];
|
||||
const tokens = useMemo(() => {
|
||||
if (props.includeNativeToken === false) {
|
||||
return (
|
||||
tokensQuery.data?.filter(
|
||||
(token) =>
|
||||
token.address.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase(),
|
||||
) || []
|
||||
);
|
||||
}
|
||||
return tokensQuery.data || [];
|
||||
}, [tokensQuery.data, props.includeNativeToken]);
|
||||
const addressChainToToken = useMemo(() => {
|
||||
const value = new Map<string, TokenMetadata>();
|
||||
for (const token of tokens) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
|
||||
async function fetchChainsFromApi() {
|
||||
// always fetch from prod for chains for now
|
||||
// TODO: re-visit this
|
||||
const res = await fetch("https://api.thirdweb.com/v1/chains");
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error || !res.ok) {
|
||||
throw new Error(
|
||||
json.error?.message || `Failed to fetch chains: ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return json.data as ChainMetadata[];
|
||||
}
|
||||
|
||||
export function useAllChainsData() {
|
||||
// trigger fetching all chains if this hook is used instead of putting this on root
|
||||
// so we can avoid fetching all chains if it's not required
|
||||
const allChainsQuery = useQuery({
|
||||
queryFn: () => fetchChainsFromApi(),
|
||||
queryKey: ["all-chains"],
|
||||
});
|
||||
|
||||
return allChainsQuery.data;
|
||||
}
|
||||
@@ -4,18 +4,24 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { TokenMetadata } from "@/lib/types";
|
||||
|
||||
async function fetchTokensFromApi(chainId?: number) {
|
||||
const domain = process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com";
|
||||
const url = new URL(
|
||||
`${domain.includes("localhost") ? "http" : "https"}://${domain}/v1/tokens`,
|
||||
const bridgeDomain =
|
||||
process.env.NEXT_PUBLIC_BRIDGE_URL || "bridge.thirdweb.com";
|
||||
const apiDomain = process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com";
|
||||
const bridgeUrl = new URL(
|
||||
`${bridgeDomain.includes("localhost") ? "http" : "https"}://${bridgeDomain}/v1/tokens`,
|
||||
);
|
||||
const apiUrl = new URL(
|
||||
`${apiDomain.includes("localhost") ? "http" : "https"}://${apiDomain}/v1/payments/x402/supported`,
|
||||
);
|
||||
|
||||
if (chainId) {
|
||||
url.searchParams.append("chainId", String(chainId));
|
||||
bridgeUrl.searchParams.append("chainId", String(chainId));
|
||||
apiUrl.searchParams.append("chainId", String(chainId));
|
||||
}
|
||||
url.searchParams.append("limit", "1000");
|
||||
url.searchParams.append("includePrices", "false");
|
||||
bridgeUrl.searchParams.append("limit", "1000");
|
||||
bridgeUrl.searchParams.append("includePrices", "false");
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
const res = await fetch(bridgeUrl.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
|
||||
@@ -31,7 +37,68 @@ async function fetchTokensFromApi(chainId?: number) {
|
||||
throw new Error(json.error.message);
|
||||
}
|
||||
|
||||
return json.data as Array<TokenMetadata>;
|
||||
const bridgeTokens = json.data as Array<TokenMetadata>;
|
||||
|
||||
if (bridgeTokens.length === 0 && chainId) {
|
||||
// try the x402 supported api, which supports testnets
|
||||
const apiRes = await fetch(apiUrl.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID || "",
|
||||
},
|
||||
});
|
||||
if (!apiRes.ok) {
|
||||
throw new Error("Failed to fetch supported tokens");
|
||||
}
|
||||
const apiJson = await apiRes.json();
|
||||
if (apiJson.error) {
|
||||
throw new Error(apiJson.error.message);
|
||||
}
|
||||
const apiTokens = apiJson as {
|
||||
kinds: Array<{
|
||||
x402Version: 1;
|
||||
scheme: "exact";
|
||||
network: string;
|
||||
extra: {
|
||||
defaultAsset: {
|
||||
address: string;
|
||||
decimals: number;
|
||||
eip712: {
|
||||
name: string;
|
||||
version: string;
|
||||
primaryType: "TransferWithAuthorization" | "Permit";
|
||||
};
|
||||
};
|
||||
supportedAssets?: Array<{
|
||||
address: string;
|
||||
decimals: number;
|
||||
eip712: {
|
||||
name: string;
|
||||
version: string;
|
||||
primaryType: "TransferWithAuthorization" | "Permit";
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const result = apiTokens.kinds
|
||||
.flatMap((token) => {
|
||||
const assets = token.extra?.defaultAsset
|
||||
? [token.extra?.defaultAsset]
|
||||
: (token.extra?.supportedAssets ?? []);
|
||||
return assets.map((asset) => ({
|
||||
chainId: chainId,
|
||||
address: asset.address,
|
||||
decimals: asset.decimals,
|
||||
symbol: asset.eip712.name,
|
||||
name: asset.eip712.name,
|
||||
})) as TokenMetadata[];
|
||||
})
|
||||
.filter((token) => token !== undefined)
|
||||
.flat();
|
||||
return result;
|
||||
}
|
||||
return bridgeTokens;
|
||||
}
|
||||
|
||||
export function useTokensData({
|
||||
|
||||
@@ -77,3 +77,5 @@ export default async function ExtensionPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // revalidate every day
|
||||
|
||||
@@ -19,3 +19,5 @@ export const metadata = createMetadata({
|
||||
},
|
||||
title: "Contracts",
|
||||
});
|
||||
|
||||
export const revalidate = 86400; // revalidate every day
|
||||
|
||||
@@ -12,15 +12,15 @@ Use `ThirdwebContract.Read` to fetch data from a smart contract without making a
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
var result = await contract.Read<T>(contract, "methodName", parameters);
|
||||
// Static
|
||||
var result = await ThirdwebContract.Read<T>(contract, "methodName", parameters);
|
||||
|
||||
// Extension
|
||||
var result = await contract.Read<T>("methodName", parameters);
|
||||
```
|
||||
|
||||
<Details summary="Parameters">
|
||||
|
||||
### contract (required)
|
||||
|
||||
An instance of `ThirdwebContract`. Represents the smart contract you want to interact with.
|
||||
|
||||
### methodName (required)
|
||||
|
||||
The name of the contract method you wish to call. Must be a `string`.
|
||||
|
||||
@@ -12,7 +12,11 @@ The `ThirdwebContract.Write` method allows you to execute transactions that alte
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
var transactionReceipt = await contract.Write(wallet, contract, "methodName", weiValue, parameters);
|
||||
// Static
|
||||
var receipt = await ThirdwebContract.Write(wallet, contract, "transfer", weiValue, toAddress, amount);
|
||||
|
||||
// Extension
|
||||
var transactionReceipt = await contract.Write(wallet, "methodName", weiValue, parameters);
|
||||
```
|
||||
|
||||
<Details summary="Parameters">
|
||||
@@ -21,10 +25,6 @@ var transactionReceipt = await contract.Write(wallet, contract, "methodName", we
|
||||
|
||||
An instance of `IThirdwebWallet`. This represents the signer of the transaction, which can be any type of wallet provider.
|
||||
|
||||
### contract (required)
|
||||
|
||||
An instance of `ThirdwebContract`. Represents the smart contract you wish to interact with.
|
||||
|
||||
### methodName (required)
|
||||
|
||||
The name of the smart contract method you intend to call. Must be a `string`.
|
||||
@@ -57,9 +57,6 @@ string contractAddress = "0x..."; // Your contract address
|
||||
var client = ThirdwebClient.Create(secretKey: "yourSecretKey");
|
||||
var contract = await ThirdwebContract.Create(client, contractAddress, chainId);
|
||||
|
||||
// The wallet that signs and sends the transaction
|
||||
var wallet = await PrivateKeyWallet.Create(client, "yourPrivateKeyHex");
|
||||
|
||||
// Assuming transfer takes an address and an amount as parameters
|
||||
string toAddress = "0x...";
|
||||
BigInteger amount = new BigInteger(1000); // The amount to transfer
|
||||
@@ -68,7 +65,7 @@ BigInteger amount = new BigInteger(1000); // The amount to transfer
|
||||
BigInteger weiValue = BigInteger.Zero;
|
||||
|
||||
// Executing the transfer
|
||||
var receipt = await contract.Write(wallet, contract, "transfer", weiValue, toAddress, amount);
|
||||
var receipt = await contract.Write(wallet, "transfer", weiValue, toAddress, amount);
|
||||
Console.WriteLine($"Transaction receipt: {receipt}");
|
||||
```
|
||||
|
||||
|
||||
@@ -23,13 +23,22 @@ Looking for the contract APIs themselves? Check out the [Contracts guides](/dotn
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
ThirdwebTransaction transaction = await contract.Prepare(
|
||||
// Static
|
||||
ThirdwebTransaction transaction = await ThirdwebContract.Prepare(
|
||||
wallet,
|
||||
contract,
|
||||
"methodName",
|
||||
weiValue,
|
||||
parameters
|
||||
);
|
||||
|
||||
// Extension
|
||||
ThirdwebTransaction transaction = await contract.Prepare(
|
||||
wallet,
|
||||
"methodName",
|
||||
weiValue,
|
||||
parameters
|
||||
);
|
||||
```
|
||||
|
||||
Once prepared, you can tweak or send the transaction:
|
||||
|
||||
@@ -27,3 +27,5 @@ export default async function Page(props: PageProps) {
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // revalidate every day
|
||||
|
||||
@@ -20,3 +20,5 @@ export const metadata = createMetadata({
|
||||
"A type-safe library to interact with any EVM-compatible blockchain in React applications.",
|
||||
title: "thirdweb React SDK",
|
||||
});
|
||||
|
||||
export const revalidate = 86400; // revalidate every day
|
||||
|
||||
@@ -13,6 +13,7 @@ const tagsToGroup = {
|
||||
"@appURI": "App URI",
|
||||
"@auth": "Auth",
|
||||
"@bridge": "Payments",
|
||||
"@x402": "x402",
|
||||
"@chain": "Chain",
|
||||
"@claimConditions": "Claim Conditions",
|
||||
"@client": "Client",
|
||||
@@ -64,6 +65,7 @@ const sidebarGroupOrder: TagKey[] = [
|
||||
"@transaction",
|
||||
"@insight",
|
||||
"@engine",
|
||||
"@x402",
|
||||
"@bridge",
|
||||
"@nebula",
|
||||
"@social",
|
||||
|
||||
@@ -14,3 +14,5 @@ const config = getTDocPage({
|
||||
export default config.default;
|
||||
export const generateStaticParams = config.generateStaticParams;
|
||||
export const generateMetadata = config.generateMetadata;
|
||||
|
||||
export const revalidate = 86400; // revalidate every day
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user