Skip to content

Commit f3c06b7

Browse files
committed
feat: checkout link creation page
1 parent 3d7c2d4 commit f3c06b7

File tree

3 files changed

+202
-5
lines changed

3 files changed

+202
-5
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"use client";
2+
3+
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
4+
import { Button } from "@/components/ui/button";
5+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6+
import { Input } from "@/components/ui/input";
7+
import { useThirdwebClient } from "@/constants/thirdweb.client";
8+
import { CreditCardIcon } from "lucide-react";
9+
import { useState } from "react";
10+
import { toast } from "sonner";
11+
import { defineChain, getContract } from "thirdweb";
12+
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
13+
import { checksumAddress } from "thirdweb/utils";
14+
15+
export function CheckoutLinkForm() {
16+
const client = useThirdwebClient();
17+
const [chainId, setChainId] = useState<number>();
18+
const [recipientAddress, setRecipientAddress] = useState("");
19+
const [tokenAddress, setTokenAddress] = useState("");
20+
const [amount, setAmount] = useState("");
21+
const [isLoading, setIsLoading] = useState(false);
22+
const [error, setError] = useState<string>();
23+
24+
const handleSubmit = async (e: React.FormEvent) => {
25+
e.preventDefault();
26+
setError(undefined);
27+
setIsLoading(true);
28+
29+
try {
30+
if (!chainId || !recipientAddress || !tokenAddress || !amount) {
31+
throw new Error("All fields are required");
32+
}
33+
34+
// Validate addresses
35+
if (!checksumAddress(recipientAddress)) {
36+
throw new Error("Invalid recipient address");
37+
}
38+
if (!checksumAddress(tokenAddress)) {
39+
throw new Error("Invalid token address");
40+
}
41+
42+
// Get token decimals
43+
const tokenContract = getContract({
44+
client,
45+
// eslint-disable-next-line no-restricted-syntax
46+
chain: defineChain(chainId),
47+
address: tokenAddress,
48+
});
49+
const { decimals } = await getCurrencyMetadata({
50+
contract: tokenContract,
51+
});
52+
53+
// Convert amount to wei
54+
const amountInWei = BigInt(Number.parseFloat(amount) * 10 ** decimals);
55+
56+
// Build checkout URL
57+
const params = new URLSearchParams({
58+
chainId: chainId.toString(),
59+
recipientAddress,
60+
tokenAddress,
61+
amount: amountInWei.toString(),
62+
});
63+
64+
const checkoutUrl = `${window.location.origin}/checkout?${params.toString()}`;
65+
66+
// Copy to clipboard
67+
await navigator.clipboard.writeText(checkoutUrl);
68+
69+
// Show success toast
70+
toast.success("Checkout link copied to clipboard.");
71+
} catch (err) {
72+
setError(err instanceof Error ? err.message : "An error occurred");
73+
} finally {
74+
setIsLoading(false);
75+
}
76+
};
77+
78+
return (
79+
<Card className="mx-auto w-full max-w-[500px]">
80+
<CardHeader>
81+
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-2">
82+
<div className="rounded-lg border border-muted p-1.5 sm:p-2">
83+
<CreditCardIcon className="size-5 sm:size-6" />
84+
</div>
85+
<CardTitle className="text-center sm:text-left">
86+
Create a Checkout Link
87+
</CardTitle>
88+
</div>
89+
</CardHeader>
90+
<CardContent>
91+
<form onSubmit={handleSubmit} className="space-y-6">
92+
<div className="space-y-2">
93+
<label htmlFor="network" className="font-medium text-sm">
94+
Network
95+
</label>
96+
<SingleNetworkSelector
97+
chainId={chainId}
98+
onChange={setChainId}
99+
client={client}
100+
className="w-full"
101+
/>
102+
</div>
103+
104+
<div className="space-y-2">
105+
<label htmlFor="recipient" className="font-medium text-sm">
106+
Recipient Address
107+
</label>
108+
<Input
109+
id="recipient"
110+
value={recipientAddress}
111+
onChange={(e) => setRecipientAddress(e.target.value)}
112+
placeholder="0x..."
113+
required
114+
className="w-full"
115+
/>
116+
</div>
117+
118+
<div className="space-y-2">
119+
<label htmlFor="token" className="font-medium text-sm">
120+
Token Address
121+
</label>
122+
<Input
123+
id="token"
124+
value={tokenAddress}
125+
onChange={(e) => setTokenAddress(e.target.value)}
126+
placeholder="0x..."
127+
required
128+
className="w-full"
129+
/>
130+
</div>
131+
132+
<div className="space-y-2">
133+
<label htmlFor="amount" className="font-medium text-sm">
134+
Amount
135+
</label>
136+
<Input
137+
id="amount"
138+
type="number"
139+
step="any"
140+
value={amount}
141+
onChange={(e) => setAmount(e.target.value)}
142+
placeholder="0.0"
143+
required
144+
className="w-full"
145+
/>
146+
</div>
147+
148+
{error && <div className="text-red-500 text-sm">{error}</div>}
149+
150+
<div className="flex gap-2">
151+
<Button
152+
type="button"
153+
variant="outline"
154+
className="flex-1"
155+
onClick={() => {
156+
if (!chainId || !recipientAddress || !tokenAddress || !amount) {
157+
toast.error("Please fill in all fields first");
158+
return;
159+
}
160+
const params = new URLSearchParams({
161+
chainId: chainId.toString(),
162+
recipientAddress,
163+
tokenAddress,
164+
amount,
165+
});
166+
window.open(`/checkout?${params.toString()}`, "_blank");
167+
}}
168+
>
169+
Preview
170+
</Button>
171+
<Button type="submit" className="flex-1" disabled={isLoading}>
172+
{isLoading ? "Creating..." : "Create"}
173+
</Button>
174+
</div>
175+
</form>
176+
</CardContent>
177+
</Card>
178+
);
179+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"use client";
2+
import { Toaster } from "sonner";
23
import { ThirdwebProvider } from "thirdweb/react";
34

45
export function Providers({ children }: { children: React.ReactNode }) {
5-
return <ThirdwebProvider>{children}</ThirdwebProvider>;
6+
return (
7+
<ThirdwebProvider>
8+
{children}
9+
<Toaster richColors theme="dark" />
10+
</ThirdwebProvider>
11+
);
612
}

apps/dashboard/src/app/checkout/page.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
44
import { checksumAddress } from "thirdweb/utils";
55
import { getClientThirdwebClient } from "../../@/constants/thirdweb-client.client";
66
import { CheckoutEmbed } from "./components/client/CheckoutEmbed.client";
7+
import { CheckoutLinkForm } from "./components/client/CheckoutLinkForm.client";
78
import type { CheckoutParams } from "./components/types";
89

910
const title = "thirdweb Checkout";
@@ -23,16 +24,27 @@ export default async function RoutesPage({
2324
}: { searchParams: Promise<CheckoutParams> }) {
2425
const params = await searchParams;
2526

26-
if (!params.chainId || Array.isArray(params.chainId)) {
27+
// If no query parameters are provided, show the form
28+
if (
29+
!params.chainId ||
30+
!params.recipientAddress ||
31+
!params.tokenAddress ||
32+
!params.amount
33+
) {
34+
return <CheckoutLinkForm />;
35+
}
36+
37+
// Validate query parameters
38+
if (Array.isArray(params.chainId)) {
2739
throw new Error("A single chainId parameter is required.");
2840
}
29-
if (!params.recipientAddress || Array.isArray(params.recipientAddress)) {
41+
if (Array.isArray(params.recipientAddress)) {
3042
throw new Error("A single recipientAddress parameter is required.");
3143
}
32-
if (!params.tokenAddress || Array.isArray(params.tokenAddress)) {
44+
if (Array.isArray(params.tokenAddress)) {
3345
throw new Error("A single tokenAddress parameter is required.");
3446
}
35-
if (!params.amount || Array.isArray(params.amount)) {
47+
if (Array.isArray(params.amount)) {
3648
throw new Error("A single amount parameter is required.");
3749
}
3850
if (Array.isArray(params.clientId)) {

0 commit comments

Comments
 (0)