Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-wasps-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Remove fiat price shown in the button in `CheckoutWidget` to avoid showing it twice in the UI.
18 changes: 18 additions & 0 deletions apps/dashboard/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/@/constants/public-envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
36 changes: 36 additions & 0 deletions apps/dashboard/src/app/bridge/_common/isValidCurrency.ts
Original file line number Diff line number Diff line change
@@ -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,
};
15 changes: 15 additions & 0 deletions apps/dashboard/src/app/bridge/_common/parseQueryParams.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
}
}
27 changes: 27 additions & 0 deletions apps/dashboard/src/app/bridge/checkout-widget/layout.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
152 changes: 152 additions & 0 deletions apps/dashboard/src/app/bridge/checkout-widget/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading