diff --git a/.changeset/young-terms-agree.md b/.changeset/young-terms-agree.md new file mode 100644 index 000000000..868ae1c8c --- /dev/null +++ b/.changeset/young-terms-agree.md @@ -0,0 +1,5 @@ +--- +"@onflow/react-sdk": minor +--- + +Enhanced the Connect component to enable visualizing different tokens like USDC and other stablecoins within the Connect modal. diff --git a/packages/demo/src/components/component-cards/connect-card.tsx b/packages/demo/src/components/component-cards/connect-card.tsx index ba5cbc122..e210b3a8c 100644 --- a/packages/demo/src/components/component-cards/connect-card.tsx +++ b/packages/demo/src/components/component-cards/connect-card.tsx @@ -1,11 +1,32 @@ +import {useState} from "react" import {Connect, useFlowChainId} from "@onflow/react-sdk" import {useDarkMode} from "../flow-provider-wrapper" import {DemoCard, type PropDefinition} from "../ui/demo-card" import {PlusGridIcon} from "../ui/plus-grid" +import {CONTRACT_ADDRESSES} from "../../constants" const IMPLEMENTATION_CODE = `import { Connect } from "@onflow/react-sdk" -` +// Basic usage - shows FLOW by default + + +// With multiple tokens - dropdown selector, first token is default +// Note: Provide only ONE identifier per token (the bridge derives the other) +` const PROPS: PropDefinition[] = [ { @@ -29,11 +50,19 @@ const PROPS: PropDefinition[] = [ }, { name: "balanceType", - type: '"cadence" | "evm" | "vault"', + type: '"cadence" | "evm" | "combined"', required: false, - description: "Type of balance to display (from cross-VM token balance)", + description: + "Type of balance to display: cadence (Cadence VM only), evm (EVM only), or combined (sum of both)", defaultValue: '"cadence"', }, + { + name: "balanceTokens", + type: "TokenConfig[]", + required: false, + description: + "Array of tokens with dropdown selector (first token is default). Each token needs symbol, name, and EXACTLY ONE of: vaultIdentifier OR erc20Address (the bridge derives the other automatically)", + }, { name: "modalConfig", type: "ConnectModalConfig", @@ -47,6 +76,41 @@ export function ConnectCard() { const {darkMode} = useDarkMode() const {data: chainId, isLoading} = useFlowChainId() const isEmulator = chainId === "emulator" || chainId === "local" + const [showMultiToken, setShowMultiToken] = useState(false) + const [balanceType, setBalanceType] = useState< + "cadence" | "evm" | "combined" + >("cadence") + + const getFlowTokenAddress = () => { + if (chainId === "emulator" || chainId === "local") { + return CONTRACT_ADDRESSES.FlowToken.emulator + } + return chainId === "testnet" + ? CONTRACT_ADDRESSES.FlowToken.testnet + : CONTRACT_ADDRESSES.FlowToken.mainnet + } + + const multiTokens = [ + { + symbol: "FLOW", + name: "Flow Token", + vaultIdentifier: `A.${getFlowTokenAddress().replace("0x", "")}.FlowToken.Vault`, + }, + // Only show USDF (PYUSD) on testnet and mainnet + // Note: Only vaultIdentifier provided - EVM address is derived by the bridge + ...(!isEmulator + ? [ + { + symbol: "USDF", + name: "USDF (PYUSD)", + vaultIdentifier: + chainId === "testnet" + ? "A.dfc20aee650fcbdf.EVMVMBridgedToken_f2e5a325f7d678da511e66b1c0ad7d5ba4df93d3.Vault" + : "A.1e4aa0b87d10b141.EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabed.Vault", + }, + ] + : []), + ] return (
-
+
- Customizable + Multi-Token Cross-VM -

- Style variants +

+ Multi-token support with cross-VM bridge integration. Provide only + a vaultIdentifier or an erc20Address and the bridge derives the + other automatically.

