diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts index ec05e6af612..0a3efea2016 100644 --- a/apps/dashboard/next.config.ts +++ b/apps/dashboard/next.config.ts @@ -197,6 +197,24 @@ const baseNextConfig: NextConfig = { ], source: "/bridge/swap-widget/:path*", }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/buy-widget", + }, + { + headers: [ + { + key: "Content-Security-Policy", + value: EmbedContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(), + }, + ], + source: "/bridge/buy-widget/:path*", + }, ]; }, images: { diff --git a/apps/dashboard/src/@/constants/public-envs.ts b/apps/dashboard/src/@/constants/public-envs.ts index 41cbb58c13e..80f2d5d965a 100644 --- a/apps/dashboard/src/@/constants/public-envs.ts +++ b/apps/dashboard/src/@/constants/public-envs.ts @@ -50,3 +50,6 @@ export const NEXT_PUBLIC_CHECKOUT_IFRAME_CLIENT_ID = export const NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID = process.env.NEXT_PUBLIC_SWAP_IFRAME_CLIENT_ID; + +export const NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID = + process.env.NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID; diff --git a/apps/dashboard/src/app/bridge/buy-widget/BuyWidgetEmbed.client.tsx b/apps/dashboard/src/app/bridge/buy-widget/BuyWidgetEmbed.client.tsx new file mode 100644 index 00000000000..d22d738d950 --- /dev/null +++ b/apps/dashboard/src/app/bridge/buy-widget/BuyWidgetEmbed.client.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useMemo } from "react"; +import { type Address, defineChain } from "thirdweb"; +import { BuyWidget, type SupportedFiatCurrency } from "thirdweb/react"; +import { NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID } from "@/constants/public-envs"; +import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server"; + +export function BuyWidgetEmbed({ + chainId, + tokenAddress, + amount, + showThirdwebBranding, + theme, + currency, + title, + description, + image, + paymentMethods, + buttonLabel, + receiverAddress, + country, +}: { + chainId?: number; + tokenAddress?: Address; + amount?: string; + showThirdwebBranding?: boolean; + theme: "light" | "dark"; + currency?: SupportedFiatCurrency; + title?: string; + description?: string; + image?: string; + paymentMethods?: ("crypto" | "card")[]; + buttonLabel?: string; + receiverAddress?: Address; + country?: string; +}) { + const client = useMemo( + () => + getConfiguredThirdwebClient({ + clientId: NEXT_PUBLIC_BUY_IFRAME_CLIENT_ID, + secretKey: undefined, + teamId: undefined, + }), + [], + ); + + const chain = useMemo(() => { + if (!chainId) return undefined; + // eslint-disable-next-line no-restricted-syntax + return defineChain(chainId); + }, [chainId]); + + return ( + { + sendMessageToParent({ + source: "buy-widget", + type: "success", + }); + }} + onError={(error) => { + sendMessageToParent({ + source: "buy-widget", + type: "error", + message: error.message, + }); + }} + /> + ); +} + +function sendMessageToParent(content: object) { + try { + window.parent.postMessage(content, "*"); + } catch (error) { + console.error("Failed to send post message to parent window"); + console.error(error); + } +} diff --git a/apps/dashboard/src/app/bridge/buy-widget/layout.tsx b/apps/dashboard/src/app/bridge/buy-widget/layout.tsx new file mode 100644 index 00000000000..e69d8c4295d --- /dev/null +++ b/apps/dashboard/src/app/bridge/buy-widget/layout.tsx @@ -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 BuyWidgetLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/apps/dashboard/src/app/bridge/buy-widget/page.tsx b/apps/dashboard/src/app/bridge/buy-widget/page.tsx new file mode 100644 index 00000000000..0942e58802a --- /dev/null +++ b/apps/dashboard/src/app/bridge/buy-widget/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from "next"; +import "@workspace/ui/global.css"; +import type { SupportedFiatCurrency } from "thirdweb/react"; +import { isValidCurrency } from "../_common/isValidCurrency"; +import { + onlyAddress, + onlyNumber, + parseQueryParams, +} from "../_common/parseQueryParams"; +import { BridgeProvidersLite } from "../(general)/components/client/Providers.client"; +import { BuyWidgetEmbed } from "./BuyWidgetEmbed.client"; + +const title = "thirdweb Buy: Purchase Crypto with Ease"; +const description = + "Buy crypto tokens with credit card or other payment methods. Simple and secure purchases with thirdweb."; + +export const metadata: Metadata = { + description, + openGraph: { + description, + title, + }, + title, +}; + +type SearchParams = { + [key: string]: string | string[] | undefined; +}; + +export default async function Page(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + + // Token params + const chainId = parseQueryParams(searchParams.chain, onlyNumber); + const tokenAddress = parseQueryParams(searchParams.tokenAddress, onlyAddress); + const amount = parseQueryParams(searchParams.amount, (v) => v); + + // Optional params + const showThirdwebBranding = parseQueryParams( + searchParams.showThirdwebBranding, + // biome-ignore lint/complexity/noUselessTernary: this is easier to understand + (v) => (v === "false" ? false : true), + ); + + const theme = + parseQueryParams(searchParams.theme, (v) => + v === "light" ? "light" : "dark", + ) || "dark"; + + const currency = parseQueryParams(searchParams.currency, (v) => + isValidCurrency(v) ? (v as SupportedFiatCurrency) : undefined, + ); + + // Metadata params + const widgetTitle = parseQueryParams(searchParams.title, (v) => v); + const widgetDescription = parseQueryParams( + searchParams.description, + (v) => v, + ); + const image = parseQueryParams(searchParams.image, (v) => v); + + // Payment params + const paymentMethods = parseQueryParams(searchParams.paymentMethods, (v) => { + if (v === "crypto" || v === "card") { + return [v] as ("crypto" | "card")[]; + } + return undefined; + }); + + const buttonLabel = parseQueryParams(searchParams.buttonLabel, (v) => v); + const receiverAddress = parseQueryParams(searchParams.receiver, onlyAddress); + const country = parseQueryParams(searchParams.country, (v) => v); + + return ( + +
+ +
+
+ ); +}