Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d5595e7f | ||
|
|
d0bbbfd39d | ||
|
|
e26d81ce1d | ||
|
|
be3316ec1b | ||
|
|
872369770f | ||
|
|
3d48826928 | ||
|
|
fa640ad64b | ||
|
|
eb1a9c3c3e | ||
|
|
a54c1aba35 | ||
|
|
bea4f01742 | ||
|
|
e19f7a21ad | ||
|
|
c37af4f191 | ||
|
|
f964b8aaff | ||
|
|
424e6b8f9b | ||
|
|
763d3de5a8 | ||
|
|
f6f138ccbc |
@@ -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.
|
||||
|
||||
⸻
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -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?",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+16
-9
@@ -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",
|
||||
},
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
-8
@@ -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>
|
||||
|
||||
|
||||
+6
-158
@@ -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,
|
||||
};
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+137
-133
@@ -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>
|
||||
|
||||
+9
-9
@@ -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",
|
||||
|
||||
+43
-22
@@ -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"
|
||||
|
||||
+2
-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`,
|
||||
};
|
||||
|
||||
|
||||
-95
@@ -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>
|
||||
);
|
||||
}
|
||||
-51
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-81
@@ -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,
|
||||
},
|
||||
|
||||
+21
-14
@@ -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
|
||||
|
||||
+36
-3
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+14
-3
@@ -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}
|
||||
|
||||
+56
-69
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-1
@@ -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",
|
||||
|
||||
+6
-199
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
+13
-7
@@ -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">
|
||||
|
||||
+6
-53
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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}
|
||||
/>
|
||||
|
||||
-226
@@ -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>
|
||||
);
|
||||
}
|
||||
+40
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+65
@@ -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>
|
||||
);
|
||||
}
|
||||
+172
@@ -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>
|
||||
);
|
||||
}
|
||||
+85
@@ -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>
|
||||
);
|
||||
}
|
||||
+65
@@ -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>
|
||||
);
|
||||
}
|
||||
+114
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+61
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
+891
@@ -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>
|
||||
);
|
||||
}
|
||||
+210
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export { default as CountrySelector } from "./country-selector";
|
||||
export {
|
||||
countryNames,
|
||||
countryPrefixes,
|
||||
getCountryFlag,
|
||||
tierPricing,
|
||||
} from "./utils";
|
||||
+482
@@ -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);
|
||||
};
|
||||
+3
-8
@@ -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}
|
||||
+65
@@ -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>
|
||||
);
|
||||
}
|
||||
+81
@@ -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>
|
||||
);
|
||||
}
|
||||
+69
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+31
@@ -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>
|
||||
);
|
||||
}
|
||||
+133
@@ -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>
|
||||
);
|
||||
}
|
||||
+160
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+158
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+155
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+249
@@ -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>
|
||||
);
|
||||
}
|
||||
+63
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
+1
-1
@@ -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 =
|
||||
+2
-2
@@ -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 =
|
||||
|
||||
+6
-29
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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();
|
||||
},
|
||||
+3
-3
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -40,8 +40,8 @@ const links = [
|
||||
name: "Contracts",
|
||||
},
|
||||
{
|
||||
href: "/payments",
|
||||
name: "Payments",
|
||||
href: "/x402",
|
||||
name: "x402",
|
||||
},
|
||||
{
|
||||
href: "/bridge",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
Reference in New Issue
Block a user