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..fd049f26e17 --- /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 getUniversalBridgeTokens(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..c711c7c02ac --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/TokenSelector.tsx @@ -0,0 +1,125 @@ +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"; +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; + enabled?: boolean; +}) { + const { tokens, isFetching } = useTokensData({ + clientId: props.client.clientId, + chainId: props.chainId, + enabled: props.enabled, + }); + + 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 && ( + + Address + {shortenAddress(token.address, 4)} + + )} +
+ ); + }, + [tokens, props.disableChainId, props.client], + ); + + return ( + { + props.onChange(tokenAddress); + }} + closeOnSelect={true} + showCheck={false} + placeholder={ + isFetching ? "Loading Tokens..." : props.placeholder || "Select Token" + } + overrideSearchFn={searchFn} + renderOption={renderOption} + className={props.className} + popoverContentClassName={props.popoverContentClassName} + disabled={isFetching || 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..ec9663d0358 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, @@ -126,7 +128,7 @@ export const SelectWithSearch = React.forwardRef< {renderOption && selectedOption @@ -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 new file mode 100644 index 00000000000..9c2dac7ca82 --- /dev/null +++ b/apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx @@ -0,0 +1,380 @@ +"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 { ChevronDownIcon, CreditCardIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +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() { + const client = useThirdwebClient(); + const [chainId, setChainId] = useState(); + 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 handleImageUpload = useCallback( + async (file: File) => { + try { + setImage(file); + setUploadingImage(true); + + 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, + tokenAddressWithChain, + recipientAddress, + amount, + ); + + 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); + } + + window.open(`/checkout?${params.toString()}`, "_blank"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "An error occurred"); + } + }, [ + amount, + chainId, + client, + imageUri, + recipientAddress, + title, + tokenAddressWithChain, + ]); + + return ( + + +
+
+ +
+ + Create a Checkout Link + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + setRecipientAddress(e.target.value)} + placeholder="Address or ENS" + required + className="w-full" + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="0.0" + required + className="w-full" + /> +
+ +
+ + +
+
+
+
+ + setTitle(e.target.value)} + placeholder="Checkout for..." + className="w-full" + /> +
+ +
+ +
+ +
+
+
+
+
+
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} + +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 = toUnits(decimalAmount, currencyMetadata.decimals); + + return { + chainId, + tokenAddress, + recipientAddress: ens.address, + amount: amountInWei, + }; +} 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)) { diff --git a/apps/dashboard/src/hooks/tokens/tokens.ts b/apps/dashboard/src/hooks/tokens/tokens.ts new file mode 100644 index 00000000000..3f03105f639 --- /dev/null +++ b/apps/dashboard/src/hooks/tokens/tokens.ts @@ -0,0 +1,109 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { Address } from "thirdweb"; +import { + type TokenMetadata, + getUniversalBridgeTokens, +} 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, + enabled, +}: { clientId: string; chainId?: number; enabled?: boolean }) { + const tokensQuery = useQuery({ + queryKey: ["universal-bridge-tokens", chainId], + queryFn: () => getUniversalBridgeTokens({ clientId, chainId }), + enabled, + }); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!tokensQuery.data) { + return; + } + + tokensStore.setValue(tokensQuery.data); + }, [tokensQuery.data]); + + return { + tokens: useStore(structuredTokensStore), + isLoading: tokensQuery.isLoading, + isFetching: tokensQuery.isFetching, + }; +}