Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d5595e7f | ||
|
|
d0bbbfd39d | ||
|
|
e26d81ce1d | ||
|
|
be3316ec1b | ||
|
|
872369770f | ||
|
|
3d48826928 | ||
|
|
fa640ad64b | ||
|
|
eb1a9c3c3e | ||
|
|
a54c1aba35 | ||
|
|
bea4f01742 | ||
|
|
e19f7a21ad | ||
|
|
c37af4f191 | ||
|
|
f964b8aaff | ||
|
|
424e6b8f9b | ||
|
|
763d3de5a8 | ||
|
|
f6f138ccbc | ||
|
|
f5d5e73e57 | ||
|
|
673e51b11c | ||
|
|
282edf7ccb | ||
|
|
a87e91b99c | ||
|
|
0e630c4550 | ||
|
|
11c7bf0955 | ||
|
|
c9e430d092 | ||
|
|
1f440d0c97 | ||
|
|
3a0aac4393 | ||
|
|
7b13015053 | ||
|
|
7f081618e1 | ||
|
|
881c7a96f7 | ||
|
|
ad68969559 | ||
|
|
915d955da1 | ||
|
|
7da16cff2f | ||
|
|
f0edc0f253 | ||
|
|
0a2d269782 | ||
|
|
3d996d1d23 | ||
|
|
40781b8170 | ||
|
|
d907017217 | ||
|
|
cf53c2d7f1 | ||
|
|
207cb9dbeb | ||
|
|
b4d365bdc0 | ||
|
|
ea4e2fe376 | ||
|
|
1d54cff7b4 | ||
|
|
fd04eefea9 | ||
|
|
4cc68310dc | ||
|
|
ccdce496e0 | ||
|
|
2725f337f8 | ||
|
|
2c6ea3e6dd | ||
|
|
219fc69d72 | ||
|
|
8d17685683 | ||
|
|
8ef418c543 | ||
|
|
b88f75b494 | ||
|
|
c83f1c6f7a | ||
|
|
bc0295487b | ||
|
|
90b779107d | ||
|
|
afe187de95 | ||
|
|
759b93beff | ||
|
|
8e357b3cb3 | ||
|
|
6c318f83d6 |
@@ -188,9 +188,16 @@ jobs:
|
||||
- name: Build Packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Report bundle size
|
||||
- name: Report bundle size (thirdweb)
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package_manager: pnpm
|
||||
directory: packages/thirdweb
|
||||
directory: packages/thirdweb
|
||||
|
||||
- name: Report bundle size (nexus)
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package_manager: pnpm
|
||||
directory: packages/nexus
|
||||
@@ -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.
|
||||
|
||||
⸻
|
||||
|
||||
@@ -10,7 +10,7 @@ module.exports = [
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/payments",
|
||||
"/payments/x402",
|
||||
"/x402",
|
||||
"/nexus",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
|
||||
@@ -479,6 +479,11 @@ async function redirects() {
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/payments/x402",
|
||||
destination: "/x402",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export type AuthOption =
|
||||
| "steam"
|
||||
| "apple"
|
||||
| "coinbase"
|
||||
| "line";
|
||||
| "line"
|
||||
| "epic";
|
||||
|
||||
export type Ecosystem = {
|
||||
name: string;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const customClaimAmounts: Record<number, number> = {
|
||||
531050104: 5,
|
||||
// Arc testnet
|
||||
5042002: 1,
|
||||
// Injective testnet
|
||||
1439: 1,
|
||||
};
|
||||
|
||||
const defaultClaimAmount = 0.01;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+10
-4
@@ -10,7 +10,11 @@ import {
|
||||
CircleXIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { NATIVE_TOKEN_ADDRESS, type ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
getAddress,
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebClient,
|
||||
} from "thirdweb";
|
||||
import type { Status, Token } from "thirdweb/bridge";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import { toTokens } from "thirdweb/utils";
|
||||
@@ -123,6 +127,8 @@ function TokenInfo(props: {
|
||||
const isNativeToken =
|
||||
props.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
|
||||
|
||||
const tokenAddress = getAddress(props.token.address);
|
||||
|
||||
return (
|
||||
<div className="flex-1 pt-10 pb-9 px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -206,7 +212,7 @@ function TokenInfo(props: {
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground ">{props.addressLabel}</p>
|
||||
<WalletAddress
|
||||
address={props.walletAddress}
|
||||
address={getAddress(props.walletAddress)}
|
||||
client={props.client}
|
||||
className="py-0.5 h-auto text-sm [&>*]:!font-sans tabular-nums [&>*]:font-normal text-muted-foreground hover:text-foreground"
|
||||
iconClassName="size-3"
|
||||
@@ -222,8 +228,8 @@ function TokenInfo(props: {
|
||||
? `/${chainQuery.data?.slug || props.token.chainId}`
|
||||
: `/${chainQuery.data?.slug || props.token.chainId}/${props.token.address}`
|
||||
}
|
||||
textToShow={props.token.address}
|
||||
textToCopy={props.token.address}
|
||||
textToShow={tokenAddress}
|
||||
textToCopy={tokenAddress}
|
||||
copyTooltip="Copy Token Address"
|
||||
className="-translate-x-1"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Clock4Icon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { toTokens } from "thirdweb";
|
||||
import { getAddress, toTokens } from "thirdweb";
|
||||
import { status } from "thirdweb/bridge";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
@@ -153,6 +153,9 @@ function GeneericTxDetails(props: {
|
||||
|
||||
const timestamp = getDatefromTimestamp(block.timestamp);
|
||||
|
||||
const fromAddress = getAddress(transaction.from);
|
||||
const toAddress = transaction.to ? getAddress(transaction.to) : undefined;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 lg:p-6 bg-card">
|
||||
{/* section 1 */}
|
||||
@@ -216,8 +219,8 @@ function GeneericTxDetails(props: {
|
||||
<section className="border-b py-6 space-y-5 lg:space-y-3">
|
||||
<GridItem label="From" tooltip="The sending party of the transaction.">
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.from}
|
||||
textToShow={transaction.from}
|
||||
textToCopy={fromAddress}
|
||||
textToShow={fromAddress}
|
||||
tooltip="Copy from address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
@@ -225,14 +228,14 @@ function GeneericTxDetails(props: {
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
{transaction.to && (
|
||||
{toAddress && (
|
||||
<GridItem
|
||||
label="Interacted with (To)"
|
||||
tooltip="The receiving party of the transaction (could be a contract address)."
|
||||
>
|
||||
<CopyTextButton
|
||||
textToCopy={transaction.to}
|
||||
textToShow={transaction.to}
|
||||
textToCopy={toAddress}
|
||||
textToShow={toAddress}
|
||||
tooltip="Copy to address"
|
||||
variant="ghost"
|
||||
copyIconPosition="right"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
+1
@@ -58,6 +58,7 @@ const authOptions = [
|
||||
"apple",
|
||||
"coinbase",
|
||||
"line",
|
||||
"epic",
|
||||
] as const satisfies AuthOption[];
|
||||
|
||||
type AuthOptionsFormData = {
|
||||
|
||||
+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>
|
||||
|
||||
+8
@@ -14,6 +14,7 @@ type AggregatedMetrics = {
|
||||
|
||||
export function ProjectHighlightsCard(props: {
|
||||
selectedChart: string | undefined;
|
||||
aggregatedUserStats: InAppWalletStats[];
|
||||
userStats: InAppWalletStats[];
|
||||
volumeStats: UniversalBridgeStats[];
|
||||
teamSlug: string;
|
||||
@@ -22,6 +23,7 @@ export function ProjectHighlightsCard(props: {
|
||||
}) {
|
||||
const {
|
||||
selectedChart,
|
||||
aggregatedUserStats,
|
||||
userStats,
|
||||
volumeStats,
|
||||
teamSlug,
|
||||
@@ -70,6 +72,12 @@ export function ProjectHighlightsCard(props: {
|
||||
: "activeUsers"
|
||||
}
|
||||
aggregateFn={(_data, key) => {
|
||||
if (key === "activeUsers") {
|
||||
return aggregatedUserStats.reduce(
|
||||
(acc, curr) => acc + curr.uniqueWalletsConnected,
|
||||
0,
|
||||
);
|
||||
}
|
||||
return timeSeriesData.reduce((acc, curr) => acc + curr[key], 0);
|
||||
}}
|
||||
chartConfig={chartConfig}
|
||||
|
||||
@@ -378,8 +378,18 @@ async function AsyncAppHighlightsCard(props: {
|
||||
params: PageParams;
|
||||
authToken: string;
|
||||
}) {
|
||||
const [walletUserStatsTimeSeries, universalBridgeUsage] =
|
||||
const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] =
|
||||
await Promise.allSettled([
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
period: "all",
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
to: props.range.to,
|
||||
},
|
||||
props.authToken,
|
||||
),
|
||||
getInAppWalletUsage(
|
||||
{
|
||||
from: props.range.from,
|
||||
@@ -408,6 +418,11 @@ async function AsyncAppHighlightsCard(props: {
|
||||
) {
|
||||
return (
|
||||
<ProjectHighlightsCard
|
||||
aggregatedUserStats={
|
||||
aggregatedUserStats.status === "fulfilled"
|
||||
? aggregatedUserStats.value
|
||||
: []
|
||||
}
|
||||
selectedChart={props.selectedChart}
|
||||
selectedChartQueryParam={props.selectedChartQueryParam}
|
||||
teamSlug={props.params.team_slug}
|
||||
|
||||
+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,
|
||||
},
|
||||
|
||||
+88
-31
@@ -1,45 +1,102 @@
|
||||
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
|
||||
"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 { TransactionAnalyticsFilter } from "./filter";
|
||||
import { TransactionsChartCard } from "./tx-chart/tx-chart";
|
||||
import type { SolanaWallet } from "../solana-wallets/wallet-table/types";
|
||||
import { EngineChecklist } from "./ftux.client";
|
||||
import { TransactionsAnalytics } from "./tx-chart/tx-chart";
|
||||
|
||||
export function TransactionsAnalyticsPageContent(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
showAnalytics: boolean;
|
||||
wallets?: Wallet[];
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
teamId: string;
|
||||
isManagedVault: boolean;
|
||||
testTxWithWallet: string | undefined;
|
||||
testSolanaTxWithWallet: string | undefined;
|
||||
solanaWallets: SolanaWallet[];
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{props.showAnalytics && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<TransactionAnalyticsFilter />
|
||||
</div>
|
||||
<TransactionsChartCard
|
||||
project={props.project}
|
||||
searchParams={props.searchParams}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets ?? []}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
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;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
if (engineTxSummaryQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-[642px] grow items-center justify-center bg-card rounded-xl border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
</ResponsiveSearchParamsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const hasTransactions = engineTxSummaryQuery.data
|
||||
? engineTxSummaryQuery.data.totalCount > 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col gap-10">
|
||||
<EngineChecklist
|
||||
isManagedVault={props.isManagedVault}
|
||||
client={props.client}
|
||||
hasTransactions={hasTransactions}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
testTxWithWallet={props.testTxWithWallet}
|
||||
testSolanaTxWithWallet={props.testSolanaTxWithWallet}
|
||||
wallets={props.wallets}
|
||||
solanaWallets={props.solanaWallets}
|
||||
/>
|
||||
|
||||
{props.showAnalytics && hasTransactions && (
|
||||
<TransactionsAnalytics
|
||||
project={props.project}
|
||||
authToken={props.authToken}
|
||||
teamSlug={props.teamSlug}
|
||||
wallets={props.wallets ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UnifiedTransactionsTable
|
||||
client={props.client}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import { DateRangeSelector } from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { normalizeTimeISOString } from "@/lib/time";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
|
||||
|
||||
export function TransactionAnalyticsFilter() {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
|
||||
from: responsiveSearchParams.from,
|
||||
interval: responsiveSearchParams.interval,
|
||||
to: responsiveSearchParams.to,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
popoverAlign="end"
|
||||
range={range}
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
return {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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";
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
import type { TransactionStats } from "@/types/analytics";
|
||||
|
||||
export async function getTransactionsChartData({
|
||||
teamId,
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
interval,
|
||||
authToken,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
authToken: string;
|
||||
}): Promise<TransactionStats[]> {
|
||||
const filters = {
|
||||
endDate: to,
|
||||
resolution: interval,
|
||||
startDate: from,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching transactions chart data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionsChartResponse = {
|
||||
result: {
|
||||
analytics: Array<{
|
||||
timeBucket: string;
|
||||
chainId: string;
|
||||
count: number;
|
||||
}>;
|
||||
metadata: {
|
||||
resolution: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data = (await response.json()) as TransactionsChartResponse;
|
||||
|
||||
return data.result.analytics.map((stat) => ({
|
||||
chainId: Number(stat.chainId),
|
||||
count: stat.count,
|
||||
date: stat.timeBucket,
|
||||
}));
|
||||
}
|
||||
+4
-10
@@ -108,10 +108,10 @@ export function TransactionsChartCardUI(props: {
|
||||
customHeader={
|
||||
<div className="relative px-6 pt-6">
|
||||
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
|
||||
Daily Transactions
|
||||
Transactions
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Amount of daily transactions by chain.
|
||||
transactions broken down by chain
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
@@ -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"
|
||||
@@ -194,13 +194,7 @@ function EmptyChartContent(props: {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
|
||||
<span className="!pointer-events-auto relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
Waiting for transactions...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No transactions found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+59
-53
@@ -1,73 +1,79 @@
|
||||
import { ResponsiveSuspense } from "responsive-rsc";
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { Project } from "@/api/project/projects";
|
||||
import { getTransactionsChart } from "../../lib/analytics";
|
||||
import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
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";
|
||||
|
||||
async function AsyncTransactionsChartCard(props: {
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
export function TransactionsAnalytics(props: {
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
authToken: string;
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const data = await getTransactionsChart({
|
||||
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: props.from,
|
||||
interval: props.interval,
|
||||
from: normalizedFrom,
|
||||
interval: interval,
|
||||
teamId: props.project.teamId,
|
||||
to: props.to,
|
||||
to: normalizedTo,
|
||||
authToken: props.authToken,
|
||||
};
|
||||
|
||||
const engineTxAnalytics = useQuery({
|
||||
queryKey: ["engine-tx-analytics", params],
|
||||
queryFn: async () => {
|
||||
const data = await getTransactionsChartData(params);
|
||||
return data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<TransactionsChartCardUI
|
||||
isPending={false}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userOpStats={data}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
|
||||
<DateRangeSelector
|
||||
popoverAlign="end"
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
/>
|
||||
|
||||
export function TransactionsChartCard(props: {
|
||||
searchParams: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
};
|
||||
project: Project;
|
||||
wallets: Wallet[];
|
||||
teamSlug: string;
|
||||
}) {
|
||||
const { range, interval } = getTxAnalyticsFiltersFromSearchParams(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveSuspense
|
||||
// TODO - change this if this component does not end up using these params
|
||||
fallback={
|
||||
<TransactionsChartCardUI
|
||||
isPending={true}
|
||||
project={props.project}
|
||||
teamSlug={props.teamSlug}
|
||||
userOpStats={[]}
|
||||
wallets={[]}
|
||||
/>
|
||||
}
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
>
|
||||
<AsyncTransactionsChartCard
|
||||
from={range.from.toISOString()}
|
||||
interval={interval}
|
||||
<IntervalSelector
|
||||
intervalType={interval}
|
||||
setIntervalType={setInterval}
|
||||
/>
|
||||
</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}
|
||||
teamSlug={props.teamSlug}
|
||||
to={range.to.toISOString()}
|
||||
userOpStats={engineTxAnalytics.data ?? []}
|
||||
wallets={props.wallets}
|
||||
/>
|
||||
</ResponsiveSuspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+125
-81
@@ -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";
|
||||
@@ -33,13 +35,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ import { useV5DashboardChain } from "@/hooks/chains/v5-adapter";
|
||||
import { WalletProductIcon } from "@/icons/WalletProductIcon";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchSolanaBalance } from "../lib/getSolanaBalance";
|
||||
import { updateDefaultProjectWallet } from "../lib/vault.client";
|
||||
import { CreateServerWallet } from "../server-wallets/components/create-server-wallet.client";
|
||||
import type { Wallet as EVMWallet } from "../server-wallets/wallet-table/types";
|
||||
@@ -79,6 +81,8 @@ interface ServerWalletsTableProps {
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
solanaPermissionError?: boolean;
|
||||
authToken: string;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
@@ -95,10 +99,15 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
solanaTotalPages,
|
||||
client,
|
||||
solanaPermissionError,
|
||||
authToken,
|
||||
pageSize,
|
||||
} = props;
|
||||
|
||||
const [activeChain, setActiveChain] = useState<WalletChain>("evm");
|
||||
const [selectedChainId, setSelectedChainId] = useState<number>(1);
|
||||
const [selectedSolanaChain, setSelectedSolanaChain] = useState<
|
||||
"solana:mainnet" | "solana:devnet"
|
||||
>("solana:mainnet");
|
||||
const [showSmartAccount, setShowSmartAccount] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -145,11 +154,33 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
</>
|
||||
)}
|
||||
{activeChain === "solana" && (
|
||||
<CreateSolanaWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
disabled={solanaPermissionError}
|
||||
/>
|
||||
<>
|
||||
<CreateSolanaWallet
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
disabled={solanaPermissionError}
|
||||
/>
|
||||
<Select
|
||||
value={selectedSolanaChain}
|
||||
onValueChange={(value) =>
|
||||
setSelectedSolanaChain(
|
||||
value as "solana:mainnet" | "solana:devnet",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[180px] rounded-full bg-background hover:bg-accent/50">
|
||||
<SelectValue placeholder="Select network" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="solana:mainnet" className="rounded-lg">
|
||||
Solana Mainnet
|
||||
</SelectItem>
|
||||
<SelectItem value="solana:devnet" className="rounded-lg">
|
||||
Solana Devnet
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -278,6 +309,8 @@ export function ServerWalletsTable(props: ServerWalletsTableProps) {
|
||||
project={project}
|
||||
teamSlug={teamSlug}
|
||||
client={client}
|
||||
authToken={authToken}
|
||||
chainId={selectedSolanaChain}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -296,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -312,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>
|
||||
);
|
||||
}
|
||||
@@ -507,11 +531,15 @@ function SolanaWalletRow({
|
||||
project,
|
||||
teamSlug,
|
||||
client,
|
||||
authToken,
|
||||
chainId,
|
||||
}: {
|
||||
wallet: SolanaWallet;
|
||||
project: Project;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
authToken: string;
|
||||
chainId: "solana:mainnet" | "solana:devnet";
|
||||
}) {
|
||||
const engineService = project.services.find(
|
||||
(s) => s.name === "engineCloud",
|
||||
@@ -547,7 +575,12 @@ function SolanaWalletRow({
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<SolanaWalletBalance publicKey={wallet.publicKey} />
|
||||
<SolanaWalletBalance
|
||||
publicKey={wallet.publicKey}
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
chainId={chainId}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
@@ -739,16 +772,27 @@ function WalletBalance({
|
||||
);
|
||||
}
|
||||
|
||||
function SolanaWalletBalance({ publicKey }: { publicKey: string }) {
|
||||
function SolanaWalletBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
}: {
|
||||
publicKey: string;
|
||||
authToken: string;
|
||||
clientId: string;
|
||||
chainId: "solana:mainnet" | "solana:devnet";
|
||||
}) {
|
||||
const balance = useQuery({
|
||||
queryFn: async () => {
|
||||
// TODO: Implement actual Solana balance fetching
|
||||
return {
|
||||
displayValue: "0",
|
||||
symbol: "SOL",
|
||||
};
|
||||
return await fetchSolanaBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId,
|
||||
});
|
||||
},
|
||||
queryKey: ["solanaWalletBalance", publicKey],
|
||||
queryKey: ["solanaWalletBalance", publicKey, chainId],
|
||||
});
|
||||
|
||||
if (balance.isFetching) {
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
|
||||
export type TransactionSummaryData = {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string; // Keep fetched data structure
|
||||
};
|
||||
|
||||
type AnalyticsSummaryApiResponse = {
|
||||
result: {
|
||||
summary: {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string;
|
||||
};
|
||||
metadata: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Fetches data from the /analytics-summary endpoint
|
||||
export async function getTransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
authToken: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<TransactionSummaryData> {
|
||||
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",
|
||||
totalGasUnitsUsed: "0",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
console.error("Unauthorized fetching transaction summary");
|
||||
return defaultData;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnalyticsSummaryApiResponse;
|
||||
|
||||
return {
|
||||
totalCount: data.result.summary.totalCount ?? 0,
|
||||
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
|
||||
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction summary:", error);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
-147
@@ -1,157 +1,10 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_ENGINE_CLOUD_URL } from "@/constants/public-envs";
|
||||
import type { TransactionStats } from "@/types/analytics";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionsResponse,
|
||||
} from "../analytics/tx-table/types";
|
||||
|
||||
// Define the structure of the data we expect back from our fetch function
|
||||
export type TransactionSummaryData = {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string; // Keep fetched data structure
|
||||
};
|
||||
|
||||
// Define the structure of the API response
|
||||
type AnalyticsSummaryApiResponse = {
|
||||
result: {
|
||||
summary: {
|
||||
totalCount: number;
|
||||
totalGasCostWei: string;
|
||||
totalGasUnitsUsed: string;
|
||||
};
|
||||
metadata: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Fetches data from the /analytics-summary endpoint
|
||||
export async function getTransactionAnalyticsSummary(props: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
}): Promise<TransactionSummaryData> {
|
||||
const authToken = await getAuthToken();
|
||||
const body = {};
|
||||
const defaultData: TransactionSummaryData = {
|
||||
totalCount: 0,
|
||||
totalGasCostWei: "0",
|
||||
totalGasUnitsUsed: "0",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
console.error("Unauthorized fetching transaction summary");
|
||||
return defaultData;
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnalyticsSummaryApiResponse;
|
||||
|
||||
return {
|
||||
totalCount: data.result.summary.totalCount ?? 0,
|
||||
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
|
||||
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction summary:", error);
|
||||
return defaultData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransactionsChart({
|
||||
teamId,
|
||||
clientId,
|
||||
from,
|
||||
to,
|
||||
interval,
|
||||
}: {
|
||||
teamId: string;
|
||||
clientId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
interval: "day" | "week";
|
||||
}): Promise<TransactionStats[]> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const filters = {
|
||||
endDate: to,
|
||||
resolution: interval,
|
||||
startDate: from,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
|
||||
{
|
||||
body: JSON.stringify(filters),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-team-id": teamId,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// TODO - need to handle this error state, like we do with the connect charts
|
||||
throw new Error(
|
||||
`Error fetching transactions chart data: ${response.status} ${
|
||||
response.statusText
|
||||
} - ${await response.text().catch(() => "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
|
||||
type TransactionsChartResponse = {
|
||||
result: {
|
||||
analytics: Array<{
|
||||
timeBucket: string;
|
||||
chainId: string;
|
||||
count: number;
|
||||
}>;
|
||||
metadata: {
|
||||
resolution: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const data = (await response.json()) as TransactionsChartResponse;
|
||||
|
||||
return data.result.analytics.map((stat) => ({
|
||||
chainId: Number(stat.chainId),
|
||||
count: stat.count,
|
||||
date: stat.timeBucket,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSingleTransaction({
|
||||
teamId,
|
||||
clientId,
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { configure, getSolanaWalletBalance } from "@thirdweb-dev/api";
|
||||
import { THIRDWEB_API_HOST } from "@/constants/urls";
|
||||
|
||||
// Configure the API client to use the correct base URL
|
||||
configure({
|
||||
override: {
|
||||
baseUrl: THIRDWEB_API_HOST,
|
||||
},
|
||||
});
|
||||
|
||||
export async function fetchSolanaBalance({
|
||||
publicKey,
|
||||
authToken,
|
||||
clientId,
|
||||
chainId = "solana:mainnet",
|
||||
}: {
|
||||
publicKey: string;
|
||||
authToken: string;
|
||||
clientId: string;
|
||||
chainId?: "solana:mainnet" | "solana:devnet";
|
||||
}): Promise<{
|
||||
displayValue: string;
|
||||
symbol: string;
|
||||
value: string;
|
||||
decimals: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await getSolanaWalletBalance({
|
||||
path: {
|
||||
address: publicKey,
|
||||
},
|
||||
query: {
|
||||
chainId,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
console.error(
|
||||
"Error fetching Solana balance:",
|
||||
response.error || "No data returned",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
displayValue: response.data.result.displayValue,
|
||||
symbol: "SOL",
|
||||
value: response.data.result.value,
|
||||
decimals: response.data.result.decimals,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching Solana balance:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { getFiltersFromSearchParams } from "@/lib/time";
|
||||
|
||||
export function getTxAnalyticsFiltersFromSearchParams(params: {
|
||||
from?: string | undefined | string[];
|
||||
to?: string | undefined | string[];
|
||||
interval?: string | undefined | string[];
|
||||
}) {
|
||||
return getFiltersFromSearchParams({
|
||||
defaultRange: "last-30",
|
||||
from: params.from,
|
||||
interval: params.interval,
|
||||
to: params.to,
|
||||
});
|
||||
}
|
||||
+6
-218
@@ -1,223 +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 { EngineChecklist } from "./analytics/ftux.client";
|
||||
import { TransactionAnalyticsSummary } from "./analytics/summary";
|
||||
import { ServerWalletsTable } from "./components/server-wallets-table.client";
|
||||
import { getTransactionAnalyticsSummary } from "./lib/analytics";
|
||||
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 initialData = await getTransactionAnalyticsSummary({
|
||||
clientId: project.publishableKey,
|
||||
teamId: project.teamId,
|
||||
}).catch(() => undefined);
|
||||
const hasTransactions = initialData ? initialData.totalCount > 0 : 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">
|
||||
<EngineChecklist
|
||||
isManagedVault={isManagedVault}
|
||||
client={client}
|
||||
hasTransactions={hasTransactions}
|
||||
project={project}
|
||||
teamSlug={params.team_slug}
|
||||
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
|
||||
testSolanaTxWithWallet={
|
||||
searchParams.testSolanaTxWithWallet as string | undefined
|
||||
}
|
||||
wallets={wallets ?? []}
|
||||
solanaWallets={solanaAccounts.data.items}
|
||||
/>
|
||||
{hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet && (
|
||||
<TransactionAnalyticsSummary
|
||||
clientId={project.publishableKey}
|
||||
initialData={initialData}
|
||||
teamId={project.teamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* transactions */}
|
||||
<TransactionsAnalyticsPageContent
|
||||
client={client}
|
||||
project={project}
|
||||
searchParams={searchParams}
|
||||
showAnalytics={
|
||||
hasTransactions &&
|
||||
!searchParams.testTxWithWallet &&
|
||||
!searchParams.testSolanaTxWithWallet
|
||||
}
|
||||
teamSlug={params.team_slug}
|
||||
wallets={wallets}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ProjectPage>
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/team/${params.team_slug}/${params.project_slug}/wallets/server-wallets`,
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -70,8 +70,8 @@ export function CreateSolanaWallet(props: {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-2 rounded-full bg-foreground hover:bg-foreground/90"
|
||||
variant="outline"
|
||||
className="gap-1.5 rounded-full bg-background text-foreground"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
|
||||
+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`,
|
||||
);
|
||||
}
|
||||
|
||||
+2
-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">
|
||||
@@ -87,6 +87,7 @@ const authMethodsToPickFrom: InAppWalletAuth[] = [
|
||||
"line",
|
||||
"x",
|
||||
"tiktok",
|
||||
"epic",
|
||||
"coinbase",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
|
||||
+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}
|
||||
/>
|
||||
|
||||
-225
@@ -1,225 +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}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { payLandingWallets } from "./wallets";
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
export function StyledBuyWidget(
|
||||
props: Omit<BuyWidgetProps, "client" | "theme">,
|
||||
props: Omit<
|
||||
BuyWidgetProps,
|
||||
"client" | "theme" | "onSuccess" | "onError" | "onCancel"
|
||||
> & {
|
||||
successUrl?: string;
|
||||
},
|
||||
) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@@ -22,6 +27,22 @@ export function StyledBuyWidget(
|
||||
connectOptions={{
|
||||
wallets: payLandingWallets,
|
||||
}}
|
||||
onSuccess={() => {
|
||||
if (props.successUrl) {
|
||||
try {
|
||||
const url = new URL(props.successUrl);
|
||||
url.searchParams.set("success", "true");
|
||||
window.location.href = url.toString();
|
||||
} catch (error) {
|
||||
// Log URL construction error for debugging
|
||||
console.error(
|
||||
"Failed to construct redirect URL:",
|
||||
props.successUrl,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,19 +21,44 @@ type SearchParams = {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
|
||||
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
|
||||
const onlyNumber = (v: string) =>
|
||||
Number.isNaN(Number(v)) ? undefined : Number(v);
|
||||
|
||||
/**
|
||||
* Validates and normalizes a URL string.
|
||||
* Only allows http: and https: protocols with a valid hostname.
|
||||
* @returns normalized URL string on success, undefined on failure
|
||||
*/
|
||||
const onlyUrl = (v: string): string | undefined => {
|
||||
try {
|
||||
const url = new URL(v);
|
||||
// Only allow http or https protocols
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
// Ensure hostname is non-empty
|
||||
if (!url.hostname) {
|
||||
return undefined;
|
||||
}
|
||||
// Return normalized URL
|
||||
return url.toString();
|
||||
} catch {
|
||||
// Invalid URL format
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export default async function PayPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
|
||||
const onlyNumber = (v: string) =>
|
||||
Number.isNaN(Number(v)) ? undefined : Number(v);
|
||||
|
||||
const receiver = parse(searchParams.receiver, onlyAddress);
|
||||
const token = parse(searchParams.token, onlyAddress);
|
||||
const chain = parse(searchParams.chain, onlyNumber);
|
||||
const amount = parse(searchParams.amount, onlyNumber);
|
||||
const successUrl = parse(searchParams.successUrl, onlyUrl);
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
@@ -51,6 +76,7 @@ export default async function PayPage(props: {
|
||||
tokenAddress={token}
|
||||
receiverAddress={receiver}
|
||||
amount={amount ? amount.toString() : undefined}
|
||||
successUrl={successUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@abstract-foundation/agw-react": "^1.6.4",
|
||||
"@abstract-foundation/agw-react": "^1.10.0",
|
||||
"@ai-sdk/react": "^2.0.25",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user