From f3c06b72a3d92e0079bc001cf28e7e0b14226115 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Thu, 8 May 2025 23:32:27 -0700 Subject: [PATCH 1/5] feat: checkout link creation page --- .../client/CheckoutLinkForm.client.tsx | 179 ++++++++++++++++++ .../components/client/Providers.client.tsx | 8 +- apps/dashboard/src/app/checkout/page.tsx | 20 +- 3 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx diff --git a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx new file mode 100644 index 00000000000..5bd3807a438 --- /dev/null +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useThirdwebClient } from "@/constants/thirdweb.client"; +import { CreditCardIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { defineChain, getContract } from "thirdweb"; +import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; +import { checksumAddress } from "thirdweb/utils"; + +export function CheckoutLinkForm() { + const client = useThirdwebClient(); + const [chainId, setChainId] = useState(); + const [recipientAddress, setRecipientAddress] = useState(""); + const [tokenAddress, setTokenAddress] = useState(""); + const [amount, setAmount] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(undefined); + setIsLoading(true); + + try { + if (!chainId || !recipientAddress || !tokenAddress || !amount) { + throw new Error("All fields are required"); + } + + // Validate addresses + if (!checksumAddress(recipientAddress)) { + throw new Error("Invalid recipient address"); + } + if (!checksumAddress(tokenAddress)) { + throw new Error("Invalid token address"); + } + + // Get token decimals + const tokenContract = getContract({ + client, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(chainId), + address: tokenAddress, + }); + const { decimals } = await getCurrencyMetadata({ + contract: tokenContract, + }); + + // Convert amount to wei + const amountInWei = BigInt(Number.parseFloat(amount) * 10 ** decimals); + + // Build checkout URL + const params = new URLSearchParams({ + chainId: chainId.toString(), + recipientAddress, + tokenAddress, + amount: amountInWei.toString(), + }); + + const checkoutUrl = `${window.location.origin}/checkout?${params.toString()}`; + + // Copy to clipboard + await navigator.clipboard.writeText(checkoutUrl); + + // Show success toast + toast.success("Checkout link copied to clipboard."); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+
+ +
+ + Create a Checkout Link + +
+
+ +
+
+ + +
+ +
+ + setRecipientAddress(e.target.value)} + placeholder="0x..." + required + className="w-full" + /> +
+ +
+ + setTokenAddress(e.target.value)} + placeholder="0x..." + required + className="w-full" + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="0.0" + required + className="w-full" + /> +
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/checkout/components/client/Providers.client.tsx b/apps/dashboard/src/app/checkout/components/client/Providers.client.tsx index a3412078c16..c0d0d4326ca 100644 --- a/apps/dashboard/src/app/checkout/components/client/Providers.client.tsx +++ b/apps/dashboard/src/app/checkout/components/client/Providers.client.tsx @@ -1,6 +1,12 @@ "use client"; +import { Toaster } from "sonner"; import { ThirdwebProvider } from "thirdweb/react"; export function Providers({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + + ); } diff --git a/apps/dashboard/src/app/checkout/page.tsx b/apps/dashboard/src/app/checkout/page.tsx index a9e96304cfb..0ab03245e8d 100644 --- a/apps/dashboard/src/app/checkout/page.tsx +++ b/apps/dashboard/src/app/checkout/page.tsx @@ -4,6 +4,7 @@ import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; import { checksumAddress } from "thirdweb/utils"; import { getClientThirdwebClient } from "../../@/constants/thirdweb-client.client"; import { CheckoutEmbed } from "./components/client/CheckoutEmbed.client"; +import { CheckoutLinkForm } from "./components/client/CheckoutLinkForm.client"; import type { CheckoutParams } from "./components/types"; const title = "thirdweb Checkout"; @@ -23,16 +24,27 @@ export default async function RoutesPage({ }: { searchParams: Promise }) { const params = await searchParams; - if (!params.chainId || Array.isArray(params.chainId)) { + // If no query parameters are provided, show the form + if ( + !params.chainId || + !params.recipientAddress || + !params.tokenAddress || + !params.amount + ) { + return ; + } + + // Validate query parameters + if (Array.isArray(params.chainId)) { throw new Error("A single chainId parameter is required."); } - if (!params.recipientAddress || Array.isArray(params.recipientAddress)) { + if (Array.isArray(params.recipientAddress)) { throw new Error("A single recipientAddress parameter is required."); } - if (!params.tokenAddress || Array.isArray(params.tokenAddress)) { + if (Array.isArray(params.tokenAddress)) { throw new Error("A single tokenAddress parameter is required."); } - if (!params.amount || Array.isArray(params.amount)) { + if (Array.isArray(params.amount)) { throw new Error("A single amount parameter is required."); } if (Array.isArray(params.clientId)) { From 2792071be18b43bb058629bb256d4a1305bd427c Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Fri, 9 May 2025 10:52:53 -0700 Subject: [PATCH 2/5] feat: adds token selector --- .../src/@/api/universal-bridge/constants.ts | 1 + .../src/@/api/universal-bridge/tokens.ts | 42 ++++++ .../@/components/blocks/NetworkSelectors.tsx | 1 + .../blocks/TokenSelector.stories.tsx | 49 +++++++ .../src/@/components/blocks/TokenSelector.tsx | 122 ++++++++++++++++++ .../components/blocks/select-with-search.tsx | 10 +- .../client/CheckoutLinkForm.client.tsx | 64 +++++---- apps/dashboard/src/hooks/tokens/tokens.ts | 111 ++++++++++++++++ 8 files changed, 372 insertions(+), 28 deletions(-) create mode 100644 apps/dashboard/src/@/api/universal-bridge/constants.ts create mode 100644 apps/dashboard/src/@/api/universal-bridge/tokens.ts create mode 100644 apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx create mode 100644 apps/dashboard/src/@/components/blocks/TokenSelector.tsx create mode 100644 apps/dashboard/src/hooks/tokens/tokens.ts diff --git a/apps/dashboard/src/@/api/universal-bridge/constants.ts b/apps/dashboard/src/@/api/universal-bridge/constants.ts new file mode 100644 index 00000000000..7f2d826be2c --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/constants.ts @@ -0,0 +1 @@ +export const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST; diff --git a/apps/dashboard/src/@/api/universal-bridge/tokens.ts b/apps/dashboard/src/@/api/universal-bridge/tokens.ts new file mode 100644 index 00000000000..5bbccdc94f3 --- /dev/null +++ b/apps/dashboard/src/@/api/universal-bridge/tokens.ts @@ -0,0 +1,42 @@ +"use server"; +import { getAuthToken } from "app/(app)/api/lib/getAuthToken"; +import { UB_BASE_URL } from "./constants"; + +export type TokenMetadata = { + name: string; + symbol: string; + address: string; + decimals: number; + chainId: number; + iconUri?: string; +}; + +export async function getUniversalBrigeTokens(props: { + clientId?: string; + chainId?: number; +}) { + const authToken = await getAuthToken(); + const url = new URL(`${UB_BASE_URL}/v1/tokens`); + + if (props.chainId) { + url.searchParams.append("chainId", String(props.chainId)); + } + url.searchParams.append("limit", "1000"); + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-client-id-override": props.clientId, + Authorization: `Bearer ${authToken}`, + } as Record, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + + const json = await res.json(); + return json.data as Array; +} diff --git a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx index 0bb2a48dcac..59e21c07a45 100644 --- a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx +++ b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx @@ -218,6 +218,7 @@ export function SingleNetworkSelector(props: { { props.onChange(Number(chainId)); diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx new file mode 100644 index 00000000000..157d690b3b7 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { + BadgeContainer, + storybookThirdwebClient, +} from "../../../stories/utils"; +import { TokenSelector } from "./TokenSelector"; + +const meta = { + title: "blocks/Cards/TokenSelector", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +function Story() { + return ( +
+ +
+ ); +} + +function Variant(props: { + label: string; + selectedChainId?: number; +}) { + const [tokenAddress, setTokenAddress] = useState(""); + return ( + + + + ); +} diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx new file mode 100644 index 00000000000..77dc53fb339 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -0,0 +1,122 @@ +import { useCallback, useMemo } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { useTokensData } from "../../../hooks/tokens/tokens"; +import { replaceIpfsUrl } from "../../../lib/sdk"; +import { fallbackChainIcon } from "../../../utils/chain-icons"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Img } from "./Img"; +import { SelectWithSearch } from "./select-with-search"; + +type Option = { label: string; value: string }; + +export function TokenSelector(props: { + tokenAddress: string | undefined; + onChange: (tokenAddress: string) => void; + className?: string; + popoverContentClassName?: string; + chainId?: number; + side?: "left" | "right" | "top" | "bottom"; + disableChainId?: boolean; + align?: "center" | "start" | "end"; + placeholder?: string; + client: ThirdwebClient; + disabled?: boolean; +}) { + const { tokens, isLoading } = useTokensData({ + clientId: props.client.clientId, + chainId: props.chainId, + }); + + const options = useMemo(() => { + return tokens.allTokens.map((token) => { + return { + label: token.symbol, + value: `${token.chainId}:${token.address}`, + }; + }); + }, [tokens.allTokens]); + + const searchFn = useCallback( + (option: Option, searchValue: string) => { + const token = tokens.addressChainToToken.get(option.value); + if (!token) { + return false; + } + + if (Number.isInteger(Number.parseInt(searchValue))) { + return String(token.chainId).startsWith(searchValue); + } + return ( + token.name.toLowerCase().includes(searchValue.toLowerCase()) || + token.symbol.toLowerCase().includes(searchValue.toLowerCase()) || + token.address.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, + [tokens], + ); + + const renderOption = useCallback( + (option: Option) => { + const token = tokens.addressChainToToken.get(option.value); + if (!token) { + return option.label; + } + const resolvedSrc = token.iconUri + ? replaceIpfsUrl(token.iconUri, props.client) + : fallbackChainIcon; + + return ( +
+ + } + skeleton={ +
+ } + /> + {token.symbol} + + + {!props.disableChainId && ( + + Chain ID + {token.chainId} + + )} +
+ ); + }, + [tokens, props.disableChainId, props.client], + ); + + return ( + { + props.onChange(tokenAddress); + }} + closeOnSelect={true} + showCheck={false} + placeholder={ + isLoading ? "Loading Tokens..." : props.placeholder || "Select Token" + } + overrideSearchFn={searchFn} + renderOption={renderOption} + className={props.className} + popoverContentClassName={props.popoverContentClassName} + disabled={isLoading || props.disabled} + side={props.side} + align={props.align} + /> + ); +} diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 50dcda770e5..70a08a5aeaa 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -34,6 +34,7 @@ interface SelectWithSearchProps side?: "left" | "right" | "top" | "bottom"; align?: "center" | "start" | "end"; closeOnSelect?: boolean; + showCheck?: boolean; } export const SelectWithSearch = React.forwardRef< @@ -52,6 +53,7 @@ export const SelectWithSearch = React.forwardRef< popoverContentClassName, searchPlaceholder, closeOnSelect, + showCheck = true, ...props }, ref, @@ -193,9 +195,11 @@ export const SelectWithSearch = React.forwardRef< i === optionsToShow.length - 1 ? lastItemRef : undefined } > -
- {isSelected && } -
+ {showCheck && ( +
+ {isSelected && } +
+ )}
{renderOption ? renderOption(option) : option.label} diff --git a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx index 5bd3807a438..469c4bf6829 100644 --- a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -1,9 +1,11 @@ "use client"; import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { TokenSelector } from "@/components/blocks/TokenSelector"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useThirdwebClient } from "@/constants/thirdweb.client"; import { CreditCardIcon } from "lucide-react"; import { useState } from "react"; @@ -16,7 +18,7 @@ export function CheckoutLinkForm() { const client = useThirdwebClient(); const [chainId, setChainId] = useState(); const [recipientAddress, setRecipientAddress] = useState(""); - const [tokenAddress, setTokenAddress] = useState(""); + const [tokenAddressWithChain, setTokenAddressWithChain] = useState(""); const [amount, setAmount] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); @@ -27,7 +29,7 @@ export function CheckoutLinkForm() { setIsLoading(true); try { - if (!chainId || !recipientAddress || !tokenAddress || !amount) { + if (!chainId || !recipientAddress || !tokenAddressWithChain || !amount) { throw new Error("All fields are required"); } @@ -35,10 +37,17 @@ export function CheckoutLinkForm() { if (!checksumAddress(recipientAddress)) { throw new Error("Invalid recipient address"); } - if (!checksumAddress(tokenAddress)) { + if (!checksumAddress(tokenAddressWithChain)) { throw new Error("Invalid token address"); } + const [_chainId, tokenAddress] = tokenAddressWithChain.split(":"); + if (Number(_chainId) !== chainId) { + throw new Error("Chain ID does not match token chain"); + } + if (!tokenAddress) { + throw new Error("Missing token address"); + } // Get token decimals const tokenContract = getContract({ client, @@ -57,7 +66,7 @@ export function CheckoutLinkForm() { const params = new URLSearchParams({ chainId: chainId.toString(), recipientAddress, - tokenAddress, + tokenAddress: tokenAddressWithChain, amount: amountInWei.toString(), }); @@ -90,9 +99,9 @@ export function CheckoutLinkForm() {
-
- - setRecipientAddress(e.target.value)} - placeholder="0x..." - required + +
- + setTokenAddress(e.target.value)} + id="recipient" + value={recipientAddress} + onChange={(e) => setRecipientAddress(e.target.value)} placeholder="0x..." required className="w-full" @@ -130,9 +139,9 @@ export function CheckoutLinkForm() {
- { - if (!chainId || !recipientAddress || !tokenAddress || !amount) { + if ( + !chainId || + !recipientAddress || + !tokenAddressWithChain || + !amount + ) { toast.error("Please fill in all fields first"); return; } const params = new URLSearchParams({ chainId: chainId.toString(), recipientAddress, - tokenAddress, + tokenAddress: tokenAddressWithChain, amount, }); window.open(`/checkout?${params.toString()}`, "_blank"); diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts new file mode 100644 index 00000000000..5ae93af22a7 --- /dev/null +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { Address } from "thirdweb"; +import { + type TokenMetadata, + getUniversalBrigeTokens, +} from "../../@/api/universal-bridge/tokens"; +import { createStore, useStore } from "../../@/lib/reactive"; + +type StructuredTokensStore = { + allTokens: TokenMetadata[]; + nameToToken: Map; + symbolToTokens: Map; + chainToTokens: Map; + addressToToken: Map; + addressChainToToken: Map; +}; + +function createStructuredTokensStore() { + const store = createStore({ + allTokens: [], + nameToToken: new Map(), + symbolToTokens: new Map(), + chainToTokens: new Map(), + addressToToken: new Map(), + addressChainToToken: new Map(), + }); + + const dependencies = [tokensStore]; + for (const dep of dependencies) { + dep.subscribe(() => { + updateStructuredTokensStore(tokensStore.getValue()); + }); + } + + function updateStructuredTokensStore(tokens: TokenMetadata[]) { + // if original tokens are not loaded yet - ignore + if (tokens.length === 0) { + return; + } + + const allTokens: TokenMetadata[] = []; + const nameToToken: Map = new Map(); + const symbolToTokens: Map = new Map(); + const chainToTokens: Map = new Map(); + const addressToTokens: Map = new Map(); + const addressChainToToken: Map = + new Map(); + + for (const token of tokens) { + allTokens.push(token); + nameToToken.set(token.name, [ + ...(nameToToken.get(token.name) || []), + token, + ]); + symbolToTokens.set(token.symbol, [ + ...(symbolToTokens.get(token.symbol) || []), + token, + ]); + chainToTokens.set(token.chainId, [ + ...(chainToTokens.get(token.chainId) || []), + token, + ]); + addressToTokens.set(token.address as Address, token); + addressChainToToken.set(`${token.chainId}:${token.address}`, token); + } + + store.setValue({ + allTokens, + nameToToken, + symbolToTokens, + chainToTokens, + addressToToken: addressToTokens, + addressChainToToken: addressChainToToken, + }); + } + + return store; +} + +const tokensStore = /* @__PURE__ */ createStore([]); +const structuredTokensStore = /* @__PURE__ */ createStructuredTokensStore(); + +export function useTokensData({ + clientId, + chainId, +}: { clientId: string; chainId?: number }) { + const tokensQuery = useQuery({ + queryKey: ["universal-bridge-tokens", chainId], + queryFn: () => getUniversalBrigeTokens({ clientId, chainId }), + }); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // already set + if (tokensStore.getValue().length > 0) { + return; + } + + if (!tokensQuery.data) { + return; + } + + tokensStore.setValue(tokensQuery.data); + }, [tokensQuery.data]); + + return { + tokens: useStore(structuredTokensStore), + isLoading: tokensQuery.isLoading, + }; +} From 6713fe80308b9c8dc7a9a74650b03ba065649d32 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Fri, 9 May 2025 11:22:13 -0700 Subject: [PATCH 3/5] feat: ens resolution --- .../src/@/components/blocks/TokenSelector.tsx | 8 +- .../client/CheckoutLinkForm.client.tsx | 107 +++++++++++------- apps/dashboard/src/hooks/tokens/tokens.ts | 5 +- 3 files changed, 78 insertions(+), 42 deletions(-) diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx index 77dc53fb339..7a981d3927b 100644 --- a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -22,10 +22,12 @@ export function TokenSelector(props: { placeholder?: string; client: ThirdwebClient; disabled?: boolean; + enabled?: boolean; }) { - const { tokens, isLoading } = useTokensData({ + const { tokens, isFetching } = useTokensData({ clientId: props.client.clientId, chainId: props.chainId, + enabled: props.enabled, }); const options = useMemo(() => { @@ -108,13 +110,13 @@ export function TokenSelector(props: { closeOnSelect={true} showCheck={false} placeholder={ - isLoading ? "Loading Tokens..." : props.placeholder || "Select Token" + isFetching ? "Loading Tokens..." : props.placeholder || "Select Token" } overrideSearchFn={searchFn} renderOption={renderOption} className={props.className} popoverContentClassName={props.popoverContentClassName} - disabled={isLoading || props.disabled} + disabled={isFetching || props.disabled} side={props.side} align={props.align} /> diff --git a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx index 469c4bf6829..d8a1e9b7d32 100644 --- a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -10,9 +10,9 @@ import { useThirdwebClient } from "@/constants/thirdweb.client"; import { CreditCardIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; -import { defineChain, getContract } from "thirdweb"; +import { type ThirdwebClient, defineChain, getContract } from "thirdweb"; import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; -import { checksumAddress } from "thirdweb/utils"; +import { resolveEns } from "../../../../lib/ens"; export function CheckoutLinkForm() { const client = useThirdwebClient(); @@ -33,41 +33,20 @@ export function CheckoutLinkForm() { throw new Error("All fields are required"); } - // Validate addresses - if (!checksumAddress(recipientAddress)) { - throw new Error("Invalid recipient address"); - } - if (!checksumAddress(tokenAddressWithChain)) { - throw new Error("Invalid token address"); - } - - const [_chainId, tokenAddress] = tokenAddressWithChain.split(":"); - if (Number(_chainId) !== chainId) { - throw new Error("Chain ID does not match token chain"); - } - if (!tokenAddress) { - throw new Error("Missing token address"); - } - // Get token decimals - const tokenContract = getContract({ + const inputs = await parseInputs( client, - // eslint-disable-next-line no-restricted-syntax - chain: defineChain(chainId), - address: tokenAddress, - }); - const { decimals } = await getCurrencyMetadata({ - contract: tokenContract, - }); - - // Convert amount to wei - const amountInWei = BigInt(Number.parseFloat(amount) * 10 ** decimals); + chainId, + tokenAddressWithChain, + recipientAddress, + amount, + ); // Build checkout URL const params = new URLSearchParams({ - chainId: chainId.toString(), - recipientAddress, - tokenAddress: tokenAddressWithChain, - amount: amountInWei.toString(), + chainId: inputs.chainId.toString(), + recipientAddress: inputs.recipientAddress, + tokenAddress: inputs.tokenAddress, + amount: inputs.amount.toString(), }); const checkoutUrl = `${window.location.origin}/checkout?${params.toString()}`; @@ -121,6 +100,7 @@ export function CheckoutLinkForm() { className="w-full" client={client} disabled={!chainId} + enabled={!!chainId} />
@@ -132,7 +112,7 @@ export function CheckoutLinkForm() { id="recipient" value={recipientAddress} onChange={(e) => setRecipientAddress(e.target.value)} - placeholder="0x..." + placeholder="Address or ENS" required className="w-full" /> @@ -161,7 +141,7 @@ export function CheckoutLinkForm() { type="button" variant="outline" className="flex-1" - onClick={() => { + onClick={async () => { if ( !chainId || !recipientAddress || @@ -171,11 +151,18 @@ export function CheckoutLinkForm() { toast.error("Please fill in all fields first"); return; } - const params = new URLSearchParams({ - chainId: chainId.toString(), + const inputs = await parseInputs( + client, + chainId, + tokenAddressWithChain, recipientAddress, - tokenAddress: tokenAddressWithChain, amount, + ); + const params = new URLSearchParams({ + chainId: inputs.chainId.toString(), + recipientAddress: inputs.recipientAddress, + tokenAddress: inputs.tokenAddress, + amount: inputs.amount.toString(), }); window.open(`/checkout?${params.toString()}`, "_blank"); }} @@ -191,3 +178,47 @@ export function CheckoutLinkForm() { ); } + +async function parseInputs( + client: ThirdwebClient, + chainId: number, + tokenAddressWithChain: string, + recipientAddressOrEns: string, + decimalAmount: string, +) { + const [_chainId, tokenAddress] = tokenAddressWithChain.split(":"); + if (Number(_chainId) !== chainId) { + throw new Error("Chain ID does not match token chain"); + } + if (!tokenAddress) { + throw new Error("Missing token address"); + } + + const ensPromise = resolveEns(recipientAddressOrEns, client); + const currencyPromise = getCurrencyMetadata({ + contract: getContract({ + client, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(chainId), + address: tokenAddress, + }), + }); + const [ens, currencyMetadata] = await Promise.all([ + ensPromise, + currencyPromise, + ]); + if (!ens.address) { + throw new Error("Invalid recipient address"); + } + + const amountInWei = BigInt( + Number.parseFloat(decimalAmount) * 10 ** currencyMetadata.decimals, + ); + + return { + chainId, + tokenAddress, + recipientAddress: ens.address, + amount: amountInWei, + }; +} diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts index 5ae93af22a7..f1138421444 100644 --- a/apps/dashboard/src/hooks/tokens/tokens.ts +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -84,10 +84,12 @@ const structuredTokensStore = /* @__PURE__ */ createStructuredTokensStore(); export function useTokensData({ clientId, chainId, -}: { clientId: string; chainId?: number }) { + enabled, +}: { clientId: string; chainId?: number; enabled?: boolean }) { const tokensQuery = useQuery({ queryKey: ["universal-bridge-tokens", chainId], queryFn: () => getUniversalBrigeTokens({ clientId, chainId }), + enabled, }); // eslint-disable-next-line no-restricted-syntax @@ -107,5 +109,6 @@ export function useTokensData({ return { tokens: useStore(structuredTokensStore), isLoading: tokensQuery.isLoading, + isFetching: tokensQuery.isFetching, }; } From 04018ab41a210ce6532521cd6266074121225658 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Fri, 9 May 2025 11:36:19 -0700 Subject: [PATCH 4/5] fix token fetching --- .../src/@/components/blocks/TokenSelector.tsx | 5 +++-- .../src/@/components/blocks/select-with-search.tsx | 2 +- .../components/client/CheckoutLinkForm.client.tsx | 13 +++++++++++-- apps/dashboard/src/hooks/tokens/tokens.ts | 5 ----- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx index 7a981d3927b..c711c7c02ac 100644 --- a/apps/dashboard/src/@/components/blocks/TokenSelector.tsx +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import type { ThirdwebClient } from "thirdweb"; +import { shortenAddress } from "thirdweb/utils"; import { useTokensData } from "../../../hooks/tokens/tokens"; import { replaceIpfsUrl } from "../../../lib/sdk"; import { fallbackChainIcon } from "../../../utils/chain-icons"; @@ -89,8 +90,8 @@ export function TokenSelector(props: { {!props.disableChainId && ( - Chain ID - {token.chainId} + Address + {shortenAddress(token.address, 4)} )}
diff --git a/apps/dashboard/src/@/components/blocks/select-with-search.tsx b/apps/dashboard/src/@/components/blocks/select-with-search.tsx index 70a08a5aeaa..ec9663d0358 100644 --- a/apps/dashboard/src/@/components/blocks/select-with-search.tsx +++ b/apps/dashboard/src/@/components/blocks/select-with-search.tsx @@ -128,7 +128,7 @@ export const SelectWithSearch = React.forwardRef< {renderOption && selectedOption diff --git a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx index d8a1e9b7d32..edb1bc0b3f6 100644 --- a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useThirdwebClient } from "@/constants/thirdweb.client"; import { CreditCardIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { type ThirdwebClient, defineChain, getContract } from "thirdweb"; import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; @@ -23,6 +23,10 @@ export function CheckoutLinkForm() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + const isFormComplete = useMemo(() => { + return chainId && recipientAddress && tokenAddressWithChain && amount; + }, [chainId, recipientAddress, tokenAddressWithChain, amount]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(undefined); @@ -141,6 +145,7 @@ export function CheckoutLinkForm() { type="button" variant="outline" className="flex-1" + disabled={isLoading || !isFormComplete} onClick={async () => { if ( !chainId || @@ -169,7 +174,11 @@ export function CheckoutLinkForm() { > Preview -
diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts index f1138421444..c560a013746 100644 --- a/apps/dashboard/src/hooks/tokens/tokens.ts +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -94,11 +94,6 @@ export function useTokensData({ // eslint-disable-next-line no-restricted-syntax useEffect(() => { - // already set - if (tokensStore.getValue().length > 0) { - return; - } - if (!tokensQuery.data) { return; } From f6e12d5ff2a6f87f9a80cf77ab972c4c9523c246 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Fri, 9 May 2025 13:19:42 -0700 Subject: [PATCH 5/5] feat: add title and image upload --- .../src/@/api/universal-bridge/tokens.ts | 2 +- .../client/CheckoutLinkForm.client.tsx | 243 ++++++++++++++---- apps/dashboard/src/hooks/tokens/tokens.ts | 4 +- 3 files changed, 198 insertions(+), 51 deletions(-) diff --git a/apps/dashboard/src/@/api/universal-bridge/tokens.ts b/apps/dashboard/src/@/api/universal-bridge/tokens.ts index 5bbccdc94f3..fd049f26e17 100644 --- a/apps/dashboard/src/@/api/universal-bridge/tokens.ts +++ b/apps/dashboard/src/@/api/universal-bridge/tokens.ts @@ -11,7 +11,7 @@ export type TokenMetadata = { iconUri?: string; }; -export async function getUniversalBrigeTokens(props: { +export async function getUniversalBridgeTokens(props: { clientId?: string; chainId?: number; }) { diff --git a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx index edb1bc0b3f6..9c2dac7ca82 100644 --- a/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -7,11 +7,18 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useThirdwebClient } from "@/constants/thirdweb.client"; -import { CreditCardIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { ChevronDownIcon, CreditCardIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import { type ThirdwebClient, defineChain, getContract } from "thirdweb"; +import { + type ThirdwebClient, + defineChain, + getContract, + toUnits, +} from "thirdweb"; import { getCurrencyMetadata } from "thirdweb/extensions/erc20"; +import { resolveScheme, upload } from "thirdweb/storage"; +import { FileInput } from "../../../../components/shared/FileInput"; import { resolveEns } from "../../../../lib/ens"; export function CheckoutLinkForm() { @@ -20,23 +27,121 @@ export function CheckoutLinkForm() { const [recipientAddress, setRecipientAddress] = useState(""); const [tokenAddressWithChain, setTokenAddressWithChain] = useState(""); const [amount, setAmount] = useState(""); + const [title, setTitle] = useState(""); + const [image, setImage] = useState(null); + const [imageUri, setImageUri] = useState(""); + const [uploadingImage, setUploadingImage] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); + const [showAdvanced, setShowAdvanced] = useState(false); const isFormComplete = useMemo(() => { return chainId && recipientAddress && tokenAddressWithChain && amount; }, [chainId, recipientAddress, tokenAddressWithChain, amount]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(undefined); - setIsLoading(true); + const handleImageUpload = useCallback( + async (file: File) => { + try { + setImage(file); + setUploadingImage(true); - try { - if (!chainId || !recipientAddress || !tokenAddressWithChain || !amount) { - throw new Error("All fields are required"); + const uri = await upload({ + client, + files: [file], + }); + + // eslint-disable-next-line no-restricted-syntax + const resolvedUrl = resolveScheme({ + uri, + client, + }); + + setImageUri(resolvedUrl); + toast.success("Image uploaded successfully"); + } catch (error) { + console.error("Error uploading image:", error); + toast.error("Failed to upload image"); + setImage(null); + } finally { + setUploadingImage(false); } + }, + [client], + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(undefined); + setIsLoading(true); + + try { + if ( + !chainId || + !recipientAddress || + !tokenAddressWithChain || + !amount + ) { + throw new Error("All fields are required"); + } + + const inputs = await parseInputs( + client, + chainId, + tokenAddressWithChain, + recipientAddress, + amount, + ); + + // Build checkout URL + const params = new URLSearchParams({ + chainId: inputs.chainId.toString(), + recipientAddress: inputs.recipientAddress, + tokenAddress: inputs.tokenAddress, + amount: inputs.amount.toString(), + }); + + // Add title as name parameter if provided + if (title) { + params.set("name", title); + } + + // Add image URI if available + if (imageUri) { + params.set("image", imageUri); + } + + const checkoutUrl = `${window.location.origin}/checkout?${params.toString()}`; + + // Copy to clipboard + await navigator.clipboard.writeText(checkoutUrl); + + // Show success toast + toast.success("Checkout link copied to clipboard."); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }, + [ + amount, + chainId, + client, + imageUri, + recipientAddress, + title, + tokenAddressWithChain, + ], + ); + + const handlePreview = useCallback(async () => { + if (!chainId || !recipientAddress || !tokenAddressWithChain || !amount) { + toast.error("Please fill in all fields first"); + return; + } + try { const inputs = await parseInputs( client, chainId, @@ -45,7 +150,6 @@ export function CheckoutLinkForm() { amount, ); - // Build checkout URL const params = new URLSearchParams({ chainId: inputs.chainId.toString(), recipientAddress: inputs.recipientAddress, @@ -53,19 +157,29 @@ export function CheckoutLinkForm() { amount: inputs.amount.toString(), }); - const checkoutUrl = `${window.location.origin}/checkout?${params.toString()}`; + // Add title as name parameter if provided + if (title) { + params.set("name", title); + } - // Copy to clipboard - await navigator.clipboard.writeText(checkoutUrl); + // Add image URI if available + if (imageUri) { + params.set("image", imageUri); + } - // Show success toast - toast.success("Checkout link copied to clipboard."); + window.open(`/checkout?${params.toString()}`, "_blank"); } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); - } finally { - setIsLoading(false); + toast.error(err instanceof Error ? err.message : "An error occurred"); } - }; + }, [ + amount, + chainId, + client, + imageUri, + recipientAddress, + title, + tokenAddressWithChain, + ]); return ( @@ -138,6 +252,65 @@ export function CheckoutLinkForm() { />
+
+ + +
+
+
+
+ + setTitle(e.target.value)} + placeholder="Checkout for..." + className="w-full" + /> +
+ +
+ +
+ +
+
+
+
+
+
+ {error &&
{error}
}
@@ -146,31 +319,7 @@ export function CheckoutLinkForm() { variant="outline" className="flex-1" disabled={isLoading || !isFormComplete} - onClick={async () => { - if ( - !chainId || - !recipientAddress || - !tokenAddressWithChain || - !amount - ) { - toast.error("Please fill in all fields first"); - return; - } - const inputs = await parseInputs( - client, - chainId, - tokenAddressWithChain, - recipientAddress, - amount, - ); - const params = new URLSearchParams({ - chainId: inputs.chainId.toString(), - recipientAddress: inputs.recipientAddress, - tokenAddress: inputs.tokenAddress, - amount: inputs.amount.toString(), - }); - window.open(`/checkout?${params.toString()}`, "_blank"); - }} + onClick={handlePreview} > Preview @@ -220,9 +369,7 @@ async function parseInputs( throw new Error("Invalid recipient address"); } - const amountInWei = BigInt( - Number.parseFloat(decimalAmount) * 10 ** currencyMetadata.decimals, - ); + const amountInWei = toUnits(decimalAmount, currencyMetadata.decimals); return { chainId, diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts index c560a013746..3f03105f639 100644 --- a/apps/dashboard/src/hooks/tokens/tokens.ts +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -3,7 +3,7 @@ import { useEffect } from "react"; import type { Address } from "thirdweb"; import { type TokenMetadata, - getUniversalBrigeTokens, + getUniversalBridgeTokens, } from "../../@/api/universal-bridge/tokens"; import { createStore, useStore } from "../../@/lib/reactive"; @@ -88,7 +88,7 @@ export function useTokensData({ }: { clientId: string; chainId?: number; enabled?: boolean }) { const tokensQuery = useQuery({ queryKey: ["universal-bridge-tokens", chainId], - queryFn: () => getUniversalBrigeTokens({ clientId, chainId }), + queryFn: () => getUniversalBridgeTokens({ clientId, chainId }), enabled, });