-
- {isLoading ? ( -
+
+
+ {isLoading ? ( +
+
+
+ ) : isEmulator ? (
-
- ) : isEmulator ? ( + className={`text-center py-4 px-6 rounded-lg border ${ + darkMode + ? "bg-orange-900/20 border-orange-800/50" + : "bg-orange-50 border-orange-200" + }`} + > + + + +

+ Emulator Network Detected +

+

+ Connect component requires testnet or mainnet +

+
+ ) : ( +
+ {showMultiToken ? ( + + ) : ( + + )} +
+ )} +
+ + {!isEmulator && (
- - - -

- Emulator Network Detected -

-

- Connect component requires testnet or mainnet -

-
- ) : ( -
- +
+ + +
+ +
+ +
+ {(["cadence", "evm", "combined"] as const).map(type => ( + + ))} +
+
)}
diff --git a/packages/react-sdk/src/components/Connect.tsx b/packages/react-sdk/src/components/Connect.tsx index 585fea31c..a6a1c9946 100644 --- a/packages/react-sdk/src/components/Connect.tsx +++ b/packages/react-sdk/src/components/Connect.tsx @@ -1,4 +1,10 @@ -import React, {useState} from "react" +import React, {useState, useEffect, useMemo} from "react" +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react" import {useFlowCurrentUser} from "../hooks" import { useCrossVmTokenBalance, @@ -11,10 +17,21 @@ import {StyleWrapper} from "./internal/StyleWrapper" import {UserIcon} from "../icons/UserIcon" import {CopyIcon} from "../icons/CopyIcon" import {LogOutIcon} from "../icons/LogOutIcon" +import {ExternalLinkIcon} from "../icons/ExternalLink" import {ScheduledTransactionList} from "./ScheduledTransactionList" +import {CONTRACT_ADDRESSES} from "../constants" +import {getFlowscanAccountUrl} from "../utils/flowscan" type BalanceType = keyof UseCrossVmTokenBalanceData +export type TokenConfig = { + symbol: string + name: string +} & ( + | {vaultIdentifier: string; erc20Address?: never} + | {vaultIdentifier?: never; erc20Address: string} +) + export interface ConnectModalConfig { scheduledTransactions?: { show?: boolean @@ -27,6 +44,7 @@ interface ConnectProps { onConnect?: () => void onDisconnect?: () => void balanceType?: BalanceType + balanceTokens?: TokenConfig[] modalConfig?: ConnectModalConfig } @@ -35,6 +53,7 @@ export const Connect: React.FC = ({ onConnect, onDisconnect, balanceType = "cadence", + balanceTokens, modalConfig = {}, }) => { const {user, authenticate, unauthenticate} = useFlowCurrentUser() @@ -42,6 +61,68 @@ export const Connect: React.FC = ({ const [copied, setCopied] = useState(false) const {data: chainId} = useFlowChainId() + // Default token configuration for FlowToken - memoized to avoid recreation + const defaultTokens: TokenConfig[] = useMemo(() => { + if (!chainId) return [] + + const getFlowTokenAddress = () => { + if (chainId === "emulator" || chainId === "local") + return CONTRACT_ADDRESSES.local.FlowToken + return chainId === "testnet" + ? CONTRACT_ADDRESSES.testnet.FlowToken + : CONTRACT_ADDRESSES.mainnet.FlowToken + } + + const address = getFlowTokenAddress().replace("0x", "") + return [ + { + symbol: "FLOW", + name: "Flow Token", + vaultIdentifier: `A.${address}.FlowToken.Vault`, + }, + ] + }, [chainId]) + + // Use provided tokens or default to FLOW - memoized to avoid recreation + const availableTokens = useMemo( + () => + balanceTokens && balanceTokens.length > 0 ? balanceTokens : defaultTokens, + [balanceTokens, defaultTokens] + ) + + // Initialize with first token, but will update when availableTokens changes + const [selectedToken, setSelectedToken] = useState( + availableTokens[0] || defaultTokens[0] + ) + + // Update selectedToken when availableTokens changes (when chainId loads or balanceTokens prop changes) + useEffect(() => { + setSelectedToken((prev: any) => { + // If no tokens available yet, return undefined + if (!availableTokens || availableTokens.length === 0) return undefined + // If prev is undefined (first load), return first available token + if (!prev) return availableTokens[0] + + // Find the same token in the new list (match by symbol) + const updatedToken = availableTokens.find(t => t.symbol === prev.symbol) + + // If the token is no longer in the list, switch to first token + if (!updatedToken) { + return availableTokens[0] + } + + // If we found the same token but it now has more data (chainId loaded), use the updated version + if ( + (!prev.vaultIdentifier && updatedToken.vaultIdentifier) || + (!prev.erc20Address && updatedToken.erc20Address) + ) { + return updatedToken + } + + // Keep the current selection if nothing changed + return prev + }) + }, [availableTokens]) const showScheduledTransactions = modalConfig.scheduledTransactions?.show ?? false const modalWidth = showScheduledTransactions @@ -50,9 +131,14 @@ export const Connect: React.FC = ({ const {data: balanceData} = useCrossVmTokenBalance({ owner: user?.addr, - vaultIdentifier: `A.${chainId === "testnet" ? "7e60df042a9c0868" : "1654653399040a61"}.FlowToken.Vault`, + vaultIdentifier: selectedToken?.vaultIdentifier, + erc20Address: selectedToken?.erc20Address, query: { - enabled: !!user?.addr && !!chainId, + enabled: + !!user?.addr && + !!chainId && + !!selectedToken && + (!!selectedToken?.vaultIdentifier || !!selectedToken?.erc20Address), }, }) @@ -61,10 +147,18 @@ export const Connect: React.FC = ({ ? `${user.addr.slice(0, 6)}...${user.addr.slice(-4)}` : "" + const flowscanUrl = getFlowscanAccountUrl(user?.addr || "", chainId) + + // Get balance for the selected type const displayBalance = - balanceData && typeof balanceData !== "string" - ? `${Number(balanceData[balanceType].formatted).toLocaleString()} FLOW` - : "0.00 FLOW" + balanceData && + typeof balanceData !== "string" && + balanceData[balanceType]?.formatted + ? Number(balanceData[balanceType].formatted).toLocaleString(undefined, { + maximumFractionDigits: 4, + minimumFractionDigits: 0, + }) + : "0" const handleButtonClick = async () => { if (user?.loggedIn) { @@ -113,19 +207,154 @@ export const Connect: React.FC = ({
- +
-
- {displayAddress} +
+
+ {displayAddress} +
+ {flowscanUrl && ( + + + + )}
-
- {displayBalance} +
+ +
+
+ {availableTokens.length > 1 && ( +
+ + {({open}) => ( +
+ +
+ + Token + +
+ + {selectedToken?.symbol} + + + + +
+
+
+ {open && ( + + {availableTokens.map((token: TokenConfig) => ( + + {({selected}) => ( +
+
+
+ {token.name} +
+
+ {token.symbol} +
+
+ {selected && ( + + + + )} +
+ )} +
+ ))} +
+ )} +
+ )} +
+
+ )} + +
+
+ Balance +
+
+ {displayBalance}{" "} + + {selectedToken?.symbol} + +
+
-
+ +