Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7c458eaa49 Version Packages (#7814)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-08 11:04:39 -07:00
gregandGitHub f7ce6becd6 [SDK] Fix: Adds onExecuteTransaction to TransactionPayment (#7816) 2025-08-07 20:27:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
52d1ba32c4 Chore(deps): Bump viem from 2.28.1 to 2.33.2 (#7793)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-07 18:31:12 -07:00
saminacodes 1a7315c815 arb-docs (#7799)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the documentation for thirdweb's `Stylus` contracts, detailing the deployment and usage of `Stylus` contracts and `Airdrop` contracts on the `Arbitrum` network, including benefits, prerequisites, and step-by-step tutorials.

### Detailed summary
- Added a new section on `Contract security` in `page.mdx`.
- Updated `sidebar.tsx` to include collapsible sections for `Arbitrum Stylus` with links to contract deployment guides.
- Created detailed documentation for `Stylus Contracts` and `Stylus Airdrop Contracts` in their respective `page.mdx` files, including benefits, prerequisites, and step-by-step deployment instructions.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Documentation**
  * Added a new page detailing Stylus Airdrop Contract deployment and usage on Arbitrum, including multiple airdrop patterns and integration examples.
  * Introduced a guide for deploying Stylus contracts on Arbitrum, covering project setup, deployment, and NFT minting.
  * Added a contract security section highlighting third-party audits and linking to audit reports.
  * Updated the sidebar with a new "Arbitrum Stylus" section for easier navigation to Stylus contract guides.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-08 00:48:15 +00:00
gregandGitHub 50c6c193c6 [SDK] Fix: Bridge.tokens domain (#7815) 2025-08-07 16:55:42 -07:00
gregandGitHub e114b2c4f9 [SDK] Fix: Skip payment method selection when balance is sufficient (#7813) 2025-08-07 16:37:53 -07:00
MananTank d812cc370b [MNY-92] Dashboard: Add tokens section in explore (#7812)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on improving the project selection and token creation flow within the dashboard application. It includes the addition of new components, updates to existing ones, and enhancements to user notifications and alerts.

### Detailed summary
- Updated import paths for `project-selector` and `team-selector`.
- Added `TokenBanner` component for displaying token creation options.
- Enhanced `DismissibleAlert` to support conditional rendering based on visibility state.
- Introduced `GenericProjectSelector` for improved project selection.
- Modified `ExplorePage` layout and styling for better presentation.
- Added `TokensSection` component with links for launching tokens and NFTs.
- Updated `reportTokenUpsellClicked` function for event tracking.
- Improved UI elements for better user experience and responsiveness.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced a "Tokens" section on the Explore page, showcasing options for creating ERC-20 tokens (Coins) and NFT Collections.
  * Added a promotional token banner on deploy pages for select published token contracts.
  * Launched a new project selection page within the team context, streamlining project navigation and selection.
  * Added a generic project selector component to enhance project selection flexibility.
  * Added tracking for clicks on token creation cards and token upsell banner interactions to improve analytics.

* **Improvements**
  * Enhanced layout and styling of the Explore page for better readability and organization.
  * Updated project and team selector components for more flexible descriptions and improved navigation.
  * Updated team page alert to preserve dismissal state across sessions.

* **Chores**
  * Refined import paths for improved code organization and maintainability.
  * Refactored dismissible alert component to support optional state persistence.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-07 20:45:31 +00:00
MananTank 7883127d51 Dashboard: Add caching to analytics charts (#7804)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the analytics functionality across various components by adding an `authToken` parameter to API calls, ensuring secure access to analytics data.

### Detailed summary
- Added `authToken` prop to multiple components for secure API access.
- Updated API functions to accept `authToken` for authentication.
- Modified analytics data fetching methods to include `authToken`.
- Changed default values and parameters in several functions for consistency.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Analytics data fetching now uses caching for improved performance.
  * Authentication is enforced for all analytics-related pages and components, with users redirected to login if not authenticated.

* **Refactor**
  * All analytics API calls and related components now require an explicit authentication token, improving security and consistency.
  * Parameters for analytics functions and components are more consistently structured, often grouped into objects for clarity.

* **Style**
  * Default chart display updated from "totalVolume" to "activeUsers" in highlights cards for a more relevant initial view.

* **Bug Fixes**
  * Standardized error handling across analytics data requests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-07 14:33:04 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
53d0d74d9b Version Packages (#7810)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-07 02:59:39 -07:00
Joaquim VergesandGitHub c0948fb9fe [SDK] Handle environments where process is not defined (#7809) 2025-08-07 02:48:44 -07:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9ae2991ad7 Version Packages (#7806)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-06 19:24:10 -07:00
MananTank bab4ff5e14 Dashboard: Improved chart responsiveness, code cleanup (#7801)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing analytics components by introducing responsive time filters, updating chart components to support new props, and refactoring existing code for improved clarity and functionality.

### Detailed summary
- Removed `range-selector.tsx` and refactored `AnalyticsHeader.tsx`.
- Added `ResponsiveTimeFilters` for better time range handling.
- Updated `EcosystemWalletsSummary` and `EcosystemAnalyticsPage` to use new props.
- Refactored various chart components to accept `selectedChart` and `selectedChartQueryParam`.
- Enhanced `AsyncTransactionsChartCard` and `AsyncTotalSponsoredCard` for better data handling.
- Replaced `LoadingChartState` with responsive loading states across components.
- Consolidated logic for handling time series data in analytics cards.
- Improved type definitions for better clarity and consistency.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/page.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced responsive and modular time filter controls for analytics dashboards.
  * Added new highlights cards for team and project analytics, providing aggregated metrics in a combined bar chart format.
  * Added a new combined bar chart card for gas sponsorship data with dynamic chart selection.
  * Added components that map transaction and sponsorship data with chain metadata for improved analytics detail.

* **Refactor**
  * Replaced multiple analytics and chart components with streamlined, asynchronous, and responsive variants.
  * Standardized date range and interval filtering using a centralized utility and responsive providers.
  * Updated chart selection to use in-app state and callbacks instead of URL-based navigation.
  * Simplified analytics page layouts by replacing Suspense with responsive suspense components.
  * Externalized data processing and state management from several chart card components to improve modularity.
  * Removed deprecated components and replaced them with updated responsive and async implementations.

* **Bug Fixes**
  * Improved handling of date ranges and intervals, ensuring accurate and consistent filtering.

* **Chores**
  * Removed unused or redundant components and consolidated stories for better maintainability.
  * Cleaned up imports and type definitions across analytics and project overview pages.

* **Style**
  * Enhanced button and layout styling for chart selection and analytics cards.

* **Documentation**
  * Updated interactive stories for analytics components to improve clarity and usability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-07 01:24:29 +00:00
621c1de62e [SDK] Add buttonLabel prop to checkout widgets (#7805)
Co-authored-by: Cursor Agent <[email protected]>
2025-08-06 18:23:53 -07:00
Joaquim VergesandGitHub 8bb2b0a227 [Docs] Update pregenerate wallets API endpoint and request format (#7791) 2025-08-06 13:49:16 -07:00
83 changed files with 4296 additions and 2106 deletions
+14
View File
@@ -503,3 +503,17 @@ export function reportFundWalletSuccessful() {
export function reportFundWalletFailed(params: { errorMessage: string }) {
posthog.capture("fund wallet failed", params);
}
/**
* ### Why do we need to report this event?
* - To track the conversion rate of the users choosing to create a token from new flow instead of the old flow
*
* ### Who is responsible for this event?
* @MananTank
*/
export function reportTokenUpsellClicked(params: {
assetType: "nft" | "coin";
pageType: "explore" | "deploy-contract";
}) {
posthog.capture("token upsell clicked", params);
}
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ export function DateRangeSelector(props: {
}) {
const { range, setRange } = props;
const daysDiff = differenceInCalendarDays(range.to, range.from);
const matchingRange =
normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime()
? durationPresets.find((preset) => preset.days === daysDiff)
@@ -85,7 +86,7 @@ export function getLastNDaysRange(id: DurationId) {
throw new Error(`Invalid duration id: ${id}`);
}
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
const todayDate = new Date(Date.now());
const value: Range = {
from: subDays(todayDate, durationInfo.days),
@@ -1,104 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import {
DateRangeSelector,
getLastNDaysRange,
} from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { useDashboardRouter } from "@/lib/DashboardRouter";
export function RangeSelector({
range,
interval,
}: {
range?: Range;
interval: "day" | "week";
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useDashboardRouter();
const [localRange, setRange] = useState<Range>(
range || getLastNDaysRange("last-120"),
);
const [localInterval, setInterval] = useState<"day" | "week">(interval);
useQuery({
queryFn: async () => {
if (range) {
setRange(range);
return range;
}
if (searchParams) {
const fromStringified = searchParams.get("from");
const from = fromStringified
? new Date(fromStringified)
: getLastNDaysRange("last-120").from;
const toStringified = searchParams.get("to");
const to = toStringified ? new Date(toStringified) : new Date();
const type = (searchParams.get("type") as DurationId) || "last-120";
setRange({ from, to, type });
setInterval(interval);
return { from, to, type } satisfies Range;
}
return getLastNDaysRange("last-120");
},
queryKey: ["analytics-range", searchParams?.toString(), range],
});
// prefetch for each interval and default range
useQuery({
queryFn: async () => {
const newSearchParams = new URLSearchParams(searchParams || {});
for (const interval of ["day", "week"] as const) {
newSearchParams.set("interval", interval);
for (const type of [
"last-120",
"last-60",
"last-30",
"last-7",
] as const) {
const newRange = getLastNDaysRange(type);
newSearchParams.set("from", newRange.from.toISOString());
newSearchParams.set("to", newRange.to.toISOString());
newSearchParams.set("type", newRange.type);
router.prefetch(`${pathname}?${newSearchParams.toString()}`);
}
}
return true;
},
queryKey: ["analytics-range", searchParams?.toString()],
});
return (
<div className="flex justify-end gap-3 flex-col lg:flex-row">
<DateRangeSelector
className="rounded-full"
range={localRange}
setRange={(newRange) => {
setRange(newRange);
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("from", newRange.from.toISOString());
newSearchParams.set("to", newRange.to.toISOString());
newSearchParams.set("type", newRange.type);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
<IntervalSelector
className="bg-card rounded-full"
intervalType={localInterval}
setIntervalType={(newInterval) => {
setInterval(newInterval);
const newSearchParams = new URLSearchParams(searchParams || {});
newSearchParams.set("interval", newInterval);
router.push(`${pathname}?${newSearchParams.toString()}`);
}}
/>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import {
DateRangeSelector,
type DurationId,
} from "@/components/analytics/date-range-selector";
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
export function ResponsiveTimeFilters(props: { defaultRange: DurationId }) {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getFiltersFromSearchParams({
defaultRange: props.defaultRange,
from: responsiveSearchParams.from,
interval: responsiveSearchParams.interval,
to: responsiveSearchParams.to,
});
return (
<div className="flex justify-end gap-3 flex-col lg:flex-row">
<DateRangeSelector
className="rounded-full"
range={range}
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
const newParams = {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
return newParams;
});
}}
/>
<IntervalSelector
className="bg-card rounded-full"
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
const newParams = {
...v,
interval: newInterval,
};
return newParams;
});
}}
/>
</div>
);
}
@@ -1,12 +1,39 @@
"use client";
import { XIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "@/hooks/useLocalStorage";
export function DismissibleAlert(props: {
export function DismissibleAlert(
props: {
title: React.ReactNode;
header?: React.ReactNode;
className?: string;
description: React.ReactNode;
children?: React.ReactNode;
} & (
| {
preserveState: true;
localStorageId: string;
}
| {
preserveState: false;
}
),
) {
if (props.preserveState) {
return <DismissibleAlertWithLocalStorage {...props} />;
}
return <DismissibleAlertWithoutLocalStorage {...props} />;
}
function DismissibleAlertWithLocalStorage(props: {
title: React.ReactNode;
header?: React.ReactNode;
description: React.ReactNode;
children?: React.ReactNode;
localStorageId: string;
}) {
const [isVisible, setIsVisible] = useLocalStorage(
@@ -17,19 +44,48 @@ export function DismissibleAlert(props: {
if (!isVisible) return null;
return <AlertUI {...props} onClose={() => setIsVisible(false)} />;
}
function DismissibleAlertWithoutLocalStorage(props: {
title: React.ReactNode;
description: React.ReactNode;
children?: React.ReactNode;
}) {
const [isVisible, setIsVisible] = useState(true);
if (!isVisible) return null;
return <AlertUI {...props} onClose={() => setIsVisible(false)} />;
}
function AlertUI(props: {
title: React.ReactNode;
header?: React.ReactNode;
description: React.ReactNode;
children?: React.ReactNode;
className?: string;
onClose: () => void;
}) {
return (
<div className="relative rounded-lg border border-border bg-card p-4">
<Button
aria-label="Close alert"
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
onClick={() => setIsVisible(false)}
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
<div className="text-muted-foreground text-sm">{props.description}</div>
<div className={props.className}>
<div className="relative rounded-lg border border-border bg-card p-4 lg:p-6 overflow-hidden">
<Button
aria-label="Close alert"
className="absolute top-4 right-4 h-auto w-auto p-1 text-muted-foreground"
onClick={props.onClose}
variant="ghost"
>
<XIcon className="size-5" />
</Button>
<div>
{props.header}
<h2 className="mb-0.5 font-semibold">{props.title} </h2>
<div className="text-muted-foreground text-sm">
{props.description}
</div>
{props.children}
</div>
</div>
</div>
);
@@ -4,6 +4,7 @@ import { ContractRow } from "./components/contract-row";
import { DeployUpsellCard } from "./components/upsells/deploy-your-own";
import { PublishUpsellCard } from "./components/upsells/publish-submit";
import { EXPLORE_PAGE_DATA } from "./data";
import { TokensSection } from "./tokens-section";
const title = "List of smart contracts for EVM Developers";
const description =
@@ -22,11 +23,11 @@ export default async function ExplorePage() {
return (
<div className="flex flex-col">
<div className="border-b py-10">
<div className="container">
<h1 className="mb-2 font-bold text-3xl lg:text-5xl tracking-tighter">
<div className="container max-w-7xl">
<h1 className="mb-2 font-bold text-4xl lg:text-5xl tracking-tight">
Explore
</h1>
<p className="max-w-screen-sm text-base text-muted-foreground">
<p className="max-w-screen-sm text-sm lg:text-base text-muted-foreground">
The best place for web3 developers to explore smart contracts from
world-class web3 protocols & engineers — all deployable with one
click.
@@ -34,7 +35,11 @@ export default async function ExplorePage() {
</div>
</div>
<div className="container flex flex-col gap-4 py-10">
<div className="container max-w-7xl pt-8 pb-4">
<TokensSection />
</div>
<div className="container max-w-7xl flex flex-col gap-8 py-10">
<div className="flex flex-col gap-14">
{EXPLORE_PAGE_DATA.map((category, idx) => (
<Fragment key={category.id}>
@@ -0,0 +1,111 @@
"use client";
import { ZapIcon } from "lucide-react";
import Link from "next/link";
import { reportTokenUpsellClicked } from "@/analytics/report";
import { GridPattern } from "@/components/ui/background-patterns";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function TokensSection() {
return (
<div className="bg-card p-4 lg:py-8 lg:px-6 border rounded-xl relative w-full overflow-hidden">
<GridPattern
width={30}
height={30}
strokeDasharray={"4 2"}
className="text-border dark:text-border/70 hidden lg:block translate-x-5"
style={{
maskImage:
"linear-gradient(to bottom left,white,transparent,transparent)",
}}
/>
<Badge
variant="outline"
className="text-sm bg-background h-auto px-3 py-1 gap-2 mb-4"
>
<div className="rounded-full bg-primary size-2" />
New
</Badge>
<h2 className="font-semibold text-2xl tracking-tight mb-1">
Launch Your Tokens Effortlessly
</h2>
<p className="text-muted-foreground mb-6 text-sm lg:text-base">
Deploy contract and configure all settings you need to launch your token
in one seamless flow
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-4xl">
<CardLink
assetType="coin"
title="Launch Coin"
description="Launch an ERC-20 token to create a cryptocurrency"
href="/team/~/~project/tokens/create/token"
bullets={[
"Deploy Contract",
"Configure Price and Supply",
"Airdrop Tokens",
]}
/>
<CardLink
assetType="nft"
title="Launch NFT Collection"
description="Launch an ERC-721 or ERC-1155 NFT collection"
href="/team/~/~project/tokens/create/nft"
bullets={[
"Deploy Contract",
"Upload NFTs",
"Configure Price and Supply",
]}
/>
</div>
</div>
);
}
function CardLink(props: {
title: string;
description: string;
href: string;
assetType: "nft" | "coin";
bullets: string[];
}) {
return (
<div
className={cn(
"relative flex flex-col rounded-lg border p-4 cursor-pointer hover:border-active-border bg-background",
)}
>
<div className="mb-3 flex">
<div className="flex items-center justify-center rounded-full border p-2 bg-card">
<ZapIcon className="size-4 text-muted-foreground" />
</div>
</div>
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
<Link
className="before:absolute before:inset-0"
href={props.href}
onClick={() => {
reportTokenUpsellClicked({
assetType: props.assetType,
pageType: "explore",
});
}}
>
{props.title}
</Link>
</h3>
<p className="text-muted-foreground text-sm">{props.description}</p>
<ul className="mt-4 space-y-1 text-sm list-disc list-inside text-muted-foreground">
{props.bullets.map((bullet) => (
<li key={bullet}>{bullet}</li>
))}
</ul>
</div>
);
}
@@ -10,6 +10,7 @@ import { ZERO_FEE_VERSIONS } from "@/constants/fee-config";
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
import { PublishedContractBreadcrumbs } from "../[publisher]/[contract_id]/components/breadcrumbs.client";
import { DeployContractHeader } from "./contract-header";
import { TokenBanner } from "./token-banner";
import { DeployFormForUri } from "./uri-based-deploy";
type PublishBasedDeployProps = {
@@ -95,6 +96,10 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
),
]);
const isTWPublisher =
contractMetadata?.publisher ===
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024";
return (
<div>
<div className="border-border border-b border-dashed">
@@ -111,6 +116,23 @@ export async function DeployFormForPublishInfo(props: PublishBasedDeployProps) {
/>
</div>
{isTWPublisher &&
(contractMetadata.name === "DropERC20" ||
contractMetadata.name === "TokenERC20") && (
<TokenBanner type="erc20" />
)}
{isTWPublisher &&
(contractMetadata.name === "DropERC721" ||
contractMetadata.name === "TokenERC721") && (
<TokenBanner type="erc721" />
)}
{isTWPublisher &&
(contractMetadata.name === "DropERC1155" ||
contractMetadata.name === "TokenERC1155") && (
<TokenBanner type="erc1155" />
)}
<div className="container max-w-5xl py-8">
<DeployFormForUri
contractMetadata={contractMetadata}
@@ -0,0 +1,63 @@
"use client";
import { ArrowUpRightIcon } from "lucide-react";
import Link from "next/link";
import { reportTokenUpsellClicked } from "@/analytics/report";
import { DismissibleAlert } from "@/components/blocks/dismissible-alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function TokenBanner(props: { type: "erc20" | "erc721" | "erc1155" }) {
const title =
props.type === "erc20"
? "Launch Your Token Effortlessly"
: "Launch Your NFT Collection Effortlessly";
const description =
props.type === "erc20"
? "Deploy contract, set price and supply, airdrop tokens all in one seamless flow"
: props.type === "erc721"
? "Deploy contract, upload NFTs, set price all in one seamless flow"
: "Deploy contract, upload NFTs, set prices and supply all in one seamless flow";
const href =
props.type === "erc20"
? "/team/~/~project/tokens/create/token"
: "/team/~/~project/tokens/create/nft";
return (
<DismissibleAlert
header={
<Badge
variant="outline"
className="bg-background gap-1.5 py-1 px-2.5 h-auto mb-3"
>
<div className="rounded-full bg-primary size-1.5" />
New
</Badge>
}
title={title}
className="container max-w-5xl mt-8"
preserveState={false}
description={description}
>
<Button
variant="default"
size="sm"
className="gap-2 rounded-full mt-4 px-6"
asChild
>
<Link
href={href}
target="_blank"
onClick={() => {
reportTokenUpsellClicked({
assetType: props.type === "erc20" ? "coin" : "nft",
pageType: "deploy-contract",
});
}}
>
Try Now <ArrowUpRightIcon className="size-3.5" />
</Link>
</Button>
</DismissibleAlert>
);
}
@@ -1,35 +1,53 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { ChainMetadata } from "thirdweb/chains";
import { cn } from "@/lib/utils";
import type { UserOpStats } from "@/types/analytics";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
export async function TotalSponsoredChartCardUI({
data,
aggregatedData,
searchParams,
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",
},
};
export function TotalSponsoredChartCardUI({
processedAggregatedData,
selectedChart,
className,
onlyMainnet,
title,
chains,
data,
description,
selectedChartQueryParam,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams?: { [key: string]: string | string[] | undefined };
selectedChart: string | undefined;
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
description?: string;
chains: ChainMetadata[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
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 setResponsiveSearchParams = useSetResponsiveSearchParams();
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
@@ -55,35 +73,6 @@ export async function TotalSponsoredChartCardUI({
}))
.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 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",
},
};
if (onlyMainnet) {
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
@@ -106,15 +95,23 @@ export async function TotalSponsoredChartCardUI({
return (
<CombinedBarChartCard
activeChart={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof typeof chartConfig)
: "mainnet"
}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
className={className}
data={timeSeriesData}
existingQueryParams={searchParams}
isCurrency
queryKey="totalSponsored"
title={title || "Gas Sponsored"}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -1,37 +1,53 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
"use client";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { ChainMetadata } from "thirdweb/chains";
import { cn } from "@/lib/utils";
import type { TransactionStats } from "@/types/analytics";
import { BarChart } from "../../../components/Analytics/BarChart";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { EmptyAccountAbstractionChartContent } from "../../[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/SponsoredTransactionsChartCard";
export async function TransactionsChartCardUI({
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",
},
};
export function TransactionsChartCardUI({
data,
aggregatedData,
searchParams,
className,
onlyMainnet,
title,
description,
selectedChart,
selectedChartQueryParam,
chains,
processedAggregatedData,
}: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams?: { [key: string]: string | string[] | undefined };
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
selectedChart: string | undefined;
description?: string;
chains: ChainMetadata[];
processedAggregatedData: {
mainnet: number;
testnet: number;
total: number;
};
}) {
const uniqueChainIds = [
...new Set(data.map((item) => item.chainId).filter(Boolean)),
];
const chains = await Promise.all(
uniqueChainIds.map((chainId) =>
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
// Process data to combine by date and chain type
const dateMap = new Map<string, { mainnet: number; testnet: number }>();
@@ -57,35 +73,6 @@ export async function TransactionsChartCardUI({
}))
.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.count, 0),
testnet: aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
};
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",
},
};
if (onlyMainnet) {
const filteredData = timeSeriesData.filter((d) => d.mainnet > 0);
return (
@@ -107,15 +94,22 @@ export async function TransactionsChartCardUI({
return (
<CombinedBarChartCard
activeChart={
(searchParams?.client_transactions as keyof typeof chartConfig) ??
"mainnet"
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof typeof chartConfig)
: "mainnet"
}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
className={className}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="client_transactions"
title={title || "Transactions"}
// Get the trend from the last two COMPLETE periods
trendFn={(data, key) =>
@@ -0,0 +1,51 @@
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import type { TransactionStats } from "@/types/analytics";
import { TransactionsChartCardUI } from "./TransactionsCard";
export async function TransactionsChartCardWithChainMapping(props: {
data: TransactionStats[];
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
selectedChart: string | undefined;
description?: string;
aggregatedData: TransactionStats[];
}) {
const uniqueChainIds = [
...new Set(props.data.map((item) => item.chainId).filter(Boolean)),
];
const chains = await Promise.all(
uniqueChainIds.map((chainId) =>
// eslint-disable-next-line no-restricted-syntax
getChainMetadata(defineChain(Number(chainId))).catch(() => undefined),
),
).then((chains) => chains.filter((c) => c) as ChainMetadata[]);
const processedAggregatedData = {
mainnet: props.aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
testnet: props.aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.count, 0),
total: props.aggregatedData.reduce((acc, curr) => acc + curr.count, 0),
};
return (
<TransactionsChartCardUI
className={props.className}
data={props.data}
onlyMainnet={props.onlyMainnet}
processedAggregatedData={processedAggregatedData}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
chains={chains}
/>
);
}
@@ -38,7 +38,10 @@ export default async function Page(props: {
});
const projects = await getProjects(params.team_slug);
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
const projectsWithTotalWallets = await getProjectsWithAnalytics(
projects,
authToken,
);
return (
<div className="flex grow flex-col">
@@ -64,6 +67,7 @@ export default async function Page(props: {
<FreePlanUpsellBannerUI highlightPlan="growth" teamSlug={team.slug} />
) : (
<DismissibleAlert
preserveState={true}
description="Engines, contracts, project settings, and more are now managed within projects. Open or create a project to access them."
localStorageId={`${team.id}-engines-alert`}
title="Looking for Engines?"
@@ -78,6 +82,7 @@ export default async function Page(props: {
async function getProjectsWithAnalytics(
projects: Project[],
authToken: string,
): Promise<Array<ProjectWithAnalytics>> {
return Promise.all(
projects.map(async (project) => {
@@ -85,13 +90,16 @@ async function getProjectsWithAnalytics(
const today = new Date();
const thirtyDaysAgo = subDays(today, 30);
const data = await getWalletConnections({
from: thirtyDaysAgo,
period: "all",
projectId: project.id,
teamId: project.teamId,
to: today,
});
const data = await getWalletConnections(
{
from: thirtyDaysAgo,
period: "all",
projectId: project.id,
teamId: project.teamId,
to: today,
},
authToken,
);
let uniqueWalletsConnected = 0;
for (const d of data) {
@@ -0,0 +1,134 @@
"use client";
import { useMemo } from "react";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
import { EmptyStateContent } from "../../../../components/Analytics/EmptyStateCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
export function TeamHighlightsCard({
userStats,
volumeStats,
selectedChart,
selectedChartQueryParam,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const timeSeriesData = useMemo(
() => processTimeSeriesData(userStats, volumeStats),
[userStats, volumeStats],
);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/payments"}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
title="Team Highlights"
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
activeChart={
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof AggregatedMetrics)
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
@@ -1,9 +1,11 @@
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import { defineChain } from "thirdweb";
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
import { getWalletInfo, type WalletId } from "thirdweb/wallets";
import {
getClientTransactions,
@@ -13,31 +15,35 @@ import {
getWalletConnections,
getWalletUsers,
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getTeamBySlug } from "@/api/team/get-team";
import {
type DurationId,
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
} from "@/types/analytics";
import { AnalyticsHeader } from "../../../../components/Analytics/AnalyticsHeader";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
import { loginRedirect } from "@/utils/redirects";
import { PieChartCard } from "../../../../components/Analytics/PieChartCard";
import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard";
import { TransactionsChartCardUI } from "../../_components/TransactionsCard";
import { TransactionsChartCardWithChainMapping } from "../../_components/transaction-card-with-chain-mapping";
import { TeamHighlightsCard } from "./highlights-card";
type SearchParams = {
usersChart?: string;
from?: string;
to?: string;
type?: string;
interval?: string;
usersChart?: string | string[];
from?: string | string[];
to?: string | string[];
type?: string | string[];
interval?: string | string[];
client_transactions?: string | string[];
appHighlights?: string | string[];
userOpUsage?: string | string[];
};
export default async function TeamOverviewPage(props: {
@@ -49,105 +55,162 @@ export default async function TeamOverviewPage(props: {
props.searchParams,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/analytics`);
}
const team = await getTeamBySlug(params.team_slug);
if (!team) {
redirect("/team");
}
const interval = (searchParams.interval as "day" | "week") ?? "week";
const rangeType = (searchParams.type as DurationId) || "last-120";
const range: Range = {
from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from),
to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to),
type: rangeType,
};
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
return (
<div className="flex grow flex-col">
<div className="border-b">
<AnalyticsHeader
interval={interval}
range={range}
showRangeSelector={true}
title="Analytics"
/>
</div>
<div className="flex grow flex-col justify-between gap-10 container max-w-7xl pt-8 pb-16">
<div className="flex grow flex-col gap-6">
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncAppHighlightsCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
<Suspense
fallback={<LoadingChartState className="h-[431px] border" />}
>
<AsyncWalletDistributionCard range={range} teamId={team.id} />
</Suspense>
<Suspense
fallback={<LoadingChartState className="h-[431px] border" />}
>
<AsyncAuthMethodDistributionCard range={range} teamId={team.id} />
</Suspense>
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex grow flex-col">
{/* header */}
<div className="border-b">
<div className="container max-w-7xl flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
<div className="flex-1">
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
Analytics
</h1>
</div>
<div className="max-sm:w-full">
<ResponsiveTimeFilters defaultRange={defaultRange} />
</div>
</div>
</div>
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTransactionsChartCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="flex grow flex-col justify-between gap-10 container max-w-7xl pt-8 pb-16">
<div className="flex grow flex-col gap-6">
<ResponsiveSuspense
searchParamsUsed={["appHighlights", "from", "to", "interval"]}
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTeamHighlightsCard
authToken={authToken}
selectedChartQueryParam="appHighlights"
interval={interval}
range={range}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
<Suspense
fallback={<LoadingChartState className="h-[458px] border" />}
>
<AsyncTotalSponsoredCard
interval={interval}
range={range}
searchParams={searchParams}
teamId={team.id}
/>
</Suspense>
<div className="grid gap-6 md:grid-cols-2">
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to"]}
>
<AsyncWalletDistributionCard
range={range}
teamId={team.id}
authToken={authToken}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to"]}
>
<AsyncAuthMethodDistributionCard
range={range}
teamId={team.id}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={[
"from",
"to",
"interval",
"client_transactions",
]}
>
<AsyncTransactionsChartCard
selectedChartQueryParam="client_transactions"
authToken={authToken}
interval={interval}
range={range}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "userOpUsage"]}
>
<AsyncTotalSponsoredCard
authToken={authToken}
selectedChartQueryParam="userOpUsage"
interval={interval}
range={range}
selectedChart={
typeof searchParams.userOpUsage === "string"
? searchParams.userOpUsage
: undefined
}
teamId={team.id}
/>
</ResponsiveSuspense>
</div>
</div>
</div>
</div>
</ResponsiveSearchParamsProvider>
);
}
async function AsyncAppHighlightsCard(props: {
async function AsyncTeamHighlightsCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
authToken: string;
}) {
const [walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getWalletUsers({
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
}),
getUniversalBridgeUsage({
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
}),
getWalletUsers(
{
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
@@ -156,13 +219,12 @@ async function AsyncAppHighlightsCard(props: {
walletUserStatsTimeSeries.value.some((w) => w.totalUsers !== 0)
) {
return (
<div className="">
<AppHighlightsCard
searchParams={props.searchParams}
userStats={walletUserStatsTimeSeries.value}
volumeStats={universalBridgeUsage.value}
/>
</div>
<TeamHighlightsCard
selectedChart={props.selectedChart}
userStats={walletUserStatsTimeSeries.value}
selectedChartQueryParam={props.selectedChartQueryParam}
volumeStats={universalBridgeUsage.value}
/>
);
}
@@ -177,13 +239,17 @@ async function AsyncAppHighlightsCard(props: {
async function AsyncWalletDistributionCard(props: {
teamId: string;
range: Range;
authToken: string;
}) {
const walletConnections = await getWalletConnections({
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
}).catch(() => undefined);
const walletConnections = await getWalletConnections(
{
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return walletConnections && walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
@@ -198,13 +264,17 @@ async function AsyncWalletDistributionCard(props: {
async function AsyncAuthMethodDistributionCard(props: {
teamId: string;
range: Range;
authToken: string;
}) {
const inAppWalletUsage = await getInAppWalletUsage({
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
}).catch(() => undefined);
const inAppWalletUsage = await getInAppWalletUsage(
{
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
@@ -220,31 +290,40 @@ async function AsyncTransactionsChartCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
authToken: string;
}) {
const [clientTransactionsTimeSeries, clientTransactions] =
await Promise.allSettled([
getClientTransactions({
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
}),
getClientTransactions({
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
}),
getClientTransactions(
{
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
getClientTransactions(
{
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
]);
return clientTransactionsTimeSeries.status === "fulfilled" &&
clientTransactions.status === "fulfilled" &&
clientTransactions.value.length > 0 ? (
<TransactionsChartCardUI
<TransactionsChartCardWithChainMapping
selectedChartQueryParam={props.selectedChartQueryParam}
aggregatedData={clientTransactions.value}
data={clientTransactionsTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
/>
) : (
<EmptyStateCard
@@ -258,30 +337,39 @@ async function AsyncTotalSponsoredCard(props: {
teamId: string;
range: Range;
interval: "day" | "week";
searchParams: SearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
authToken: string;
}) {
const [userOpUsageTimeSeries, userOpUsage] = await Promise.allSettled([
getUserOpUsage({
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
}),
getUserOpUsage({
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
}),
getUserOpUsage(
{
from: props.range.from,
period: props.interval,
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
getUserOpUsage(
{
from: props.range.from,
period: "all",
teamId: props.teamId,
to: props.range.to,
},
props.authToken,
),
]);
return userOpUsageTimeSeries.status === "fulfilled" &&
userOpUsage.status === "fulfilled" &&
userOpUsage.value.length > 0 ? (
<TotalSponsoredChartCardUI
<AsyncTotalSponsoredChartCard
aggregatedData={userOpUsage.value}
data={userOpUsageTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
) : (
<EmptyStateCard
@@ -291,120 +379,6 @@ async function AsyncTotalSponsoredCard(props: {
);
}
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
function AppHighlightsCard({
userStats,
volumeStats,
searchParams,
}: {
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your apps haven't collected any fees yet."
link={"https://portal.thirdweb.com/payments"}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
activeChart={
(searchParams?.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="appHighlights"
title="App Highlights"
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data
@@ -446,3 +420,49 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
/>
);
}
async function AsyncTotalSponsoredChartCard(props: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
selectedChart: string | undefined;
className?: string;
onlyMainnet?: boolean;
title?: string;
selectedChartQueryParam: string;
description?: string;
}) {
const chains = await Promise.all(
props.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 processedAggregatedData = {
mainnet: props.aggregatedData
.filter(
(d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
testnet: props.aggregatedData
.filter(
(d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet,
)
.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
total: props.aggregatedData.reduce(
(acc, curr) => acc + curr.sponsoredUsd,
0,
),
};
return (
<TotalSponsoredChartCardUI
chains={chains}
processedAggregatedData={processedAggregatedData}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
data={props.data}
/>
);
}
@@ -1,10 +1,13 @@
import { Suspense } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import { getEcosystemWalletUsage } from "@/api/analytics";
import type { Partner } from "@/api/team/ecosystems";
import {
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import type { EcosystemWalletStats } from "@/types/analytics";
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
import { EcosystemWalletsSummary } from "./Summary";
@@ -14,74 +17,159 @@ export async function EcosystemAnalyticsPage({
interval,
range,
partners,
defaultRange,
authToken,
}: {
ecosystemSlug: string;
teamId: string;
interval: "day" | "week";
range?: Range;
range: Range;
defaultRange: DurationId;
partners: Partner[];
authToken: string;
}) {
if (!range) {
range = getLastNDaysRange("last-120");
}
const allTimeStatsPromise = getEcosystemWalletUsage({
ecosystemSlug,
from: new Date(2022, 0, 1),
period: "all",
teamId,
to: new Date(),
});
const monthlyStatsPromise = getEcosystemWalletUsage({
ecosystemSlug,
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
period: "month",
teamId,
to: new Date(),
});
const statsPromise = getEcosystemWalletUsage({
ecosystemSlug,
from: range.from,
period: interval,
teamId,
to: range.to,
}).catch(() => null);
const [allTimeStats, monthlyStats, stats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
statsPromise,
]);
return (
<div>
<EcosystemWalletsSummary
allTimeStats={allTimeStats ?? []}
monthlyStats={monthlyStats ?? []}
/>
<Suspense
fallback={
<EcosystemWalletsSummary
allTimeStats={[]}
monthlyStats={[]}
isPending={true}
/>
}
>
<AsyncEcosystemWalletsSummary
ecosystemSlug={ecosystemSlug}
teamId={teamId}
authToken={authToken}
/>
</Suspense>
<div className="h-6" />
<RangeSelector interval={interval} range={range} />
<ResponsiveTimeFilters defaultRange={defaultRange} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<EcosystemWalletUsersChartCard
ecosystemWalletStats={stats || []}
groupBy="authenticationMethod"
isPending={false}
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<EcosystemWalletUsersAnalyticsUI
ecosystemWalletStats={[]}
groupBy="authenticationMethod"
isPending={true}
partners={partners}
/>
}
>
<AsyncEcosystemWalletUsersAnalytics
ecosystemSlug={ecosystemSlug}
interval={interval}
partners={partners}
range={range}
teamId={teamId}
authToken={authToken}
/>
<EcosystemWalletUsersChartCard
ecosystemWalletStats={stats || []}
groupBy="ecosystemPartnerId"
isPending={false}
partners={partners}
/>
</div>
</ResponsiveSuspense>
</div>
);
}
async function AsyncEcosystemWalletsSummary(props: {
ecosystemSlug: string;
teamId: string;
authToken: string;
}) {
const { ecosystemSlug, teamId, authToken } = props;
const allTimeStatsPromise = getEcosystemWalletUsage(
{
ecosystemSlug,
from: new Date(2022, 0, 1),
period: "all",
teamId,
to: new Date(),
},
authToken,
);
const monthlyStatsPromise = getEcosystemWalletUsage(
{
ecosystemSlug,
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
period: "month",
teamId,
to: new Date(),
},
authToken,
);
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
monthlyStatsPromise,
]);
return (
<EcosystemWalletsSummary
allTimeStats={allTimeStats ?? []}
monthlyStats={monthlyStats ?? []}
isPending={false}
/>
);
}
async function AsyncEcosystemWalletUsersAnalytics(props: {
ecosystemSlug: string;
teamId: string;
interval: "day" | "week";
range: Range;
partners: Partner[];
authToken: string;
}) {
const { ecosystemSlug, teamId, interval, range, partners, authToken } = props;
const stats = await getEcosystemWalletUsage(
{
ecosystemSlug,
from: range.from,
period: interval,
teamId,
to: range.to,
},
authToken,
).catch(() => null);
return (
<EcosystemWalletUsersAnalyticsUI
ecosystemWalletStats={stats || []}
groupBy="authenticationMethod"
isPending={false}
partners={partners}
/>
);
}
function EcosystemWalletUsersAnalyticsUI(props: {
ecosystemWalletStats: EcosystemWalletStats[];
isPending: boolean;
groupBy: "authenticationMethod" | "ecosystemPartnerId";
partners: Partner[];
}) {
return (
<div className="flex flex-col gap-4 lg:gap-6">
<EcosystemWalletUsersChartCard
ecosystemWalletStats={props.ecosystemWalletStats || []}
groupBy="authenticationMethod"
isPending={props.isPending}
partners={props.partners}
/>
<EcosystemWalletUsersChartCard
ecosystemWalletStats={props.ecosystemWalletStats || []}
groupBy="ecosystemPartnerId"
isPending={props.isPending}
partners={props.partners}
/>
</div>
);
}
@@ -5,6 +5,7 @@ import type { EcosystemWalletStats } from "@/types/analytics";
export function EcosystemWalletsSummary(props: {
allTimeStats: EcosystemWalletStats[] | undefined;
monthlyStats: EcosystemWalletStats[] | undefined;
isPending: boolean;
}) {
const allTimeStats = props.allTimeStats?.reduce(
(acc, curr) => {
@@ -30,13 +31,13 @@ export function EcosystemWalletsSummary(props: {
<div className="grid grid-cols-2 gap-4 lg:gap-6">
<StatCard
icon={ActivityIcon}
isPending={false}
isPending={props.isPending}
label="Total Users"
value={allTimeStats?.uniqueWalletsConnected || 0}
/>
<StatCard
icon={UserIcon}
isPending={false}
isPending={props.isPending}
label="Monthly Active Users"
value={monthlyStats?.uniqueWalletsConnected || 0}
/>
@@ -1,7 +1,10 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { fetchEcosystem } from "@/api/team/ecosystems";
import { getTeamBySlug } from "@/api/team/get-team";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { getFiltersFromSearchParams } from "@/lib/time";
import { fetchPartners } from "../configuration/hooks/fetchPartners";
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
@@ -47,21 +50,25 @@ export default async function Page(props: {
teamId: team.id,
});
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
return (
<EcosystemAnalyticsPage
ecosystemSlug={ecosystem.slug}
interval={searchParams.interval || "week"}
partners={partners}
range={
searchParams.from && searchParams.to
? {
from: new Date(searchParams.from),
to: new Date(searchParams.to),
type: "custom",
}
: undefined
}
teamId={team.id}
/>
<ResponsiveSearchParamsProvider value={searchParams}>
<EcosystemAnalyticsPage
authToken={authToken}
ecosystemSlug={ecosystem.slug}
interval={interval}
defaultRange={defaultRange}
partners={partners}
range={range}
teamId={team.id}
/>
</ResponsiveSearchParamsProvider>
);
}
@@ -35,11 +35,15 @@ function AccountAbstractionSummaryInner(props: {
async function AsyncAccountAbstractionSummary(props: {
teamId: string;
projectId: string;
authToken: string;
}) {
const aggregateUserOpStats = await getAggregateUserOpUsage({
projectId: props.projectId,
teamId: props.teamId,
});
const aggregateUserOpStats = await getAggregateUserOpUsage(
{
projectId: props.projectId,
teamId: props.teamId,
},
props.authToken,
);
return (
<AccountAbstractionSummaryInner
@@ -52,6 +56,7 @@ async function AsyncAccountAbstractionSummary(props: {
export function AccountAbstractionSummary(props: {
teamId: string;
projectId: string;
authToken: string;
}) {
return (
<Suspense
@@ -65,6 +70,7 @@ export function AccountAbstractionSummary(props: {
<AsyncAccountAbstractionSummary
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</Suspense>
);
@@ -61,13 +61,16 @@ export default async function Page(props: {
type: rangeType,
};
const userOpStats = await getUserOpUsage({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
});
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
@@ -79,6 +82,7 @@ export default async function Page(props: {
<AccountAbstractionSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<div className="h-10" />
@@ -1,19 +1,14 @@
import { Suspense } from "react";
import { getEngineCloudMethodUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export function EngineCloudChartCard(props: AnalyticsQueryParams) {
return (
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<EngineCloudChartCardAsync {...props} />
</Suspense>
export async function EngineCloudChartCardAsync(props: {
params: AnalyticsQueryParams;
authToken: string;
}) {
const rawData = await getEngineCloudMethodUsage(
props.params,
props.authToken,
);
}
async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) {
const rawData = await getEngineCloudMethodUsage(props);
return <EngineCloudBarChartCardUI rawData={rawData} />;
}
@@ -1,20 +1,11 @@
import { Suspense } from "react";
import { getRpcMethodUsage } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { RpcMethodBarChartCardUI } from "./RpcMethodBarChartCardUI";
export function RpcMethodBarChartCard(props: AnalyticsQueryParams) {
return (
// TODO: Add better LoadingChartState
<Suspense fallback={<LoadingChartState className="h-[377px] border" />}>
<RpcMethodBarChartCardAsync {...props} />
</Suspense>
);
}
async function RpcMethodBarChartCardAsync(props: AnalyticsQueryParams) {
const rawData = await getRpcMethodUsage(props);
export async function RpcMethodBarChartCardAsync(props: {
params: AnalyticsQueryParams;
authToken: string;
}) {
const rawData = await getRpcMethodUsage(props.params, props.authToken);
return <RpcMethodBarChartCardUI rawData={rawData} />;
}
@@ -3,29 +3,29 @@ import { getChainMetadata } from "thirdweb/chains";
import { fetchDashboardContractMetadata } from "@/hooks/useDashboardContractMetadata";
import type { TransactionStats } from "@/types/analytics";
import { PieChartCard } from "../../../../../components/Analytics/PieChartCard";
import { TransactionsChartCardUI } from "../../../../(team)/_components/TransactionsCard";
import { TransactionsChartCardWithChainMapping } from "../../../../(team)/_components/transaction-card-with-chain-mapping";
export function TransactionsChartsUI({
data,
aggregatedData,
searchParams,
client,
}: {
export function TransactionsChartsUI(props: {
data: TransactionStats[];
aggregatedData: TransactionStats[];
searchParams: { [key: string]: string | string[] | undefined };
selectedChartQueryParam: string;
client: ThirdwebClient;
selectedChart: string | undefined;
}) {
return (
<>
<TransactionsChartCardUI
aggregatedData={aggregatedData}
data={data}
searchParams={searchParams}
<TransactionsChartCardWithChainMapping
aggregatedData={props.aggregatedData}
data={props.data}
selectedChartQueryParam={props.selectedChartQueryParam}
selectedChart={props.selectedChart}
/>
<div className="grid gap-6 md:grid-cols-2">
<ChainDistributionCard data={aggregatedData} />
<ContractDistributionCard client={client} data={aggregatedData} />
<ChainDistributionCard data={props.aggregatedData} />
<ContractDistributionCard
client={props.client}
data={props.aggregatedData}
/>
</div>
</>
);
@@ -1,36 +1,26 @@
import { Suspense } from "react";
import type { ThirdwebClient } from "thirdweb";
import { getClientTransactions } from "@/api/analytics";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import type { AnalyticsQueryParams } from "@/types/analytics";
import { TransactionsChartsUI } from "./TransactionCharts";
export function TransactionsCharts(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
client: ThirdwebClient;
},
) {
return (
// TODO: Add better LoadingChartState
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<TransactionsChartCardAsync {...props} />
</Suspense>
);
}
async function TransactionsChartCardAsync(
props: AnalyticsQueryParams & {
searchParams: { [key: string]: string | string[] | undefined };
client: ThirdwebClient;
},
) {
export async function TransactionsChartCardAsync(props: {
params: AnalyticsQueryParams;
client: ThirdwebClient;
selectedChartQueryParam: string;
selectedChart: string | undefined;
authToken: string;
}) {
const { params, authToken, client, selectedChart, selectedChartQueryParam } =
props;
const [data, aggregatedData] = await Promise.all([
getClientTransactions(props),
getClientTransactions({
...props,
period: "all",
}),
getClientTransactions(params, authToken),
getClientTransactions(
{
...params,
period: "all",
},
authToken,
),
]);
if (!aggregatedData.length) {
@@ -40,9 +30,10 @@ async function TransactionsChartCardAsync(
return (
<TransactionsChartsUI
aggregatedData={aggregatedData}
client={props.client}
client={client}
data={data}
searchParams={props.searchParams}
selectedChart={selectedChart}
selectedChartQueryParam={selectedChartQueryParam}
/>
);
}
@@ -51,39 +51,52 @@ export async function InsightAnalytics(props: {
teamId: string;
range: Range;
interval: "day" | "week";
authToken: string;
}) {
const { projectId, teamId, range, interval } = props;
const allTimeRequestsPromise = getInsightUsage({
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
});
const chainsDataPromise = getInsightChainUsage({
from: range.from,
limit: 10,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
});
const statusCodesDataPromise = getInsightStatusCodeUsage({
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
});
const endpointsDataPromise = getInsightEndpointUsage({
from: range.from,
limit: 10,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
});
const allTimeRequestsPromise = getInsightUsage(
{
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
);
const chainsDataPromise = getInsightChainUsage(
{
from: range.from,
limit: 10,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
);
const statusCodesDataPromise = getInsightStatusCodeUsage(
{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
);
const endpointsDataPromise = getInsightEndpointUsage(
{
from: range.from,
limit: 10,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
);
const [allTimeRequestsData, statusCodesData, endpointsData, chainsData] =
await Promise.all([
@@ -54,6 +54,7 @@ export default async function Page(props: {
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
<div className="h-10" />
@@ -0,0 +1,139 @@
"use client";
import { EmptyStateContent } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { useSetResponsiveSearchParams } from "responsive-rsc";
import type { UniversalBridgeStats, WalletUserStats } from "@/types/analytics";
import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard";
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
export function ProjectHighlightsCard(props: {
selectedChart: string | undefined;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
teamSlug: string;
projectSlug: string;
selectedChartQueryParam: string;
}) {
const {
selectedChart,
userStats,
volumeStats,
teamSlug,
projectSlug,
selectedChartQueryParam,
} = props;
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${teamSlug}/${projectSlug}/payments/settings`}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
title="Project Highlights"
activeChart={
selectedChart && selectedChart in chartConfig
? (selectedChart as keyof AggregatedMetrics)
: "activeUsers"
}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
onSelect={(key) => {
setResponsiveSearchParams((v) => {
return {
...v,
[selectedChartQueryParam]: key,
};
});
}}
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
/**
* Processes time series data to combine wallet and user statistics
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
@@ -0,0 +1,71 @@
"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
}
/>
);
}
@@ -1,9 +1,9 @@
import {
EmptyStateCard,
EmptyStateContent,
} from "app/(app)/team/components/Analytics/EmptyStateCard";
import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import {
ResponsiveSearchParamsProvider,
ResponsiveSuspense,
} from "responsive-rsc";
import type { ThirdwebClient } from "thirdweb";
import {
type ChainMetadata,
@@ -21,43 +21,47 @@ import {
} from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject, type Project } from "@/api/project/projects";
import {
type DurationId,
getLastNDaysRange,
type Range,
import type {
DurationId,
Range,
} from "@/components/analytics/date-range-selector";
import { LoadingChartState } from "@/components/analytics/empty-chart-state";
import { RangeSelector } from "@/components/analytics/range-selector";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getFiltersFromSearchParams } from "@/lib/time";
import type {
InAppWalletStats,
UniversalBridgeStats,
UserOpStats,
WalletStats,
WalletUserStats,
} from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCard } from "./components/EngineCloudChartCard";
import { EngineCloudChartCardAsync } from "./components/EngineCloudChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
import { RpcMethodBarChartCardAsync } from "./components/RpcMethodBarChartCard";
import { TransactionsChartCardAsync } from "./components/Transactions";
import { ProjectHighlightsCard } from "./overview/highlights-card";
import { TotalSponsoredCardUI } from "./overview/total-sponsored";
interface PageParams {
type PageParams = {
team_slug: string;
project_slug: string;
}
};
interface PageSearchParams {
[key: string]: string | undefined;
}
type PageSearchParams = {
from: string | undefined | string[];
to: string | undefined | string[];
type: string | undefined | string[];
interval: string | undefined | string[];
appHighlights: string | undefined | string[];
client_transactions: string | undefined | string[];
totalSponsored: string | undefined | string[];
};
interface PageProps {
type PageProps = {
params: Promise<PageParams>;
searchParams: Promise<PageSearchParams>;
}
};
export default async function ProjectOverviewPage(props: PageProps) {
const [params, searchParams] = await Promise.all([
@@ -78,15 +82,16 @@ export default async function ProjectOverviewPage(props: PageProps) {
redirect(`/team/${params.team_slug}`);
}
const interval = (searchParams.interval as "day" | "week") ?? "week";
const rangeType = (searchParams.type as DurationId) || "last-120";
const range: Range = {
from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from),
to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to),
type: rangeType,
};
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange: "last-30",
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const activeStatus = await isProjectActive({
authToken,
projectId: project.id,
teamId: project.teamId,
});
@@ -99,28 +104,28 @@ export default async function ProjectOverviewPage(props: PageProps) {
});
return (
<div className="flex grow flex-col">
<div className="w-full border-b py-10">
<div className="container max-w-7xl">
<Header
interval={interval}
range={range}
showRangeSelector={isActive}
title={project.name}
/>
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex grow flex-col">
<div className="w-full border-b py-10">
<div className="container max-w-7xl">
<Header
defaultRange={defaultRange}
showRangeSelector={isActive}
title={project.name}
/>
</div>
</div>
</div>
<div className="h-6" />
<div className="h-6" />
{!isActive ? (
<div className="container max-w-7xl">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex max-w-7xl grow flex-col">
<Suspense fallback={<GenericLoadingPage />}>
{!isActive ? (
<div className="container max-w-7xl">
<ProjectFTUX project={project} teamSlug={params.team_slug} />
</div>
) : (
<div className="container flex max-w-7xl grow flex-col">
<ProjectAnalytics
authToken={authToken}
client={client}
interval={interval}
params={params}
@@ -128,12 +133,12 @@ export default async function ProjectOverviewPage(props: PageProps) {
range={range}
searchParams={searchParams}
/>
</Suspense>
</div>
)}
</div>
)}
<div className="h-20" />
</div>
<div className="h-20" />
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -144,138 +149,162 @@ async function ProjectAnalytics(props: {
interval: "day" | "week";
searchParams: PageSearchParams;
client: ThirdwebClient;
authToken: string;
}) {
const { project, params, range, interval, searchParams, client } = props;
const { project, params, range, interval, searchParams, client, authToken } =
props;
return (
<div className="flex grow flex-col gap-6">
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "appHighlights"]}
>
<AsyncAppHighlightsCard
authToken={authToken}
client={client}
interval={interval}
params={params}
project={project}
range={range}
searchParams={searchParams}
selectedChart={
typeof searchParams.appHighlights === "string"
? searchParams.appHighlights
: undefined
}
selectedChartQueryParam="appHighlights"
/>
</Suspense>
</ResponsiveSuspense>
<div className="grid gap-6 md:grid-cols-2">
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<AsyncWalletDistributionCard project={project} range={range} />
</Suspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncWalletDistributionCard
project={project}
range={range}
authToken={authToken}
/>
</ResponsiveSuspense>
<Suspense fallback={<LoadingChartState className="h-[431px] border" />}>
<AsyncAuthMethodDistributionCard project={project} range={range} />
</Suspense>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[431px] border" />}
searchParamsUsed={["from", "to", "interval"]}
>
<AsyncAuthMethodDistributionCard
project={project}
range={range}
authToken={authToken}
/>
</ResponsiveSuspense>
</div>
<TransactionsCharts
client={client}
from={range.from}
period={interval}
searchParams={searchParams}
teamId={project.teamId}
to={range.to}
/>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "client_transactions"]}
>
<TransactionsChartCardAsync
client={client}
params={{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
}}
authToken={authToken}
selectedChart={
typeof searchParams.client_transactions === "string"
? searchParams.client_transactions
: undefined
}
selectedChartQueryParam="client_transactions"
/>
</ResponsiveSuspense>
<Suspense fallback={<LoadingChartState className="h-[458px] border" />}>
<ResponsiveSuspense
fallback={<LoadingChartState className="h-[458px] border" />}
searchParamsUsed={["from", "to", "interval", "totalSponsored"]}
>
<AsyncTotalSponsoredCard
interval={interval}
project={project}
range={range}
searchParams={searchParams}
selectedChart={
typeof searchParams.totalSponsored === "string"
? searchParams.totalSponsored
: undefined
}
selectedChartQueryParam="totalSponsored"
authToken={authToken}
/>
</Suspense>
</ResponsiveSuspense>
<RpcMethodBarChartCard
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
<EngineCloudChartCard
from={range.from}
period={interval}
projectId={project.id}
teamId={project.teamId}
to={range.to}
/>
<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>
);
}
type AggregatedMetrics = {
activeUsers: number;
newUsers: number;
totalVolume: number;
feesCollected: number;
};
type TimeSeriesMetrics = AggregatedMetrics & {
date: string;
};
/**
* Processes time series data to combine wallet and user statistics
*/
function processTimeSeriesData(
userStats: WalletUserStats[],
volumeStats: UniversalBridgeStats[],
): TimeSeriesMetrics[] {
const metrics: TimeSeriesMetrics[] = [];
for (const stat of userStats) {
const volume = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.amountUsdCents / 100, 0);
const fees = volumeStats
.filter(
(v) =>
new Date(v.date).toISOString() ===
new Date(stat.date).toISOString() && v.status === "completed",
)
.reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0);
metrics.push({
activeUsers: stat.totalUsers ?? 0,
date: stat.date,
feesCollected: fees,
newUsers: stat.newUsers ?? 0,
totalVolume: volume,
});
}
return metrics;
}
export async function AsyncTotalSponsoredCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
selectedChart: string | undefined;
selectedChartQueryParam: string;
authToken: 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,
}),
getUserOpUsage({
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
}),
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" &&
@@ -285,7 +314,8 @@ export async function AsyncTotalSponsoredCard(props: {
<TotalSponsoredCard
aggregatedData={userOpUsage.value}
data={userOpUsageTimeSeries.value}
searchParams={props.searchParams}
selectedChart={props.selectedChart}
selectedChartQueryParam={props.selectedChartQueryParam}
/>
</div>
) : (
@@ -299,14 +329,18 @@ export async function AsyncTotalSponsoredCard(props: {
async function AsyncAuthMethodDistributionCard(props: {
project: Project;
range: Range;
authToken: string;
}) {
const inAppWalletUsage = await getInAppWalletUsage({
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
}).catch(() => undefined);
const inAppWalletUsage = await getInAppWalletUsage(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return inAppWalletUsage && inAppWalletUsage.length > 0 ? (
<AuthMethodDistributionCard data={inAppWalletUsage} />
@@ -322,26 +356,34 @@ async function AsyncAppHighlightsCard(props: {
project: Project;
range: Range;
interval: "day" | "week";
searchParams: PageSearchParams;
selectedChartQueryParam: string;
selectedChart: string | undefined;
client: ThirdwebClient;
params: PageParams;
authToken: string;
}) {
const [walletUserStatsTimeSeries, universalBridgeUsage] =
await Promise.allSettled([
getWalletUsers({
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
}),
getUniversalBridgeUsage({
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
}),
getWalletUsers(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
getUniversalBridgeUsage(
{
from: props.range.from,
period: props.interval,
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
),
]);
if (
@@ -350,13 +392,11 @@ async function AsyncAppHighlightsCard(props: {
)
return (
<div>
<AppHighlightsCard
chartKey={
(props.searchParams.appHighlights as keyof AggregatedMetrics) ??
"totalVolume"
}
params={props.params}
searchParams={props.searchParams}
<ProjectHighlightsCard
selectedChart={props.selectedChart}
selectedChartQueryParam="appHighlights"
teamSlug={props.params.team_slug}
projectSlug={props.params.project_slug}
userStats={
walletUserStatsTimeSeries.status === "fulfilled"
? walletUserStatsTimeSeries.value
@@ -382,14 +422,18 @@ async function AsyncAppHighlightsCard(props: {
async function AsyncWalletDistributionCard(props: {
project: Project;
range: Range;
authToken: string;
}) {
const walletConnections = await getWalletConnections({
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
}).catch(() => undefined);
const walletConnections = await getWalletConnections(
{
from: props.range.from,
period: "all",
projectId: props.project.id,
teamId: props.project.teamId,
to: props.range.to,
},
props.authToken,
).catch(() => undefined);
return walletConnections && walletConnections.length > 0 ? (
<WalletDistributionCard data={walletConnections} />
@@ -401,75 +445,6 @@ async function AsyncWalletDistributionCard(props: {
);
}
function AppHighlightsCard({
chartKey,
userStats,
volumeStats,
params,
searchParams,
}: {
chartKey: keyof AggregatedMetrics;
userStats: WalletUserStats[];
volumeStats: UniversalBridgeStats[];
params: PageParams;
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const timeSeriesData = processTimeSeriesData(userStats, volumeStats);
const chartConfig = {
activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" },
feesCollected: {
color: "hsl(var(--chart-4))",
emptyContent: (
<EmptyStateContent
description="Your app hasn't collected any fees yet."
link={`/team/${params.team_slug}/${params.project_slug}/payments/settings`}
metric="Fees"
/>
),
isCurrency: true,
label: "Fee Revenue",
},
newUsers: { color: "hsl(var(--chart-3))", label: "New Users" },
totalVolume: {
color: "hsl(var(--chart-2))",
emptyContent: (
<EmptyStateContent
description="Onramp, swap, and bridge with thirdweb's Payments."
link="https://portal.thirdweb.com/payments"
metric="Payments"
/>
),
isCurrency: true,
label: "Total Volume",
},
} as const;
return (
<CombinedBarChartCard
activeChart={chartKey}
aggregateFn={(_data, key) => {
if (key === "activeUsers") {
return Math.max(...timeSeriesData.map((d) => d[key]));
}
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
}}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
queryKey="appHighlights"
title="App Highlights"
trendFn={(data, key) =>
data.filter((d) => (d[key] as number) > 0).length >= 2
? ((data[data.length - 2]?.[key] as number) ?? 0) /
((data[0]?.[key] as number) ?? 0) -
1
: undefined
}
/>
);
}
async function WalletDistributionCard({ data }: { data: WalletStats[] }) {
const formattedData = await Promise.all(
data
@@ -515,11 +490,13 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) {
async function TotalSponsoredCard({
data,
aggregatedData,
searchParams,
selectedChart,
selectedChartQueryParam,
}: {
data: UserOpStats[];
aggregatedData: UserOpStats[];
searchParams: { [key: string]: string | string[] | undefined };
selectedChart: string | undefined;
selectedChartQueryParam: string;
}) {
const chains = await Promise.all(
data.map(
@@ -567,52 +544,22 @@ async function TotalSponsoredCard({
total: aggregatedData.reduce((acc, curr) => acc + curr.sponsoredUsd, 0),
};
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={
(searchParams?.totalSponsored as keyof typeof chartConfig) ?? "mainnet"
}
aggregateFn={(_data, key) => processedAggregatedData[key]}
chartConfig={chartConfig}
data={timeSeriesData}
existingQueryParams={searchParams}
isCurrency
queryKey="totalSponsored"
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
}
<TotalSponsoredCardUI
processedAggregatedData={processedAggregatedData}
selectedChart={selectedChart}
selectedChartQueryParam={selectedChartQueryParam}
timeSeriesData={timeSeriesData}
/>
);
}
export function Header(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
defaultRange: DurationId;
}) {
const { title, interval, range, showRangeSelector } = props;
const { title, showRangeSelector, defaultRange } = props;
return (
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center">
@@ -623,7 +570,7 @@ export function Header(props: {
</div>
{showRangeSelector && (
<div className="max-sm:w-full">
<RangeSelector interval={interval} range={range} />
<ResponsiveTimeFilters defaultRange={defaultRange} />
</div>
)}
</div>
@@ -54,6 +54,7 @@ export default async function Page(props: {
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
<div className="h-10" />
@@ -26,6 +26,7 @@ export async function PayAnalytics(props: {
teamId: string;
range: Range;
interval: "day" | "week";
authToken: string;
}) {
const { projectId, teamId, range, interval } = props;
@@ -37,23 +38,29 @@ export async function PayAnalytics(props: {
month: "short" as const,
};
const volumeDataPromise = getUniversalBridgeUsage({
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
}).catch((error) => {
const volumeDataPromise = getUniversalBridgeUsage(
{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
const walletDataPromise = getUniversalBridgeWalletUsage({
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
}).catch((error) => {
const walletDataPromise = getUniversalBridgeWalletUsage(
{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
@@ -17,31 +17,41 @@ export async function RPCAnalytics(props: {
teamId: string;
range: Range;
interval: "day" | "week";
authToken: string;
}) {
const { projectId, teamId, range, interval } = props;
const { projectId, teamId, range, interval, authToken } = props;
// TODO: add requests by status code, but currently not performant enough
const allRequestsByUsageTypePromise = getRpcUsageByType({
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
});
const requestsByUsageTypePromise = getRpcUsageByType({
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
});
const evmMethodsPromise = getRpcMethodUsage({
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
}).catch((error) => {
const allRequestsByUsageTypePromise = getRpcUsageByType(
{
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
},
authToken,
);
const requestsByUsageTypePromise = getRpcUsageByType(
{
from: range.from,
period: interval,
projectId: projectId,
teamId: teamId,
to: range.to,
},
authToken,
);
const evmMethodsPromise = getRpcMethodUsage(
{
from: range.from,
period: "all",
projectId: projectId,
teamId: teamId,
to: range.to,
},
authToken,
).catch((error) => {
console.error(error);
return [];
});
@@ -52,6 +52,7 @@ export default async function Page(props: {
projectClientId={project.publishableKey}
projectId={project.id}
range={range}
authToken={authToken}
teamId={project.teamId}
/>
<div className="h-10" />
@@ -51,23 +51,30 @@ function InAppWalletsSummaryInner(props: {
async function AsyncInAppWalletsSummary(props: {
teamId: string;
projectId: string;
authToken: string;
}) {
const { teamId, projectId } = props;
const allTimeStatsPromise = getInAppWalletUsage({
from: new Date(2022, 0, 1),
period: "all",
projectId,
teamId,
to: new Date(),
});
const { teamId, projectId, authToken } = props;
const allTimeStatsPromise = getInAppWalletUsage(
{
from: new Date(2022, 0, 1),
period: "all",
projectId,
teamId,
to: new Date(),
},
authToken,
);
const monthlyStatsPromise = getInAppWalletUsage({
from: subDays(new Date(), 30),
period: "month",
projectId,
teamId,
to: new Date(),
});
const monthlyStatsPromise = getInAppWalletUsage(
{
from: subDays(new Date(), 30),
period: "month",
projectId,
teamId,
to: new Date(),
},
authToken,
);
const [allTimeStats, monthlyStats] = await Promise.all([
allTimeStatsPromise,
@@ -86,6 +93,7 @@ async function AsyncInAppWalletsSummary(props: {
export function InAppWalletsSummary(props: {
teamId: string;
projectId: string;
authToken: string;
}) {
return (
<Suspense
@@ -100,6 +108,7 @@ export function InAppWalletsSummary(props: {
<AsyncInAppWalletsSummary
projectId={props.projectId}
teamId={props.teamId}
authToken={props.authToken}
/>
</Suspense>
);
@@ -1,10 +1,9 @@
import { Suspense } from "react";
import { ResponsiveSuspense } from "responsive-rsc";
import { getInAppWalletUsage } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import type { InAppWalletStats } from "@/types/analytics";
import { InAppWalletUsersChartCardUI } from "./InAppWalletUsersChartCard";
@@ -15,33 +14,27 @@ type InAppWalletAnalyticsProps = {
isPending: boolean;
};
function InAppWalletAnalyticsInner({
interval,
range,
function InAppWalletAnalyticsUI({
stats,
isPending,
}: InAppWalletAnalyticsProps) {
return (
<div>
<RangeSelector interval={interval} range={range} />
<div className="h-6" />
<div className="flex flex-col gap-4 lg:gap-6">
<InAppWalletUsersChartCardUI
description="The total number of active in-app wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
title="Unique Users"
/>
</div>
</div>
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active in-app wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
);
}
type AsyncInAppWalletAnalyticsProps = Omit<
InAppWalletAnalyticsProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncInAppWalletAnalytics(
@@ -49,19 +42,22 @@ async function AsyncInAppWalletAnalytics(
) {
const range = props.range ?? getLastNDaysRange("last-120");
const stats = await getInAppWalletUsage({
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
}).catch((error) => {
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 (
<InAppWalletAnalyticsInner
<InAppWalletAnalyticsUI
{...props}
isPending={false}
range={range}
@@ -72,12 +68,13 @@ async function AsyncInAppWalletAnalytics(
export function InAppWalletAnalytics(props: AsyncInAppWalletAnalyticsProps) {
return (
<Suspense
<ResponsiveSuspense
searchParamsUsed={["from", "to", "interval"]}
fallback={
<InAppWalletAnalyticsInner {...props} isPending={true} stats={[]} />
<InAppWalletAnalyticsUI {...props} isPending={true} stats={[]} />
}
>
<AsyncInAppWalletAnalytics {...props} />
</Suspense>
</ResponsiveSuspense>
);
}
@@ -1,6 +1,11 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { Range } from "@/components/analytics/date-range-selector";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
@@ -18,20 +23,18 @@ export default async function Page(props: {
props.params,
]);
const range =
searchParams.from && searchParams.to
? {
from: new Date(searchParams.from),
to: new Date(searchParams.to),
type: searchParams.type ?? "last-120",
}
: undefined;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/wallets`);
}
const interval: "day" | "week" = ["day", "week"].includes(
searchParams.interval ?? "",
)
? (searchParams.interval as "day" | "week")
: "week";
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const project = await getProject(params.team_slug, params.project_slug);
@@ -40,15 +43,24 @@ export default async function Page(props: {
}
return (
<div>
<InAppWalletsSummary projectId={project.id} teamId={project.teamId} />
<div className="h-10" />
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range as Range}
teamId={project.teamId}
/>
</div>
<ResponsiveSearchParamsProvider value={searchParams}>
<div>
<InAppWalletsSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<div className="h-10" />
<ResponsiveTimeFilters defaultRange={defaultRange} />
<div className="h-6" />
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -90,14 +90,17 @@ export default async function WebhooksAnalyticsPage(props: {
const webhookId = selectedWebhookId === "all" ? undefined : selectedWebhookId;
const [requestsData, latencyData] = await Promise.all([
(async () => {
const res = await getWebhookRequests({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
const res = await getWebhookRequests(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
},
authToken,
);
if ("error" in res) {
console.error("Failed to fetch webhook requests:", res.error);
return [];
@@ -105,14 +108,17 @@ export default async function WebhooksAnalyticsPage(props: {
return res.data;
})(),
(async () => {
const res = await getWebhookLatency({
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
});
const res = await getWebhookLatency(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
webhookId,
},
authToken,
);
if ("error" in res) {
console.error("Failed to fetch webhook latency:", res.error);
return [];
@@ -80,14 +80,17 @@ export default async function WebhooksPage(props: {
// Fetch metrics for all webhooks in parallel
const webhookMetrics = await Promise.all(
webhookConfigs.map(async (config) => {
const metricsResult = await getWebhookSummary({
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
period: "day",
projectId: project.id,
teamId: project.teamId, // 24 hours ago
to: new Date(),
webhookId: config.id,
});
const metricsResult = await getWebhookSummary(
{
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
period: "day",
projectId: project.id,
teamId: project.teamId, // 24 hours ago
to: new Date(),
webhookId: config.id,
},
authToken,
);
return {
metrics:
@@ -1,26 +0,0 @@
import type { Range } from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
export function AnalyticsHeader(props: {
title: string;
interval: "day" | "week";
range: Range;
showRangeSelector: boolean;
}) {
const { title, interval, range, showRangeSelector } = props;
return (
<div className="container max-w-7xl flex flex-col items-start gap-3 py-10 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">
<RangeSelector interval={interval} range={range} />
</div>
)}
</div>
);
}
@@ -1,8 +1,9 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { useState } from "react";
import { CombinedBarChartCard } from "./CombinedBarChartCard";
const meta = {
component: CombinedBarChartCard,
component: Variant,
decorators: [
(Story) => (
<div className="container max-w-6xl py-10">
@@ -11,7 +12,7 @@ const meta = {
),
],
title: "Analytics/CombinedBarChartCard",
} satisfies Meta<typeof CombinedBarChartCard>;
} satisfies Meta<typeof Variant>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -64,32 +65,20 @@ function generateTimeSeriesData(days: number) {
return data;
}
export const UserActivity: Story = {
args: {
activeChart: "dailyUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "dailyUsers",
title: "User Activity",
},
};
export const Default: Story = {};
export const MonthlyUsers: Story = {
args: {
activeChart: "monthlyUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "monthlyUsers",
title: "Monthly Users",
},
};
function Variant() {
const [activeChart, setActiveChart] = useState<
"dailyUsers" | "annualUsers" | "monthlyUsers"
>("dailyUsers");
export const AnnualUsers: Story = {
args: {
activeChart: "annualUsers",
chartConfig,
data: generateTimeSeriesData(30),
queryKey: "annualUsers",
title: "Annual Users",
},
};
return (
<CombinedBarChartCard
title="User Activity"
activeChart={activeChart}
chartConfig={chartConfig}
data={generateTimeSeriesData(30)}
onSelect={setActiveChart}
/>
);
}
@@ -1,4 +1,4 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toUSD } from "@/utils/number";
import { BarChart } from "./BarChart";
@@ -13,8 +13,6 @@ type CombinedBarChartConfig<K extends string> = {
};
};
// TODO - don't reload page on tab change -> make this a client component, load all the charts at once on server and switch between them on client
export function CombinedBarChartCard<
T extends string,
K extends Exclude<T, "date">,
@@ -23,7 +21,6 @@ export function CombinedBarChartCard<
chartConfig,
data,
activeChart,
queryKey,
isCurrency = false,
aggregateFn = (data, key) =>
data[data.length - 1]?.[key] as number | undefined,
@@ -33,18 +30,17 @@ export function CombinedBarChartCard<
((data[data.length - 2]?.[key] as number) ?? 0) -
1
: undefined,
existingQueryParams,
className,
onSelect,
}: {
title?: string;
chartConfig: CombinedBarChartConfig<K>;
data: { [key in T]: number | string }[];
activeChart: K;
queryKey: string;
isCurrency?: boolean;
aggregateFn?: (d: typeof data, key: K) => number | undefined;
trendFn?: (d: typeof data, key: K) => number | undefined;
existingQueryParams?: { [key: string]: string | string[] | undefined };
onSelect: (key: K) => void;
className?: string;
}) {
return (
@@ -61,18 +57,12 @@ export function CombinedBarChartCard<
{Object.keys(chartConfig).map((chart: string) => {
const key = chart as K;
return (
<Link
className="relative z-30 flex min-w-[200px] flex-1 flex-col justify-center gap-1 border-l first:border-l-0 hover:bg-accent/50"
<Button
variant="ghost"
className="rounded-none p-0 h-auto bg-transparent relative z-30 flex min-w-[200px] flex-1 flex-col items-start justify-center gap-1 border-l first:border-l-0 hover:bg-accent/50"
data-active={activeChart === chart}
href={{
query: {
...existingQueryParams,
[queryKey]: key,
},
}}
onClick={() => onSelect(key)}
key={chart}
prefetch
scroll={false}
>
<Stat
label={chartConfig[key].label}
@@ -87,7 +77,7 @@ export function CombinedBarChartCard<
className="absolute right-0 bottom-0 left-0 h-0 bg-foreground transition-all duration-300 ease-out data-[active=true]:h-[3px]"
data-active={activeChart === chart}
/>
</Link>
</Button>
);
})}
</div>
@@ -5,7 +5,7 @@ import { AppFooter } from "@/components/footers/app-footer";
import { DotsBackgroundPattern } from "@/components/ui/background-patterns";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TeamHeader } from "../../components/TeamHeader/team-header";
import { createTeamLink, TeamSelectorCard } from "./components/team-selector";
import { createTeamLink, TeamSelectorCard } from "../_components/team-selector";
export default async function Page(props: {
params: Promise<{
@@ -16,7 +16,7 @@ export function ProjectAndTeamSelectorCard(props: {
projects: PartialProject[];
}[];
client: ThirdwebClient;
description: React.ReactNode;
description: React.ReactNode | undefined;
onSelect: (selected: { team: Team; project: PartialProject }) => void;
}) {
const teamAndProjects = props.teamAndProjects.filter(
@@ -31,10 +31,14 @@ export function ProjectAndTeamSelectorCard(props: {
<div className="mb-2 self-start rounded-full border p-2">
<BoxIcon className="size-5 text-muted-foreground" />
</div>
<h1 className="mb-0.5 font-semibold text-xl tracking-tight">
Select a project
</h1>
<p className="text-muted-foreground text-sm">{props.description}</p>
<div className="space-y-0.5">
<h1 className="font-semibold text-xl tracking-tight">
Select a project
</h1>
{props.description && (
<p className="text-muted-foreground text-sm">{props.description}</p>
)}
</div>
</div>
<div
@@ -5,7 +5,7 @@ import type { PartialProject } from "@/api/project/getProjectContracts";
import type { Team } from "@/api/team/get-team";
import { useAddContractToProject } from "@/hooks/project-contracts";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { ProjectAndTeamSelectorCard } from "../../../../../components/TeamAndProjectSelectorCard";
import { ProjectAndTeamSelectorCard } from "./TeamAndProjectSelectorCard";
export function SelectProjectForContract(props: {
chainSlug: string;
@@ -78,3 +78,27 @@ export function ImportAndSelectProjectForContract(props: {
/>
);
}
export function GenericProjectSelector(props: {
client: ThirdwebClient;
paths: string[] | undefined;
description: React.ReactNode | undefined;
teamAndProjects: {
team: Team;
projects: PartialProject[];
}[];
}) {
const router = useDashboardRouter();
return (
<ProjectAndTeamSelectorCard
client={props.client}
description={props.description}
onSelect={(selection) => {
router.push(
`/team/${selection.team.slug}/${selection.project.slug}${props.paths?.length ? `/${props.paths.join("/")}` : ""}`,
);
}}
teamAndProjects={props.teamAndProjects}
/>
);
}
@@ -17,7 +17,7 @@ import { TeamHeader } from "../../../../../components/TeamHeader/team-header";
import {
ImportAndSelectProjectForContract,
SelectProjectForContract,
} from "./components/project-selector";
} from "../../../../_components/project-selector";
export default async function Page(props: {
params: Promise<{
@@ -0,0 +1,76 @@
import { getValidAccount } from "@/api/account/get-account";
import { getAuthToken } from "@/api/auth-token";
import { getProjects } from "@/api/project/projects";
import { getTeams } from "@/api/team/get-team";
import { AppFooter } from "@/components/footers/app-footer";
import { DotsBackgroundPattern } from "@/components/ui/background-patterns";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { TeamHeader } from "../../../components/TeamHeader/team-header";
import { GenericProjectSelector } from "../../_components/project-selector";
export default async function Page(props: {
params: Promise<{
paths?: string[];
}>;
}) {
const params = await props.params;
const pagePath = `/team/~/~${params.paths?.length ? `/${params.paths.join("/")}` : ""}`;
const [authToken, account, teams] = await Promise.all([
getAuthToken(),
await getValidAccount(pagePath),
getTeams(),
]);
if (!authToken || !account || !teams) {
loginRedirect(pagePath);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: undefined,
});
const teamAndAllProjects = await Promise.all(
teams.map(async (team) => {
return {
projects: await getProjects(team.slug).catch(() => []),
team,
};
}),
);
return (
<ProjectSelectionLayout>
<GenericProjectSelector
client={client}
paths={params.paths}
description={
params.paths?.[0] === "tokens"
? "Select a project to continue with token creation"
: "Select a project to continue"
}
teamAndProjects={teamAndAllProjects}
/>
</ProjectSelectionLayout>
);
}
function ProjectSelectionLayout(props: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-dvh flex-col">
<div className="border-b bg-card">
<TeamHeader />
</div>
<div className="relative flex grow flex-col overflow-hidden">
<DotsBackgroundPattern />
<div className="container z-10 flex grow flex-col items-center justify-center py-20">
{props.children}
</div>
</div>
<AppFooter className="relative z-10" />
</div>
);
}
@@ -71,7 +71,7 @@ function Example() {
<${componentName}
client={client}
chain={defineChain(${options.payOptions.buyTokenChain.id})}
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.buttonLabel ? `\n\t buttonLabel="${options.payOptions.buttonLabel}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
options.payOptions.widget === "transaction"
? `\n\t transaction={claimTo({
contract: nftContract,
@@ -41,6 +41,7 @@ export type BridgeComponentsPlaygroundOptions = {
title: string | undefined;
image: string | undefined;
description: string | undefined;
buttonLabel: string | undefined;
buyTokenAddress?: Address;
buyTokenAmount: string;
@@ -73,6 +73,7 @@ export function LeftSection(props: {
const modalTitleId = useId();
const modalTitleIconId = useId();
const modalDescriptionId = useId();
const buttonLabelId = useId();
const themeId = useId();
const cryptoPaymentId = useId();
const cardPaymentId = useId();
@@ -486,6 +487,26 @@ export function LeftSection(props: {
value={options.payOptions.description}
/>
</div>
{/* Button Label */}
<div className="flex flex-col gap-2">
<Label htmlFor={buttonLabelId}>Button Label</Label>
<Input
className="bg-card"
id={buttonLabelId}
onChange={(e) =>
setOptions((v) => ({
...v,
payOptions: {
...payOptions,
buttonLabel: e.target.value,
},
}))
}
placeholder="Custom button text (optional)"
value={options.payOptions.buttonLabel}
/>
</div>
</div>
</CollapsibleSection>
@@ -66,6 +66,9 @@ export function RightSection(props: {
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -91,6 +94,9 @@ export function RightSection(props: {
tokenAddress={props.options.payOptions.buyTokenAddress}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -112,6 +118,9 @@ export function RightSection(props: {
})}
currency={props.options.payOptions.currency}
showThirdwebBranding={props.options.payOptions.showThirdwebBranding}
{...(props.options.payOptions.buttonLabel && {
buttonLabel: props.options.payOptions.buttonLabel,
})}
/>
);
}
@@ -13,6 +13,7 @@ const defaultConnectOptions: BridgeComponentsPlaygroundOptions = {
buyTokenChain: arbitrum,
description: "",
image: "",
buttonLabel: "",
paymentMethods: ["crypto", "card"],
sellerAddress: "0x0000000000000000000000000000000000000000",
title: "",
@@ -0,0 +1,224 @@
import { Step, Steps, createMetadata } from "@doc";
export const metadata = createMetadata({
title: "Stylus Airdrop Contracts | thirdweb Documentation",
description:
"Use Stylus Airdrop contracts on Arbitrum for efficient token distribution at scale. WASM-powered smart contracts enable fast, low-cost airdrops to thousands of wallets.",
image: {
title: "Stylus Airdrop Contract",
icon: "contracts",
},
});
# Stylus Airdrop Contract
If you need to send tokens to thousands of wallets at once, you can leverage the Stylus Airdrop contracts.
Learn how to deploy Stylus Airdrop contracts through dashboard or CLI for inexpensive, WASM-powered distribution on [Arbitrum](https://thirdweb.com/arbitrum) chain.
### Benefits
- Ink, not gas: Stylus executes WASM thousands of times faster than the EVM, so sending 10 000 NFTs costs a fraction of normal gas.
- Rust Tooling: Cargo tests, Clippy lints, and the full crates ecosystem for your drop logic.
- Interoperability: Rust contracts call Solidity (and vice-versa) with zero wrappers.
- Battle-tested Logic: Template ports the same claim, snapshot &amp; signature pattern used by thirdweb’s Solidity pre-built contracts.
## Deploy through dashboard
Deploying a Stylus Airdrop contract is easy through the thirdweb dashboard and ideal when you don't want to modify any code on the contract.
<Steps>
<Step title="Select Stylus Contract">
Navigate to the Stylus Contracts section on Explore and select any Airdrop contract for your project.
</Step>
<Step title="Deploy Contract">
Select Arbitrum Sepolia or any other Stylus-supported network, then select Deploy.
</Step>
<Step title="Upload Recipients">
You can either upload a CSV file with the recipient addresses and amounts and select `Run Airdrop`
Or set a claim condition for recipients to claim their tokens on their own.
</Step>
</Steps>
## Deploy through CLI
If you want to modify the contract code or deploy a custom Airdrop contract, you can use the thirdweb CLI.
<Steps>
<Step title="Create a new Stylus Airdrop project">
In your CLI, run the following command to create a new directory with an airdrop template contract.
```bash
npx thirdweb create-stylus --template airdrop-erc721
```
</Step>
<Step title="Modify contract logic">
In the `src/lib.rs` file you can modify the contract logic such as adding fees, gating logic, analytics events, and more.
</Step>
<Step title="Build & Test the Contract">
To build your project, run the following command:
```bash
cargo stylus build
```
</Step>
<Step title="Deploy or Publish Your Contract">
You can publish and deploy your project to Arbitrum. Publishing stores your contract metadata in thirdweb’s on-chain registry so anyone (including you) can deploy that exact version later with a few clicks.
To publish your contract, ensure you have your thirdweb secret key from your created project, then run the following command:
```bash
npx thirdweb publish-stylus -k YOUR_TW_SECRET_KEY
```
If you'd prefer to just deploy a single instance of the contract without publishing, run the following command to deploy:
```bash
npx thirdweb deploy-stylus -k YOUR_TW_SECRET_KEY
```
Once the transaction confirms, the CLI will redirect you to the contract management dashboard where you can mint, transfer, view events, or pull ready-made SDK snippets.
</Step>
</Steps>
## Interacting with the Contract
Using the thirdweb SDKs, you can interact with your Stylus Airdrop contract to mint tokens, transfer ownership, and more.
The following includes three common patterns using thirdweb TypeScript SDK:
### Owner-executed batch airdrop
**When to use:** you already have every recipient’s address and want to send everything in a single transaction.
```javascript
import { createThirdwebClient, getContract, sendTransaction} from "thirdweb";
import { arbitrumSepolia } from "thirdweb/chains";
import { airdropERC721 } from "thirdweb/extensions/airdrop";
import { createWallet, injectedProvider } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const wallet = createWallet("io.metamask"); // or any wallet id
const account = await wallet.connect({ client });
const contract = getContract({
client,
chain: arbitrumSepolia,
address: "<DEPLOYED_ADDRESS>",
});
const contents = [
{ recipient: "0xAbc…", tokenId: 1n },
{ recipient: "0x123…", tokenId: 2n },
];
const tx = airdropERC721({
contract,
tokenAddress: contract.address,
contents,
});
await sendTransaction({ transaction: tx, account });
```
(Swap for airdropERC20 or airdropERC1155 helpers as needed.)
### Claim-based airdrop (Merkle snapshot)
**When to use:** you know the recipients but want them to claim the tokens at their convenience (gas paid by the claimer).
1. Generate snapshot off-chain
```javascript
import { generateMerkleTreeInfoERC721 } from "thirdweb/extensions/airdrop";
const snapshot = [
{ address: "0xAbc…", tokenId: 7n },
{ address: "0x123…", tokenId: 8n },
];
const { merkleRoot, contents } = await generateMerkleTreeInfoERC721({ snapshot });
```
merkleRoot is a single 32-byte value representing the whole list.
2. Store root on-chain (_setMerkleRoot(root) in the template).
```javascript
const tx = setMerkleRoot({
contract,
root: merkleRoot,
});
await sendTransaction({ transaction: tx, account }); // executed by admin
```
3. Recipient claims:
```javascript
import { claimERC721, fetchProofsERC721 } from "thirdweb/extensions/airdrop";
const proof = await fetchProofsERC721({
contract,
merkleRoot,
recipient: "0xAbc…",
});
const tx = claimERC721({
contract,
tokenAddress: contract.address,
proof,
});
await sendTransaction({ transaction: tx, account });
```
### Signature Based Airdrop (Dynamic Authentication)
**When to use:** you don’t know the full list ahead of time (e.g., quest rewards). An authorised backend signs a payload per user; the user submits it on-chain.
1. Generate signed payload server-side:
```javascript
import { generateAirdropSignatureERC721 } from "thirdweb/extensions/airdrop";
const { signature, payload } = await generateAirdropSignatureERC721({
contract,
signer: account, // ADMIN role or owner
to: "0xRecipient",
tokenId: 42n,
});
```
2. Recipient executes:
```javascript
import { airdropERC721WithSignature } from "thirdweb/extensions/airdrop";
const tx = airdropERC721WithSignature({
contract,
signature,
payload,
});
await sendTransaction({ transaction: tx, account: recipientAccount });
```
### Resources
- [ERC-721 Airdrop Template](https://github.com/thirdweb-example/stylus-airdrop-erc721-template)
- [TypeScript SDK Documentation > Airdrops](https://portal.thirdweb.com/references/typescript/v5/airdrop/airdropERC721)
@@ -0,0 +1,135 @@
import { Steps, Step, createMetadata } from "@doc";
export const metadata = createMetadata({
title: "Stylus Contracts | thirdweb Documentation",
description:
"Deploy Stylus contracts on Arbitrum for efficient token distribution at scale. WASM-powered smart contracts enable fast, low-cost airdrops to thousands of wallets.",
image: {
title: "Deploy Stylus Contracts",
icon: "contracts",
},
});
# Deploy Stylus Contracts on Arbitrum
Arbitrum Stylus brings a second, WebAssembly (WASM) virtual machine to every Arbitrum chain, so you can write contracts in Rust (or C/C++) while staying 100 % interoperable with existing Solidity code.
Under the hood, Stylus executes WASM thousands of times faster than the EVM, so transactions are metered in a new, much-smaller unit called ink rather than gas — translating to dramatic fee savings for compute- or memory-heavy logic. Learn more about Stylus on the [Arbitrum documentation](https://docs.arbitrum.io/stylus/concepts/how-it-works).
This tutorial will cover how to create a simple NFT template project with thirdweb CLI and deploy it to the Arbitrum network using Stylus. You can also follow this tutorial to deploy an ERC-20, ERC-1155, or Airdrop contract.
## Benefits
- Rust Tooling: Cargo, Clippy, unit tests, Rust crates, and the broader Rust ecosystem are at your disposal.
- Ink-priced execution: Complex math, on-chain SVG generation, even lightweight ML models are suddenly affordable.
- Opt-in re-entrancy protection: The Rust SDK disables re-entrancy by default; you only enable it when you really need it.
- Multi-VM composability: Rust contract can call Solidity contracts (and vice-versa) without wrappers.
## Prerequisites
- [Create a project on your thirdweb account](https://thirdweb.com/dashboard)
- Install thirdweb CLI by running `npm install -g thirdweb`
- Install Rust tool chain by running `curl https://sh.rustup.rs -sSf | sh` or visit rust-lang.org
- Install solc by running `npm install -g solc` or visit soliditylang.org
- Install Node version 18 or higher
## Scaffold an ERC-721 Stylus project
<Steps>
<Step title="Initialize a new Stylus project">
In your CLI, create a new directory and run the following command to create a new stylus project:
```bash
npx thirdweb create-stylus
```
Select the ERC-721 template when prompted. This will scaffold a new Stylus project with a basic ERC-721 contract.
</Step>
<Step title="Set Collection Name & Symbol">
Open src/lib.rs and set the collection name and symbol. You may also tweak any other minting logic as well such as supply cap, roles, and fees.
</Step>
<Step title="Build & test the project">
Stylus compiles your Rust to WASM and generates an ABI JSON that thirdweb uses for the dashboard & SDK.
Run the following command to build your project:
```bash
cargo stylus build
```
You can also run the following command to test your project:
```bash
cargo test
```
</Step>
<Step title="Deploy or Publish Your Contract">
You can publish and deploy your project to Arbitrum. Publishing stores your contract metadata in thirdweb’s on-chain registry so anyone (including you) can deploy that exact version later with a few clicks.
To publish your contract, ensure you have your thirdweb secret key from your created project, then run the following command:
```bash
npx thirdweb publish-stylus -k YOUR_TW_SECRET_KEY
```
If you'd prefer to just deploy a single instance of the contract without publishing, run the following command to deploy:
```bash
npx thirdweb deploy-stylus -k YOUR_TW_SECRET_KEY
```
Once the transaction confirms, the CLI will redirect you to the contract management dashboard where you can mint, transfer, view events, or pull ready-made SDK snippets.
</Step>
<Step title="(Optional) Mint NFTs Programmatically">
You can mint NFTs programmatically using the thirdweb SDKs. Use the following code snippet to mint NFTs:
```javascript
import { createThirdwebClient, getContract, sendTransaction } from "thirdweb";
import { arbitrumSepolia } from "thirdweb/chains";
import { mintTo } from "thirdweb/extensions/erc721";
import { createWallet, injectedProvider } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId });
const wallet = createWallet("io.metamask"); // or any wallet id
const account = await wallet.connect({ client });
// 3 · Wrap the deployed contract
const contract = getContract({
client,
chain: arbitrumSepolia,
address: "<DEPLOYED_ADDRESS>",
});
// 4 · Prepare the mint transaction
const transaction = mintTo({
contract,
to: "0xRecipient",
nft: {
name: "Stylus NFT #1",
// image, description, attributes, … are optional
},
});
// 5 · Send the transaction
const result = await sendTransaction({ transaction, account });
console.log("Minted token:", result);
```
</Step>
</Steps>
## Next Steps
- Learn more about Arbitrum Stylus through the official docs on architecture, gas/ink, and safety features. [View Arbitrum Documentation.](https://docs.arbitrum.io/stylus/concepts/how-it-works)
@@ -0,0 +1,3 @@
# Contract security
All thirdweb pre-built contracts are audited by third-party security firms. You can see the attached audit reports for each contract linked on the contract page on Explore.
+14 -1
View File
@@ -45,7 +45,20 @@ export const sidebar: SideBar = {
],
name: "Guides",
},
{ separator: true },
{
isCollapsible: true,
links: [
{
href: `${slug}/arbitrum-stylus/stylus-contract`,
name: "Deploy Stylus Contract",
},
{
href: `${slug}/arbitrum-stylus/airdrop-contract`,
name: "Stylus Airdrop Contract",
},
],
name: "Arbitrum Stylus",
},
{
isCollapsible: false,
links: [
@@ -57,13 +57,31 @@ You will be asked to enter the following values
### Usage example
<Tabs defaultValue="typescript">
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">HTTP</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="dotnet">.NET</TabsTrigger>
</TabsList>
<TabsContent value="http">
```http
POST https://api.thirdweb.com/v1/wallets/user/generic-auth
Content-Type: application/json
x-client-id: <your-client-id>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "jwt",
"jwt": "<your-jwt>"
}
```
</TabsContent>
<TabsContent value="react">
```typescript
@@ -81,7 +99,7 @@ const handlePostLogin = async () => {
wallet.connect({
client,
strategy: "jwt",
jwt: "<your-jwt-token>",
jwt: "<your-jwt>",
});
return wallet;
});
@@ -107,7 +125,7 @@ const wallet = inAppWallet();
const account = await wallet.connect({
client,
strategy: "jwt",
jwt: "<your-jwt-token>",
jwt: "<your-jwt>",
});
// use the account to send transactions
@@ -122,7 +140,7 @@ using Thirdweb;
var client = ThirdwebClient.Create(clientId: "your-client-id");
var wallet = await InAppWallet.Create(client: client, authProvider: AuthProvider.JWT);
var address = await wallet.LoginWithCustomAuth(jwt: "<your-jwt-token>");
var address = await wallet.LoginWithCustomAuth(jwt: "<your-jwt>");
```
</TabsContent>
@@ -170,13 +188,33 @@ You can also pass a list of headers. These headers will be sent with every reque
Once you've logged in with your own auth, you can pass the user's JWT to the in-app wallet to authenticate and connect.
<Tabs defaultValue="typescript">
<Tabs defaultValue="http">
<TabsList>
<TabsTrigger value="http">HTTP</TabsTrigger>
<TabsTrigger value="typescript">TypeScript</TabsTrigger>
<TabsTrigger value="react">React</TabsTrigger>
<TabsTrigger value="dotnet">.NET</TabsTrigger>
</TabsList>
<TabsContent value="http">
```http
POST https://api.thirdweb.com/v1/wallets/user/generic-auth
Content-Type: application/json
x-client-id: <your-client-id>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "auth-payload",
"payload": "<your-auth-payload>"
}
```
</TabsContent>
<TabsContent value="react">
```typescript
@@ -214,8 +252,6 @@ const MyComponent = () => {
<TabsContent value="typescript">
In other frameworks, use your own instance of the wallet to authenticate and connect.
```typescript
import { inAppWallet } from "thirdweb/wallets";
@@ -7,7 +7,7 @@ export const metadata = createMetadata({
icon: "wallets",
},
title: "Pregenerate Wallets | thirdweb",
description: "Create wallets before users sign up with thirdweb’s wallet pregeneration. Pre-fund with tokens, NFTs, or game assets to enable smooth onboarding and asset claiming.",
description: "Create wallets before users sign up with thirdweb's wallet pregeneration. Pre-fund with tokens, NFTs, or game assets to enable smooth onboarding and asset claiming.",
});
# Pregenerate Wallets
@@ -35,21 +35,34 @@ You can distribute assets to wallets before users claim them, enabling:
To pregenerate an in-app or ecosystem wallet wallet, you can make a `POST` request to the following endpoint:
```
https://in-app-wallet.thirdweb.com/api/v1/pregenerate
```http
POST https://api.thirdweb.com/v1/wallets/user/pregenerate
Content-Type: application/json
x-secret-key: <your-secret-key>
x-ecosystem-id: <your-ecosystem-id> (optional)
x-ecosystem-partner-id: <your-ecosystem-partner-id> (optional)
{
"type": "email",
"email": "[email protected]"
}
```
## Request Body
The request body should be a JSON object with the following parameters:
- `strategy`: The strategy for wallet generation
- `email` or `phone` or `userId`: The user identifier associated with the wallet to be generated
- `type`: The type of wallet identifier to pregenerate a wallet for:
- Email based: `email`, `google`, `facebook`, `discord`
- Phone based: `phone`
- Wallet based: `signer`
- User ID based: `custom_jwt` or `custom_auth_endpoint`
- `email` or `phone` or `userId` or `walletAddress`: The user identifier associated with the wallet to be generated
### Email based wallets
```
{ strategy: "email", email: "[email protected]" }
{ type: "email", email: "[email protected]" }
```
When the user logs in with any method associated with that email (including google, facebook, discord auth), they will get access to the same pregenerated wallet.
@@ -57,13 +70,13 @@ When the user logs in with any method associated with that email (including goog
### Phone based wallets
```
{ strategy: "phone", phone: "+1321123321" }
{ type: "phone", phone: "+1321123321" }
```
### Custom user id based wallets
```
{ strategy: "custom_auth_endpoint", userId: "some_user_id" }
{ type: "custom_auth_endpoint", userId: "some_user_id" }
```
{/* TODO: update link when custom auth documentation has been updated */}
@@ -83,13 +96,13 @@ You need to include the following headers:
Here's an example curl command to pregenerate a thirdweb wallet for the user `[email protected]`:
```bash
curl -X POST 'https://in-app-wallet.thirdweb.com/api/v1/pregenerate' \
curl -X POST 'https://api.thirdweb.com/v1/wallets/user/pregenerate' \
-H 'x-ecosystem-id: ecosystem.example-eco-123' \
-H 'x-ecosystem-partner-id: 1415d24e-c7b0-4fce-846e-740841ef2c32' \
-H 'x-secret-key: 9f8e7d6c5b4a3f2e1d0c9b8a7ffge434b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7' \
-H 'x-secret-key: <your-project-secret-key>' \
-H 'Content-Type: application/json' \
-d '{
"strategy": "email",
"type": "email",
"email": "[email protected]"
}'
```
@@ -107,9 +120,10 @@ A successful API call returns a JSON object in the following format:
```json
{
"wallet": {
"result": {
"address": "string",
"createdAt": "string",
"profile": "object[]"
}
}
```
+21
View File
@@ -1,5 +1,26 @@
# @thirdweb-dev/nebula
## 0.2.30
### Patch Changes
- Updated dependencies [[`e114b2c`](https://github.com/thirdweb-dev/js/commit/e114b2c4f98aa9470afffcd5f809953bb69c5c4f)]:
- thirdweb@5.105.30
## 0.2.29
### Patch Changes
- Updated dependencies [[`c0948fb`](https://github.com/thirdweb-dev/js/commit/c0948fb9fe71b6403ac08f84e4c6f30bdbea8e3a)]:
- thirdweb@5.105.29
## 0.2.28
### Patch Changes
- Updated dependencies [[`621c1de`](https://github.com/thirdweb-dev/js/commit/621c1de62e486b8698d9bd38fabde73fef9f7de5)]:
- thirdweb@5.105.28
## 0.2.27
### Patch Changes
+1 -1
View File
@@ -57,5 +57,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.27"
"version": "0.2.30"
}
+18
View File
@@ -1,5 +1,23 @@
# thirdweb
## 5.105.30
### Patch Changes
- [#7813](https://github.com/thirdweb-dev/js/pull/7813) [`e114b2c`](https://github.com/thirdweb-dev/js/commit/e114b2c4f98aa9470afffcd5f809953bb69c5c4f) Thanks [@gregfromstl](https://github.com/gregfromstl)! - Skips payment selection in the TransactionWidget if the user's balance is sufficient to complete the transaction.
## 5.105.29
### Patch Changes
- [#7809](https://github.com/thirdweb-dev/js/pull/7809) [`c0948fb`](https://github.com/thirdweb-dev/js/commit/c0948fb9fe71b6403ac08f84e4c6f30bdbea8e3a) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Handle environments where process is not defined
## 5.105.28
### Patch Changes
- [#7805](https://github.com/thirdweb-dev/js/pull/7805) [`621c1de`](https://github.com/thirdweb-dev/js/commit/621c1de62e486b8698d9bd38fabde73fef9f7de5) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - Add ability to override button label for all payment widgets
## 5.105.27
### Patch Changes
+2 -2
View File
@@ -39,7 +39,7 @@
"qrcode": "1.5.3",
"toml": "3.0.0",
"uqr": "0.1.2",
"viem": "2.28.1",
"viem": "2.33.2",
"zod": "3.25.75"
},
"devDependencies": {
@@ -409,5 +409,5 @@
}
},
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "5.105.27"
"version": "5.105.30"
}
@@ -64,7 +64,6 @@ export function useTransactionDetails({
return useQuery({
enabled: !!transaction.to && !!chainMetadata.data,
queryFn: async (): Promise<TransactionDetails> => {
// Create contract instance for metadata fetching
const contract = getContract({
address: transaction.to as string,
chain: transaction.chain,
@@ -84,7 +83,7 @@ export function useTransactionDetails({
client,
erc20Value?.tokenAddress || NATIVE_TOKEN_ADDRESS,
transaction.chain.id,
).catch(() => null),
),
hasSponsoredTransactions
? 0n
: getTransactionGasCost(transaction).catch(() => null),
@@ -230,6 +230,8 @@ export function usePaymentMachine(
if (event.type === "DESTINATION_CONFIRMED")
return "methodSelection";
if (event.type === "ERROR_OCCURRED") return "error";
if (event.type === "CONTINUE_TO_TRANSACTION")
return "post-buy-transaction";
break;
case "methodSelection":
@@ -42,6 +42,7 @@ export type UIOptions = Prettify<
image?: string;
};
currency?: SupportedFiatCurrency;
buttonLabel?: string;
} & (
| {
mode: "fund_wallet";
@@ -287,6 +288,7 @@ export function BridgeOrchestrator({
client={client}
connectOptions={modifiedConnectOptions}
onContinue={handleRequirementsResolved}
onExecuteTransaction={() => send({ type: "CONTINUE_TO_TRANSACTION" })}
showThirdwebBranding={showThirdwebBranding}
uiOptions={uiOptions}
/>
@@ -180,6 +180,11 @@ export type BuyWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -335,6 +340,7 @@ export function BuyWidget(props: BuyWidgetProps) {
},
mode: "fund_wallet",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
},
type: "success",
};
@@ -364,6 +370,8 @@ export function BuyWidget(props: BuyWidgetProps) {
title: props.title,
},
mode: "fund_wallet",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
},
type: "success",
};
@@ -186,6 +186,11 @@ export type CheckoutWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -310,6 +315,7 @@ export function CheckoutWidget(props: CheckoutWidgetProps) {
},
mode: "direct_payment",
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
paymentInfo: {
amount: props.amount,
feePayer: props.feePayer === "seller" ? "receiver" : "sender",
@@ -64,7 +64,11 @@ export function DirectPayment({
);
};
const buyNow = (
const buyNow = uiOptions.buttonLabel ? (
<Text color="primaryButtonText" size="md">
{uiOptions.buttonLabel}
</Text>
) : (
<Container flex="row" gap="3xs">
<Text color="primaryButtonText" size="md">
Buy Now ·
@@ -336,13 +336,16 @@ export function FundWallet({
}}
variant="primary"
>
Buy {amount} {uiOptions.destinationToken.symbol}
{uiOptions.buttonLabel ||
`Buy ${amount} ${uiOptions.destinationToken.symbol}`}
</Button>
) : (
<ConnectButton
client={client}
connectButton={{
label: `Buy ${amount} ${uiOptions.destinationToken.symbol}`,
label:
uiOptions.buttonLabel ||
`Buy ${amount} ${uiOptions.destinationToken.symbol}`,
}}
theme={theme}
{...connectOptions}
@@ -48,6 +48,11 @@ export interface TransactionPaymentProps {
*/
onContinue: (amount: string, token: Token, receiverAddress: Address) => void;
/**
* Request to execute the transaction immediately (skips funding flow)
*/
onExecuteTransaction: () => void;
/**
* Connect options for wallet connection
*/
@@ -64,6 +69,7 @@ export function TransactionPayment({
uiOptions,
client,
onContinue,
onExecuteTransaction,
connectOptions,
showThirdwebBranding = true,
}: TransactionPaymentProps) {
@@ -112,7 +118,7 @@ export function TransactionPayment({
transactionDataQuery.data?.functionInfo?.functionName || "Contract Call";
const isLoading = transactionDataQuery.isLoading || chainMetadata.isLoading;
const buttonLabel = `Execute ${functionName}`;
const buttonLabel = uiOptions.buttonLabel || `Execute ${functionName}`;
if (isLoading) {
return (
@@ -378,6 +384,16 @@ export function TransactionPayment({
return;
}
// If the user has enough to pay, skip the payment step altogether
if (
userBalance &&
Number(userBalance) >=
Number(transactionDataQuery.data.totalCost)
) {
onExecuteTransaction();
return;
}
// otherwise, use the full transaction cost
onContinue(
transactionDataQuery.data.totalCost,
@@ -189,6 +189,11 @@ export type TransactionWidgetProps = {
* @default "USD"
*/
currency?: SupportedFiatCurrency;
/**
* Custom label for the main action button.
*/
buttonLabel?: string;
};
// Enhanced UIOptions to handle unsupported token state
@@ -213,7 +218,7 @@ type UIOptionsResult =
* @example
* ### Default configuration
*
* By default, the `TransactionWidget` component will allows users to fund their wallets with crypto or fiat on any of the supported chains..
* By default, the `TransactionWidget` component allows users to fund their wallets with crypto or fiat on any of the supported chains.
*
* ```tsx
* <TransactionWidget
@@ -349,6 +354,7 @@ export function TransactionWidget(props: TransactionWidgetProps) {
return {
data: {
currency: props.currency || "USD",
buttonLabel: props.buttonLabel,
metadata: {
description: props.description,
image: props.image,
@@ -223,3 +223,34 @@ export const NoImageLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Purchase Now' instead of the default 'Buy Now' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Purchase Now'.",
},
},
},
};
@@ -194,3 +194,34 @@ export const LargeAmountLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: FUND_WALLET_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Add Funds Now' instead of the default 'Buy [amount] [symbol]' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: FUND_WALLET_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Add Funds Now'.",
},
},
},
};
@@ -29,6 +29,7 @@ const TransactionPaymentWithTheme = (
const meta = {
args: {
client: storyClient,
onExecuteTransaction: () => {},
onContinue: (_amount, _token, _receiverAddress) => {},
theme: "dark",
uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer,
@@ -168,3 +169,34 @@ export const ContractInteractionLight: Story = {
},
},
};
export const CustomButtonLabel: Story = {
args: {
theme: "dark",
uiOptions: TRANSACTION_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "dark" },
docs: {
description: {
story:
"Example showcasing custom button label functionality. The button shows 'Execute Now' instead of the default 'Execute [functionName]' text.",
},
},
},
};
export const CustomButtonLabelLight: Story = {
args: {
theme: "light",
uiOptions: TRANSACTION_UI_OPTIONS.customButton,
},
parameters: {
backgrounds: { default: "light" },
docs: {
description: {
story: "Light theme version with custom button label 'Execute Now'.",
},
},
},
};
@@ -684,7 +684,7 @@ type TransactionUIOptions = Extract<UIOptions, { mode: "transaction" }>;
// UI Options for FundWallet mode
export const FUND_WALLET_UI_OPTIONS: Record<
"ethDefault" | "ethWithAmount" | "usdcDefault" | "uniLarge",
"ethDefault" | "ethWithAmount" | "usdcDefault" | "uniLarge" | "customButton",
FundWalletUIOptions
> = {
ethDefault: {
@@ -718,11 +718,26 @@ export const FUND_WALLET_UI_OPTIONS: Record<
initialAmount: "5",
mode: "fund_wallet" as const,
},
customButton: {
destinationToken: ETH,
initialAmount: "0.01",
metadata: {
description: "Test custom button label for funding",
title: "Custom Fund Wallet",
},
mode: "fund_wallet" as const,
buttonLabel: "Add Funds Now",
},
};
// UI Options for DirectPayment mode
export const DIRECT_PAYMENT_UI_OPTIONS: Record<
"digitalArt" | "concertTicket" | "subscription" | "sneakers" | "credits",
| "digitalArt"
| "concertTicket"
| "subscription"
| "sneakers"
| "credits"
| "customButton",
DirectPaymentUIOptions
> = {
concertTicket: {
@@ -794,11 +809,26 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record<
token: USDC,
},
},
customButton: {
metadata: {
description: "Test custom button label functionality",
image: PRODUCT_METADATA.digitalArt.image,
title: "Custom Button Test",
},
mode: "direct_payment" as const,
buttonLabel: "Purchase Now",
paymentInfo: {
amount: "0.05",
feePayer: "sender" as const,
sellerAddress: RECEIVER_ADDRESSES.primary,
token: ETH,
},
},
};
// UI Options for Transaction mode
export const TRANSACTION_UI_OPTIONS: Record<
"ethTransfer" | "erc20Transfer" | "contractInteraction",
"ethTransfer" | "erc20Transfer" | "contractInteraction" | "customButton",
TransactionUIOptions
> = {
contractInteraction: {
@@ -825,4 +855,13 @@ export const TRANSACTION_UI_OPTIONS: Record<
mode: "transaction" as const,
transaction: ethTransferTransaction,
},
customButton: {
metadata: {
description: "Test custom button label for transactions",
title: "Custom Transaction",
},
mode: "transaction" as const,
buttonLabel: "Execute Now",
transaction: ethTransferTransaction,
},
};
@@ -3,7 +3,9 @@ export const ZKSYNC_SINGLETON_FACTORY =
export const CONTRACT_DEPLOYER_ADDRESS =
"0x0000000000000000000000000000000000008006" as const;
export const KNOWN_CODES_STORAGE = "0x0000000000000000000000000000000000008004";
export const PUBLISHED_PRIVATE_KEY = process.env.ZKSYNC_PUBLISHED_PRIVATE_KEY;
export const PUBLISHED_PRIVATE_KEY = process
? process.env.ZKSYNC_PUBLISHED_PRIVATE_KEY
: undefined;
export const singletonFactoryAbi = [
"function deploy(bytes32,bytes32,bytes) external payable",
+3 -2
View File
@@ -1,4 +1,5 @@
export const IS_DEV =
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
process &&
(process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test");
export const IS_TEST = process.env.NODE_ENV === "test";
export const IS_TEST = process && process.env.NODE_ENV === "test";
+6
View File
@@ -1,5 +1,11 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.127
## 0.2.126
## 0.2.125
## 0.2.124
## 0.2.123
+1 -1
View File
@@ -55,5 +55,5 @@
"type": "module",
"types": "./dist/types/exports/thirdweb.d.ts",
"typings": "./dist/types/exports/thirdweb.d.ts",
"version": "0.2.124"
"version": "0.2.127"
}
+863 -468
View File
File diff suppressed because it is too large Load Diff