Skip to content

Commit 4fc02b3

Browse files
committed
[MNY-342] Dashboard: Add Buy Widget iframe (#8613)
<!-- ## 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 introduces a new `BuyWidget` feature, allowing users to purchase crypto tokens using various payment methods. It adds necessary environment variables, updates configuration for security policies, and implements the layout and functionality for the `BuyWidgetEmbed` component. ### Detailed summary - Added `NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID` to `public-envs.ts`. - Updated `next.config.ts` with new Content Security Policy headers for `/bridge/buy-widget`. - Created `BuyWidgetLayout` component in `layout.tsx` for the buy widget page. - Implemented `BuyWidgetEmbed` component in `BuyWidgetEmbed.client.tsx` for handling crypto purchases. - Enhanced `page.tsx` to manage search parameters and integrate `BuyWidgetEmbed`. > ✨ 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 ## Release Notes * **New Features** * Added a new buy widget enabling direct cryptocurrency purchases with configurable payment methods (crypto and card) and multi-currency support * Implemented personalization options including custom titles, descriptions, images, and button labels * Enhanced security infrastructure to support the new widget functionality <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 26a9153 commit 4fc02b3

File tree

5 files changed

+240
-0
lines changed

5 files changed

+240
-0
lines changed

apps/dashboard/next.config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,24 @@ const baseNextConfig: NextConfig = {
197197
],
198198
source: "/bridge/swap-widget/:path*",
199199
},
200+
{
201+
headers: [
202+
{
203+
key: "Content-Security-Policy",
204+
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
205+
},
206+
],
207+
source: "/bridge/buy-widget",
208+
},
209+
{
210+
headers: [
211+
{
212+
key: "Content-Security-Policy",
213+
value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
214+
},
215+
],
216+
source: "/bridge/buy-widget/:path*",
217+
},
200218
];
201219
},
202220
images: {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID =
5050

5151
export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID =
5252
process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID;
53+
54+
export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID =
55+
process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import { type Address, defineChain } from "thirdweb";
5+
import { BuyWidget, type SupportedFiatCurrency } from "thirdweb/react";
6+
import { NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID } from "@/constants/public-envs";
7+
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
8+
9+
export function BuyWidgetEmbed({
10+
chainId,
11+
tokenAddress,
12+
amount,
13+
showThirdwebBranding,
14+
theme,
15+
currency,
16+
title,
17+
description,
18+
image,
19+
paymentMethods,
20+
buttonLabel,
21+
receiverAddress,
22+
country,
23+
}: {
24+
chainId?: number;
25+
tokenAddress?: Address;
26+
amount?: string;
27+
showThirdwebBranding?: boolean;
28+
theme: "light" | "dark";
29+
currency?: SupportedFiatCurrency;
30+
title?: string;
31+
description?: string;
32+
image?: string;
33+
paymentMethods?: ("crypto" | "card")[];
34+
buttonLabel?: string;
35+
receiverAddress?: Address;
36+
country?: string;
37+
}) {
38+
const client = useMemo(
39+
() =>
40+
getConfiguredThirdwebClient({
41+
clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID,
42+
secretKey: undefined,
43+
teamId: undefined,
44+
}),
45+
[],
46+
);
47+
48+
const chain = useMemo(() => {
49+
if (!chainId) return undefined;
50+
// eslint-disable-next-line no-restricted-syntax
51+
return defineChain(chainId);
52+
}, [chainId]);
53+
54+
return (
55+
<BuyWidget
56+
className="shadow-xl"
57+
client={client}
58+
chain={chain}
59+
tokenAddress={tokenAddress}
60+
amount={amount}
61+
showThirdwebBranding={showThirdwebBranding}
62+
theme={theme}
63+
currency={currency}
64+
title={title}
65+
description={description}
66+
image={image}
67+
paymentMethods={paymentMethods}
68+
buttonLabel={buttonLabel}
69+
receiverAddress={receiverAddress}
70+
country={country}
71+
onSuccess={() => {
72+
sendMessageToParent({
73+
source: "buy-widget",
74+
type: "success",
75+
});
76+
}}
77+
onError={(error) => {
78+
sendMessageToParent({
79+
source: "buy-widget",
80+
type: "error",
81+
message: error.message,
82+
});
83+
}}
84+
/>
85+
);
86+
}
87+
88+
function sendMessageToParent(content: object) {
89+
try {
90+
window.parent.postMessage(content, "*");
91+
} catch (error) {
92+
console.error("Failed to send post message to parent window");
93+
console.error(error);
94+
}
95+
}
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 BuyWidgetLayout({
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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Metadata } from "next";
2+
import "@workspace/ui/global.css";
3+
import type { SupportedFiatCurrency } from "thirdweb/react";
4+
import { isValidCurrency } from "../_common/isValidCurrency";
5+
import {
6+
onlyAddress,
7+
onlyNumber,
8+
parseQueryParams,
9+
} from "../_common/parseQueryParams";
10+
import { BridgeProvidersLite } from "../(general)/components/client/Providers.client";
11+
import { BuyWidgetEmbed } from "./BuyWidgetEmbed.client";
12+
13+
const title = "thirdweb Buy: Purchase Crypto with Ease";
14+
const description =
15+
"Buy crypto tokens with credit card or other payment methods. Simple and secure purchases with thirdweb.";
16+
17+
export const metadata: Metadata = {
18+
description,
19+
openGraph: {
20+
description,
21+
title,
22+
},
23+
title,
24+
};
25+
26+
type SearchParams = {
27+
[key: string]: string | string[] | undefined;
28+
};
29+
30+
export default async function Page(props: {
31+
searchParams: Promise<SearchParams>;
32+
}) {
33+
const searchParams = await props.searchParams;
34+
35+
// Token params
36+
const chainId = parseQueryParams(searchParams.chain, onlyNumber);
37+
const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress);
38+
const amount = parseQueryParams(searchParams.amount, (v) => v);
39+
40+
// Optional params
41+
const showThirdwebBranding = parseQueryParams(
42+
searchParams.showThirdwebBranding,
43+
// biome-ignore lint/complexity/noUselessTernary: this is easier to understand
44+
(v) => (v === "false" ? false : true),
45+
);
46+
47+
const theme =
48+
parseQueryParams(searchParams.theme, (v) =>
49+
v === "light" ? "light" : "dark",
50+
) || "dark";
51+
52+
const currency = parseQueryParams(searchParams.currency, (v) =>
53+
isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined,
54+
);
55+
56+
// Metadata params
57+
const widgetTitle = parseQueryParams(searchParams.title, (v) => v);
58+
const widgetDescription = parseQueryParams(
59+
searchParams.description,
60+
(v) => v,
61+
);
62+
const image = parseQueryParams(searchParams.image, (v) => v);
63+
64+
// Payment params
65+
const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => {
66+
if (v === "crypto" || v === "card") {
67+
return [v] as ("crypto" | "card")[];
68+
}
69+
return undefined;
70+
});
71+
72+
const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v);
73+
const receiverAddress = parseQueryParams(searchParams.receiver, onlyAddress);
74+
const country = parseQueryParams(searchParams.country, (v) => v);
75+
76+
return (
77+
<BridgeProvidersLite forcedTheme={theme}>
78+
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
79+
<BuyWidgetEmbed
80+
chainId={chainId}
81+
tokenAddress={tokenAddress}
82+
amount={amount}
83+
showThirdwebBranding={showThirdwebBranding}
84+
theme={theme}
85+
currency={currency}
86+
title={widgetTitle}
87+
description={widgetDescription}
88+
image={image}
89+
paymentMethods={paymentMethods}
90+
buttonLabel={buttonLabel}
91+
receiverAddress={receiverAddress}
92+
country={country}
93+
/>
94+
</div>
95+
</BridgeProvidersLite>
96+
);
97+
}

0 commit comments

Comments
 (0)