Skip to content

Commit fdd2557

Browse files
committed
[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 -->
1 parent 56a1c0a commit fdd2557

File tree

13 files changed

+412
-86
lines changed

13 files changed

+412
-86
lines changed

.changeset/short-wasps-show.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Remove fiat price shown in the button in `CheckoutWidget` to avoid showing it twice in the UI.

apps/dashboard/next.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,24 @@ const baseNextConfig: NextConfig = {
161161
],
162162
source: "/bridge/widget/:path*",
163163
},
164+
{
165+
headers: [
166+
{
167+
key: "Content-Security-Policy",
168+
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
169+
},
170+
],
171+
source: "/bridge/checkout-widget",
172+
},
173+
{
174+
headers: [
175+
{
176+
key: "Content-Security-Policy",
177+
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
178+
},
179+
],
180+
source: "/bridge/checkout-widget/:path*",
181+
},
164182
];
165183
},
166184
images: {

apps/dashboard/src/@/constants/public-envs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ export const NEXT_PUBLIC_ASSET_PAGE_CLIENT_ID =
4444

4545
export const NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID =
4646
process.env.NEXT_PUBLIC_BRIDGE_IFRAME_CLIENT_ID;
47+
48+
export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
49+
process.env.NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { SupportedFiatCurrency } from "thirdweb/react";
2+
3+
export function isValidCurrency(
4+
currency: string,
5+
): currency is SupportedFiatCurrency {
6+
if (currency in VALID_CURRENCIES) {
7+
return true;
8+
}
9+
return false;
10+
}
11+
12+
const VALID_CURRENCIES: Record<SupportedFiatCurrency, true> = {
13+
USD: true,
14+
EUR: true,
15+
GBP: true,
16+
JPY: true,
17+
KRW: true,
18+
CNY: true,
19+
INR: true,
20+
NOK: true,
21+
SEK: true,
22+
CHF: true,
23+
AUD: true,
24+
CAD: true,
25+
NZD: true,
26+
MXN: true,
27+
BRL: true,
28+
CLP: true,
29+
CZK: true,
30+
DKK: true,
31+
HKD: true,
32+
HUF: true,
33+
IDR: true,
34+
ILS: true,
35+
ISK: true,
36+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { isAddress } from "thirdweb";
2+
3+
export function parseQueryParams<T>(
4+
value: string | string[] | undefined,
5+
fn: (value: string) => T | undefined,
6+
): T | undefined {
7+
if (typeof value === "string") {
8+
return fn(value);
9+
}
10+
return undefined;
11+
}
12+
13+
export const onlyAddress = (v: string) => (isAddress(v) ? v : undefined);
14+
export const onlyNumber = (v: string) =>
15+
Number.isNaN(Number(v)) ? undefined : Number(v);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import type { Address } from "thirdweb";
5+
import { defineChain } from "thirdweb";
6+
import { CheckoutWidget, type SupportedFiatCurrency } from "thirdweb/react";
7+
import { createWallet } from "thirdweb/wallets";
8+
import { appMetadata } from "@/constants/connect";
9+
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
10+
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
11+
12+
const bridgeWallets = [
13+
createWallet("io.metamask"),
14+
createWallet("com.coinbase.wallet", {
15+
appMetadata,
16+
}),
17+
createWallet("me.rainbow"),
18+
createWallet("io.rabby"),
19+
createWallet("io.zerion.wallet"),
20+
createWallet("com.okex.wallet"),
21+
];
22+
23+
export function CheckoutWidgetEmbed({
24+
chainId,
25+
amount,
26+
seller,
27+
tokenAddress,
28+
name,
29+
description,
30+
image,
31+
buttonLabel,
32+
feePayer,
33+
country,
34+
showThirdwebBranding,
35+
theme,
36+
currency,
37+
}: {
38+
chainId: number;
39+
amount: string;
40+
seller: Address;
41+
tokenAddress?: Address;
42+
name?: string;
43+
description?: string;
44+
image?: string;
45+
buttonLabel?: string;
46+
feePayer?: "user" | "seller";
47+
country?: string;
48+
showThirdwebBranding?: boolean;
49+
theme: "light" | "dark";
50+
currency?: SupportedFiatCurrency;
51+
}) {
52+
const client = useMemo(
53+
() =>
54+
getConfiguredThirdwebClient({
55+
clientId: NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID,
56+
secretKey: undefined,
57+
teamId: undefined,
58+
}),
59+
[],
60+
);
61+
62+
// eslint-disable-next-line no-restricted-syntax
63+
const chain = useMemo(() => defineChain(chainId), [chainId]);
64+
65+
return (
66+
<CheckoutWidget
67+
className="shadow-xl"
68+
client={client}
69+
chain={chain}
70+
amount={amount}
71+
seller={seller}
72+
tokenAddress={tokenAddress}
73+
name={name}
74+
description={description}
75+
image={image}
76+
buttonLabel={buttonLabel}
77+
feePayer={feePayer}
78+
country={country}
79+
showThirdwebBranding={showThirdwebBranding}
80+
theme={theme}
81+
currency={currency}
82+
connectOptions={{
83+
wallets: bridgeWallets,
84+
appMetadata,
85+
}}
86+
onSuccess={(data) => {
87+
sendMessageToParent("success", data);
88+
}}
89+
onError={(error) => {
90+
sendMessageToParent("error", {
91+
message: error.message,
92+
});
93+
}}
94+
/>
95+
);
96+
}
97+
98+
function sendMessageToParent(
99+
type: "success" | "error",
100+
data: object | undefined,
101+
) {
102+
try {
103+
window.parent.postMessage(
104+
{
105+
source: "checkout-widget",
106+
type,
107+
data,
108+
},
109+
"*",
110+
);
111+
} catch (error) {
112+
console.error("Failed to send post message to parent window");
113+
console.error(error);
114+
}
115+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Inter } from "next/font/google";
2+
import { cn } from "@/lib/utils";
3+
4+
const fontSans = Inter({
5+
display: "swap",
6+
subsets: ["latin"],
7+
variable: "--font-sans",
8+
});
9+
10+
export default function BridgeEmbedLayout({
11+
children,
12+
}: {
13+
children: React.ReactNode;
14+
}) {
15+
return (
16+
<html lang="en" suppressHydrationWarning>
17+
<body
18+
className={cn(
19+
"min-h-dvh bg-background font-sans antialiased flex flex-col",
20+
fontSans.variable,
21+
)}
22+
>
23+
{children}
24+
</body>
25+
</html>
26+
);
27+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { Metadata } from "next";
2+
import "@workspace/ui/global.css";
3+
import { InlineCode } from "@workspace/ui/components/code/inline-code";
4+
import { AlertTriangleIcon } from "lucide-react";
5+
import type { SupportedFiatCurrency } from "thirdweb/react";
6+
import { NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID } from "@/constants/public-envs";
7+
import { isValidCurrency } from "../_common/isValidCurrency";
8+
import {
9+
onlyAddress,
10+
onlyNumber,
11+
parseQueryParams,
12+
} from "../_common/parseQueryParams";
13+
import { BridgeProviders } from "../(general)/components/client/Providers.client";
14+
import { CheckoutWidgetEmbed } from "./CheckoutWidgetEmbed.client";
15+
16+
const title = "thirdweb Checkout: Accept Crypto & Fiat Payments";
17+
const description =
18+
"Accept fiat or crypto payments on any chain—direct to your wallet. Instant checkout, webhook support, and full control over post-sale actions.";
19+
20+
export const metadata: Metadata = {
21+
description,
22+
openGraph: {
23+
description,
24+
title,
25+
},
26+
title,
27+
};
28+
29+
type SearchParams = {
30+
[key: string]: string | string[] | undefined;
31+
};
32+
33+
export default async function Page(props: {
34+
searchParams: Promise<SearchParams>;
35+
}) {
36+
const searchParams = await props.searchParams;
37+
38+
// Required params
39+
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
40+
const amount = parseQueryParams(searchParams.amount, (v) => v);
41+
const seller = parseQueryParams(searchParams.seller, onlyAddress);
42+
43+
// Optional params
44+
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
45+
const title = parseQueryParams(searchParams.title, (v) => v);
46+
const productDescription = parseQueryParams(
47+
searchParams.description,
48+
(v) => v,
49+
);
50+
const image = parseQueryParams(searchParams.image, (v) => v);
51+
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
52+
const feePayer = parseQueryParams(searchParams.feePayer, (v) =>
53+
v === "seller" || v === "user" ? v : undefined,
54+
);
55+
const country = parseQueryParams(searchParams.country, (v) => v);
56+
57+
const showThirdwebBranding = parseQueryParams(
58+
searchParams.showThirdwebBranding,
59+
(v) => v !== "false",
60+
);
61+
62+
const theme =
63+
parseQueryParams(searchParams.theme, (v) =>
64+
v === "light" ? "light" : "dark",
65+
) || "dark";
66+
67+
const currency = parseQueryParams(searchParams.currency, (v) =>
68+
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
69+
);
70+
71+
// Validate required params
72+
if (!chainId || !amount || !seller) {
73+
return (
74+
<Providers theme={theme}>
75+
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
76+
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-xl">
77+
<div className="p-2.5 inline-flex rounded-full bg-background mb-4 border">
78+
<AlertTriangleIcon className="size-5 text-destructive-text" />
79+
</div>
80+
<h2 className="mb-2 font-semibold text-destructive-text text-lg">
81+
Invalid Configuration
82+
</h2>
83+
<p className="text-muted-foreground text-sm mb-4">
84+
The following query parameters are required but are missing:
85+
</p>
86+
<ul className="mt-2 text-left text-muted-foreground text-sm space-y-2">
87+
{!chainId && (
88+
<li>
89+
<InlineCode code="chain" /> - Chain ID (e.g., 1, 8453,
90+
42161)
91+
</li>
92+
)}
93+
{!amount && (
94+
<li>
95+
<InlineCode code="amount" /> - Amount to charge (e.g.,
96+
"0.01")
97+
</li>
98+
)}
99+
{!seller && (
100+
<li>
101+
<InlineCode code="seller" /> - Seller wallet address
102+
</li>
103+
)}
104+
</ul>
105+
</div>
106+
</div>
107+
</Providers>
108+
);
109+
}
110+
111+
return (
112+
<Providers theme={theme}>
113+
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
114+
<CheckoutWidgetEmbed
115+
chainId={chainId}
116+
amount={amount}
117+
seller={seller}
118+
tokenAddress={tokenAddress}
119+
name={title}
120+
description={productDescription}
121+
image={image}
122+
buttonLabel={buttonLabel}
123+
feePayer={feePayer}
124+
country={country}
125+
showThirdwebBranding={showThirdwebBranding}
126+
theme={theme}
127+
currency={currency}
128+
/>
129+
</div>
130+
</Providers>
131+
);
132+
}
133+
134+
function Providers({
135+
children,
136+
theme,
137+
}: {
138+
children: React.ReactNode;
139+
theme: string;
140+
}) {
141+
if (!NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID) {
142+
throw new Error("NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID is not set");
143+
}
144+
return (
145+
<BridgeProviders
146+
clientId={NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID}
147+
forcedTheme={theme}
148+
>
149+
{children}
150+
</BridgeProviders>
151+
);
152+
}

0 commit comments

Comments
 (0)