- Found {totalRecords} {activeChain === "evm" ? "EVM" : "Solana"} wallets
+
+
+
+ {totalRecords
+ ? `Showing ${rangeStart.toLocaleString()}-${rangeEnd.toLocaleString()} of ${totalRecords.toLocaleString()} ` +
+ (activeChain === "evm" ? "EVM" : "Solana") +
+ " wallets"
+ : `No ${activeChain === "evm" ? "EVM" : "Solana"} wallets found`}
+
+
-
-
-
- 1 ? currentPage - 1 : 1
- }`}
- legacyBehavior
- passHref
- >
-
-
-
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(
- (pageNumber) => (
-
-
-
- {pageNumber}
-
-
-
- ),
- )}
-
-
- = totalPages
- ? "pointer-events-none opacity-50"
- : ""
- }
- />
-
-
-
-
);
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx
index 9e3deda8ddf..3f025ee281e 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/page.tsx
@@ -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
Error: Failed to connect to Vault
;
- }
-
- 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 (
-
- Send, monitor, and manage transactions.{" "}
-
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",
- },
- ],
- }}
- >
-
- {/* transactions */}
-
-
- {/* Server Wallets (EVM + Solana) */}
- {eoas.error ? (
-
-
- EVM Wallet Error
-
-
- {eoas.error.message}
-
-
- ) : (
-
- )}
-
-
+ const params = await props.params;
+ redirect(
+ `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
);
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx
index eee8b5c2d51..96ef882d499 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx
@@ -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 && (
-
+
Managed Vault
-
- 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.
+
+ 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.{" "}
+
+ Learn more about Vault
+
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx
index 6c4455e2e1d..2fced3d5b6e 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx
@@ -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 (
-
-
-
+ const params = await props.params;
+ redirect(
+ `/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets/configuration`,
);
}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/InAppWalletUsersChartCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/InAppWalletUsersChartCard.stories.tsx
index 0da4d996b3d..19e4dedc564 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/InAppWalletUsersChartCard.stories.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/InAppWalletUsersChartCard.stories.tsx
@@ -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 (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx
index 5ff5dcc2fd8..3957433fb34 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/analytics/chart/index.tsx
@@ -21,7 +21,7 @@ function InAppWalletAnalyticsUI({
return (
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx
deleted file mode 100644
index 0c080f97206..00000000000
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/page.tsx
+++ /dev/null
@@ -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 (
-
-
- Create wallets for your users with flexible authentication
- options.
-
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",
- },
- ],
- }}
- >
-
-
-
-
-
-
- {/* Server Wallets Section (EVM + Solana) */}
-
- {eoas.error ? (
-
-
- EVM Wallet Error
-
-
- {eoas.error.message || "Failed to load EVM wallets"}
-
-
- ) : (
-
- )}
-
-
- {/* User Wallets Section */}
-
-
- User wallets
-
-
-
-
-
-
- );
-}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx
new file mode 100644
index 00000000000..89916b46b21
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/configuration/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx
new file mode 100644
index 00000000000..6bdebd1593c
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/layout.tsx
@@ -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 (
+
+ {props.children}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx
new file mode 100644
index 00000000000..fb9db6fe97f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/page.tsx
@@ -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 Error: Failed to connect to Vault
;
+ }
+
+ 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 (
+
+
+
+ {eoas.error ? (
+
+
+ EVM Wallet Error
+
+
{eoas.error.message}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx
new file mode 100644
index 00000000000..1ab1ab0a8b1
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/configuration/page.tsx
@@ -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 (
+
+ {!bundlerService ? (
+
+
+ Account Abstraction service is disabled
+
+ Enable Account Abstraction service in{" "}
+
+ project settings
+ {" "}
+ to configure the sponsorship rules
+
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx
new file mode 100644
index 00000000000..d7e767de589
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/layout.tsx
@@ -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 (
+
+ {props.children}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx
new file mode 100644
index 00000000000..b94480b13ad
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/sponsored-gas/page.tsx
@@ -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;
+}) {
+ 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 && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts
new file mode 100644
index 00000000000..0ddb49eb77f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/sms.ts
@@ -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: [],
+ };
+ }
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx
new file mode 100644
index 00000000000..d2ed8376738
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/index.tsx
@@ -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) => {
+ await updateProjectClient(
+ {
+ projectId: props.project.id,
+ teamId: props.teamId,
+ },
+ projectValues,
+ );
+ },
+ });
+
+ function handleUpdateProject(projectValues: Partial) {
+ updateProject.mutate(projectValues, {
+ onError: (err) => {
+ toast.error("Failed to update an API Key");
+ console.error(err);
+ },
+ onSuccess: () => {
+ toast.success("Configuration updated successfully");
+ },
+ });
+ }
+
+ return (
+
+ );
+}
+
+const InAppWalletSettingsPageUI: React.FC<
+ InAppWalletSettingsPageProps & {
+ updateApiKey: (
+ projectValues: Partial,
+ trackingData: UpdateAPIKeyTrackingData,
+ ) => void;
+ isUpdating: boolean;
+ smsCountryTiers: SMSCountryTiers;
+ }
+> = (props) => {
+ const embeddedWalletService = props.project.services.find(
+ (service) => service.name === "embeddedWallets",
+ );
+
+ if (!embeddedWalletService) {
+ return (
+
+
+ Wallets service is disabled
+
+ Enable Wallets service in the{" "}
+
+ project settings
+ {" "}
+ to configure settings
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const InAppWalletSettingsUI: React.FC<
+ InAppWalletSettingsPageProps & {
+ updateApiKey: (
+ projectValues: Partial,
+ 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({
+ 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 (
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+function BrandingFieldset(props: {
+ form: UseFormReturn
;
+ teamPlan: Team["billingPlan"];
+ teamSlug: string;
+ requiredPlan: Team["billingPlan"];
+ client: ThirdwebClient;
+ isUpdating: boolean;
+}) {
+ return (
+
+
+
+ }
+ >
+
+
+ props.form.setValue(
+ "branding",
+ checked
+ ? {
+ applicationImageUrl: "",
+ applicationName: "",
+ }
+ : undefined,
+ ),
+ }}
+ teamSlug={props.teamSlug}
+ trackingLabel="customEmailLogoAndName"
+ />
+
+
+
+
+
+
+ );
+}
+
+function AppImageFormControl(props: {
+ uri: string | undefined;
+ setUri: (uri: string) => void;
+ client: ThirdwebClient;
+}) {
+ const [image, setImage] = useState
();
+ 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 (
+
+
+
{
+ 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 && (
+
+
+
+ )}
+
+
+ );
+}
+
+function SMSCountryFields(props: {
+ form: UseFormReturn;
+ smsCountryTiers: SMSCountryTiers;
+ teamPlan: Team["billingPlan"];
+ requiredPlan: Team["billingPlan"];
+ teamSlug: string;
+}) {
+ return (
+
+
+
+ props.form.setValue(
+ "smsEnabledCountryISOs",
+ checked ? ["US", "CA"] : [],
+ ),
+ }}
+ teamSlug={props.teamSlug}
+ trackingLabel="sms"
+ />
+
+
+
+ (
+
+ )}
+ />
+
+
+ );
+}
+
+function JSONWebTokenFields(props: {
+ form: UseFormReturn;
+ teamPlan: Team["billingPlan"];
+ teamSlug: string;
+ requiredPlan: Team["billingPlan"];
+}) {
+ return (
+
+
+ Optionally allow users to authenticate with a custom JWT.{" "}
+
+ Learn more
+
+ >
+ }
+ switchId="authentication-switch"
+ title="Custom JSON Web Token"
+ >
+ {
+ props.form.setValue(
+ "customAuthentication",
+ checked ? { aud: "", jwksUri: "" } : undefined,
+ );
+ },
+ }}
+ teamSlug={props.teamSlug}
+ trackingLabel="customAuthJWT"
+ />
+
+
+
+ (
+
+ JWKS URI
+
+
+
+ Enter the URI of the JWKS
+
+
+ )}
+ />
+
+ (
+
+ AUD Value
+
+
+
+
+ Enter the audience claim for the JWT
+
+
+
+ )}
+ />
+
+
+ );
+}
+
+function AuthEndpointFields(props: {
+ form: UseFormReturn;
+ teamPlan: Team["billingPlan"];
+ teamSlug: string;
+ requiredPlan: Team["billingPlan"];
+}) {
+ const expandCustomAuthEndpointField =
+ props.form.watch("customAuthEndpoint") !== undefined;
+
+ return (
+
+
+ Optionally allow users to authenticate with any arbitrary payload
+ that you provide.{" "}
+
+ Learn more
+
+ >
+ }
+ switchId="auth-endpoint-switch"
+ title="Custom Authentication Endpoint"
+ >
+ {
+ props.form.setValue(
+ "customAuthEndpoint",
+ checked
+ ? {
+ authEndpoint: "",
+ customHeaders: [],
+ }
+ : undefined,
+ );
+ },
+ }}
+ teamSlug={props.teamSlug}
+ trackingLabel="customAuthEndpoint"
+ />
+
+
+ {/* useFieldArray used on this component - it creates empty customAuthEndpoint.customHeaders array on mount */}
+ {/* So only mount if expandCustomAuthEndpointField is true */}
+ {expandCustomAuthEndpointField && (
+
+ )}
+
+ );
+}
+
+function AuthEndpointFieldsContent(props: {
+ form: UseFormReturn;
+}) {
+ const customHeaderFields = useFieldArray({
+ control: props.form.control,
+ name: "customAuthEndpoint.customHeaders",
+ });
+
+ return (
+
+
(
+
+ Authentication Endpoint
+
+
+
+
+ Enter the URL of your server where we will send the user payload
+ for verification
+
+
+
+ )}
+ />
+
+
+
+
+
+
+ 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
+
+
+
+ );
+}
+
+function NativeAppsFieldset(props: {
+ form: UseFormReturn;
+ isUpdating: boolean;
+}) {
+ const { form } = props;
+ return (
+
+ }
+ >
+
(
+
+ Allowed redirect URIs
+
+
+
+
+ Enter redirect URIs separated by commas or new lines. This is
+ often your application's deep link.
+
+ Currently only used in Unity, Unreal Engine and React Native
+ platform when users authenticate through social logins.
+
+
+
+ )}
+ />
+
+ );
+}
+
+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 (
+
+ {props.children}
+
+ );
+}
+
+function Fieldset(props: {
+ legend: string;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {props.footer}
+
+ );
+}
+
+function FieldsetWithDescription(props: {
+ legend: string;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+ description: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {props.footer}
+
+ );
+}
+
+function SwitchContainer(props: {
+ switchId: string;
+ title: string;
+ description: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {props.description}
+
+
+ {props.children}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/country-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/country-selector.tsx
new file mode 100644
index 00000000000..cdc6f2863f3
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/country-selector.tsx
@@ -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 (
+
+ {Object.entries(countryTiers).map(([tier, tierCountries], index) => {
+ const selectedTierCountries = tierCountries.filter((country) =>
+ isCountrySelected(country),
+ );
+
+ return (
+ toggleTier(tier)}
+ tierCountries={tierCountries}
+ selectedTierCountries={selectedTierCountries}
+ onToggleCountry={toggleCountry}
+ />
+ );
+ })}
+
+ );
+}
+
+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 (
+
+ {/* header */}
+
+ {/* left */}
+
+
+
+
+ {isPartiallySelected && (
+
+ ({selectedCountries.length}/{countries.length})
+
+ )}
+
+
+ {/* right */}
+
+
+ {tierPricing[tier as keyof typeof tierPricing]}
+
+
+
+
+
+
+ {/* body */}
+ {isExpanded && (
+
+ {countries.map((country) => {
+ const isSelected = selectedCountries.includes(country);
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/index.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/index.ts
new file mode 100644
index 00000000000..9874e95d678
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/index.ts
@@ -0,0 +1,7 @@
+export { default as CountrySelector } from "./country-selector";
+export {
+ countryNames,
+ countryPrefixes,
+ getCountryFlag,
+ tierPricing,
+} from "./utils";
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/utils.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/utils.ts
new file mode 100644
index 00000000000..8e2541fd84f
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/sms-country-select/utils.ts
@@ -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 = {
+ // 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 = {
+ // 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);
+};
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx
similarity index 83%
rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx
rename to apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx
index 38319af7c89..5e3f3b942c9 100644
--- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/wallets/page.tsx
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx
@@ -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 (
-
-
;
+}) {
+ 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 (
+
+ {props.children}
+
+ );
+}
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/page.tsx
new file mode 100644
index 00000000000..2cf67d54718
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/page.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ );
+}