Compare commits

..
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
80d5595e7f Version Packages (#8397)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-12 12:51:24 -08:00
Joaquim VergesandGitHub d0bbbfd39d [SDK] Add all connected wallets in onConnect callbacks (#8403) 2025-11-12 12:44:46 -08:00
Joaquim VergesandGitHub e26d81ce1d [SDK] Update useAuthToken to find auth token for any connected wallet (#8402) 2025-11-12 11:06:26 -08:00
MananTank be3316ec1b [PRO-96] Playground: Update product grouping (#8401)
<!--

## 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 restructuring the `payments` and `contracts` sections of the codebase, consolidating them under a new `bridge` section, and updating various links and components to reflect the new structure.

### Detailed summary
- Deleted multiple files related to `payments` and `contracts`.
- Introduced new `bridge` components and pages.
- Updated links in various `.mdx` and `.tsx` files to point to the new `bridge` paths.
- Renamed components such as `BuyPlayground` to `BuyWidget`.
- Updated sidebar links for improved navigation.
- Adjusted feature cards to reflect the new structure and titles.

> ✨ 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**
  * Reorganized navigation menu with new Bridge section consolidating payment widgets
  * Added x402 as prominent top-level navigation item
  * Expanded Account Abstraction section with new submenu items

* **Changes**
  * Updated widget names and playground URLs across documentation and dashboard links
  * Restructured Transactions and Account Abstraction into dedicated submenus under Wallets
  * Renamed and relocated playground widget pages to Bridge section

* **Removals**
  * Removed Contracts section from navigation and playground
  * Removed standalone Payments overview page
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-12 18:13:07 +00:00
FirekeeperandGitHub 872369770f [Dashboard] Add date range filtering to transaction analytics (#8400) 2025-11-12 21:31:10 +07:00
Joaquim VergesandGitHub 3d48826928 [Dashboard] Change default x402 metric from volume to payments (#8399) 2025-11-11 23:26:15 -08:00
Joaquim VergesandGitHub fa640ad64b [Dashboard] Add x402 chain analytics chart and empty state (#8398) 2025-11-11 17:32:03 -08:00
MananTank eb1a9c3c3e Add callout for verifying webhook in dashboard and portal (#8396)
<!--

## 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 `BridgeWebhookModalContent` component by adding a warning alert for payload verification and modifies the documentation for webhook creation and payload parsing to reflect changes in terminology and content structure.

### Detailed summary
- Added `UnderlineLink` and `Alert` components to `webhooks.client.tsx`.
- Introduced a warning alert regarding payload verification in the webhook modal.
- Updated the version selection to only display for specific conditions.
- Changed documentation headings and improved clarity on webhook creation and payload parsing.
- Revised example payloads and verification instructions in the documentation.

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

<!-- end pr-codex -->
2025-11-11 23:20:42 +00:00
MananTank a54c1aba35 SDK: Add verify parameter in Bridge.Webhook.parse function (#8395)
<!--

## 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 adds a `verify` parameter to the `Bridge.Webhook.parse` function in the `thirdweb` library, allowing for validation of webhook payloads against specified criteria such as receiver address, destination token address, chain ID, and minimum destination amount.

### Detailed summary
- Introduced `verify` parameter in `Bridge.Webhook.parse` function.
- Added validation checks for:
  - `receiverAddress`
  - `destinationTokenAddress`
  - `destinationChainId`
  - `minDestinationAmount`
- Enhanced error handling for mismatched values.
- Updated tests to cover verification scenarios for both onchain and onramp transactions.

> ✨ 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**
  * Webhook parsing now supports optional payload verification: enforce receiver address (case-insensitive), destination token, destination chain ID, and minimum destination amount. Verification works for both on-chain and on-ramp payloads and returns descriptive errors on mismatch to help ensure incoming webhooks meet your requirements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-11 23:20:42 +00:00
Joaquim VergesandGitHub bea4f01742 [Dashboard] Add x402 payments section (#8394) 2025-11-11 14:43:04 -08:00
0xFirekeeper e19f7a21ad [Dashboard] New Project > Wallets product grouping and subsections (#8359)
Reorganized wallet-related routes under a new sidebar structure, replacing legacy pages with redirects to new user, server, and sponsored gas wallet sections. Added new layout and overview/configuration pages for server wallets and sponsored gas, updated sidebar navigation to reflect new wallet grouping, and removed legacy Account Abstraction and Vault pages in favor of the new structure.

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on renaming "in-app wallets" to "user wallets" throughout the application, along with restructuring related components, routes, and API calls. It also introduces new configurations and adjusts UI elements to reflect these changes.

### Detailed summary
- Renamed `in-app wallets` to `user wallets` in various components and texts.
- Deleted unused files related to `in-app wallets`.
- Updated API error messages to reflect the new terminology.
- Adjusted routing paths for user wallet configurations.
- Updated UI elements and titles to align with the new terminology.
- Refactored sidebar links to include new wallet types.
- Added new components for user wallet management.
- Implemented new pagination and analytics features for user wallets.

> The following files were skipped due to too many changes: `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/utils.ts`, `apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.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**
  * Pages and layouts for Server Wallets, Sponsored Gas, and User Wallets (overview + configuration); key management and analytics pages added.
  * In-app/User wallet settings UI: branding, JWT/custom auth endpoint, image uploads, SMS country selector, and SMS country data/utilities.

* **Navigation Updates**
  * Sidebar reorganized to group wallet features; submenu discovery and mobile behavior improved.

* **Behavioral Changes**
  * Legacy analytics/settings pages replaced with redirects to new wallet routes.

* **UX**
  * Wallet tables: URL-driven pagination, page-size-aware ranges, clearer “Showing X–Y of Z” messaging; wording updated from “In-App” to “User” wallets; announcement banner added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-10 22:32:28 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c37af4f191 Version Packages (#8389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-10 14:31:30 -08:00
Joaquim VergesandGitHub f964b8aaff [SDK] Loosen peer dependency requirements for React Native (#8390) 2025-11-10 10:47:57 -08:00
YashandGitHub 424e6b8f9b Stylus modules (#8383) 2025-11-10 10:33:56 -08:00
MananTank 763d3de5a8 [MNY-303] Various UI/UX Improvements in Bridge components (#8388)
<!--

## 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 various UI/UX improvements across multiple components in the `thirdweb` package, particularly enhancing visual consistency and user interaction within the Bridge and Token Selection components.

### Detailed summary
- Adjusted font weights in several components from `600` to `500`.
- Updated `Spacer` component usage for consistent spacing.
- Enhanced `TokenSelection` UI with tabbed navigation for "All Tokens" and "Your Tokens".
- Added fallback images for icons in `TokenAndChain` and `ChainButton`.
- Refactored `CopyIcon` to use a `Button` instead of a `div` for better accessibility.
- Improved loading states and skeletons in `TokenSelectionScreen`.
- Simplified conditional rendering for transaction details in `PaymentReceipt`.

> ✨ 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**
  * Token selection: separate "All Tokens" and "Your Tokens" tabs with per-tab paging and "Load More"
  * Payment receipts: per-transaction explorer links and copy-to-clipboard controls
  * Enhanced success/details views showing expanded per-transaction status entries

* **UI/UX Improvements**
  * Refined typography weights across Bridge screens for clearer hierarchy
  * Adjusted icon sizing and added visual fallbacks for missing chain/token icons
  * Improved spacing, alignment, scrolling and containment in Bridge layouts
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-10 18:11:43 +00:00
Joaquim VergesandGitHub f6f138ccbc [Portal] Move x402 from payments to top-level section (#8386) 2025-11-09 18:09:15 -08:00
187 changed files with 6219 additions and 2559 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
2. Formatting & Linting
- Biome governs formatting and linting; its rules live in biome.json.
- Run pnpm biome check --apply before committing.
- Run `pnpm fix` & `pnpm lint` before committing, make sure there are no linting errors.
- Avoid editor‑specific configs; rely on the shared settings.
- make sure everything builds after each file change by running `pnpm build`
@@ -136,7 +136,7 @@ export function reportContractDeployed(properties: {
}
```
- **Client-side only**: never import `posthog-js` in server components.
- **Client-side only**: never import `posthog-js` in server components.
- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event.
⸻
+43 -1
View File
@@ -16,6 +16,8 @@ import type {
WalletStats,
WebhookLatencyStats,
WebhookSummaryStats,
X402QueryParams,
X402SettlementStats,
} from "@/types/analytics";
import { getChains } from "./chain";
@@ -184,7 +186,7 @@ const cached_getInAppWalletUsage = unstable_cache(
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch in-app wallet usage, ${res?.status} - ${res.statusText} - ${reason}`,
`Failed to fetch user wallets usage, ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
@@ -906,3 +908,43 @@ export function getInsightUsage(
) {
return cached_getInsightUsage(normalizedParams(params), authToken);
}
const cached_getX402Settlements = unstable_cache(
async (
params: X402QueryParams,
authToken: string,
): Promise<X402SettlementStats[]> => {
const searchParams = buildSearchParams(params);
if (params.groupBy) {
searchParams.append("groupBy", params.groupBy);
}
const res = await fetchAnalytics({
authToken,
url: `v2/x402/settlements?${searchParams.toString()}`,
init: {
method: "GET",
},
});
if (res?.status !== 200) {
const reason = await res?.text();
console.error(
`Failed to fetch x402 settlements: ${res?.status} - ${res.statusText} - ${reason}`,
);
return [];
}
const json = await res.json();
return json.data as X402SettlementStats[];
},
["getX402Settlements"],
{
revalidate: 60 * 60, // 1 hour
},
);
export function getX402Settlements(params: X402QueryParams, authToken: string) {
return cached_getX402Settlements(normalizedParams(params), authToken);
}
@@ -38,11 +38,11 @@ type ShadcnSidebarBaseLink = {
isActive?: (pathname: string) => boolean;
};
type ShadcnSidebarLink =
export type ShadcnSidebarLink =
| ShadcnSidebarBaseLink
| {
group: string;
links: ShadcnSidebarBaseLink[];
links: ShadcnSidebarLink[];
}
| {
separator: true;
@@ -97,10 +97,7 @@ export function FullWidthSidebarLayout(props: {
function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
const activeLink = useActiveShadcnSidebarLink(props.links);
const parentSubNav = props.links.find(
(link) =>
"subMenu" in link && link.links.some((l) => l.href === activeLink?.href),
);
const parentSubNav = findParentSubmenu(props.links, activeLink?.href);
return (
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
@@ -109,7 +106,7 @@ function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
className="h-4 bg-muted-foreground/50"
orientation="vertical"
/>
{parentSubNav && "subMenu" in parentSubNav && (
{parentSubNav && (
<>
<span className="text-sm">{parentSubNav.subMenu.label}</span>
<ChevronRightIcon className="size-4 text-muted-foreground/50 -mx-1.5" />
@@ -131,24 +128,65 @@ function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) {
return pathname?.startsWith(link.href);
}
for (const link of links) {
if ("links" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
function walk(
navLinks: ShadcnSidebarLink[],
): ShadcnSidebarBaseLink | undefined {
for (const link of navLinks) {
if ("subMenu" in link) {
for (const subLink of link.links) {
if (isActive(subLink)) {
return subLink;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
}
}
} else if ("href" in link) {
if (isActive(link)) {
return link;
if ("links" in link && !("subMenu" in link)) {
const nested = walk(link.links);
if (nested) {
return nested;
}
}
}
return undefined;
}
return walk(links);
}, [links, pathname]);
return activeLink;
}
function findParentSubmenu(
links: ShadcnSidebarLink[],
activeHref: string | undefined,
): Extract<ShadcnSidebarLink, { subMenu: unknown }> | undefined {
if (!activeHref) {
return undefined;
}
for (const link of links) {
if ("subMenu" in link) {
if (link.links.some((subLink) => subLink.href === activeHref)) {
return link;
}
}
if ("links" in link && !("subMenu" in link)) {
const nested = findParentSubmenu(link.links, activeHref);
if (nested) {
return nested;
}
}
}
return undefined;
}
function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) {
const pathname = usePathname();
@@ -12,10 +12,6 @@ import {
type ProjectPageProps = {
header: ProjectPageHeaderProps;
footer?: ProjectPageFooterProps;
/**
* @deprecated only for legacy pages where we still need internal tabs for the moment, currently:
* - /webhooks
*/
tabs?: TabPathLink[];
};
@@ -337,7 +337,7 @@ export function InAppWalletUsersPageContent(
isFetched={walletsQuery.isFetched}
isPending={walletsQuery.isPending}
tableContainerClassName="rounded-none border-x-0 border-b-0"
title="in-app wallets"
title="User wallets"
/>
<div className="flex justify-center gap-3 border-t bg-card p-6">
@@ -5,7 +5,6 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useLocalStorage } from "@/hooks/useLocalStorage";
// biome-ignore lint/correctness/noUnusedVariables: banner is toggled on-demand via API content changes
function AnnouncementBannerUI(props: {
href: string;
label: string;
@@ -45,12 +44,11 @@ function AnnouncementBannerUI(props: {
}
export function AnnouncementBanner() {
// return (
// <AnnouncementBannerUI
// href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
// label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
// trackingLabel="product-rebrand"
// />
// );
return null;
return (
<AnnouncementBannerUI
href="https://blog.thirdweb.com/changelog/project-view-update-wallets/"
label="Project View Update - Wallets"
trackingLabel="project-view-update-wallets"
/>
);
}
@@ -1,5 +1,5 @@
export const siwaExamplePrompts = [
"How do I add in-app wallet with sign in with google to my react app?",
"How do I add user wallet with sign in with google to my react app?",
"How do I send a transaction in Unity?",
"What does this contract revert error mean?",
"I see thirdweb support id in my console log, can you help me?",
+1 -1
View File
@@ -248,7 +248,7 @@ export function teamSubscriptionsStub(
// In-App Wallets
{
amount: usage.inAppWalletAmount?.amount || 0,
description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`,
description: `${usage.inAppWalletAmount?.quantity || 0} x User Wallets (Tier 1 at $0.00 / month)`,
thirdwebSku: "usage:in_app_wallet",
},
// AA Sponsorship
+59
View File
@@ -94,3 +94,62 @@ export interface AnalyticsQueryParams {
period?: "day" | "week" | "month" | "year" | "all";
limit?: number;
}
export interface X402SettlementsOverall {
date: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByChainId {
date: string;
chainId: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByPayer {
date: string;
payer: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByReceiver {
date: string;
receiver: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export interface X402SettlementsByResource {
date: string;
resource: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
interface X402SettlementsByAsset {
date: string;
asset: string;
totalRequests: number;
totalValue: number;
totalValueUSD: number;
}
export type X402SettlementStats =
| X402SettlementsOverall
| X402SettlementsByChainId
| X402SettlementsByPayer
| X402SettlementsByReceiver
| X402SettlementsByResource
| X402SettlementsByAsset;
export interface X402QueryParams extends AnalyticsQueryParams {
groupBy?: "overall" | "chainId" | "payer" | "resource" | "asset" | "receiver";
}
+1 -1
View File
@@ -1,6 +1,6 @@
const usdCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 2, // prefix with $
maximumFractionDigits: 6, // prefix with $
minimumFractionDigits: 0, // don't show decimal places if value is a whole number
notation: "compact", // at max 2 decimal places
roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations
+1 -1
View File
@@ -23,7 +23,7 @@ export const TEAM_PLANS: Record<
features: [
"Email Support",
"48hr Guaranteed Response",
"Custom In-App Wallet Auth",
"Custom User Wallet Auth",
],
price: 99,
subTitle: "Everything in Starter, plus:",
@@ -225,6 +225,10 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
moduleInfo: {
bytecodeUri: selectedModule.metadata.bytecodeUri,
},
isStylus:
(selectedModule.metadata.compilers?.stylus &&
selectedModule.metadata.compilers?.stylus?.length > 0) ||
false,
});
},
queryKey: [
@@ -432,6 +436,7 @@ async function isModuleCompatible(options: {
moduleInfo: {
bytecodeUri: string;
};
isStylus: boolean;
client: ThirdwebClient;
}) {
// 1. get module's bytecode
@@ -444,15 +449,17 @@ async function isModuleCompatible(options: {
// 2. check compatibility with core and installed modules
try {
const isCompatible = await checkModulesCompatibility({
chain: options.contractInfo.chain,
client: options.client,
coreBytecode: options.contractInfo.bytecode,
moduleBytecodes: [
moduleBytecode,
...options.contractInfo.installedModuleBytecodes,
],
});
const isCompatible =
options.isStylus ||
(await checkModulesCompatibility({
chain: options.contractInfo.chain,
client: options.client,
coreBytecode: options.contractInfo.bytecode,
moduleBytecodes: [
moduleBytecode,
...options.contractInfo.installedModuleBytecodes,
],
}));
return isCompatible;
} catch (e) {
@@ -183,13 +183,64 @@ const STYLUS = {
],
description:
"Airdrop your NFTs or tokens to a large number of recipients. Built with Arbitrum Stylus.",
displayName: "Arbitrum Stylus Contracts",
displayName: "Airdrop - Arbitrum Stylus",
id: "stylus",
name: "Stylus",
showInExplore: true,
isBeta: true,
} satisfies ExploreCategory;
const MODULAR_CONTRACTS_STYLUS = {
contracts: [
// erc721 token
[
"thirdweb.eth/ERC721CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC721",
"deployer.thirdweb.eth/BatchMetadataERC721",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC721",
],
{
description: "ERC721 NFTs that only owners can mint.",
title: "Modular NFT Collection",
},
],
// erc1155 token
[
"thirdweb.eth/ERC1155CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC1155",
"deployer.thirdweb.eth/BatchMetadataERC1155",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC1155",
"deployer.thirdweb.eth/SequentialTokenIdERC1155",
],
{
description: "ERC1155 NFTs that only owners can mint.",
title: "Modular Edition",
},
],
// erc20 token
[
"thirdweb.eth/ERC20CoreInitializable",
[
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusMintableERC20",
"0x6453a486d52e0eb6e79ec4491038e2522a926936/StylusTransferableERC20",
],
{
description: "ERC20 Tokens that only owners can mint.",
title: "Modular Token",
},
],
],
description:
"Collection of highly customizable and upgradeable smart contracts built with the modular contracts framework.",
displayName: "Modular Contracts - Arbitrum Stylus",
id: "modular-contracts",
name: "modular",
showInExplore: true,
isBeta: true,
} satisfies ExploreCategory;
const CATEGORIES: Record<string, ExploreCategory> = {
[POPULAR.id]: POPULAR,
[NFTS.id]: NFTS,
@@ -203,6 +254,7 @@ const CATEGORIES: Record<string, ExploreCategory> = {
[COMMERCE.id]: COMMERCE,
[STAKING.id]: STAKING,
[GOVERNANCE.id]: GOVERNANCE,
[MODULAR_CONTRACTS_STYLUS.id]: MODULAR_CONTRACTS_STYLUS,
};
export function getCategory(id: string) {
@@ -92,7 +92,7 @@ export function TeamSidebarLayout(props: {
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/connect/sign-in/button",
href: "https://playground.thirdweb.com/wallets/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
@@ -6,9 +6,9 @@ export const SmartWalletsBillingAlert = (props: { teamSlug: string }) => {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction on Mainnet</AlertTitle>
<AlertTitle>Gas Sponsorship on Mainnet</AlertTitle>
<AlertDescription>
To enable AA on mainnet chains,{" "}
To enable Gas Sponsorship on mainnet chains,{" "}
<UnderlineLink href={`/team/${props.teamSlug}/~/billing`}>
subscribe to a billing plan.
</UnderlineLink>
@@ -14,7 +14,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
export function DefaultFactoriesSection() {
const data = [
@@ -38,13 +37,6 @@ export function DefaultFactoriesSection() {
</h3>
<p className="text-muted-foreground text-sm">
Ready to use account factories that are pre-deployed on each chain.{" "}
<UnderlineLink
href="https://playground.thirdweb.com/connect/account-abstraction/connect"
rel="noopener noreferrer"
target="_blank"
>
Learn how to use these in your apps
</UnderlineLink>
</p>
</div>
@@ -1,163 +1,11 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { SmartAccountIcon } from "@/icons/SmartAccountIcon";
import { getAbsoluteUrl } from "@/utils/vercel";
import { AccountAbstractionSummary } from "./AccountAbstractionAnalytics/AccountAbstractionSummary";
import { SmartWalletsBillingAlert } from "./Alerts";
import { AccountAbstractionAnalytics } from "./aa-analytics";
import { searchParamLoader } from "./search-params";
interface PageParams {
team_slug: string;
project_slug: string;
}
import { redirect } from "next/navigation";
// Redirect old Account Abstraction page to new Sponsored Gas Overview
export default async function Page(props: {
params: Promise<PageParams>;
searchParams: Promise<SearchParams>;
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const interval = searchParams.interval ?? "week";
const rangeType = searchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? searchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? searchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
const isBundlerServiceEnabled = !!project.services.find(
(s) => s.name === "bundler",
);
const hasSmartWalletsWithoutBilling =
isBundlerServiceEnabled &&
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
return (
<ProjectPage
header={{
icon: SmartAccountIcon,
client,
title: "Account Abstraction",
description:
"Integrate EIP-7702 and EIP-4337 compliant smart accounts for gasless sponsorships and more.",
actions: null,
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/account-abstraction`,
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
},
{
type: "playground",
href: "https://playground.thirdweb.com/account-abstraction/eip-7702",
},
],
}}
>
{hasSmartWalletsWithoutBilling && (
<>
<SmartWalletsBillingAlert teamSlug={params.team_slug} />
<div className="h-10" />
</>
)}
<div className="flex grow flex-col gap-10">
<AccountAbstractionSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<AccountAbstractionAnalytics
client={client}
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
userOpStats={userOpStats}
/>
</div>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`,
);
}
const seo = {
desc: "Add account abstraction to your web3 app & unlock powerful features for seamless onboarding, customizable transactions, & maximum security. Get started.",
title: "The Complete Account Abstraction Toolkit | thirdweb",
};
export const metadata: Metadata = {
description: seo.desc,
openGraph: {
description: seo.desc,
images: [
{
alt: seo.title,
height: 630,
url: `${getAbsoluteUrl()}/assets/og-image/dashboard-wallets-smart-wallet.png`,
width: 1200,
},
],
title: seo.title,
},
title: seo.title,
};
@@ -371,7 +371,7 @@ export function AccountAbstractionSettingsPage(
of the{" "}
<UnderlineLink
className="text-primary-500"
href="https://portal.thirdweb.com/wallets/smart-wallet/infrastructure#supported-chains"
href="https://thirdweb.com/chainlist?service=account-abstraction"
rel="noopener noreferrer"
target="_blank"
>
@@ -69,6 +69,9 @@ export default async function Page(props: {
icon: <WebhookIcon className="size-3.5 text-muted-foreground" />,
},
},
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/payments`,
},
links: [
{
type: "docs",
@@ -1,23 +1,23 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import {
ArrowLeftRightIcon,
BookTextIcon,
BoxIcon,
DatabaseIcon,
HomeIcon,
LockIcon,
RssIcon,
Settings2Icon,
WebhookIcon,
} from "lucide-react";
import { FullWidthSidebarLayout } from "@/components/blocks/full-width-sidebar-layout";
import { Badge } from "@/components/ui/badge";
import {
FullWidthSidebarLayout,
type ShadcnSidebarLink,
} from "@/components/blocks/full-width-sidebar-layout";
import { BridgeIcon } from "@/icons/BridgeIcon";
import { ContractIcon } from "@/icons/ContractIcon";
import { InsightIcon } from "@/icons/InsightIcon";
import { NebulaIcon } from "@/icons/NebulaIcon";
import { PayIcon } from "@/icons/PayIcon";
import { SmartAccountIcon } from "@/icons/SmartAccountIcon";
import { TokenIcon } from "@/icons/TokenIcon";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
@@ -25,136 +25,140 @@ export function ProjectSidebarLayout(props: {
layoutPath: string;
children: React.ReactNode;
}) {
const contentSidebarLinks = [
{
exactMatch: true,
href: props.layoutPath,
icon: HomeIcon,
label: "Overview",
},
{
separator: true,
},
{
group: "Build",
links: [
{
subMenu: {
icon: WalletProductIcon,
label: "Wallets",
},
links: [
{
href: `${props.layoutPath}/wallets/user-wallets`,
label: "User Wallets",
},
{
href: `${props.layoutPath}/wallets/server-wallets`,
label: "Server Wallets",
},
{
href: `${props.layoutPath}/wallets/sponsored-gas`,
label: "Gas Sponsorship",
},
],
},
{
href: `${props.layoutPath}/contracts`,
icon: ContractIcon,
label: "Contracts",
},
{
href: `${props.layoutPath}/ai`,
icon: NebulaIcon,
label: "AI",
},
],
},
{
separator: true,
},
{
group: "Monetize",
links: [
{
href: `${props.layoutPath}/x402`,
icon: PayIcon,
label: (
<span className="flex items-center gap-2">
x402 <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/bridge`,
icon: BridgeIcon,
label: "Bridge",
},
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: "Tokens",
},
],
},
{
separator: true,
},
{
group: "Scale",
links: [
{
href: `${props.layoutPath}/insight`,
icon: InsightIcon,
label: "Insight",
},
{
href: `${props.layoutPath}/rpc`,
icon: RssIcon,
label: "RPC",
},
// linkely want to move this to `team` level eventually
{
href: `${props.layoutPath}/engine`,
icon: DatabaseIcon,
label: "Engine",
},
],
},
] satisfies ShadcnSidebarLink[];
const footerSidebarLinks = [
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
label: "Project Settings",
},
{
separator: true,
},
{
href: "https://portal.thirdweb.com",
icon: BookTextIcon,
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/wallets/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
] satisfies ShadcnSidebarLink[];
return (
<FullWidthSidebarLayout
contentSidebarLinks={[
{
exactMatch: true,
href: props.layoutPath,
icon: HomeIcon,
label: "Overview",
},
{
separator: true,
},
{
group: "Build",
links: [
{
href: `${props.layoutPath}/wallets`,
icon: WalletProductIcon,
label: "Wallets",
},
{
href: `${props.layoutPath}/transactions`,
icon: ArrowLeftRightIcon,
label: (
<span className="flex items-center gap-2">
Transactions <Badge>New</Badge>
</span>
),
},
{
href: `${props.layoutPath}/contracts`,
icon: ContractIcon,
label: "Contracts",
},
{
href: `${props.layoutPath}/ai`,
icon: NebulaIcon,
label: "AI",
},
],
},
{
separator: true,
},
{
group: "Monetize",
links: [
{
href: `${props.layoutPath}/payments`,
icon: PayIcon,
label: "Payments",
},
{
href: `${props.layoutPath}/bridge`,
icon: BridgeIcon,
label: "Bridge",
},
{
href: `${props.layoutPath}/tokens`,
icon: TokenIcon,
label: "Tokens",
},
],
},
{
separator: true,
},
{
group: "Scale",
links: [
{
href: `${props.layoutPath}/insight`,
icon: InsightIcon,
label: "Insight",
},
{
href: `${props.layoutPath}/account-abstraction`,
icon: SmartAccountIcon,
label: "Account Abstraction",
},
{
href: `${props.layoutPath}/rpc`,
icon: RssIcon,
label: "RPC",
},
{
href: `${props.layoutPath}/vault`,
icon: LockIcon,
label: "Vault",
},
// linkely want to move this to `team` level eventually
{
href: `${props.layoutPath}/engine`,
icon: DatabaseIcon,
label: "Engine",
},
],
},
]}
footerSidebarLinks={[
{
separator: true,
},
{
href: `${props.layoutPath}/webhooks/contracts`,
icon: WebhookIcon,
isActive: (pathname) => {
return pathname.startsWith(`${props.layoutPath}/webhooks`);
},
label: "Webhooks",
},
{
href: `${props.layoutPath}/settings`,
icon: Settings2Icon,
label: "Project Settings",
},
{
separator: true,
},
{
href: "https://portal.thirdweb.com",
icon: BookTextIcon,
label: "Documentation",
},
{
href: "https://playground.thirdweb.com/connect/sign-in/button",
icon: BoxIcon,
label: "Playground",
},
]}
contentSidebarLinks={contentSidebarLinks}
footerSidebarLinks={footerSidebarLinks}
>
{props.children}
</FullWidthSidebarLayout>
@@ -81,7 +81,7 @@ export default async function Page(props: {
},
{
type: "playground",
href: "https://playground.thirdweb.com/payments/ui-components",
href: "https://playground.thirdweb.com/bridge",
},
{
type: "api",
@@ -93,20 +93,20 @@ export default async function Page(props: {
center: {
links: [
{
href: "https://playground.thirdweb.com/payments/ui-components",
label: "UI Component",
href: "https://playground.thirdweb.com/bridge/buy-widget",
label: "Buy Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/fund-wallet",
label: "Buy Crypto",
href: "https://playground.thirdweb.com/bridge/checkout-widget",
label: "Checkout Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/commerce",
label: "Checkout",
href: "https://playground.thirdweb.com/bridge/transaction-widget",
label: "Transaction Widget",
},
{
href: "https://playground.thirdweb.com/connect/payments/transactions",
label: "Transactions",
href: "https://playground.thirdweb.com/bridge/swap-widget",
label: "Swap Widget",
},
],
title: "Demos",
@@ -2,9 +2,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { formatDistanceToNow } from "date-fns";
import {
DotIcon,
InfoIcon,
MoreVerticalIcon,
PencilIcon,
PlusIcon,
@@ -25,6 +27,7 @@ import {
updateWebhook,
} from "@/api/universal-bridge/developer";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
@@ -435,28 +438,30 @@ function BridgeWebhookModalContent(
)}
/>
<FormField
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="v2" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">v2</SelectItem>
<SelectItem value="1">v1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the data format of the webhook payload (v2 recommended,
v1 for legacy users).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{props.type === "edit" && props.webhook.version === 1 && (
<FormField
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="v2" />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">v2</SelectItem>
<SelectItem value="1">v1</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the data format of the webhook payload (v2 recommended,
v1 for legacy users).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{props.type === "create" && (
<section>
@@ -486,6 +491,22 @@ function BridgeWebhookModalContent(
</section>
)}
<Alert variant="warning">
<InfoIcon className="size-4" />
<AlertTitle> Verify payload before processing </AlertTitle>
<AlertDescription>
Make sure to verify receiver, destination chain, token address and
amount to ensure that it represents the expected state of the
transaction before processing it in your backend.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/bridge/webhooks#verify-payload"
target="_blank"
>
Learn more
</UnderlineLink>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
className="gap-2"
@@ -220,9 +220,9 @@ export function ProjectGeneralSettingsPageUI(props: {
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
const paths = {
aaConfig: `${projectLayout}/settings/account-abstraction`,
aaConfig: `${projectLayout}/wallets/sponsored-gas/configuration`,
afterDeleteRedirectTo: `/team/${props.teamSlug}`,
inAppConfig: `${projectLayout}/settings/wallets`,
inAppConfig: `${projectLayout}/wallets/user-wallets/configuration`,
payConfig: `${projectLayout}/settings/payments`,
};
@@ -1,95 +0,0 @@
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { DefaultFactoriesSection } from "../../account-abstraction/factories/AccountFactories";
import { YourFactoriesSection } from "../../account-abstraction/factories/AccountFactories/your-factories";
import { AccountAbstractionSettingsPage } from "../../account-abstraction/settings/SponsorshipPolicies";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [team, project, authToken] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/settings/account-abstraction`);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const bundlerService = project.services.find((s) => s.name === "bundler");
return (
<div>
<ProjectSettingsBreadcrumb
teamSlug={team_slug}
projectSlug={project_slug}
page="Account Abstraction"
/>
<div className="space-y-8 mt-6">
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
<UnderlineLink
href={`/team/${team_slug}/${project_slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure the sponsorship rules
</AlertDescription>
</Alert>
) : (
<>
<AccountAbstractionSettingsPage
bundlerService={bundlerService}
client={client}
project={project}
teamId={team.id}
teamSlug={team.slug}
validTeamPlan={getValidTeamPlan(team)}
/>
<DefaultFactoriesSection />
<YourFactoriesSection
authToken={authToken}
clientThirdwebClient={client}
projectId={project.id}
projectSlug={project.slug}
teamId={team.id}
teamSlug={team.slug}
/>
</>
)}
</div>
</div>
);
}
@@ -1,6 +1,4 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
export type SMSCountryTiers = {
tier1: string[];
@@ -10,52 +8,3 @@ export type SMSCountryTiers = {
tier5: string[];
tier6: string[];
};
export async function getSMSCountryTiers() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/sms/list-country-tiers`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
}
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
@@ -13,7 +13,6 @@ import type { Project } from "@/api/project/projects";
import type { Team } from "@/api/team/get-team";
import { FileInput } from "@/components/blocks/FileInput";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
@@ -31,7 +30,6 @@ import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { planToTierRecordForGating } from "@/constants/planToTierRecord";
import { updateProjectClient } from "@/hooks/useApi";
import { cn } from "@/lib/utils";
import {
type ApiKeyEmbeddedWalletsValidationSchema,
@@ -57,82 +55,6 @@ type UpdateAPIKeyTrackingData = {
hasCustomAuthEndpoint: boolean;
};
export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
const updateProject = useMutation({
mutationFn: async (projectValues: Partial<Project>) => {
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.teamId,
},
projectValues,
);
},
});
function handleUpdateProject(projectValues: Partial<Project>) {
updateProject.mutate(projectValues, {
onError: (err) => {
toast.error("Failed to update an API Key");
console.error(err);
},
onSuccess: () => {
toast.success("In-App Wallet API Key configuration updated");
},
});
}
return (
<InAppWalletSettingsPageUI
{...props}
isUpdating={updateProject.isPending}
smsCountryTiers={props.smsCountryTiers}
updateApiKey={handleUpdateProject}
/>
);
}
const InAppWalletSettingsPageUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
smsCountryTiers: SMSCountryTiers;
}
> = (props) => {
const embeddedWalletService = props.project.services.find(
(service) => service.name === "embeddedWallets",
);
if (!embeddedWalletService) {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>In-App wallets service is disabled</AlertTitle>
<AlertDescription>
Enable In-App wallets service in the{" "}
<UnderlineLink
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure settings
</AlertDescription>
</Alert>
);
}
return (
<InAppWalletSettingsUI
{...props}
client={props.client}
embeddedWalletService={embeddedWalletService}
/>
);
};
export const InAppWalletSettingsUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
@@ -191,7 +113,7 @@ export const InAppWalletSettingsUI: React.FC<
) {
return toast.error("Custom JSON Web Token configuration is invalid", {
description:
"To use In-App Wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
"To use User Wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
dismissible: true,
duration: 9000,
});
@@ -202,7 +124,7 @@ export const InAppWalletSettingsUI: React.FC<
"Custom Authentication Endpoint configuration is invalid",
{
description:
"To use In-App Wallets with Custom Authentication Endpoint, provide a valid URL.",
"To use User Wallets with Custom Authentication Endpoint, provide a valid URL.",
dismissible: true,
duration: 9000,
},
@@ -1,14 +1,16 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@workspace/ui/components/spinner";
import { useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { Project } from "@/api/project/projects";
import { getLastNDaysRange } from "@/components/analytics/date-range-selector";
import { normalizeTimeISOString } from "@/lib/time";
import { UnifiedTransactionsTable } from "../components/transactions-table.client";
import { getTransactionAnalyticsSummary } from "../lib/analytics-summary.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
import { EngineChecklist } from "./ftux.client";
import { TransactionAnalyticsSummary } from "./summary";
import { TransactionsAnalytics } from "./tx-chart/tx-chart";
export function TransactionsAnalyticsPageContent(props: {
@@ -24,18 +26,30 @@ export function TransactionsAnalyticsPageContent(props: {
testSolanaTxWithWallet: string | undefined;
solanaWallets: SolanaWallet[];
}) {
const defaultRange = useMemo(() => {
const range = getLastNDaysRange("last-30");
return {
from: normalizeTimeISOString(range.from),
to: normalizeTimeISOString(range.to),
};
}, []);
const engineTxSummaryQuery = useQuery({
queryKey: [
"engine-tx-analytics-summary",
props.teamId,
props.project.publishableKey,
props.authToken,
defaultRange.from,
defaultRange.to,
],
queryFn: async () => {
const data = await getTransactionAnalyticsSummary({
clientId: props.project.publishableKey,
teamId: props.teamId,
authToken: props.authToken,
startDate: defaultRange.from,
endDate: defaultRange.to,
});
return data;
},
@@ -70,19 +84,12 @@ export function TransactionsAnalyticsPageContent(props: {
/>
{props.showAnalytics && hasTransactions && (
<div className="flex flex-col gap-6">
<TransactionAnalyticsSummary
clientId={props.project.publishableKey}
teamId={props.project.teamId}
initialData={engineTxSummaryQuery.data}
/>
<TransactionsAnalytics
project={props.project}
authToken={props.authToken}
teamSlug={props.teamSlug}
wallets={props.wallets ?? []}
/>
</div>
<TransactionsAnalytics
project={props.project}
authToken={props.authToken}
teamSlug={props.teamSlug}
wallets={props.wallets ?? []}
/>
)}
<UnifiedTransactionsTable
@@ -1,7 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ActivityIcon, CoinsIcon } from "lucide-react";
import { toEther } from "thirdweb/utils";
import { StatCard } from "@/components/analytics/stat"; // Assuming correct path
import type { TransactionSummaryData } from "../lib/analytics-summary.client";
import {
getTransactionAnalyticsSummary,
type TransactionSummaryData,
} from "../lib/analytics-summary.client";
// Renders the UI based on fetched data or pending state
function TransactionAnalyticsSummaryUI(props: {
@@ -88,9 +93,37 @@ function TransactionAnalyticsSummaryUI(props: {
export function TransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
initialData: TransactionSummaryData | undefined;
authToken: string;
startDate?: string;
endDate?: string;
}) {
const engineTxSummaryQuery = useQuery({
queryKey: [
"engine-tx-analytics-summary",
props.teamId,
props.clientId,
props.authToken,
props.startDate,
props.endDate,
],
queryFn: async () => {
const data = await getTransactionAnalyticsSummary({
clientId: props.clientId,
teamId: props.teamId,
authToken: props.authToken,
startDate: props.startDate,
endDate: props.endDate,
});
return data;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
});
return (
<TransactionAnalyticsSummaryUI data={props.initialData} isPending={false} />
<TransactionAnalyticsSummaryUI
data={engineTxSummaryQuery.data}
isPending={engineTxSummaryQuery.isPending}
/>
);
}
@@ -184,7 +184,7 @@ function EmptyChartContent(props: {
<Button
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/vault`,
`/team/${props.teamSlug}/${props.project.slug}/wallets/server-wallets/configuration`,
);
}}
variant="primary"
@@ -9,6 +9,7 @@ import {
import { IntervalSelector } from "@/components/analytics/interval-selector";
import { normalizeTimeISOString } from "@/lib/time";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionAnalyticsSummary } from "../summary";
import { getTransactionsChartData } from "./data";
import { TransactionsChartCardUI } from "./tx-chart-ui";
@@ -21,12 +22,15 @@ export function TransactionsAnalytics(props: {
const [range, setRange] = useState(() => getLastNDaysRange("last-30"));
const [interval, setInterval] = useState<"day" | "week">("day");
const normalizedFrom = normalizeTimeISOString(range.from);
const normalizedTo = normalizeTimeISOString(range.to);
const params = {
clientId: props.project.publishableKey,
from: normalizeTimeISOString(range.from),
from: normalizedFrom,
interval: interval,
teamId: props.project.teamId,
to: normalizeTimeISOString(range.to),
to: normalizedTo,
authToken: props.authToken,
};
@@ -42,7 +46,7 @@ export function TransactionsAnalytics(props: {
return (
<div className="flex flex-col gap-6">
<div className="flex justify-end">
<div className="flex justify-start">
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
popoverAlign="end"
@@ -56,6 +60,13 @@ export function TransactionsAnalytics(props: {
/>
</div>
</div>
<TransactionAnalyticsSummary
authToken={props.authToken}
clientId={props.project.publishableKey}
teamId={props.project.teamId}
startDate={normalizedFrom}
endDate={normalizedTo}
/>
<TransactionsChartCardUI
isPending={engineTxAnalytics.isPending}
project={props.project}
@@ -10,6 +10,7 @@ import {
XIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
@@ -21,6 +22,7 @@ import {
import type { Project } from "@/api/project/projects";
import { FundWalletModal } from "@/components/blocks/fund-wallets-modal";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
import { SolanaAddress } from "@/components/blocks/solana-address";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
@@ -32,14 +34,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
@@ -88,6 +82,7 @@ interface ServerWalletsTableProps {
client: ThirdwebClient;
solanaPermissionError?: boolean;
authToken: string;
pageSize: number;
}
export function ServerWalletsTable(props: ServerWalletsTableProps) {
@@ -105,6 +100,7 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
client,
solanaPermissionError,
authToken,
pageSize,
} = props;
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
@@ -333,13 +329,13 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
</TableContainer>
{totalPages > 1 && (
<WalletsPagination
<ServerWalletsPagination
activeChain={activeChain}
currentPage={currentPage}
currentCount={wallets.length}
pageSize={pageSize}
totalPages={totalPages}
totalRecords={totalRecords}
teamSlug={teamSlug}
projectSlug={project.slug}
/>
)}
</>
@@ -349,78 +345,69 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
);
}
// Wallets Pagination Component
function WalletsPagination({
function ServerWalletsPagination({
activeChain,
currentPage,
currentCount,
pageSize,
totalPages,
totalRecords,
teamSlug,
projectSlug,
}: {
activeChain: WalletChain;
currentPage: number;
currentCount: number;
pageSize: number;
totalPages: number;
totalRecords: number;
teamSlug: string;
projectSlug: string;
}) {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pageParam = activeChain === "evm" ? "page" : "solana_page";
const effectivePageSize = pageSize > 0 ? pageSize : currentCount;
const rangeStart =
totalRecords && effectivePageSize
? Math.min((currentPage - 1) * effectivePageSize + 1, totalRecords)
: 0;
const computedCount = currentCount
? currentCount
: totalRecords && effectivePageSize
? Math.min(effectivePageSize, totalRecords - rangeStart + 1)
: 0;
const rangeEnd = totalRecords
? Math.min(rangeStart + Math.max(computedCount - 1, 0), totalRecords)
: 0;
const handlePageChange = (page: number) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (page <= 1) {
params.delete(pageParam);
} else {
params.set(pageParam, page.toString());
}
const queryString = params.toString();
router.push(queryString ? `${pathname}?${queryString}` : pathname);
};
return (
<div className="flex flex-col items-center border-t p-6">
<div className="mb-4 text-muted-foreground text-sm">
Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
<div className="border-t px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<span className="text-sm text-muted-foreground">
{totalRecords
? `Showing ${rangeStart.toLocaleString()}-${rangeEnd.toLocaleString()} of ${totalRecords.toLocaleString()} ` +
(activeChain === "evm" ? "EVM" : "Solana") +
" wallets"
: `No ${activeChain === "evm" ? "EVM" : "Solana"} wallets found`}
</span>
<PaginationButtons
activePage={currentPage}
className="justify-start lg:justify-end"
onPageClick={handlePageChange}
totalPages={totalPages}
/>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage > 1 ? currentPage - 1 : 1
}`}
legacyBehavior
passHref
>
<PaginationPrevious
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</Link>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={`page-${pageNumber}`}>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${pageNumber}`}
passHref
>
<PaginationLink isActive={currentPage === pageNumber}>
{pageNumber}
</PaginationLink>
</Link>
</PaginationItem>
),
)}
<PaginationItem>
<Link
href={`/team/${teamSlug}/${projectSlug}/transactions?${pageParam}=${
currentPage < totalPages ? currentPage + 1 : totalPages
}`}
passHref
>
<PaginationNext
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</Link>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
@@ -27,8 +27,21 @@ export async function getTransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
authToken: string;
startDate?: string;
endDate?: string;
}): Promise<TransactionSummaryData> {
const body = {};
const body: {
startDate?: string;
endDate?: string;
} = {};
if (props.startDate) {
body.startDate = props.startDate;
}
if (props.endDate) {
body.endDate = props.endDate;
}
const defaultData: TransactionSummaryData = {
totalCount: 0,
totalGasCostWei: "0",
@@ -1,204 +1,11 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { ArrowLeftRightIcon } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { ServerWalletsTable } from "./components/server-wallets-table.client";
import type { Wallet } from "./server-wallets/wallet-table/types";
import { listSolanaAccounts } from "./solana-wallets/lib/vault.client";
import type { SolanaWallet } from "./solana-wallets/wallet-table/types";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function TransactionsAnalyticsPage(props: {
// Redirect old Transactions page to new Server Wallets Overview
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[] | undefined;
to?: string | string[] | undefined;
interval?: string | string[] | undefined;
testTxWithWallet?: string | string[] | undefined;
testSolanaTxWithWallet?: string | string[] | undefined;
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const wallets = eoas.data?.items as Wallet[] | undefined;
// Fetch Solana accounts - gracefully handle permission errors
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check if error is a permission error
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
icon: ArrowLeftRightIcon,
title: "Transactions",
description: (
<>
Send, monitor, and manage transactions.{" "}
<br className="max-sm:hidden" /> Send transactions from user or
server wallets, sponsor gas, monitor transaction status, and more
</>
),
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions",
},
{
type: "playground",
href: "https://playground.thirdweb.com/transactions/airdrop-tokens",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/transactions",
},
],
}}
>
<div className="flex flex-col gap-10">
{/* transactions */}
<TransactionsAnalyticsPageContent
client={client}
authToken={authToken}
project={project}
showAnalytics={
!searchParams.testTxWithWallet &&
!searchParams.testSolanaTxWithWallet
}
teamSlug={params.team_slug}
wallets={wallets ?? []}
solanaWallets={solanaAccounts.data.items}
teamId={project.teamId}
isManagedVault={isManagedVault}
testTxWithWallet={
typeof searchParams.testTxWithWallet === "string"
? searchParams.testTxWithWallet
: undefined
}
testSolanaTxWithWallet={
typeof searchParams.testSolanaTxWithWallet === "string"
? searchParams.testSolanaTxWithWallet
: undefined
}
/>
{/* Server Wallets (EVM + Solana) */}
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
authToken={authToken}
/>
)}
</div>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
@@ -1,3 +1,4 @@
import { UnderlineLink } from "@workspace/ui/components/UnderlineLink";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import type { Project } from "@/api/project/projects";
@@ -53,15 +54,20 @@ export function KeyManagement({
{isManagedVault && (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
<h2 className="font-semibold text-xl tracking-tight mb-1">
Managed Vault
</h2>
<p className="text-muted-foreground text-sm">
Your vault is currently managed by Thirdweb so you can access it
via you project secret key. You can eject and manage your own
vault keys at any time. Doing so means you'll need to pass your
own vault access token to the transactions API additionally to
your project secret key.
<p className="text-muted-foreground text-sm max-w-3xl">
If you choose to eject and manage your own vault keys, you'll need
to provide your vault access token alongside your project secret
key when using the Transactions API.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/vault"
target="_blank"
rel="noopener noreferrer"
>
Learn more about Vault
</UnderlineLink>
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
@@ -1,58 +1,11 @@
import { LockIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { KeyManagement } from "./components/key-management";
import { redirect } from "next/navigation";
export default async function VaultPage(props: {
// Redirect old Vault page to new Server Wallets Configuration
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!project || !authToken) {
notFound();
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
client,
title: "Vault",
icon: LockIcon,
description:
"Secure, non-custodial key management system for your server wallets.",
actions: null,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/vault",
},
],
}}
>
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
</ProjectPage>
const params = await props.params;
redirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/configuration`,
);
}
@@ -19,7 +19,7 @@ export const Variants: Story = {
function Component() {
const title = "This is Title";
const description =
"This is an example of a description about in-app wallet usage chart";
"This is an example of a description about user wallet usage chart";
return (
<div className="container flex max-w-6xl flex-col gap-10 py-10">
<BadgeContainer label="30 days">
@@ -21,7 +21,7 @@ function InAppWalletAnalyticsUI({
return (
<InAppWalletUsersChartCardUI
title="Unique Users"
description="The total number of active in-app wallet users on your project."
description="The total number of active user wallet users on your project."
inAppWalletStats={stats || []}
isPending={isPending}
/>
@@ -1,226 +0,0 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { ServerWalletsTable } from "../transactions/components/server-wallets-table.client";
import type { Wallet } from "../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../transactions/solana-wallets/wallet-table/types";
import { InAppWalletAnalytics } from "./analytics/chart";
import { InAppWalletsSummary } from "./analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
page?: string;
solana_page?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/wallets`);
}
const defaultRange: DurationId = "last-30";
const { range, interval } = getFiltersFromSearchParams({
defaultRange,
from: searchParams.from,
interval: searchParams.interval,
to: searchParams.to,
});
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
// Fetch server wallets with pagination (5 per page)
const pageSize = 5;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
// Fetch EVM wallets
const eoas =
vaultClient && managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
// Fetch Solana wallets
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
// Check for Solana permission errors
const isSolanaPermissionError = solanaAccounts.error?.message?.includes(
"AUTH_INSUFFICIENT_SCOPE",
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<ProjectPage
header={{
icon: WalletProductIcon,
client,
title: "Wallets",
description: (
<>
Create wallets for your users with flexible authentication
options.
<br className="max-sm:hidden" /> Choose from email/phone
verification, OAuth, passkeys, or external wallet connections
</>
),
actions: null,
settings: {
href: `/team/${params.team_slug}/${params.project_slug}/settings/wallets`,
},
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets",
},
{
type: "playground",
href: "https://playground.thirdweb.com/wallets/in-app-wallet",
},
{
type: "api",
href: "https://api.thirdweb.com/reference#tag/wallets",
},
],
}}
>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<InAppWalletsSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
range={range}
/>
<InAppWalletAnalytics
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
authToken={authToken}
/>
{/* Server Wallets Section (EVM + Solana) */}
<div className="flex flex-col gap-4">
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">
{eoas.error.message || "Failed to load EVM wallets"}
</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={eoas.data.items as Wallet[]}
project={project}
solanaCurrentPage={solanCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError || false}
authToken={authToken}
/>
)}
</div>
{/* User Wallets Section */}
<div className="flex flex-col gap-4">
<h2 className="font-semibold text-2xl tracking-tight">
User wallets
</h2>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</div>
</ProjectPage>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { loginRedirect } from "@/utils/redirects";
import { KeyManagement } from "../../../vault/components/key-management";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!authToken) {
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/server-wallets/configuration`,
);
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
return (
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
isManagedVault={isManagedVault}
project={project}
/>
);
}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "Server Wallets",
description:
"Wallet used to relay transactions from your backend at scale",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets/server",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,172 @@
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { NEXT_PUBLIC_THIRDWEB_VAULT_URL } from "@/constants/public-envs";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { TransactionsAnalyticsPageContent } from "../../transactions/analytics/analytics-page";
import { ServerWalletsTable } from "../../transactions/components/server-wallets-table.client";
import type { Wallet } from "../../transactions/server-wallets/wallet-table/types";
import { listSolanaAccounts } from "../../transactions/solana-wallets/lib/vault.client";
import type { SolanaWallet } from "../../transactions/solana-wallets/wallet-table/types";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[];
to?: string | string[];
interval?: string | string[];
testTxWithWallet?: string | string[];
testSolanaTxWithWallet?: string | string[];
page?: string;
solana_page?: string;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: NEXT_PUBLIC_THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey;
const pageSize = 10;
const currentPage = Number.parseInt(searchParams.page ?? "1");
const solanaCurrentPage = Number.parseInt(searchParams.solana_page ?? "1");
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {
page: currentPage - 1,
// @ts-expect-error - TODO: fix this
page_size: pageSize,
},
},
})
: { data: { items: [], totalRecords: 0 }, error: null, success: true };
const wallets = (eoas.data?.items as Wallet[] | undefined) ?? [];
let solanaAccounts: {
data: { items: SolanaWallet[]; totalRecords: number };
error: Error | null;
success: boolean;
};
if (managementAccessToken) {
solanaAccounts = await listSolanaAccounts({
managementAccessToken,
page: solanaCurrentPage,
limit: pageSize,
projectId: project.id,
});
} else {
solanaAccounts = {
data: { items: [], totalRecords: 0 },
error: null,
success: true,
};
}
const isSolanaPermissionError =
solanaAccounts.error?.message.includes("AUTH_INSUFFICIENT_SCOPE") ?? false;
const testTxWithWallet =
typeof searchParams.testTxWithWallet === "string"
? searchParams.testTxWithWallet
: undefined;
const testSolanaTxWithWallet =
typeof searchParams.testSolanaTxWithWallet === "string"
? searchParams.testSolanaTxWithWallet
: undefined;
const hasTransactions = wallets.length > 0;
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<div className="flex flex-col gap-10">
<TransactionsAnalyticsPageContent
authToken={authToken}
client={client}
isManagedVault={isManagedVault}
project={project}
showAnalytics={
hasTransactions && !testTxWithWallet && !testSolanaTxWithWallet
}
solanaWallets={solanaAccounts.data.items}
teamId={project.teamId}
teamSlug={params.team_slug}
testSolanaTxWithWallet={testSolanaTxWithWallet}
testTxWithWallet={testTxWithWallet}
wallets={wallets}
/>
{eoas.error ? (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 p-4">
<p className="text-destructive font-semibold mb-2">
EVM Wallet Error
</p>
<p className="text-sm text-muted-foreground">{eoas.error.message}</p>
</div>
) : (
<ServerWalletsTable
client={client}
evmCurrentPage={currentPage}
evmTotalPages={Math.ceil(eoas.data.totalRecords / pageSize)}
evmTotalRecords={eoas.data.totalRecords}
evmWallets={wallets}
pageSize={pageSize}
project={project}
solanaCurrentPage={solanaCurrentPage}
solanaTotalPages={Math.ceil(
solanaAccounts.data.totalRecords / pageSize,
)}
solanaTotalRecords={solanaAccounts.data.totalRecords}
solanaWallets={solanaAccounts.data.items}
teamSlug={params.team_slug}
solanaPermissionError={isSolanaPermissionError}
authToken={authToken}
/>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { CircleAlertIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { DefaultFactoriesSection } from "../../../account-abstraction/factories/AccountFactories";
import { YourFactoriesSection } from "../../../account-abstraction/factories/AccountFactories/your-factories";
import { AccountAbstractionSettingsPage } from "../../../account-abstraction/settings/SponsorshipPolicies";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [team, project, authToken] = await Promise.all([
getTeamBySlug(team_slug),
getProject(team_slug, project_slug),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/sponsored-gas/configuration`,
);
}
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: team.id,
});
const bundlerService = project.services.find((s) => s.name === "bundler");
return (
<div className="space-y-8">
{!bundlerService ? (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Account Abstraction service is disabled</AlertTitle>
<AlertDescription>
Enable Account Abstraction service in{" "}
<UnderlineLink href={`/team/${team_slug}/${project_slug}/settings`}>
project settings
</UnderlineLink>{" "}
to configure the sponsorship rules
</AlertDescription>
</Alert>
) : (
<>
<AccountAbstractionSettingsPage
bundlerService={bundlerService}
client={client}
project={project}
teamId={team.id}
teamSlug={team.slug}
validTeamPlan={getValidTeamPlan(team)}
/>
<DefaultFactoriesSection />
<YourFactoriesSection
authToken={authToken}
clientThirdwebClient={client}
projectId={project.id}
projectSlug={project.slug}
teamId={team.id}
teamSlug={team.slug}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "Gas Sponsorship",
description:
"Cover gas fees on behalf of your users for a seamless onboarding experience",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/transactions/sponsor",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs/server";
import { getUserOpUsage } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { getTeamBySlug } from "@/api/team/get-team";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { loginRedirect } from "@/utils/redirects";
import { AccountAbstractionSummary } from "../../account-abstraction/AccountAbstractionAnalytics/AccountAbstractionSummary";
import { SmartWalletsBillingAlert } from "../../account-abstraction/Alerts";
import { AccountAbstractionAnalytics } from "../../account-abstraction/aa-analytics";
import { searchParamLoader } from "../../account-abstraction/search-params";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<SearchParams>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
searchParamLoader(props.searchParams),
getAuthToken(),
]);
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/sponsored-gas`,
);
}
const [team, project] = await Promise.all([
getTeamBySlug(params.team_slug),
getProject(params.team_slug, params.project_slug),
]);
if (!team) {
redirect("/team");
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const interval = searchParams.interval ?? "week";
const rangeType = searchParams.range || "last-120";
const range: Range = {
from:
rangeType === "custom"
? searchParams.from
: getLastNDaysRange(rangeType).from,
to:
rangeType === "custom"
? searchParams.to
: getLastNDaysRange(rangeType).to,
type: rangeType,
};
const userOpStats = await getUserOpUsage(
{
from: range.from,
period: interval,
projectId: project.id,
teamId: project.teamId,
to: range.to,
},
authToken,
);
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
const isBundlerServiceEnabled = !!project.services.find(
(s) => s.name === "bundler",
);
const hasSmartWalletsWithoutBilling =
isBundlerServiceEnabled &&
team.billingStatus !== "validPayment" &&
team.billingStatus !== "pastDue";
return (
<>
{hasSmartWalletsWithoutBilling && (
<>
<SmartWalletsBillingAlert teamSlug={params.team_slug} />
<div className="h-10" />
</>
)}
<div className="flex grow flex-col gap-10">
<AccountAbstractionSummary
projectId={project.id}
teamId={project.teamId}
authToken={authToken}
/>
<AccountAbstractionAnalytics
client={client}
projectId={project.id}
teamId={project.teamId}
teamSlug={params.team_slug}
userOpStats={userOpStats}
/>
</div>
</>
);
}
@@ -0,0 +1,61 @@
import "server-only";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { API_SERVER_SECRET } from "@/constants/server-envs";
export type SMSCountryTiers = {
tier1: string[];
tier2: string[];
tier3: string[];
tier4: string[];
tier5: string[];
tier6: string[];
};
export async function getSMSCountryTiers() {
if (!API_SERVER_SECRET) {
throw new Error("API_SERVER_SECRET is not set");
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/sms/list-country-tiers`,
{
headers: {
"Content-Type": "application/json",
"x-service-api-key": API_SERVER_SECRET,
},
next: {
revalidate: 15 * 60, //15 minutes
},
},
);
if (!res.ok) {
console.error(
"Failed to fetch sms country tiers",
res.status,
res.statusText,
);
res.body?.cancel();
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
try {
return (await res.json()).data as SMSCountryTiers;
} catch (e) {
console.error("Failed to parse sms country tiers", e);
return {
tier1: [],
tier2: [],
tier3: [],
tier4: [],
tier5: [],
tier6: [],
};
}
}
@@ -0,0 +1,891 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import type { ProjectEmbeddedWalletsService } from "@thirdweb-dev/service-utils";
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { upload } from "thirdweb/storage";
import type { Project } from "@/api/project/projects";
import type { Team } from "@/api/team/get-team";
import { FileInput } from "@/components/blocks/FileInput";
import { GatedSwitch } from "@/components/blocks/GatedSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DynamicHeight } from "@/components/ui/DynamicHeight";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/Spinner";
import { Textarea } from "@/components/ui/textarea";
import { UnderlineLink } from "@/components/ui/UnderlineLink";
import { planToTierRecordForGating } from "@/constants/planToTierRecord";
import { updateProjectClient } from "@/hooks/useApi";
import { cn } from "@/lib/utils";
import {
type ApiKeyEmbeddedWalletsValidationSchema,
apiKeyEmbeddedWalletsValidationSchema,
} from "@/schema/validations";
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
import { toArrFromList } from "@/utils/string";
import type { SMSCountryTiers } from "../api/sms";
import { CountrySelector } from "./sms-country-select";
type InAppWalletSettingsPageProps = {
project: Project;
teamId: string;
teamSlug: string;
teamPlan: Team["billingPlan"];
smsCountryTiers: SMSCountryTiers;
client: ThirdwebClient;
};
type UpdateAPIKeyTrackingData = {
hasCustomBranding: boolean;
hasCustomJwt: boolean;
hasCustomAuthEndpoint: boolean;
};
export function InAppWalletSettingsPage(props: InAppWalletSettingsPageProps) {
const updateProject = useMutation({
mutationFn: async (projectValues: Partial<Project>) => {
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.teamId,
},
projectValues,
);
},
});
function handleUpdateProject(projectValues: Partial<Project>) {
updateProject.mutate(projectValues, {
onError: (err) => {
toast.error("Failed to update an API Key");
console.error(err);
},
onSuccess: () => {
toast.success("Configuration updated successfully");
},
});
}
return (
<InAppWalletSettingsPageUI
{...props}
isUpdating={updateProject.isPending}
smsCountryTiers={props.smsCountryTiers}
updateApiKey={handleUpdateProject}
/>
);
}
const InAppWalletSettingsPageUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
smsCountryTiers: SMSCountryTiers;
}
> = (props) => {
const embeddedWalletService = props.project.services.find(
(service) => service.name === "embeddedWallets",
);
if (!embeddedWalletService) {
return (
<Alert variant="warning">
<CircleAlertIcon className="size-5" />
<AlertTitle>Wallets service is disabled</AlertTitle>
<AlertDescription>
Enable Wallets service in the{" "}
<UnderlineLink
href={`/team/${props.teamSlug}/${props.project.slug}/settings`}
>
project settings
</UnderlineLink>{" "}
to configure settings
</AlertDescription>
</Alert>
);
}
return (
<InAppWalletSettingsUI
{...props}
client={props.client}
embeddedWalletService={embeddedWalletService}
/>
);
};
const InAppWalletSettingsUI: React.FC<
InAppWalletSettingsPageProps & {
updateApiKey: (
projectValues: Partial<Project>,
trackingData: UpdateAPIKeyTrackingData,
) => void;
isUpdating: boolean;
embeddedWalletService: ProjectEmbeddedWalletsService;
client: ThirdwebClient;
}
> = (props) => {
const services = props.project.services;
const config = props.embeddedWalletService;
const hasCustomBranding =
!!config.applicationImageUrl?.length || !!config.applicationName?.length;
const authRequiredPlan = "growth";
const brandingRequiredPlan = "starter";
// growth or higher plan required
const canEditSmsCountries =
planToTierRecordForGating[props.teamPlan] >=
planToTierRecordForGating[authRequiredPlan];
const form = useForm<ApiKeyEmbeddedWalletsValidationSchema>({
resolver: zodResolver(apiKeyEmbeddedWalletsValidationSchema),
values: {
customAuthEndpoint: config.customAuthEndpoint || undefined,
customAuthentication: config.customAuthentication || undefined,
...(hasCustomBranding
? {
branding: {
applicationImageUrl: config.applicationImageUrl || undefined,
applicationName: config.applicationName || undefined,
},
}
: undefined),
redirectUrls: (config.redirectUrls || []).join("\n"),
smsEnabledCountryISOs: config.smsEnabledCountryISOs
? config.smsEnabledCountryISOs
: canEditSmsCountries
? ["US", "CA"]
: [],
},
});
const handleSubmit = form.handleSubmit((values) => {
const { customAuthentication, customAuthEndpoint, branding, redirectUrls } =
values;
if (
customAuthentication &&
(!customAuthentication.aud.length || !customAuthentication.jwksUri.length)
) {
return toast.error("Custom JSON Web Token configuration is invalid", {
description:
"To use user wallets with Custom JSON Web Token, provide JWKS URI and AUD.",
dismissible: true,
duration: 9000,
});
}
if (customAuthEndpoint && !customAuthEndpoint.authEndpoint.length) {
return toast.error(
"Custom Authentication Endpoint configuration is invalid",
{
description:
"To use user wallets with Custom Authentication Endpoint, provide a valid URL.",
dismissible: true,
duration: 9000,
},
);
}
const newServices = services.map((service) => {
if (service.name !== "embeddedWallets") {
return service;
}
return {
...service,
applicationImageUrl: branding?.applicationImageUrl,
applicationName: branding?.applicationName || props.project.name,
customAuthEndpoint,
customAuthentication,
redirectUrls: toArrFromList(redirectUrls || "", true),
smsEnabledCountryISOs: values.smsEnabledCountryISOs,
};
});
props.updateApiKey(
{
services: newServices,
},
{
hasCustomAuthEndpoint: !!customAuthEndpoint,
hasCustomBranding: !!branding,
hasCustomJwt: !!customAuthentication,
},
);
});
return (
<Form {...form}>
<form autoComplete="off" className="space-y-6" onSubmit={handleSubmit}>
{/* Branding */}
<BrandingFieldset
client={props.client}
form={form}
requiredPlan={brandingRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
isUpdating={props.isUpdating}
/>
<NativeAppsFieldset form={form} isUpdating={props.isUpdating} />
{/* Authentication */}
<Fieldset
legend="Authentication"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button
className="gap-2"
type="submit"
size="sm"
variant="outline"
>
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<JSONWebTokenFields
form={form}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
<div className="my-5 border-t border-dashed" />
<AuthEndpointFields
form={form}
requiredPlan={authRequiredPlan}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
<div className="my-5 border-t border-dashed" />
<SMSCountryFields
form={form}
requiredPlan={authRequiredPlan}
smsCountryTiers={props.smsCountryTiers}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
</Fieldset>
</form>
</Form>
);
};
function BrandingFieldset(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
client: ThirdwebClient;
isUpdating: boolean;
}) {
return (
<FieldsetWithDescription
legend="Branding"
description="Set an app name and logo to be used in the emails sent to users"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<div className="absolute top-8 right-6">
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("branding"),
id: "branding-switch",
onCheckedChange: (checked) =>
props.form.setValue(
"branding",
checked
? {
applicationImageUrl: "",
applicationName: "",
}
: undefined,
),
}}
teamSlug={props.teamSlug}
trackingLabel="customEmailLogoAndName"
/>
</div>
<GatedCollapsibleContainer
className="border-t border-dashed pt-5"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("branding")}
requiredPlan={props.requiredPlan}
>
<div className="flex flex-col gap-6 lg:flex-row">
<FormField
control={props.form.control}
name="branding.applicationImageUrl"
render={() => (
<FormItem className="lg:border-r lg:border-dashed lg:pr-6">
<div className="space-y-1">
<FormLabel>Application Image URL</FormLabel>
<FormDescription className="!mb-4">
Logo that will display in the emails sent to users.{" "}
<br className="max-sm:hidden" /> The image must be squared
with recommended size of 72x72 px.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<AppImageFormControl
client={props.client}
setUri={(uri) => {
props.form.setValue("branding.applicationImageUrl", uri, {
shouldDirty: true,
shouldTouch: true,
});
}}
uri={props.form.watch("branding.applicationImageUrl")}
/>
</FormControl>
</FormItem>
)}
/>
{/* Application Name */}
<FormField
control={props.form.control}
name="branding.applicationName"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormDescription>
Name that will be displayed in the emails sent to users.{" "}
<br className="max-sm:hidden" /> Defaults to your API Key's
name.
</FormDescription>
<FormControl>
<Input {...field} className="max-w-md" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</GatedCollapsibleContainer>
</FieldsetWithDescription>
);
}
function AppImageFormControl(props: {
uri: string | undefined;
setUri: (uri: string) => void;
client: ThirdwebClient;
}) {
const [image, setImage] = useState<File | undefined>();
const resolveUrl = resolveSchemeWithErrorHandler({
client: props.client,
uri: props.uri || undefined,
});
const uploadImage = useMutation({
mutationFn: async (file: File) => {
const uri = await upload({
client: props.client,
files: [file],
});
return uri;
},
});
return (
<div className="flex">
<div className="relative">
<FileInput
accept={{ "image/*": [] }}
className="w-24 rounded-full bg-background"
client={props.client}
disableHelperText
setValue={async (v) => {
try {
setImage(v);
const uri = await uploadImage.mutateAsync(v);
props.setUri(uri);
} catch (error) {
setImage(undefined);
toast.error("Failed to upload image", {
description: error instanceof Error ? error.message : undefined,
});
}
}}
value={image || resolveUrl}
/>
{uploadImage.isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-full border bg-background/50">
<Spinner className="size-7" />
</div>
)}
</div>
</div>
);
}
function SMSCountryFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
smsCountryTiers: SMSCountryTiers;
teamPlan: Team["billingPlan"];
requiredPlan: Team["billingPlan"];
teamSlug: string;
}) {
return (
<div>
<SwitchContainer
description="Optionally allow users in selected countries to login via SMS OTP."
switchId="sms-switch"
title="SMS"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("smsEnabledCountryISOs").length,
id: "sms-switch",
onCheckedChange: (checked) =>
props.form.setValue(
"smsEnabledCountryISOs",
checked ? ["US", "CA"] : [],
),
}}
teamSlug={props.teamSlug}
trackingLabel="sms"
/>
</SwitchContainer>
<GatedCollapsibleContainer
className="grid grid-cols-1"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("smsEnabledCountryISOs").length}
requiredPlan={props.requiredPlan}
>
<FormField
control={props.form.control}
name="smsEnabledCountryISOs"
render={({ field }) => (
<CountrySelector
countryTiers={props.smsCountryTiers}
onChange={field.onChange}
selected={field.value}
/>
)}
/>
</GatedCollapsibleContainer>
</div>
);
}
function JSONWebTokenFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
}) {
return (
<div>
<SwitchContainer
description={
<>
Optionally allow users to authenticate with a custom JWT.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</>
}
switchId="authentication-switch"
title="Custom JSON Web Token"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: !!props.form.watch("customAuthentication"),
id: "authentication-switch",
onCheckedChange: (checked) => {
props.form.setValue(
"customAuthentication",
checked ? { aud: "", jwksUri: "" } : undefined,
);
},
}}
teamSlug={props.teamSlug}
trackingLabel="customAuthJWT"
/>
</SwitchContainer>
<GatedCollapsibleContainer
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
currentPlan={props.teamPlan}
isExpanded={!!props.form.watch("customAuthentication")}
requiredPlan={props.requiredPlan}
>
<FormField
control={props.form.control}
name="customAuthentication.jwksUri"
render={({ field }) => (
<FormItem>
<FormLabel>JWKS URI</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/.well-known/jwks.json"
/>
</FormControl>
<FormDescription>Enter the URI of the JWKS</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.form.control}
name="customAuthentication.aud"
render={({ field }) => (
<FormItem>
<FormLabel>AUD Value</FormLabel>
<FormControl>
<Input {...field} placeholder="AUD" />
</FormControl>
<FormDescription>
Enter the audience claim for the JWT
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</GatedCollapsibleContainer>
</div>
);
}
function AuthEndpointFields(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
teamPlan: Team["billingPlan"];
teamSlug: string;
requiredPlan: Team["billingPlan"];
}) {
const expandCustomAuthEndpointField =
props.form.watch("customAuthEndpoint") !== undefined;
return (
<div>
<SwitchContainer
description={
<>
Optionally allow users to authenticate with any arbitrary payload
that you provide.{" "}
<UnderlineLink
href="https://portal.thirdweb.com/wallets/custom-auth"
rel="noopener noreferrer"
target="_blank"
>
Learn more
</UnderlineLink>
</>
}
switchId="auth-endpoint-switch"
title="Custom Authentication Endpoint"
>
<GatedSwitch
currentPlan={props.teamPlan}
requiredPlan={props.requiredPlan}
switchProps={{
checked: expandCustomAuthEndpointField,
id: "auth-endpoint-switch",
onCheckedChange: (checked) => {
props.form.setValue(
"customAuthEndpoint",
checked
? {
authEndpoint: "",
customHeaders: [],
}
: undefined,
);
},
}}
teamSlug={props.teamSlug}
trackingLabel="customAuthEndpoint"
/>
</SwitchContainer>
{/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */}
{/* So only mount if expandCustomAuthEndpointField is true */}
{expandCustomAuthEndpointField && (
<AuthEndpointFieldsContent form={props.form} />
)}
</div>
);
}
function AuthEndpointFieldsContent(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
}) {
const customHeaderFields = useFieldArray({
control: props.form.control,
name: "customAuthEndpoint.customHeaders",
});
return (
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<FormField
control={props.form.control}
name="customAuthEndpoint.authEndpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Endpoint</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://example.com/your-auth-verifier"
/>
</FormControl>
<FormDescription>
Enter the URL of your server where we will send the user payload
for verification
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label className="mb-3 inline-block">Custom Headers</Label>
<div className="flex flex-col gap-4">
{customHeaderFields.fields.map((field, customHeaderIdx) => {
return (
<div className="flex gap-4" key={field.id}>
<Input
placeholder="Name"
type="text"
{...props.form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.key`,
)}
/>
<Input
placeholder="Value"
type="text"
{...props.form.register(
`customAuthEndpoint.customHeaders.${customHeaderIdx}.value`,
)}
/>
<Button
aria-label="Remove header"
className="!w-auto px-3"
onClick={() => {
customHeaderFields.remove(customHeaderIdx);
}}
variant="outline"
>
<Trash2Icon className="size-4 shrink-0 text-destructive-text" />
</Button>
</div>
);
})}
<Button
className="w-full gap-2 bg-background"
onClick={() => {
customHeaderFields.append({
key: "",
value: "",
});
}}
variant="outline"
>
<PlusIcon className="size-4" />
Add header
</Button>
</div>
<p className="mt-3 text-muted-foreground text-sm">
Set custom headers to be sent along the request with the payload to
the authentication endpoint above. This can be used to verify the
incoming requests
</p>
</div>
</div>
);
}
function NativeAppsFieldset(props: {
form: UseFormReturn<ApiKeyEmbeddedWalletsValidationSchema>;
isUpdating: boolean;
}) {
const { form } = props;
return (
<Fieldset
legend="Native Apps"
footer={
<div className="flex justify-end p-4 md:px-6 border-t border-dashed">
<Button className="gap-2" type="submit" size="sm" variant="outline">
{props.isUpdating && <Spinner className="size-4" />}
Save
</Button>
</div>
}
>
<FormField
control={form.control}
name="redirectUrls"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed redirect URIs</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="appName://, localhost:3000, https://example.com"
/>
</FormControl>
<FormDescription>
Enter redirect URIs separated by commas or new lines. This is
often your application's deep link.
<br className="max-sm:hidden" />
Currently only used in Unity, Unreal Engine and React Native
platform when users authenticate through social logins.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Fieldset>
);
}
function GatedCollapsibleContainer(props: {
children: React.ReactNode;
isExpanded: boolean;
className?: string;
requiredPlan: Team["billingPlan"];
currentPlan: Team["billingPlan"];
}) {
const upgradeRequired =
planToTierRecordForGating[props.currentPlan] <
planToTierRecordForGating[props.requiredPlan];
if (!props.isExpanded || upgradeRequired) {
return null;
}
return (
<div
className={cn("mt-5 fade-in-0 duration-300 animate-in", props.className)}
>
{props.children}
</div>
);
}
function Fieldset(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:px-6 md:py-5">
{/* put inside div to remove default styles on legend */}
<div className="border-b pb-4 mb-5 border-dashed font-semibold text-xl tracking-tight">
<legend> {props.legend}</legend>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
function FieldsetWithDescription(props: {
legend: string;
children: React.ReactNode;
footer?: React.ReactNode;
description: React.ReactNode;
}) {
return (
<div className="rounded-lg border bg-card relative">
<DynamicHeight>
<fieldset className="p-4 md:p-6">
{/* put inside div to remove default styles on legend */}
<div className="pr-20">
<legend className="font-semibold text-xl tracking-tight">
{props.legend}
</legend>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
{props.children}
</fieldset>
</DynamicHeight>
{props.footer}
</div>
);
}
function SwitchContainer(props: {
switchId: string;
title: string;
description: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6">
<div>
<Label className="text-base" htmlFor={props.switchId}>
{props.title}
</Label>
<p className="mt-0.5 text-muted-foreground text-sm ">
{props.description}
</p>
</div>
{props.children}
</div>
);
}
@@ -0,0 +1,210 @@
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { SMSCountryTiers } from "../../api/sms";
import { countryNames, countryPrefixes, getCountryFlag, tierPricing } from ".";
interface CountrySelectorProps {
countryTiers: SMSCountryTiers;
selected: string[];
onChange: (selectedCountries: string[]) => void;
}
export default function CountrySelector({
countryTiers,
selected,
onChange,
}: CountrySelectorProps) {
// Helper function to check if a country is selected
const isCountrySelected = (country: string) => selected.includes(country);
// Check if all countries in a tier are selected
const isTierSelected = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
return tierCountries.every((country) => isCountrySelected(country));
};
// Toggle a tier selection
const toggleTier = (tier: string) => {
const tierCountries = countryTiers[tier as keyof typeof countryTiers];
let newSelected: string[];
if (isTierSelected(tier)) {
// Deselect all countries in this tier
newSelected = selected.filter(
(country) => !tierCountries.includes(country),
);
} else {
// Select all countries in this tier
const currentSelected = new Set(selected);
for (const country of tierCountries) {
currentSelected.add(country);
}
newSelected = Array.from(currentSelected);
}
// Call onChange with the updated selection
onChange(newSelected);
};
// Toggle a single country selection
const toggleCountry = (country: string) => {
let newSelected: string[];
if (isCountrySelected(country)) {
// Remove country from selection
newSelected = selected.filter((c) => c !== country);
} else {
// Add country to selection
newSelected = [...selected, country];
}
// Call onChange with the updated selection
onChange(newSelected);
};
return (
<div className="space-y-5">
{Object.entries(countryTiers).map(([tier, tierCountries], index) => {
const selectedTierCountries = tierCountries.filter((country) =>
isCountrySelected(country),
);
return (
<TierCard
key={tier}
tierIndex={index}
tier={tier}
onToggleTier={() => toggleTier(tier)}
tierCountries={tierCountries}
selectedTierCountries={selectedTierCountries}
onToggleCountry={toggleCountry}
/>
);
})}
</div>
);
}
function TierCard(props: {
tier: string;
tierIndex: number;
onToggleTier: () => void;
tierCountries: string[];
selectedTierCountries: string[];
onToggleCountry: (country: string) => void;
}) {
const {
tier,
tierIndex,
onToggleTier,
tierCountries: countries,
selectedTierCountries: selectedCountries,
onToggleCountry,
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const isPartiallySelected =
selectedCountries.length > 0 && selectedCountries.length < countries.length;
const isTierFullySelected = selectedCountries.length === countries.length;
return (
<div className="overflow-hidden rounded-xl border bg-background">
{/* header */}
<div className="flex items-center px-4 py-3 gap-3 justify-between">
{/* left */}
<div className="flex items-center gap-2">
<Checkbox
checked={
isPartiallySelected ? "indeterminate" : isTierFullySelected
}
id={`tier-${tier}`}
onCheckedChange={onToggleTier}
/>
<label
className="flex-1 cursor-pointer font-medium text-sm"
htmlFor={`tier-${tier}`}
>
Tier {tierIndex + 1}
</label>
{isPartiallySelected && (
<span className="text-muted-foreground text-sm">
({selectedCountries.length}/{countries.length})
</span>
)}
</div>
{/* right */}
<div className="flex items-center gap-3">
<span
className={cn(
"text-sm",
tier === "tier1"
? "text-green-600 dark:text-green-500"
: "text-muted-foreground",
)}
>
{tierPricing[tier as keyof typeof tierPricing]}
</span>
<Button
variant="ghost"
className="p-0 size-7"
onClick={() => setIsExpanded(!isExpanded)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isExpanded && "rotate-180",
)}
/>
</Button>
</div>
</div>
{/* body */}
{isExpanded && (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 translate-y-[1px] translate-x-[1px] -ml-[1px] border-t",
)}
>
{countries.map((country) => {
const isSelected = selectedCountries.includes(country);
return (
<Button
variant="outline"
className={cn(
"justify-between gap-3 text-start h-auto rounded-none border-l-0 border-t-0 border-border py-2.5 bg-background",
)}
key={country}
onClick={() => onToggleCountry(country)}
title={countryNames[country] || country}
>
<div className="flex items-center gap-3">
<div className="text-3xl">{getCountryFlag(country)}</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">
{countryNames[country] || country}
</div>
<div className="text-muted-foreground text-xs tabular-nums">
{countryPrefixes[country]
? `${countryPrefixes[country]}`
: ""}
</div>
</div>
</div>
{isSelected && <CheckIcon className="size-5 text-foreground" />}
</Button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,7 @@
export { default as CountrySelector } from "./country-selector";
export {
countryNames,
countryPrefixes,
getCountryFlag,
tierPricing,
} from "./utils";
@@ -0,0 +1,482 @@
// Pricing for each tier
export const tierPricing = {
tier1: "Included",
tier2: "$0.05 per SMS",
tier3: "$0.10 per SMS",
tier4: "$0.20 per SMS",
tier5: "$0.40 per SMS",
tier6: "$1.00 per SMS",
} as const;
// Country names mapped to ISO codes
export const countryNames: Record<string, string> = {
// Tier 4
AD: "Andorra",
AE: "United Arab Emirates",
// Tier 5
AF: "Afghanistan",
AG: "Antigua and Barbuda",
AI: "Anguilla",
AL: "Albania",
AM: "Armenia",
AO: "Angola",
// Tier 6
RU: "Russia/Kazakhstan",
PG: "Papua New Guinea",
UZ: "Uzbekistan",
TZ: "Tanzania",
KM: "Comoros",
BT: "Bhutan",
// Tier 3
AR: "Argentina",
AS: "American Samoa",
AT: "Austria",
AU: "Australia",
AW: "Aruba",
AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina",
BB: "Barbados",
BD: "Bangladesh",
BE: "Belgium",
BF: "Burkina Faso",
BG: "Bulgaria",
// Tier 2
BH: "Bahrain",
BI: "Burundi",
BJ: "Benin",
BM: "Bermuda",
BN: "Brunei",
BO: "Bolivia",
BR: "Brazil",
BS: "Bahamas",
BW: "Botswana",
BY: "Belarus",
BZ: "Belize",
// Tier 1
CA: "Canada",
CD: "DR Congo",
CG: "Congo",
CH: "Switzerland",
CI: "Ivory Coast",
CK: "Cook Islands",
CL: "Chile",
CM: "Cameroon",
CN: "China",
CO: "Colombia",
CR: "Costa Rica",
CU: "Cuba",
CV: "Cape Verde",
CY: "Cyprus",
CZ: "Czech Republic",
DE: "Germany",
DJ: "Djibouti",
DK: "Denmark",
DM: "Dominica",
DO: "Dominican Republic",
DZ: "Algeria",
EC: "Ecuador",
EE: "Estonia",
EG: "Egypt",
ER: "Eritrea",
ES: "Spain",
ET: "Ethiopia",
FI: "Finland",
FJ: "Fiji",
FK: "Falkland Islands",
FM: "Micronesia",
FO: "Faroe Islands",
FR: "France",
GA: "Gabon",
GB: "United Kingdom",
GD: "Grenada",
GE: "Georgia",
GF: "French Guiana",
GH: "Ghana",
GI: "Gibraltar",
GL: "Greenland",
GM: "Gambia",
GN: "Guinea",
GP: "Guadeloupe",
GQ: "Equatorial Guinea",
GR: "Greece",
GT: "Guatemala",
GU: "Guam",
GW: "Guinea-Bissau",
GY: "Guyana",
HK: "Hong Kong",
HN: "Honduras",
HR: "Croatia",
HT: "Haiti",
HU: "Hungary",
ID: "Indonesia",
IE: "Ireland",
IL: "Israel",
IN: "India",
IQ: "Iraq",
IS: "Iceland",
IT: "Italy",
JM: "Jamaica",
JO: "Jordan",
JP: "Japan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KI: "Kiribati",
KN: "Saint Kitts and Nevis",
KR: "South Korea",
KW: "Kuwait",
KY: "Cayman Islands",
KZ: "Kazakhstan",
LA: "Laos PDR",
LB: "Lebanon",
LC: "Saint Lucia",
LI: "Liechtenstein",
LK: "Sri Lanka",
LR: "Liberia",
LS: "Lesotho",
LT: "Lithuania",
LU: "Luxembourg",
LV: "Latvia",
LY: "Libya",
MA: "Morocco",
MC: "Monaco",
MD: "Moldova",
ME: "Montenegro",
MG: "Madagascar",
MH: "Marshall Islands",
MK: "North Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MO: "Macao",
MQ: "Martinique",
MR: "Mauritania",
MS: "Montserrat",
MT: "Malta",
MU: "Mauritius",
MV: "Maldives",
MW: "Malawi",
MX: "Mexico",
MY: "Malaysia",
MZ: "Mozambique",
NA: "Namibia",
NC: "New Caledonia",
NE: "Niger",
NF: "Norfolk Island",
NG: "Nigeria",
NI: "Nicaragua",
NL: "Netherlands",
NO: "Norway",
NP: "Nepal",
NU: "Niue",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PE: "Peru",
PF: "French Polynesia",
PH: "Philippines",
PK: "Pakistan",
PL: "Poland",
PM: "Saint Pierre and Miquelon",
PR: "Puerto Rico",
PS: "Palestinian Territory",
PT: "Portugal",
PW: "Palau",
PY: "Paraguay",
QA: "Qatar",
RE: "Réunion",
RO: "Romania",
RS: "Serbia",
RW: "Rwanda",
SA: "Saudi Arabia",
SB: "Solomon Islands",
SC: "Seychelles",
SD: "Sudan",
SE: "Sweden",
SG: "Singapore",
SI: "Slovenia",
SK: "Slovakia",
SL: "Sierra Leone",
SM: "San Marino",
SN: "Senegal",
SO: "Somalia",
SR: "Suriname",
SS: "South Sudan",
ST: "Sao Tome and Principe",
SV: "El Salvador",
SY: "Syria",
SZ: "Eswatini",
TC: "Turks and Caicos Islands",
TD: "Chad",
TG: "Togo",
TH: "Thailand",
TL: "East Timor",
TM: "Turkmenistan",
TN: "Tunisia",
TO: "Tonga",
TR: "Turkey",
TT: "Trinidad and Tobago",
TV: "Tuvalu",
TW: "Taiwan",
UA: "Ukraine",
UG: "Uganda",
US: "United States",
UY: "Uruguay",
VC: "Saint Vincent and the Grenadines",
VE: "Venezuela",
VG: "Virgin Islands, British",
VI: "Virgin Islands, U.S.",
VN: "Vietnam",
VU: "Vanuatu",
WF: "Wallis and Futuna",
WS: "Samoa",
YE: "Yemen",
ZA: "South Africa",
ZM: "Zambia",
ZW: "Zimbabwe",
} as const;
export const countryPrefixes: Record<string, string> = {
// Tier 4
AD: "+376",
AE: "+971",
// Tier 5
AF: "+93",
AG: "+1",
AI: "+1",
AL: "+355",
AM: "+374",
AO: "+244",
// Tier 6
RU: "+7",
PG: "+675",
UZ: "+998",
TZ: "+255",
KM: "+269",
BT: "+975",
// Tier 3
AR: "+54",
AS: "+1",
AT: "+43",
AU: "+61",
AW: "+297",
AZ: "+994",
BA: "+387",
BB: "+1",
BD: "+880",
BE: "+32",
BF: "+226",
BG: "+359",
// Tier 2
BH: "+973",
BI: "+257",
BJ: "+229",
BM: "+1",
BN: "+673",
BO: "+591",
BR: "+55",
BS: "+1",
BW: "+267",
BY: "+375",
BZ: "+501",
// Tier 1
CA: "+1",
CD: "+243",
CG: "+242",
CH: "+41",
CI: "+225",
CK: "+682",
CL: "+56",
CM: "+237",
CN: "+86",
CO: "+57",
CR: "+506",
CU: "+53",
CV: "+238",
CY: "+357",
CZ: "+420",
DE: "+49",
DJ: "+253",
DK: "+45",
DM: "+1",
DO: "+1",
DZ: "+213",
EC: "+593",
EE: "+372",
EG: "+20",
ER: "+291",
ES: "+34",
ET: "+251",
FI: "+358",
FJ: "+679",
FK: "+500",
FM: "+691",
FO: "+298",
FR: "+33",
GA: "+241",
GB: "+44",
GD: "+1",
GE: "+995",
GF: "+594",
GH: "+233",
GI: "+350",
GL: "+299",
GM: "+220",
GN: "+224",
GP: "+590",
GQ: "+240",
GR: "+30",
GT: "+502",
GU: "+1",
GW: "+245",
GY: "+592",
HK: "+852",
HN: "+504",
HR: "+385",
HT: "+509",
HU: "+36",
ID: "+62",
IE: "+353",
IL: "+972",
IN: "+91",
IQ: "+964",
IS: "+354",
IT: "+39",
JM: "+1",
JO: "+962",
JP: "+81",
KE: "+254",
KG: "+996",
KH: "+855",
KI: "+686",
KN: "+1",
KR: "+82",
KW: "+965",
KY: "+1",
KZ: "+7",
LA: "+856",
LB: "+961",
LC: "+1",
LI: "+423",
LK: "+94",
LR: "+231",
LS: "+266",
LT: "+370",
LU: "+352",
LV: "+371",
LY: "+218",
MA: "+212",
MC: "+377",
MD: "+373",
ME: "+382",
MG: "+261",
MH: "+692",
MK: "+389",
ML: "+223",
MM: "+95",
MN: "+976",
MO: "+853",
MQ: "+596",
MR: "+222",
MS: "+1",
MT: "+356",
MU: "+230",
MV: "+960",
MW: "+265",
MX: "+52",
MY: "+60",
MZ: "+258",
NA: "+264",
NC: "+687",
NE: "+227",
NF: "+672",
NG: "+234",
NI: "+505",
NL: "+31",
NO: "+47",
NP: "+977",
NU: "+683",
NZ: "+64",
OM: "+968",
PA: "+507",
PE: "+51",
PF: "+689",
PH: "+63",
PK: "+92",
PL: "+48",
PM: "+508",
PR: "+1",
PS: "+970",
PT: "+351",
PW: "+680",
PY: "+595",
QA: "+974",
RE: "+262",
RO: "+40",
RS: "+381",
RW: "+250",
SA: "+966",
SB: "+677",
SC: "+248",
SD: "+249",
SE: "+46",
SG: "+65",
SI: "+386",
SK: "+421",
SL: "+232",
SM: "+378",
SN: "+221",
SO: "+252",
SR: "+597",
SS: "+211",
ST: "+239",
SV: "+503",
SY: "+963",
SZ: "+268",
TC: "+1",
TD: "+235",
TG: "+228",
TH: "+66",
TL: "+670",
TM: "+993",
TN: "+216",
TO: "+676",
TR: "+90",
TT: "+1",
TV: "+688",
TW: "+886",
UA: "+380",
UG: "+256",
US: "+1",
UY: "+598",
VC: "+1",
VE: "+58",
VG: "+1",
VI: "+1",
VN: "+84",
VU: "+678",
WF: "+681",
WS: "+685",
YE: "+967",
ZA: "+27",
ZM: "+260",
ZW: "+263",
} as const;
// Helper function to convert country code to flag emoji
export const getCountryFlag = (countryCode: string) => {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
@@ -5,7 +5,6 @@ import { getTeamBySlug } from "@/api/team/get-team";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
import { loginRedirect } from "@/utils/redirects";
import { ProjectSettingsBreadcrumb } from "../_components/project-settings-breadcrumb";
import { getSMSCountryTiers } from "./api/sms";
import { InAppWalletSettingsPage } from "./components";
@@ -22,7 +21,9 @@ export default async function Page(props: {
]);
if (!authToken) {
loginRedirect(`/team/${team_slug}/settings/wallets`);
loginRedirect(
`/team/${team_slug}/${project_slug}/wallets/user-wallets/configuration`,
);
}
if (!team) {
@@ -40,12 +41,6 @@ export default async function Page(props: {
return (
<div className="flex flex-col gap-6">
<ProjectSettingsBreadcrumb
teamSlug={team_slug}
projectSlug={project_slug}
page="Wallets"
/>
<InAppWalletSettingsPage
client={client}
project={project}
@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { WalletProductIcon } from "@/icons/WalletProductIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: WalletProductIcon,
title: "User Wallets",
description:
"Secure, auto-generated wallets created for authenticated users using email, socials, or passkeys",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/wallets/users",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,81 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { InAppWalletUsersPageContent } from "@/components/in-app-wallet-users-content/in-app-wallet-users-content";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { getFiltersFromSearchParams } from "@/lib/time";
import { loginRedirect } from "@/utils/redirects";
import { InAppWalletAnalytics } from "../analytics/chart";
import { InAppWalletsSummary } from "../analytics/chart/Summary";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/wallets/user-wallets`,
);
}
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);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
<InAppWalletsSummary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<InAppWalletAnalytics
authToken={authToken}
interval={interval}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<InAppWalletUsersPageContent
authToken={authToken}
client={client}
projectClientId={project.publishableKey}
teamId={project.teamId}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,69 @@
"use client";
import { BotIcon, ServerIcon, WalletIcon } from "lucide-react";
import { FeatureCard } from "../payments/components/FeatureCard.client";
export function QuickStartSection() {
return (
<section>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">Quick Start</h2>
<p className="text-muted-foreground text-sm">
Choose how to integrate x402 payments into your project.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
title="Payment gate your API"
description="Make your endpoints payable with a single line of code"
icon={ServerIcon}
id="x402_server"
setupTime={2}
features={[
"Supports 170+ chains",
"Supports 6.7k+ tokens",
"Dynamic pricing logic",
]}
link={{
href: "https://portal.thirdweb.com/x402/server",
label: "Get Started",
}}
/>
<FeatureCard
title="Let your users pay for x402 resources"
description="Handle x402 payments from any user wallet in your apps"
icon={WalletIcon}
id="x402_client"
setupTime={2}
features={[
"Works with any wallet",
"No gas required",
"One line of code",
]}
link={{
href: "https://portal.thirdweb.com/x402/client",
label: "Get Started",
}}
/>
<FeatureCard
title="Equip your agents with x402 tools"
description="Give your AI agents a wallet and the ability to pay for any x402 resource"
icon={BotIcon}
id="x402_agents"
setupTime={2}
features={[
"Remote MCP server",
"Low level APIs",
"Works with any AI framework",
]}
link={{
href: "https://portal.thirdweb.com/x402/agents",
label: "Get Started",
}}
/>
</div>
</section>
);
}
@@ -0,0 +1,30 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Metric } from "./MetricSwitcher";
import { MetricSwitcher } from "./MetricSwitcher";
export function ChartMetricSwitcher() {
const router = useDashboardRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const metric = (searchParams.get("metric") as Metric) || "payments";
const handleMetricChange = useCallback(
(newMetric: Metric) => {
const params = new URLSearchParams(searchParams.toString());
params.set("metric", newMetric);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
return (
<div className="flex justify-end">
<MetricSwitcher value={metric} onChange={handleMetricChange} />
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Metric = "payments" | "volume";
export function MetricSwitcher(props: {
value: Metric;
onChange: (value: Metric) => void;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">Show:</span>
<Select value={props.value} onValueChange={props.onChange}>
<SelectTrigger className="w-[180px] rounded-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="payments">Payments</SelectItem>
<SelectItem value="volume">Volume (USD)</SelectItem>
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,133 @@
import { CreditCardIcon, DollarSignIcon, UsersIcon } from "lucide-react";
import { Suspense } from "react";
import { getX402Settlements } from "@/api/analytics";
import type { Range } from "@/components/analytics/date-range-selector";
import { StatCard } from "@/components/analytics/stat";
import type {
X402SettlementsByPayer,
X402SettlementsOverall,
} from "@/types/analytics";
function X402SummaryInner(props: {
totalPayments: number | undefined;
totalBuyers: number | undefined;
totalVolume: number | undefined;
isPending: boolean;
}) {
const formatUSD = (value: number) => {
return `$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
})}`;
};
return (
<div className="grid grid-cols-3 gap-4">
<StatCard
icon={CreditCardIcon}
isPending={props.isPending}
label="Total Payments"
value={props.totalPayments || 0}
/>
<StatCard
icon={UsersIcon}
isPending={props.isPending}
label="Total Buyers"
value={props.totalBuyers || 0}
/>
<StatCard
formatter={formatUSD}
icon={DollarSignIcon}
isPending={props.isPending}
label="Total Volume"
value={props.totalVolume || 0}
/>
</div>
);
}
async function AsyncX402Summary(props: {
teamId: string;
projectId: string;
authToken: string;
range: Range;
}) {
const { teamId, projectId, authToken, range } = props;
const [overallStats, payerStats] = await Promise.all([
getX402Settlements(
{
from: range.from,
period: "all",
projectId,
teamId,
to: range.to,
groupBy: "overall",
},
authToken,
).catch(() => []),
getX402Settlements(
{
from: range.from,
period: "all",
projectId,
teamId,
to: range.to,
groupBy: "payer",
},
authToken,
).catch(() => []),
]);
const totalPayments = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalRequests,
0,
);
const totalVolume = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalValueUSD,
0,
);
// Count unique payers
const uniquePayers = new Set(
(payerStats as X402SettlementsByPayer[]).map((stat) => stat.payer),
);
const totalBuyers = uniquePayers.size;
return (
<X402SummaryInner
totalBuyers={totalBuyers}
totalPayments={totalPayments}
totalVolume={totalVolume}
isPending={false}
/>
);
}
export function X402Summary(props: {
teamId: string;
projectId: string;
authToken: string;
range: Range;
}) {
return (
<Suspense
fallback={
<X402SummaryInner
totalBuyers={undefined}
totalPayments={undefined}
totalVolume={undefined}
isPending={true}
/>
}
>
<AsyncX402Summary
authToken={props.authToken}
projectId={props.projectId}
range={props.range}
teamId={props.teamId}
/>
</Suspense>
);
}
@@ -0,0 +1,160 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import { useAllChainsData } from "@/hooks/chains/allChains";
import type { X402SettlementsByChainId } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByChainChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxChainsToDisplay = 10;
const isVolumeMetric = metric === "volume";
const chainsStore = useAllChainsData();
const { data, chainsToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const chainToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, chainId, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
// Convert chainId to chain name
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId.toString();
dateRecord[chainName] = (dateRecord[chainName] || 0) + value;
chainToCountMap.set(
chainName,
(chainToCountMap.get(chainName) || 0) + value,
);
}
// Sort chains by count (highest count first) - remove the ones with 0 count
const sortedChainsByCount = Array.from(chainToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const chainsToDisplayArray = sortedChainsByCount
.slice(0, maxChainsToDisplay)
.map(([chain]) => chain);
const chainsToDisplaySet = new Set(chainsToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the chain that is not in chainsToDisplay with "Other"
// Add total key that is the sum of all chains
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !chainsToDisplaySet.has(key),
);
for (const chain of keysToMove) {
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[chain] || 0);
delete dateRecord[chain];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < chainsToDisplayArray.length; i++) {
const chain = chainsToDisplayArray[i];
if (chain) {
chartConfig[chain] = {
label: chain,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" chains
if (sortedChainsByCount.length > maxChainsToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
chainsToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
chainsToDisplay: chainsToDisplayArray,
};
}, [rawData, isVolumeMetric, chainsStore]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Chain" : "Payments by Chain";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,158 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import type { X402SettlementsByPayer } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByPayerChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByPayer[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxPayersToDisplay = 10;
const isVolumeMetric = metric === "volume";
const { data, payersToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const payerToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, payer, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
// Truncate payer address for display
const displayPayer =
payer.length > 10 ? `${payer.slice(0, 6)}...${payer.slice(-4)}` : payer;
dateRecord[displayPayer] = (dateRecord[displayPayer] || 0) + value;
payerToCountMap.set(
displayPayer,
(payerToCountMap.get(displayPayer) || 0) + value,
);
}
// Sort payers by count (highest count first) - remove the ones with 0 count
const sortedPayersByCount = Array.from(payerToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const payersToDisplayArray = sortedPayersByCount
.slice(0, maxPayersToDisplay)
.map(([payer]) => payer);
const payersToDisplay = new Set(payersToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the payer that is not in payersToDisplay with "Other"
// Add total key that is the sum of all payers
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !payersToDisplay.has(key),
);
for (const payer of keysToMove) {
dateRecord.Other = (dateRecord.Other || 0) + (dateRecord[payer] || 0);
delete dateRecord[payer];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < payersToDisplayArray.length; i++) {
const payer = payersToDisplayArray[i];
if (payer) {
chartConfig[payer] = {
label: payer,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" payers
if (sortedPayersByCount.length > maxPayersToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
payersToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
payersToDisplay: payersToDisplayArray,
};
}, [rawData, isVolumeMetric]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Buyer" : "Payments by Buyer";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,155 @@
"use client";
import { format } from "date-fns";
import { type ReactNode, useMemo } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import type { ChartConfig } from "@/components/ui/chart";
import type { X402SettlementsByResource } from "@/types/analytics";
import { toUSD } from "@/utils/number";
type ChartData = Record<string, number> & {
time: string;
};
export function X402SettlementsByResourceChartCard({
rawData,
isPending,
metric = "payments",
}: {
rawData: X402SettlementsByResource[];
isPending: boolean;
metric?: "payments" | "volume";
}) {
const maxResourcesToDisplay = 10;
const isVolumeMetric = metric === "volume";
const { data, resourcesToDisplay, chartConfig, isAllEmpty } = useMemo(() => {
const dateToValueMap: Map<string, ChartData> = new Map();
const resourceToCountMap: Map<string, number> = new Map();
for (const dataItem of rawData) {
const { date, resource, totalRequests, totalValueUSD } = dataItem;
const value = isVolumeMetric ? totalValueUSD : totalRequests;
let dateRecord = dateToValueMap.get(date);
if (!dateRecord) {
dateRecord = { time: date } as ChartData;
dateToValueMap.set(date, dateRecord);
}
dateRecord[resource] = (dateRecord[resource] || 0) + value;
resourceToCountMap.set(
resource,
(resourceToCountMap.get(resource) || 0) + value,
);
}
// Sort resources by count (highest count first) - remove the ones with 0 count
const sortedResourcesByCount = Array.from(resourceToCountMap.entries())
.sort((a, b) => b[1] - a[1])
.filter((x) => x[1] > 0);
const resourcesToDisplayArray = sortedResourcesByCount
.slice(0, maxResourcesToDisplay)
.map(([resource]) => resource);
const resourcesToDisplay = new Set(resourcesToDisplayArray);
// Loop over each entry in dateToValueMap
// Replace the resource that is not in resourcesToDisplay with "Other"
// Add total key that is the sum of all resources
for (const dateRecord of dateToValueMap.values()) {
// Calculate total
let totalCountOfDay = 0;
for (const key of Object.keys(dateRecord)) {
if (key !== "time") {
totalCountOfDay += (dateRecord[key] as number) || 0;
}
}
const keysToMove = Object.keys(dateRecord).filter(
(key) => key !== "time" && !resourcesToDisplay.has(key),
);
for (const resource of keysToMove) {
dateRecord.Other =
(dateRecord.Other || 0) + (dateRecord[resource] || 0);
delete dateRecord[resource];
}
dateRecord.total = totalCountOfDay;
}
const returnValue: ChartData[] = Array.from(dateToValueMap.values()).sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
);
const chartConfig: ChartConfig = {};
for (let i = 0; i < resourcesToDisplayArray.length; i++) {
const resource = resourcesToDisplayArray[i];
if (resource) {
chartConfig[resource] = {
label: resource,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
isCurrency: isVolumeMetric,
};
}
}
// If we need to display "Other" resources
if (sortedResourcesByCount.length > maxResourcesToDisplay) {
chartConfig.Other = {
label: "Other",
color: "hsl(var(--muted-foreground))",
isCurrency: isVolumeMetric,
};
resourcesToDisplayArray.push("Other");
}
return {
chartConfig,
data: returnValue,
isAllEmpty: returnValue.every((d) => (d.total || 0) === 0),
resourcesToDisplay: resourcesToDisplayArray,
};
}, [rawData, isVolumeMetric]);
const emptyChartState = (
<div className="flex h-[250px] items-center justify-center">
<p className="text-muted-foreground text-sm">No data available</p>
</div>
);
const title = isVolumeMetric ? "Volume by Resource" : "Payments by Resource";
return (
<ThirdwebBarChart
chartClassName="aspect-auto h-[250px]"
config={chartConfig}
customHeader={
<div className="px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{title}
</h3>
</div>
}
data={data}
emptyChartState={emptyChartState}
hideLabel={false}
isPending={isPending}
showLegend
toolTipValueFormatter={(value: unknown) => {
if (isVolumeMetric) {
return `${toUSD(Number(value))}`;
}
return value as ReactNode;
}}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as string;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
variant="stacked"
/>
);
}
@@ -0,0 +1,249 @@
import { ResponsiveSuspense } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import type {
X402SettlementsByChainId,
X402SettlementsByPayer,
X402SettlementsByResource,
} from "@/types/analytics";
import { X402SettlementsByChainChartCard } from "./X402SettlementsByChainChartCard";
import { X402SettlementsByPayerChartCard } from "./X402SettlementsByPayerChartCard";
import { X402SettlementsByResourceChartCard } from "./X402SettlementsByResourceChartCard";
// Payments by Resource Chart
type X402SettlementsByResourceChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByResource[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByResourceChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByResourceChartProps) {
return (
<X402SettlementsByResourceChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByResourceChartProps = Omit<
X402SettlementsByResourceChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByResourceChart(
props: AsyncX402SettlementsByResourceChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "resource",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByResourceChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByResource[]}
/>
);
}
export function X402SettlementsByResourceChart(
props: AsyncX402SettlementsByResourceChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByResourceChartUI
{...props}
isPending={true}
stats={[]}
/>
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByResourceChart {...props} />
</ResponsiveSuspense>
);
}
// Payments by Payer Chart
type X402SettlementsByPayerChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByPayer[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByPayerChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByPayerChartProps) {
return (
<X402SettlementsByPayerChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByPayerChartProps = Omit<
X402SettlementsByPayerChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByPayerChart(
props: AsyncX402SettlementsByPayerChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "payer",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByPayerChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByPayer[]}
/>
);
}
export function X402SettlementsByPayerChart(
props: AsyncX402SettlementsByPayerChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByPayerChartUI {...props} isPending={true} stats={[]} />
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByPayerChart {...props} />
</ResponsiveSuspense>
);
}
// Payments by Chain Chart
type X402SettlementsByChainChartProps = {
interval: "day" | "week";
range: Range;
stats: X402SettlementsByChainId[];
isPending: boolean;
metric?: "payments" | "volume";
};
function X402SettlementsByChainChartUI({
stats,
isPending,
metric = "payments",
}: X402SettlementsByChainChartProps) {
return (
<X402SettlementsByChainChartCard
rawData={stats}
isPending={isPending}
metric={metric}
/>
);
}
type AsyncX402SettlementsByChainChartProps = Omit<
X402SettlementsByChainChartProps,
"stats" | "isPending"
> & {
teamId: string;
projectId: string;
authToken: string;
};
async function AsyncX402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
const range = props.range ?? getLastNDaysRange("last-30");
const stats = await getX402Settlements(
{
from: range.from,
period: props.interval,
projectId: props.projectId,
teamId: props.teamId,
to: range.to,
groupBy: "chainId",
},
props.authToken,
).catch((error) => {
console.error(error);
return [];
});
return (
<X402SettlementsByChainChartUI
{...props}
isPending={false}
range={range}
stats={stats as X402SettlementsByChainId[]}
/>
);
}
export function X402SettlementsByChainChart(
props: AsyncX402SettlementsByChainChartProps,
) {
return (
<ResponsiveSuspense
fallback={
<X402SettlementsByChainChartUI {...props} isPending={true} stats={[]} />
}
searchParamsUsed={["from", "to", "interval", "metric"]}
>
<AsyncX402SettlementsByChainChart {...props} />
</ResponsiveSuspense>
);
}
@@ -0,0 +1,63 @@
import { CodeServer } from "@/components/ui/code/code.server";
import { WaitingForIntegrationCard } from "../../components/WaitingForIntegrationCard/WaitingForIntegrationCard";
export function X402EmptyState(props: { walletAddress?: string }) {
return (
<WaitingForIntegrationCard
codeTabs={[
{
code: (
<CodeServer
className="bg-background"
code={jsCode(props.walletAddress)}
lang="ts"
/>
),
label: "JavaScript",
},
]}
ctas={[
{
href: "https://portal.thirdweb.com/x402",
label: "View Docs",
},
]}
title="Start Monetizing your API"
/>
);
}
const jsCode = (walletAddress?: string) => `\
import { createThirdwebClient } from "thirdweb";
import { facilitator, settlePayment } from "thirdweb/x402";
import { arbitrumSepolia } from "thirdweb/chains";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
const thirdwebX402Facilitator = facilitator({
client,
serverWalletAddress: "${walletAddress || "0xYourWalletAddress"}",
});
export async function GET(request: Request) {
// process the payment
const result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
method: "GET",
paymentData: request.headers.get("x-payment"),
network: arbitrumSepolia,
price: "$0.01",
facilitator: thirdwebX402Facilitator,
});
if (result.status === 200) {
// Payment successful, continue to app logic
return Response.json({ data: "premium content" });
} else {
return Response.json(result.responseBody, {
status: result.status,
headers: result.responseHeaders,
});
}
}
`;
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { loginRedirect } from "@/utils/redirects";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/x402/configuration`,
);
}
const project = await getProject(params.team_slug, params.project_slug);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border bg-card p-8 text-center">
<h2 className="text-2xl font-semibold">Coming Soon</h2>
<p className="mt-2 text-muted-foreground">
x402 payments configuration will be available soon.
</p>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { redirect } from "next/navigation";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import { ProjectPage } from "@/components/blocks/project-page/project-page";
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
import { PayIcon } from "@/icons/PayIcon";
import { loginRedirect } from "@/utils/redirects";
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const params = await props.params;
const basePath = `/team/${params.team_slug}/${params.project_slug}/x402`;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(params.team_slug, params.project_slug),
]);
if (!authToken) {
loginRedirect(basePath);
}
if (!project) {
redirect(`/team/${params.team_slug}`);
}
const client = getClientThirdwebClient({
jwt: authToken,
teamId: project.teamId,
});
return (
<ProjectPage
header={{
icon: PayIcon,
title: "x402 Payments",
description: "Instant payments for your APIs, apps and agents",
actions: null,
client,
links: [
{
type: "docs",
href: "https://portal.thirdweb.com/x402",
},
],
}}
tabs={[
{
name: "Overview",
path: `${basePath}`,
exactMatch: true,
},
{
name: "Configuration",
path: `${basePath}/configuration`,
},
]}
>
{props.children}
</ProjectPage>
);
}
@@ -0,0 +1,127 @@
import { redirect } from "next/navigation";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import { getX402Settlements } from "@/api/analytics";
import { getAuthToken } from "@/api/auth-token";
import { getProject } from "@/api/project/projects";
import type { DurationId } from "@/components/analytics/date-range-selector";
import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters";
import { getProjectWallet } from "@/lib/server/project-wallet";
import { getFiltersFromSearchParams } from "@/lib/time";
import type { X402SettlementsOverall } from "@/types/analytics";
import { loginRedirect } from "@/utils/redirects";
import {
X402SettlementsByChainChart,
X402SettlementsByPayerChart,
X402SettlementsByResourceChart,
} from "./analytics";
import { ChartMetricSwitcher } from "./analytics/ChartsSection";
import { X402Summary } from "./analytics/Summary";
import { X402EmptyState } from "./components/X402EmptyState";
import { QuickStartSection } from "./QuickstartSection.client";
export const dynamic = "force-dynamic";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string;
to?: string;
type?: string;
interval?: string;
metric?: string;
}>;
}) {
const [searchParams, params] = await Promise.all([
props.searchParams,
props.params,
]);
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/${params.project_slug}/x402`);
}
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);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
// Check if there are any payments to determine if we should show empty state
const overallStats = await getX402Settlements(
{
from: range.from,
period: "all",
projectId: project.id,
teamId: project.teamId,
to: range.to,
groupBy: "overall",
},
authToken,
).catch(() => []);
const totalPayments = (overallStats as X402SettlementsOverall[]).reduce(
(acc, curr) => acc + curr.totalRequests,
0,
);
// Get project wallet for prefilling the code snippet
const projectWallet = await getProjectWallet(project);
const metric = (searchParams.metric as "payments" | "volume") || "payments";
return (
<ResponsiveSearchParamsProvider value={searchParams}>
<div className="flex flex-col gap-4 md:gap-6">
<ResponsiveTimeFilters defaultRange={defaultRange} />
{totalPayments === 0 ? (
<X402EmptyState walletAddress={projectWallet?.address} />
) : (
<>
<X402Summary
authToken={authToken}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<ChartMetricSwitcher />
<X402SettlementsByResourceChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByChainChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
<X402SettlementsByPayerChart
authToken={authToken}
interval={interval}
metric={metric}
projectId={project.id}
range={range}
teamId={project.teamId}
/>
</>
)}
<div className="pt-4">
<QuickStartSection />
</div>
</div>
</ResponsiveSearchParamsProvider>
);
}
+28 -3
View File
@@ -59,12 +59,12 @@ const nextConfig = {
return [
{
source: "/connect/pay",
destination: "/payments/ui-components",
destination: "/bridge",
permanent: false,
},
{
source: "/connect/pay/:path*",
destination: "/payments/:path*",
destination: "/bridge",
permanent: false,
},
{
@@ -159,7 +159,32 @@ const nextConfig = {
},
{
source: "/payments/ui-components",
destination: "/payments",
destination: "/bridge",
permanent: false,
},
{
source: "/payments",
destination: "/bridge",
permanent: false,
},
{
source: "/payments/x402",
destination: "/x402",
permanent: false,
},
{
source: "/payments/fund-wallet",
destination: "/bridge/buy-widget",
permanent: false,
},
{
source: "/payments/commerce",
destination: "/bridge/checkout-widget",
permanent: false,
},
{
source: "/payments/transactions",
destination: "/bridge/transaction-widget",
permanent: false,
},
];
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server";
import { createThirdwebClient, defineChain } from "thirdweb";
import { toUnits } from "thirdweb/utils";
import { facilitator, settlePayment } from "thirdweb/x402";
import { token } from "../../payments/x402/components/constants";
import { token } from "../../x402/components/constants";
// Allow streaming responses up to 5 minutes
export const maxDuration = 300;
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { BuyPlayground } from "./BuyPlayground";
const title = "Buy Crypto Component";
const title = "Buy Widget";
const description =
"Embeddable component for users to purchase any cryptocurrency for top-ups and more with fiat or crypto-to-crypto swaps";
const ogDescription =
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { CheckoutPlayground } from "./CheckoutPlayground";
const title = "Checkout Component";
const title = "Checkout Widget";
const description =
"Enable purchase of any service or goods with fiat or cryptocurrency and setup notifications on every sale to ship goods, activate services, and more";
const ogDescription =
@@ -15,8 +15,8 @@ import {
import { Button } from "../../../components/ui/button";
import { THIRDWEB_CLIENT } from "../../../lib/client";
import { cn } from "../../../lib/utils";
import { CodeGen } from "../components/CodeGen";
import type { BridgeComponentsPlaygroundOptions } from "../components/types";
import { CodeGen } from "./CodeGen";
import type { BridgeComponentsPlaygroundOptions } from "./types";
const nftContract = getContract({
address: "0xf0d0CBf84005Dd4eC81364D1f5D7d896Bd53D1B8",
@@ -0,0 +1,14 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "@/icons/PayIcon";
import { bridgeFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
title="Bridge"
description="thirdweb Bridge lets developers swap and transfer any token across any chain instantly"
featureCards={bridgeFeatureCards}
icon={PayIcon}
/>
);
}
@@ -4,7 +4,7 @@ import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { SwapWidgetPlayground } from "./components/swap-widget-playground";
const title = "Swap Widget Component";
const title = "Swap Widget";
const description =
"Embeddable component for users to swap any cryptocurrency with cross-chain support";
const ogDescription =
@@ -4,16 +4,13 @@ import { CodeExample } from "@/components/code/code-example";
import { PayTransactionButtonPreview } from "@/components/pay/transaction-button";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { TransactionPlayground } from "./TransactionPlayground";
const title = "Onchain Transaction Components";
const title = "Transaction Button";
const description =
"Enable seamless onchain transactions for any contract with fiat or crypto with amounts calculated and automatic execution after funds are confirmed.";
const ogDescription =
"Power onchain transactions with fiat or crypto payments. Automatically calculate costs and run the transaction post onramp or token swap.";
"Transaction Button component allows users to perform onchain transaction and prompts the user to fund the wallet if required from fiat or swap";
export const metadata = createMetadata({
description: ogDescription,
description: description,
title,
image: {
icon: "payments",
@@ -28,27 +25,16 @@ export default function Page() {
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
docsLink="https://portal.thirdweb.com/references/typescript/v5/TransactionButton"
title={title}
>
<div>
<h2 className="text-xl font-semibold tracking-tight mb-1">
Transaction Widget
</h2>
<p className="text-base text-muted-foreground mb-6">
Render a prebuilt UI for performing transactions using any token or
fiat. <br /> It handles the complete payment flow, supporting both
crypto and fiat payments across 50+ chains.
</p>
<TransactionPlayground />
</div>
<NoFundsPopup />
<Example />
</PageLayout>
</ThirdwebProvider>
);
}
function NoFundsPopup() {
function Example() {
return (
<CodeExample
code={`
@@ -111,15 +97,6 @@ return (
</div>
);
};`}
header={{
description: (
<>
Any transaction with value will automatically trigger onramp to fund
the wallet if needed before executing the transaction.
</>
),
title: "Transaction Button",
}}
lang="tsx"
preview={<PayTransactionButtonPreview />}
/>
@@ -0,0 +1,36 @@
import { ArrowLeftRightIcon } from "lucide-react";
import { PageLayout } from "@/components/blocks/APIHeader";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
import { TransactionPlayground } from "./TransactionPlayground";
const title = "Transaction Widget";
const description =
"Enable seamless onchain transactions for any contract with fiat or crypto with amounts calculated and automatic execution after funds are confirmed.";
const ogDescription =
"Power onchain transactions with fiat or crypto payments. Automatically calculate costs and run the transaction post onramp or token swap.";
export const metadata = createMetadata({
description: ogDescription,
title,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={ArrowLeftRightIcon}
containerClassName="space-y-12"
description={description}
docsLink="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
title={title}
>
<TransactionPlayground />
</PageLayout>
</ThirdwebProvider>
);
}
@@ -1,71 +0,0 @@
import { RssIcon } from "lucide-react";
import { WatchEventPreview } from "@/components/blockchain-api/watch-event-preview";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
const title = "Listen Contract Events";
const description =
"Subscribe to any contract event with auto-polling hooks and type-safe event handlers. Supports all common standards out of the box";
const ogDescription =
"Listen to blockchain contract events using auto-polling hooks and type-safe functions. Supports ERC20, ERC721, and other common Web3 standards with minimal setup.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={RssIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<WatchEvent />
</PageLayout>
</ThirdwebProvider>
);
}
function WatchEvent() {
return (
<CodeExample
code={`import { useContractEvents } from "thirdweb/react";
import { getContract } from "thirdweb";
import { base } from "thirdweb/chains";
import { transferEvent } from "thirdweb/extensions/erc20";
const usdcContractOnBase = getContract({
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
chain: base,
client,
});
function App() {
// Listen to USDC transfers on Base
const contractEvents = useContractEvents({
contract: usdcContractOnBase,
events: [transferEvent()],
blockRange: 100,
});
(contractEvents.data || []).forEach((item) => {
const { from, to, value } = item.args;
console.log("{from}...{value} USDC...{to}");
});
}
`}
lang="tsx"
preview={<WatchEventPreview />}
/>
);
}
@@ -1,117 +0,0 @@
import { BlocksIcon } from "lucide-react";
import { ReadContractExtensionPreview } from "@/components/blockchain-api/read-contract-extension";
import { WriteContractExtensionPreview } from "@/components/blockchain-api/write-contract-extension";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
const title = "Pre-Built Extensions";
const description =
"High-level ready and write functions with built-in pre/post-processing for common standards.";
const ogDescription =
"Simplify smart contract reads and writes with prebuilt extensions. Handle approvals, formatting, and standards with built-in pre/post-processing.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={BlocksIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<div className="flex flex-col gap-14">
<ReadContractExtension />
<WriteContractExtension />
</div>
</PageLayout>
</ThirdwebProvider>
);
}
function ReadContractExtension() {
return (
<CodeExample
code={`import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { MediaRenderer, useReadContract } from "thirdweb/react";
const nftContract = getContract({
address: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
function App() {
const { data } = useReadContract(getNFT, {
contract: nftContract,
tokenId: 458n,
});
return (
<MediaRenderer
client={THIRDWEB_CLIENT}
src={data?.metadata.image}
/>
);
}
`}
header={{
description:
"Extensions let you do more with less code. High level functions with simple API that do pre and post processing for all common standards.",
title: "Prebuilt read extensions",
}}
lang="tsx"
preview={<ReadContractExtensionPreview />}
/>
);
}
function WriteContractExtension() {
return (
<CodeExample
code={`import { getContract } from "thirdweb";
import { sepolia } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/erc20";
const tw_coin = getContract({
address: "0xACf072b740a23D48ECd302C9052fbeb3813b60a6",
chain: sepolia,
client: THIRDWEB_CLIENT,
});
function App() {
return <TransactionButton
transaction={() =>
claimTo({
contract: twCoinContract,
to: account.address,
quantity: "10",
})
}
>
Claim
</TransactionButton>
}
`}
header={{
description:
"Extensions let you do more with less code. High level functions with simple API that do pre and post processing for all common standards.",
title: "Prebuilt write extensions",
}}
lang="tsx"
preview={<WriteContractExtensionPreview />}
/>
);
}
@@ -1,14 +0,0 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { ContractIcon } from "../../icons/ContractIcon";
import { contractsFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
title="Contracts"
description="Comprehensive toolkit to create, deploy, and manage smart contracts"
featureCards={contractsFeatureCards}
icon={ContractIcon}
/>
);
}
@@ -1,73 +0,0 @@
import { ScanTextIcon } from "lucide-react";
import { ReadContractRawPreview } from "@/components/blockchain-api/read-contract-raw";
import { PageLayout } from "@/components/blocks/APIHeader";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { createMetadata } from "@/lib/metadata";
const title = "Read Contract Data";
const description =
"Read data from any contract on EVM with Type safe functions and hooks without needing contract ABIs";
const ogDescription =
"Query smart contracts and wallet data using type-safe functions and React hooks—no ABI required. Read blockchain data with full type safety";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={ScanTextIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<div className="flex flex-col gap-14">
<ReadContractRaw />
</div>
</PageLayout>
</ThirdwebProvider>
);
}
function ReadContractRaw() {
return (
<CodeExample
code={`import { getContract } from "thirdweb";
import { ethereum } from "thirdweb/chains";
import { MediaRenderer, useReadContract } from "thirdweb/react";
const onChainCryptoPunks = getContract({
address: "0x16F5A35647D6F03D5D3da7b35409D65ba03aF3B2",
chain: ethereum,
client: THIRDWEB_CLIENT,
});
function App() {
// Read the image of the tokenId #1
const { data } = useReadContract({
contract: onChainCryptoPunks,
method: "function punkImageSvg(uint16 index) view returns (string svg)",
params: [1],
});
return (
<MediaRenderer
client={THIRDWEB_CLIENT}
src={data}
/>
);
}
`}
lang="tsx"
preview={<ReadContractRawPreview />}
/>
);
}
@@ -1,76 +0,0 @@
import { PencilIcon } from "lucide-react";
import { WriteContractRawPreview } from "@/components/blockchain-api/write-contract-raw";
import { CodeExample } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
const title = "Write Contract";
const description =
"Send transactions from the connected wallet using type-safe functions and hooks for contract calls or raw transactions";
const ogDescription =
"Send blockchain transactions with the connected wallet using type-safe functions and React hooks for contract calls or raw transactions";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "contract",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={PencilIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/typescript/v5?utm_source=playground"
>
<div className="flex flex-col gap-14">
<WriteContractRaw />
</div>
</PageLayout>
</ThirdwebProvider>
);
}
function WriteContractRaw() {
return (
<CodeExample
code={`import { getContract, prepareContractCall, toUnits } from "thirdweb";
import { sepolia } from "thirdweb/chains";
const tw_coin = getContract({
address: "0xACf072b740a23D48ECd302C9052fbeb3813b60a6",
chain: sepolia,
client: THIRDWEB_CLIENT,
});
function App() {
return <TransactionButton
transaction={() =>
prepareContractCall({
contract: tw_coin,
method:
"function transfer(address to, uint256 value) returns (bool)",
params: [
"0x...",
toUnits("5", 18),
],
})
}
>
Send
</TransactionButton>
}
`}
lang="tsx"
preview={<WriteContractRawPreview />}
/>
);
}
@@ -1,10 +1,7 @@
import {
ArrowLeftRightIcon,
BlocksIcon,
BotIcon,
BoxIcon,
BringToFrontIcon,
CircleDollarSignIcon,
CircleUserIcon,
CreditCardIcon,
DollarSignIcon,
@@ -13,11 +10,9 @@ import {
LinkIcon,
LockIcon,
PanelTopIcon,
PencilIcon,
PlaneIcon,
RectangleHorizontalIcon,
RssIcon,
ScanTextIcon,
ShieldIcon,
ShoppingBagIcon,
SquircleDashedIcon,
@@ -25,6 +20,7 @@ import {
UserIcon,
WalletCardsIcon,
} from "lucide-react";
import { SmartAccountIcon } from "../../icons/SmartAccountIcon";
export type FeatureCardMetadata = {
icon: React.FC<{ className?: string }>;
@@ -77,13 +73,26 @@ export const walletsFeatureCards: FeatureCardMetadata[] = [
description:
"Get user profiles across apps like ENS, Lens, Farcaster, and more",
},
{
icon: BoxIcon,
title: "Headless Components",
link: "/wallets/headless",
description: "Components for rendering various wallet related information",
},
{
icon: ArrowLeftRightIcon,
title: "Transactions",
description:
"Interfaces to transact onchain with features such as monitoring, gas sponsorship etc",
link: "/transactions",
},
{
icon: SmartAccountIcon,
title: "Account Abstraction",
description:
"Gasless or permissioned transactions utilizing EIP-4337 and 7702 specs",
link: "/account-abstraction",
},
];
export const headlessComponentsFeatureCards: FeatureCardMetadata[] = [
@@ -107,92 +116,40 @@ export const headlessComponentsFeatureCards: FeatureCardMetadata[] = [
},
];
export const transactionsFeatureCards: FeatureCardMetadata[] = [
export const bridgeFeatureCards: FeatureCardMetadata[] = [
{
icon: UserIcon,
title: "From User Wallets",
link: "/transactions/users",
description: "Transactions from user wallets with monitoring and retries.",
icon: ArrowLeftRightIcon,
title: "Swap Widget",
link: "/bridge/swap-widget",
description: "A widget for swapping tokens with cross-chain support",
},
{
icon: PlaneIcon,
title: "Airdrop Tokens",
link: "/transactions/airdrop-tokens",
description:
"Airdrop any token with a few lines of code with gas sponsorship, optional.",
},
{
icon: StampIcon,
title: "Mint NFTs",
link: "/transactions/mint-tokens",
description:
"Gasless and efficient token minting with just a wallet address",
},
{
icon: RssIcon,
title: "Webhooks",
link: "/transactions/webhooks",
description:
"Receive real-time notifications for transactions and wallet events.",
},
];
export const contractsFeatureCards: FeatureCardMetadata[] = [
{
icon: ScanTextIcon,
title: "Read Contract",
link: "/contracts/read",
description: "Read data from any contract on any EVM",
},
{
icon: PencilIcon,
title: "Write Contract",
link: "/contracts/write",
description: "Send transactions from the connected wallet",
},
{
icon: BlocksIcon,
title: "Pre-Built Extensions",
link: "/contracts/extensions",
description: "High-level read and write functions",
},
{
icon: RssIcon,
title: "Listen Contract Events",
link: "/contracts/events",
description: "Subscribe to any contract event",
},
];
export const paymentsFeatureCards: FeatureCardMetadata[] = [
{
icon: ShoppingBagIcon,
title: "Buy Crypto",
link: "/payments/fund-wallet",
title: "Buy Widget",
link: "/bridge/buy-widget",
description:
"Buy any token with ability to customize theme, amounts, and more",
},
{
icon: CreditCardIcon,
title: "Checkout",
link: "/payments/commerce",
title: "Checkout Widget",
link: "/bridge/checkout-widget",
description:
"Enable crypto payments for services and get notified on each sale",
},
{
icon: ArrowLeftRightIcon,
title: "Onchain Transaction",
link: "/payments/transactions",
title: "Transaction Widget",
link: "/bridge/transaction-widget",
description:
"Enable users to pay for onchain transactions with fiat or crypto",
},
{
icon: CircleDollarSignIcon,
title: "x402",
link: "/payments/x402",
icon: RectangleHorizontalIcon,
title: "Transaction Button",
link: "/bridge/transaction-button",
description:
"Use the x402 payment protocol to pay for API calls using any web3 wallet",
"Enable users to pay for onchain transactions with fiat or crypto",
},
];
@@ -257,11 +214,43 @@ export const aiFeatureCards: FeatureCardMetadata[] = [
},
];
export const bridgeFeatureCards: FeatureCardMetadata[] = [
export const x402FeatureCards: FeatureCardMetadata[] = [
{
icon: BringToFrontIcon,
title: "Swap Widget",
link: "/bridge/swap-widget",
description: "A widget for swapping tokens with cross-chain support",
icon: DollarSignIcon,
title: "x402",
link: "/x402",
description:
"Use the x402 payment protocol to pay for API calls using any web3 wallet",
},
];
export const transactionsFeatureCards: FeatureCardMetadata[] = [
{
icon: UserIcon,
title: "From User Wallets",
link: "/transactions/users",
description: "Transactions from user wallets with monitoring and retries.",
},
{
icon: PlaneIcon,
title: "Airdrop Tokens",
link: "/transactions/airdrop-tokens",
description:
"Airdrop any token with a few lines of code with gas sponsorship, optional.",
},
{
icon: StampIcon,
title: "Mint NFTs",
link: "/transactions/mint-tokens",
description:
"Gasless and efficient token minting with just a wallet address",
},
{
icon: RssIcon,
title: "Webhooks",
link: "/transactions/webhooks",
description:
"Receive real-time notifications for transactions and wallet events.",
},
];
+71 -116
View File
@@ -1,15 +1,12 @@
"use client";
import {
ArrowLeftRightIcon,
BotIcon,
BringToFrontIcon,
Code2Icon,
DollarSignIcon,
} from "lucide-react";
import type { ShadcnSidebarLink } from "@/components/blocks/full-width-sidebar-layout";
import { ContractIcon } from "../icons/ContractIcon";
import { PayIcon } from "../icons/PayIcon";
import { SmartAccountIcon } from "../icons/SmartAccountIcon";
import { TokenIcon } from "../icons/TokenIcon";
import { WalletProductIcon } from "../icons/WalletProductIcon";
@@ -98,35 +95,61 @@ const wallets: ShadcnSidebarLink = {
},
],
},
],
};
const contracts: ShadcnSidebarLink = {
subMenu: {
label: "Contracts",
icon: ContractIcon,
},
links: [
{
label: "Overview",
href: "/contracts",
exactMatch: true,
subMenu: {
label: "Transactions",
},
links: [
{
label: "Overview",
href: "/transactions",
exactMatch: true,
},
{
href: "/transactions/users",
label: "From User Wallets",
},
{
href: "/transactions/airdrop-tokens",
label: "Airdrop Tokens",
},
{
href: "/transactions/mint-tokens",
label: "Mint NFTs",
},
{
href: "/transactions/webhooks",
label: "Webhooks",
},
],
},
{
href: "/contracts/read",
label: "Read Contract",
},
{
href: "/contracts/write",
label: "Write Contract",
},
{
href: "/contracts/extensions",
label: "Pre-built Extensions",
},
{
href: "/contracts/events",
label: "Listen Contract Events",
subMenu: {
label: "Account Abstraction",
},
links: [
{
label: "Overview",
href: "/account-abstraction",
exactMatch: true,
},
{
href: "/account-abstraction/eip-4337",
label: "EIP-4337",
},
{
href: "/account-abstraction/eip-7702",
label: "EIP-7702",
},
{
href: "/account-abstraction/eip-5792",
label: "EIP-5792",
},
{
href: "/account-abstraction/native-aa",
label: "Native AA (zkSync)",
},
],
},
],
};
@@ -153,105 +176,36 @@ const tokens: ShadcnSidebarLink = {
],
};
const accountAbstractions: ShadcnSidebarLink = {
subMenu: {
label: "Account Abstraction",
icon: SmartAccountIcon,
},
links: [
{
label: "Overview",
href: "/account-abstraction",
exactMatch: true,
},
{
href: "/account-abstraction/eip-4337",
label: "EIP-4337",
},
{
href: "/account-abstraction/eip-7702",
label: "EIP-7702",
},
{
href: "/account-abstraction/eip-5792",
label: "EIP-5792",
},
{
href: "/account-abstraction/native-aa",
label: "Native AA (zkSync)",
},
],
};
const payments: ShadcnSidebarLink = {
subMenu: {
label: "Payments",
icon: PayIcon,
},
links: [
{
label: "Overview",
href: "/payments",
exactMatch: true,
},
{
href: "/payments/fund-wallet",
label: "Buy Crypto",
},
{
href: "/payments/commerce",
label: "Checkout",
},
{
href: "/payments/transactions",
label: "Onchain Transaction",
},
{
href: "/payments/x402",
label: "x402",
},
],
};
const bridge: ShadcnSidebarLink = {
subMenu: {
label: "Bridge",
icon: BringToFrontIcon,
},
links: [
{
href: "/bridge/swap-widget",
label: "Swap Widget",
},
],
};
const transactions: ShadcnSidebarLink = {
subMenu: {
label: "Transactions",
icon: ArrowLeftRightIcon,
},
links: [
{
label: "Overview",
href: "/transactions",
href: "/bridge",
exactMatch: true,
},
{
href: "/transactions/users",
label: "From User Wallets",
href: "/bridge/swap-widget",
label: "Swap Widget",
},
{
href: "/transactions/airdrop-tokens",
label: "Airdrop Tokens",
href: "/bridge/buy-widget",
label: "Buy Widget",
},
{
href: "/transactions/mint-tokens",
label: "Mint NFTs",
href: "/bridge/checkout-widget",
label: "Checkout Widget",
},
{
href: "/transactions/webhooks",
label: "Webhooks",
href: "/bridge/transaction-widget",
label: "Transaction Widget",
},
{
href: "/bridge/transaction-button",
label: "Transaction Button",
},
],
};
@@ -259,12 +213,13 @@ const transactions: ShadcnSidebarLink = {
export const sidebarLinks: ShadcnSidebarLink[] = [
ai,
wallets,
transactions,
contracts,
payments,
bridge,
tokens,
accountAbstractions,
{
label: "x402",
href: "/x402",
icon: DollarSignIcon,
},
{
href: "/reference",
label: "API Reference",
+8 -21
View File
@@ -1,14 +1,12 @@
import { FeatureCard } from "@/components/blocks/FeatureCard";
import { ThirdwebIcon } from "../icons/ThirdwebMiniLogo";
import {
accountAbstractionsFeatureCards,
aiFeatureCards,
bridgeFeatureCards,
contractsFeatureCards,
type FeatureCardMetadata,
paymentsFeatureCards,
transactionsFeatureCards,
tokensFeatureCards,
walletsFeatureCards,
x402FeatureCards,
} from "./data/pages-metadata";
export default function Page() {
@@ -37,22 +35,11 @@ export default function Page() {
</section>
<div className="container max-w-5xl space-y-12">
<FeatureSection featureCards={aiFeatureCards} title="AI" />
<FeatureSection featureCards={walletsFeatureCards} title="Wallets" />
<FeatureSection
featureCards={transactionsFeatureCards}
title="Transactions"
/>
<FeatureSection
featureCards={contractsFeatureCards}
title="Contracts"
/>
<FeatureSection featureCards={paymentsFeatureCards} title="Payments" />
<FeatureSection featureCards={bridgeFeatureCards} title="Bridge" />
<FeatureSection
featureCards={accountAbstractionsFeatureCards}
title="Account Abstraction"
/>
<FeatureSection title="AI" featureCards={aiFeatureCards} />
<FeatureSection title="Wallets" featureCards={walletsFeatureCards} />
<FeatureSection title="Bridge" featureCards={bridgeFeatureCards} />
<FeatureSection title="Tokens" featureCards={tokensFeatureCards} />
<FeatureSection title="x402" featureCards={x402FeatureCards} />
</div>
</main>
);
@@ -68,7 +55,7 @@ function FeatureSection(props: {
{props.title}
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{props.featureCards.slice(0, 6).map((card) => (
{props.featureCards.map((card) => (
<FeatureCard
key={card.title}
description={card.description}
@@ -1,14 +0,0 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "@/icons/PayIcon";
import { paymentsFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
title="Payments"
description="thirdweb Payments allow developers to create advanced payment flows to monetize their apps through product sales, peer to peer payments, token sales, and more"
featureCards={paymentsFeatureCards}
icon={PayIcon}
/>
);
}
@@ -54,7 +54,7 @@ export function X402RightSection(props: { options: X402PlaygroundOptions }) {
const url =
"/api/paywall" +
(searchParams.size > 0 ? "?" + searchParams.toString() : "");
(searchParams.size > 0 ? `?${searchParams.toString()}` : "");
const response = await fetchWithPay(url.toString());
return response.json();
},
@@ -1,7 +1,7 @@
import { CircleDollarSignIcon } from "lucide-react";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
import { PageLayout } from "../../components/blocks/APIHeader";
import { createMetadata } from "../../lib/metadata";
import { X402Playground } from "./components/X402Playground";
const title = "x402 Payments";
@@ -26,7 +26,7 @@ export default function Page() {
icon={CircleDollarSignIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground"
docsLink="https://portal.thirdweb.com/x402?utm_source=playground"
>
<X402Playground />
</PageLayout>
@@ -19,5 +19,17 @@ const getEcosystemWallet = () => {
export function EcosystemConnectEmbed(
props?: Omit<ConnectButtonProps, "client" | "theme">,
) {
return <StyledConnectEmbed {...props} wallets={[getEcosystemWallet()]} />;
return (
<StyledConnectEmbed
{...props}
wallets={[getEcosystemWallet()]}
onConnect={(activeWallet, allConnectedWallets) => {
console.log("active wallet", activeWallet.id);
console.log(
"all connected wallets",
allConnectedWallets.map((wallet) => wallet.id),
);
}}
/>
);
}
@@ -1,21 +0,0 @@
export function ContractIcon(props: { className?: string }) {
return (
<svg
className={props.className}
fill="none"
height="24"
role="presentation"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 11H8M10 15H8M16 7H8M20 6.8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V6.8C4 5.11984 4 4.27976 4.32698 3.63803C4.6146 3.07354 5.07354 2.6146 5.63803 2.32698C6.27976 2 7.11984 2 8.8 2H15.2C16.8802 2 17.7202 2 18.362 2.32698C18.9265 2.6146 19.3854 3.07354 19.673 3.63803C20 4.27976 20 5.11984 20 6.8Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
}
+8
View File
@@ -742,6 +742,14 @@ const paymentRedirects = {
"/payments/swap": "/bridge/swap",
"/payments/tokens": "/bridge/tokens",
"/payments/routes": "/bridge/routes",
"/payments/x402": "/x402",
"/payments/x402/:path*": "/x402/:path*",
"/payments/fund": "/bridge/fund",
"/payments/products": "/bridge/products",
"/payments/transactions": "/bridge/transactions",
"/payments/send": "/bridge/send",
"/payments/webhooks": "/bridge/webhooks",
"/payments/custom-data": "/bridge/custom-data",
};
const contractRedirects = {
+2 -2
View File
@@ -40,8 +40,8 @@ const links = [
name: "Contracts",
},
{
href: "/payments",
name: "Payments",
href: "/x402",
name: "x402",
},
{
href: "/bridge",
@@ -188,8 +188,8 @@ Any purchase data you provide to a payment will be included on all webhooks, sta
To connect with other auth strategies, use external wallets, or sponsor gas for users, check out the following guides:
- [Send a Payment](/payments/send)
- [Webhooks](/payments/webhooks)
- [Send a Payment](/bridge/send)
- [Webhooks](/bridge/webhooks)
## Explore Full API References
@@ -69,13 +69,13 @@ The BuyWidget handles the complete payment flow, supporting both crypto and fiat
title="Fund Wallet Live Demo"
description="Top up your wallet with crypto using the BuyWidget"
icon={ReactIcon}
href="https://playground.thirdweb.com/connect/pay/transactions"
href="https://playground.thirdweb.com/bridge/transaction-button"
/>
## Going Further
- [Custom Payment Data](/payments/custom-data) - Attach metadata to payments
- [Webhooks](/payments/webhooks) - Get notified when payments complete
- [Custom Payment Data](/bridge/custom-data) - Attach metadata to payments
- [Webhooks](/bridge/webhooks) - Get notified when payments complete
## API Reference

Some files were not shown because too many files have changed in this diff Show More