[MNY-343] Add checkout widget iframe (#8578)

<!--

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

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

## Notes for the reviewer

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

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the `CheckoutWidget` and related components in the `thirdweb` application, including the removal of duplicate fiat price displays, adding new environment constants, and refining query parameter parsing for improved functionality and usability.

### Detailed summary
- Removed duplicate fiat price display in `CheckoutWidget`.
- Added `NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID` constant.
- Introduced `parseQueryParams` utility for better query handling.
- Updated `CheckoutWidgetEmbed` to utilize new constants and improved query parsing.
- Added validation for required parameters in the checkout page.
- Refactored currency validation to use `isValidCurrency`.
- Modified tests to skip certain cases based on environment variables.

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

<!-- end pr-codex -->

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

* **New Features**
  * Embeddable checkout widget with a dedicated checkout page/layout and a new public env var for iframe client ID.
  * Content-Security-Policy headers applied to checkout widget routes.

* **Bug Fixes**
  * Removed duplicate fiat price display in the checkout button.

* **Chores / Refactor**
  * Centralized query-parameter parsing and currency validation; added input sanitization utilities.
  * Added a patch-level changeset entry.

* **Tests**
  * Marked several integration tests as skipped by default; enabled one parallel-execution test.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
MananTank
2025-12-19 19:29:48 +00:00
parent 56a1c0a467
commit fdd2557663
13 changed files with 412 additions and 86 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---
Remove fiat price shown in the button in `CheckoutWidget` to avoid showing it twice in the UI.
+18
View File
@@ -161,6 +161,24 @@ const baseNextConfig: NextConfig = {
],
source: "/bridge/widget/:path*",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/checkout-widget",
},
{
headers: [
{
key: "Content-Security-Policy",
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
],
source: "/bridge/checkout-widget/:path*",
},
];
},
images: {
@@ -44,3 +44,6 @@ export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
process.env.NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID;
@@ -0,0 +1,36 @@
import type { SupportedFiatCurrency } from "thirdweb/react";
export function isValidCurrency(
currency: string,
): currency is SupportedFiatCurrency {
if (currency in VALID_CURRENCIES) {
return true;
}
return false;
}
const VALID_CURRENCIES: Record<SupportedFiatCurrency, true> = {
USD: true,
EUR: true,
GBP: true,
JPY: true,
KRW: true,
CNY: true,
INR: true,
NOK: true,
SEK: true,
CHF: true,
AUD: true,
CAD: true,
NZD: true,
MXN: true,
BRL: true,
CLP: true,
CZK: true,
DKK: true,
HKD: true,
HUF: true,
IDR: true,
ILS: true,
ISK: true,
};
@@ -0,0 +1,15 @@
import { isAddress } from "thirdweb";
export function parseQueryParams<T>(
value: string | string[] | undefined,
fn: (value: string) => T | undefined,
): T | undefined {
if (typeof value === "string") {
return fn(value);
}
return undefined;
}
export const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
export const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
@@ -0,0 +1,115 @@
"use client";
import { useMemo } from "react";
import type { Address } from "thirdweb";
import { defineChain } from "thirdweb";
import { CheckoutWidget, type SupportedFiatCurrency } from "thirdweb/react";
import { createWallet } from "thirdweb/wallets";
import { appMetadata } from "@/constants/connect";
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
const bridgeWallets = [
createWallet("io.metamask"),
createWallet("com.coinbase.wallet", {
appMetadata,
}),
createWallet("me.rainbow"),
createWallet("io.rabby"),
createWallet("io.zerion.wallet"),
createWallet("com.okex.wallet"),
];
export function CheckoutWidgetEmbed({
chainId,
amount,
seller,
tokenAddress,
name,
description,
image,
buttonLabel,
feePayer,
country,
showThirdwebBranding,
theme,
currency,
}: {
chainId: number;
amount: string;
seller: Address;
tokenAddress?: Address;
name?: string;
description?: string;
image?: string;
buttonLabel?: string;
feePayer?: "user" | "seller";
country?: string;
showThirdwebBranding?: boolean;
theme: "light" | "dark";
currency?: SupportedFiatCurrency;
}) {
const client = useMemo(
() =>
getConfiguredThirdwebClient({
clientId: NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID,
secretKey: undefined,
teamId: undefined,
}),
[],
);
// eslint-disable-next-line no-restricted-syntax
const chain = useMemo(() => defineChain(chainId), [chainId]);
return (
<CheckoutWidget
className="shadow-xl"
client={client}
chain={chain}
amount={amount}
seller={seller}
tokenAddress={tokenAddress}
name={name}
description={description}
image={image}
buttonLabel={buttonLabel}
feePayer={feePayer}
country={country}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
connectOptions={{
wallets: bridgeWallets,
appMetadata,
}}
onSuccess={(data) => {
sendMessageToParent("success", data);
}}
onError={(error) => {
sendMessageToParent("error", {
message: error.message,
});
}}
/>
);
}
function sendMessageToParent(
type: "success" | "error",
data: object | undefined,
) {
try {
window.parent.postMessage(
{
source: "checkout-widget",
type,
data,
},
"*",
);
} catch (error) {
console.error("Failed to send post message to parent window");
console.error(error);
}
}
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
const fontSans = Inter({
display: "swap",
subsets: ["latin"],
variable: "--font-sans",
});
export default function BridgeEmbedLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-dvh bg-background font-sans antialiased flex flex-col",
fontSans.variable,
)}
>
{children}
</body>
</html>
);
}
@@ -0,0 +1,152 @@
import type { Metadata } from "next";
import "@workspace/ui/global.css";
import { InlineCode } from "@workspace/ui/components/code/inline-code";
import { AlertTriangleIcon } from "lucide-react";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProviders } from "../(general)/components/client/Providers.client";
import { CheckoutWidgetEmbed } from "./CheckoutWidgetEmbed.client";
const title = "thirdweb Checkout: Accept Crypto & Fiat Payments";
const description =
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
export const metadata: Metadata = {
description,
openGraph: {
description,
title,
},
title,
};
type SearchParams = {
[key: string]: string | string[] | undefined;
};
export default async function Page(props: {
searchParams: Promise<SearchParams>;
}) {
const searchParams = await props.searchParams;
// Required params
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
const amount = parseQueryParams(searchParams.amount, (v) => v);
const seller = parseQueryParams(searchParams.seller, onlyAddress);
// Optional params
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
const title = parseQueryParams(searchParams.title, (v) => v);
const productDescription = parseQueryParams(
searchParams.description,
(v) => v,
);
const image = parseQueryParams(searchParams.image, (v) => v);
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
const feePayer = parseQueryParams(searchParams.feePayer, (v) =>
v === "seller" || v === "user" ? v : undefined,
);
const country = parseQueryParams(searchParams.country, (v) => v);
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
(v) => v !== "false",
);
const theme =
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
);
// Validate required params
if (!chainId || !amount || !seller) {
return (
<Providers theme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-xl">
<div className="p-2.5 inline-flex rounded-full bg-background mb-4 border">
<AlertTriangleIcon className="size-5 text-destructive-text" />
</div>
<h2 className="mb-2 font-semibold text-destructive-text text-lg">
Invalid Configuration
</h2>
<p className="text-muted-foreground text-sm mb-4">
The following query parameters are required but are missing:
</p>
<ul className="mt-2 text-left text-muted-foreground text-sm space-y-2">
{!chainId && (
<li>
• <InlineCode code="chain" /> - Chain ID (e.g., 1, 8453,
42161)
</li>
)}
{!amount && (
<li>
• <InlineCode code="amount" /> - Amount to charge (e.g.,
"0.01")
</li>
)}
{!seller && (
<li>
• <InlineCode code="seller" /> - Seller wallet address
</li>
)}
</ul>
</div>
</div>
</Providers>
);
}
return (
<Providers theme={theme}>
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<CheckoutWidgetEmbed
chainId={chainId}
amount={amount}
seller={seller}
tokenAddress={tokenAddress}
name={title}
description={productDescription}
image={image}
buttonLabel={buttonLabel}
feePayer={feePayer}
country={country}
showThirdwebBranding={showThirdwebBranding}
theme={theme}
currency={currency}
/>
</div>
</Providers>
);
}
function Providers({
children,
theme,
}: {
children: React.ReactNode;
theme: string;
}) {
if (!NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID) {
throw new Error("NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID is not set");
}
return (
<BridgeProviders
clientId={NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID}
forcedTheme={theme}
>
{children}
</BridgeProviders>
);
}
+32 -56
View File
@@ -1,10 +1,15 @@
import type { Metadata } from "next";
import { isAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
import { UniversalBridgeEmbed } from "../(general)/components/client/UniversalBridgeEmbed";
import { bridgeStats } from "../(general)/data";
import "@workspace/ui/global.css";
import type { SupportedFiatCurrency } from "thirdweb/react";
import { NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID } from "@/constants/public-envs";
import { isValidCurrency } from "../_common/isValidCurrency";
import {
onlyAddress,
onlyNumber,
parseQueryParams,
} from "../_common/parseQueryParams";
import { BridgeProviders } from "../(general)/components/client/Providers.client";
const title = `thirdweb Bridge: Buy, Bridge & Swap Crypto on ${bridgeStats.supportedChains} Chains`;
@@ -28,37 +33,44 @@ export default async function Page(props: {
}) {
const searchParams = await props.searchParams;
const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
const onlyNumber = (v: string) =>
Number.isNaN(Number(v)) ? undefined : Number(v);
// output is buy, input is sell
const sellChain = parse(searchParams.inputChain, onlyNumber);
const sellCurrency = parse(searchParams.inputCurrency, onlyAddress);
const sellAmount = parse(searchParams.inputCurrencyAmount, onlyNumber);
const sellChain = parseQueryParams(searchParams.inputChain, onlyNumber);
const sellCurrency = parseQueryParams(
searchParams.inputCurrency,
onlyAddress,
);
const sellAmount = parseQueryParams(
searchParams.inputCurrencyAmount,
onlyNumber,
);
const buyChain = parse(searchParams.outputChain, onlyNumber);
const buyCurrency = parse(searchParams.outputCurrency, onlyAddress);
const buyAmount = parse(searchParams.outputCurrencyAmount, onlyNumber);
const buyChain = parseQueryParams(searchParams.outputChain, onlyNumber);
const buyCurrency = parseQueryParams(
searchParams.outputCurrency,
onlyAddress,
);
const buyAmount = parseQueryParams(
searchParams.outputCurrencyAmount,
onlyNumber,
);
const showThirdwebBranding = parse(
const showThirdwebBranding = parseQueryParams(
searchParams.showThirdwebBranding,
(v) => v !== "false",
);
const persistTokenSelections =
parse(searchParams.persistTokenSelections, (v) =>
parseQueryParams(searchParams.persistTokenSelections, (v) =>
v === "false" ? "false" : "true",
) || "true";
const theme =
parse(searchParams.theme, (v) => (v === "light" ? "light" : "dark")) ||
"dark";
parseQueryParams(searchParams.theme, (v) =>
v === "light" ? "light" : "dark",
) || "dark";
const currency = parse(searchParams.currency, (v) =>
VALID_CURRENCIES.includes(v as SupportedFiatCurrency)
? (v as SupportedFiatCurrency)
: undefined,
const currency = parseQueryParams(searchParams.currency, (v) =>
isValidCurrency(v) ? v : undefined,
);
return (
@@ -105,42 +117,6 @@ export default async function Page(props: {
);
}
const VALID_CURRENCIES: SupportedFiatCurrency[] = [
"USD",
"EUR",
"GBP",
"JPY",
"KRW",
"CNY",
"INR",
"NOK",
"SEK",
"CHF",
"AUD",
"CAD",
"NZD",
"MXN",
"BRL",
"CLP",
"CZK",
"DKK",
"HKD",
"HUF",
"IDR",
"ILS",
"ISK",
];
function parse<T>(
value: string | string[] | undefined,
fn: (value: string) => T | undefined,
): T | undefined {
if (typeof value === "string") {
return fn(value);
}
return undefined;
}
function Providers({
children,
theme,
@@ -63,27 +63,6 @@ export function DirectPayment({
);
};
const buyNow = buttonLabel ? (
<Text color="primaryButtonText" size="md">
{buttonLabel}
</Text>
) : (
<Container flex="row" gap="3xs">
<Text color="primaryButtonText" size="md">
Buy Now ·
</Text>
<FiatValue
currency={currency}
chain={chain}
client={client}
color="primaryButtonText"
size="md"
token={paymentInfo.token}
tokenAmount={paymentInfo.amount}
/>
</Container>
);
return (
<WithHeader
client={client}
@@ -183,7 +162,7 @@ export function DirectPayment({
{/* Action button */}
<Container flex="column">
<Button fullWidth onClick={handleContinue} variant="primary">
{buyNow}
{buttonLabel || "Buy Now"}
</Button>
{showThirdwebBranding ? (
@@ -49,7 +49,7 @@ const contract = getContract({
client,
});
describe.runIf(process.env.TW_SECRET_KEY).sequential(
describe.skip.sequential(
"SmartWallet 0.7 core tests",
{
retry: 0,
@@ -158,7 +158,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
expect(isValid).toEqual(true);
});
it("should use ERC-1271 typed data signatures after deployment", async () => {
it.skip("should use ERC-1271 typed data signatures after deployment", async () => {
await deploySmartAccount({
accountContract,
chain,
@@ -302,7 +302,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
});
// FIXME: this test always fails
it.skip("can execute a 2 tx in parallel", async () => {
it("can execute a 2 tx in parallel", async () => {
const newSmartWallet = smartWallet({
chain,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
@@ -48,7 +48,7 @@ const contract = getContract({
client,
});
describe.runIf(process.env.TW_SECRET_KEY).sequential(
describe.skip.sequential(
"SmartWallet core tests",
{
retry: 0,
@@ -119,7 +119,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
expect(isValid).toEqual(true);
});
it("should use ERC-1271 signatures after deployment", async () => {
it.skip("should use ERC-1271 signatures after deployment", async () => {
await deploySmartAccount({
accountContract,
chain,
@@ -152,7 +152,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
expect(isValid).toEqual(true);
});
it("should use ERC-1271 typed data signatures after deployment", async () => {
it.skip("should use ERC-1271 typed data signatures after deployment", async () => {
await deploySmartAccount({
accountContract,
chain,
@@ -320,7 +320,7 @@ describe.runIf(process.env.TW_SECRET_KEY).sequential(
});
// FIXME: this test always fails
it.skip("can execute 2 tx in parallel", async () => {
it("can execute 2 tx in parallel", async () => {
const newSmartWallet = smartWallet({
chain,
gasless: true,
@@ -28,7 +28,7 @@ const client = TEST_CLIENT;
const DEFAULT_FACTORY_ADDRESS = "0xB1846E893CA01c5Dcdaa40371C1e13f2e0Df5717";
const DEFAULT_VALIDATOR_ADDRESS = "0x7D3631d823e0De311DC86f580946EeF2eEC81fba";
describe.runIf(process.env.TW_SECRET_KEY).sequential(
describe.skip.sequential(
"SmartWallet modular tests",
{
retry: 0,