diff --git a/apps/playground-web/src/app/payments/components/RightSection.tsx b/apps/playground-web/src/app/payments/components/RightSection.tsx index 3285ac4a5bc..08c4b4f2e76 100644 --- a/apps/playground-web/src/app/payments/components/RightSection.tsx +++ b/apps/playground-web/src/app/payments/components/RightSection.tsx @@ -89,7 +89,6 @@ export function RightSection(props: { } name={props.options.payOptions.title || "Your Product Name"} paymentMethods={props.options.payOptions.paymentMethods} - presetOptions={[1, 2, 3]} seller={props.options.payOptions.sellerAddress} theme={themeObj} tokenAddress={props.options.payOptions.buyTokenAddress} diff --git a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts index a26f395026a..52fdfa933fe 100644 --- a/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts +++ b/packages/thirdweb/src/react/core/hooks/transaction/useSendTransaction.ts @@ -6,6 +6,7 @@ import * as Bridge from "../../../../bridge/index.js"; import type { Chain } from "../../../../chains/types.js"; import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js"; import type { BuyWithFiatStatus } from "../../../../pay/buyWithFiat/getStatus.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { PurchaseData } from "../../../../pay/types.js"; import type { FiatProvider } from "../../../../pay/utils/commonTypes.js"; import type { GaslessOptions } from "../../../../transaction/actions/gasless/types.js"; @@ -94,6 +95,11 @@ export type SendTransactionPayModalConfig = * The user's ISO 3166 alpha-2 country code. This is used to determine onramp provider support. */ country?: string; + /** + * The currency to use for showing the fiat values + * @default "USD" + */ + currency?: SupportedFiatCurrency; } | false; diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts index f84b92d9b9d..d51a8427616 100644 --- a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts +++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts @@ -7,7 +7,7 @@ import { getThirdwebBaseUrl } from "../../../utils/domains.js"; import { getClientFetch } from "../../../utils/fetch.js"; import { toTokens, toUnits } from "../../../utils/units.js"; import type { Wallet } from "../../../wallets/interfaces/wallet.js"; -import type { PaymentMethod } from "../machines/paymentMachine.js"; +import type { PaymentMethod } from "../../web/ui/Bridge/types.js"; import type { SupportedTokens } from "../utils/defaultTokens.js"; import { useActiveWallet } from "./wallets/useActiveWallet.js"; @@ -33,7 +33,6 @@ export function usePaymentMethods(options: { destinationAmount: string; client: ThirdwebClient; payerWallet?: Wallet; - includeDestinationToken?: boolean; supportedTokens?: SupportedTokens; }) { const { @@ -41,7 +40,6 @@ export function usePaymentMethods(options: { destinationAmount, client, payerWallet, - includeDestinationToken, supportedTokens, } = options; const localWallet = useActiveWallet(); // TODO (bridge): get all connected wallets @@ -135,7 +133,6 @@ export function usePaymentMethods(options: { destinationToken.address, destinationAmount, payerWallet?.getAccount()?.address, - includeDestinationToken, supportedTokens, ], // 5 minutes refetchOnWindowFocus: false, diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts deleted file mode 100644 index 355d93fa9ad..00000000000 --- a/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts +++ /dev/null @@ -1,519 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TEST_CLIENT } from "../../../../test/src/test-clients.js"; -import { TEST_IN_APP_WALLET_A } from "../../../../test/src/test-wallets.js"; -import type { Token } from "../../../bridge/types/Token.js"; -import { defineChain } from "../../../chains/utils.js"; -import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js"; -import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js"; -import type { WindowAdapter } from "../adapters/WindowAdapter.js"; -import type { BridgePrepareResult } from "../hooks/useBridgePrepare.js"; -import { - type PaymentMachineContext, - type PaymentMethod, - usePaymentMachine, -} from "./paymentMachine.js"; - -// Mock adapters -const mockWindowAdapter: WindowAdapter = { - open: vi.fn().mockResolvedValue(undefined), -}; - -const mockStorage: AsyncStorage = { - getItem: vi.fn().mockResolvedValue(null), - removeItem: vi.fn().mockResolvedValue(undefined), - setItem: vi.fn().mockResolvedValue(undefined), -}; - -// Test token objects -const testUSDCToken: Token = { - address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - chainId: 137, - decimals: 6, - name: "USD Coin (PoS)", - priceUsd: 1.0, - symbol: "USDC", -}; - -const testETHToken: Token = { - address: NATIVE_TOKEN_ADDRESS, - chainId: 1, - decimals: 18, - name: "Ethereum", - priceUsd: 2500.0, - symbol: "ETH", -}; - -const testTokenForPayment: Token = { - address: "0xA0b86a33E6425c03e54c4b45DCb6d75b6B72E2AA", - chainId: 1, - decimals: 18, - name: "Test Token", - priceUsd: 1.0, - symbol: "TT", -}; - -const mockBuyQuote: BridgePrepareResult = { - destinationAmount: 100000000n, - estimatedExecutionTimeMs: 120000, // 1 ETH - intent: { - amount: 100000000n, - destinationChainId: 137, - destinationTokenAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - originChainId: 1, - originTokenAddress: NATIVE_TOKEN_ADDRESS, - receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - }, // 100 USDC - originAmount: 1000000000000000000n, - steps: [ - { - destinationAmount: 100000000n, - destinationToken: testUSDCToken, - estimatedExecutionTimeMs: 120000, - originAmount: 1000000000000000000n, - originToken: testETHToken, - transactions: [ - { - action: "approval" as const, - chain: defineChain(1), - chainId: 1, - client: TEST_CLIENT, - data: "0x789" as const, - id: "0x123" as const, - to: "0x456" as const, - }, - { - action: "buy" as const, - chain: defineChain(1), - chainId: 1, - client: TEST_CLIENT, - data: "0x012" as const, - id: "0xabc" as const, - to: "0xdef" as const, - value: 1000000000000000000n, - }, - ], - }, - ], // 2 minutes - timestamp: Date.now(), - type: "buy", -}; - -describe("PaymentMachine", () => { - let adapters: PaymentMachineContext["adapters"]; - - beforeEach(() => { - adapters = { - storage: mockStorage, - window: mockWindowAdapter, - }; - }); - - it("should initialize in init state", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - const [state] = result.current; - - expect(state.value).toBe("init"); - expect(state.context.mode).toBe("fund_wallet"); - expect(state.context.adapters).toBe(adapters); - }); - - it("should handle errors and allow retry", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - const testError = new Error("Network error"); - act(() => { - const [, send] = result.current; - send({ - error: testError, - type: "ERROR_OCCURRED", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("error"); - expect(state.context.currentError).toBe(testError); - expect(state.context.retryState).toBe("init"); - - // Retry should clear error and return to beginning - act(() => { - const [, send] = result.current; - send({ - type: "RETRY", - }); - }); - - [state] = result.current; - expect(state.value).toBe("init"); - expect(state.context.currentError).toBeUndefined(); - expect(state.context.retryState).toBeUndefined(); - }); - - it("should preserve context data through transitions", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - const testToken: Token = { - address: "0xtest", - chainId: 42, - decimals: 18, - name: "Test Token", - priceUsd: 1.0, - symbol: "TEST", - }; - - // Confirm destination - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "50", - destinationToken: testToken, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - // Select payment method - const paymentMethod: PaymentMethod = { - balance: 1000000000000000000n, - originToken: testUSDCToken, - payerWallet: TEST_IN_APP_WALLET_A, - type: "wallet", - }; - - act(() => { - const [, send] = result.current; - send({ - paymentMethod, - type: "PAYMENT_METHOD_SELECTED", - }); - }); - - const [state] = result.current; - // All context should be preserved - expect(state.context.destinationToken).toEqual(testToken); - expect(state.context.destinationAmount).toBe("50"); - expect(state.context.selectedPaymentMethod).toEqual(paymentMethod); - expect(state.context.mode).toBe("fund_wallet"); - expect(state.context.adapters).toBe(adapters); - }); - - it("should handle state transitions correctly", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - const [initialState] = result.current; - expect(initialState.value).toBe("init"); - - // Only DESTINATION_CONFIRMED should be valid from initial state - act(() => { - const [, send] = result.current; - send({ - paymentMethod: { - currency: "USD", - onramp: "stripe", - payerWallet: TEST_IN_APP_WALLET_A, - type: "fiat", - }, - type: "PAYMENT_METHOD_SELECTED", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("init"); // Should stay in same state for invalid transition - - // Valid transition - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "100", - destinationToken: testTokenForPayment, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - [state] = result.current; - expect(state.value).toBe("methodSelection"); - }); - - it("should reset to initial state", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Go through some states - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "100", - destinationToken: testTokenForPayment, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - paymentMethod: { - currency: "USD", - onramp: "stripe", - payerWallet: TEST_IN_APP_WALLET_A, - type: "fiat", - }, - type: "PAYMENT_METHOD_SELECTED", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("quote"); - - // Trigger error - act(() => { - const [, send] = result.current; - send({ - error: new Error("Test error"), - type: "ERROR_OCCURRED", - }); - }); - - [state] = result.current; - expect(state.value).toBe("error"); - - // Reset - act(() => { - const [, send] = result.current; - send({ - type: "RESET", - }); - }); - - [state] = result.current; - expect(state.value).toBe("init"); - // Context should still have adapters and mode but other data should be cleared - expect(state.context.adapters).toBe(adapters); - expect(state.context.mode).toBe("fund_wallet"); - }); - - it("should handle error states from all major states", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Test error from init - act(() => { - const [, send] = result.current; - send({ - error: new Error("Init error"), - type: "ERROR_OCCURRED", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("error"); - expect(state.context.retryState).toBe("init"); - - // Reset and test error from methodSelection - act(() => { - const [, send] = result.current; - send({ type: "RESET" }); - }); - - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "100", - destinationToken: testTokenForPayment, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - error: new Error("Method selection error"), - type: "ERROR_OCCURRED", - }); - }); - - [state] = result.current; - expect(state.value).toBe("error"); - expect(state.context.retryState).toBe("methodSelection"); - }); - - it("should handle back navigation", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Go to methodSelection - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "100", - destinationToken: testTokenForPayment, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - // Go to quote - act(() => { - const [, send] = result.current; - send({ - paymentMethod: { - currency: "USD", - onramp: "stripe", - payerWallet: TEST_IN_APP_WALLET_A, - type: "fiat", - }, - type: "PAYMENT_METHOD_SELECTED", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("quote"); - - // Navigate back to methodSelection - act(() => { - const [, send] = result.current; - send({ - type: "BACK", - }); - }); - - [state] = result.current; - expect(state.value).toBe("methodSelection"); - - // Navigate back to init - act(() => { - const [, send] = result.current; - send({ - type: "BACK", - }); - }); - - [state] = result.current; - expect(state.value).toBe("init"); - }); - - it("should handle post-buy-transaction state flow", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Go through the complete happy path to reach success state - act(() => { - const [, send] = result.current; - send({ - destinationAmount: "100", - destinationToken: testTokenForPayment, - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - type: "DESTINATION_CONFIRMED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - paymentMethod: { - balance: 1000000000000000000n, - originToken: testUSDCToken, - payerWallet: TEST_IN_APP_WALLET_A, - type: "wallet", - }, - type: "PAYMENT_METHOD_SELECTED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - preparedQuote: mockBuyQuote, - type: "QUOTE_RECEIVED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - type: "ROUTE_CONFIRMED", - }); - }); - - act(() => { - const [, send] = result.current; - send({ - completedStatuses: [ - { - destinationAmount: 100000000n, - destinationChainId: 137, - destinationToken: testUSDCToken, - destinationTokenAddress: - "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - originAmount: 1000000000000000000n, - originChainId: 1, - originToken: testETHToken, - originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - paymentId: "test-payment-id", - receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - status: "COMPLETED", - transactions: [ - { - chainId: 1, - transactionHash: "0xtest123", - }, - ], - type: "buy", - }, - ], - type: "EXECUTION_COMPLETE", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("success"); - - // Continue to post-buy transaction - act(() => { - const [, send] = result.current; - send({ - type: "CONTINUE_TO_TRANSACTION", - }); - }); - - [state] = result.current; - expect(state.value).toBe("post-buy-transaction"); - - // Reset from post-buy-transaction should go back to init - act(() => { - const [, send] = result.current; - send({ - type: "RESET", - }); - }); - - [state] = result.current; - expect(state.value).toBe("init"); - // Context should be reset to initial state with only adapters and mode - expect(state.context.adapters).toBe(adapters); - expect(state.context.mode).toBe("fund_wallet"); - expect(state.context.destinationToken).toBeUndefined(); - expect(state.context.selectedPaymentMethod).toBeUndefined(); - expect(state.context.preparedQuote).toBeUndefined(); - expect(state.context.completedStatuses).toBeUndefined(); - }); -}); diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.ts deleted file mode 100644 index 30c0c2c4b5e..00000000000 --- a/packages/thirdweb/src/react/core/machines/paymentMachine.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { useCallback, useState } from "react"; -import type { Quote } from "../../../bridge/index.js"; -import type { TokenWithPrices } from "../../../bridge/types/Token.js"; -import type { Address } from "../../../utils/address.js"; -import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js"; -import type { Wallet } from "../../../wallets/interfaces/wallet.js"; -import type { WindowAdapter } from "../adapters/WindowAdapter.js"; -import type { - BridgePrepareRequest, - BridgePrepareResult, -} from "../hooks/useBridgePrepare.js"; -import type { CompletedStatusResult } from "../hooks/useStepExecutor.js"; - -/** - * Payment modes supported by BridgeEmbed - */ -type PaymentMode = "fund_wallet" | "direct_payment" | "transaction"; - -/** - * Payment method types with their required data - */ -export type PaymentMethod = - | { - type: "wallet"; - action: "buy" | "sell"; - payerWallet: Wallet; - originToken: TokenWithPrices; - balance: bigint; - quote: Quote; - } - | { - type: "fiat"; - payerWallet?: Wallet; - currency: string; - onramp: "stripe" | "coinbase" | "transak"; - }; - -/** - * Payment machine context - holds all flow state data - */ -export interface PaymentMachineContext { - // Flow configuration - mode: PaymentMode; - - // Target requirements (resolved in init state) - destinationAmount?: string; - destinationToken?: TokenWithPrices; - receiverAddress?: Address; - - // User selections (set in methodSelection state) - selectedPaymentMethod?: PaymentMethod; - - // Prepared quote data (set in quote state) - quote?: BridgePrepareResult; - request?: BridgePrepareRequest; - - // Execution results (set in execute state on completion) - completedStatuses?: CompletedStatusResult[]; - - // Error handling - currentError?: Error; - retryState?: PaymentMachineState; // State to retry from - - // Dependency injection - adapters: { - window: WindowAdapter; - storage: AsyncStorage; - }; -} - -/** - * Events that can be sent to the payment machine - */ -type PaymentMachineEvent = - | { - type: "DESTINATION_CONFIRMED"; - destinationToken: TokenWithPrices; - destinationAmount: string; - receiverAddress: Address; - } - | { type: "PAYMENT_METHOD_SELECTED"; paymentMethod: PaymentMethod } - | { - type: "QUOTE_RECEIVED"; - quote: BridgePrepareResult; - request: BridgePrepareRequest; - } - | { type: "ROUTE_CONFIRMED" } - | { type: "EXECUTION_COMPLETE"; completedStatuses: CompletedStatusResult[] } - | { type: "ERROR_OCCURRED"; error: Error } - | { type: "CONTINUE_TO_TRANSACTION" } - | { type: "RETRY" } - | { type: "RESET" } - | { type: "BACK" }; - -type PaymentMachineState = - | "init" - | "methodSelection" - | "quote" - | "preview" - | "execute" - | "success" - | "post-buy-transaction" - | "error"; - -/** - * Hook to create and use the payment machine - */ -export function usePaymentMachine( - adapters: PaymentMachineContext["adapters"], - mode: PaymentMode = "fund_wallet", -) { - const [currentState, setCurrentState] = useState("init"); - const [context, setContext] = useState({ - adapters, - mode, - }); - - const send = useCallback( - (event: PaymentMachineEvent) => { - setCurrentState((state) => { - setContext((ctx) => { - switch (state) { - case "init": - if (event.type === "DESTINATION_CONFIRMED") { - return { - ...ctx, - destinationAmount: event.destinationAmount, - destinationToken: event.destinationToken, - receiverAddress: event.receiverAddress, - }; - } else if (event.type === "ERROR_OCCURRED") { - return { - ...ctx, - currentError: event.error, - retryState: "init", - }; - } - break; - - case "methodSelection": - if (event.type === "PAYMENT_METHOD_SELECTED") { - return { - ...ctx, - quote: undefined, // reset quote when method changes - selectedPaymentMethod: event.paymentMethod, - }; - } else if (event.type === "ERROR_OCCURRED") { - return { - ...ctx, - currentError: event.error, - retryState: "methodSelection", - }; - } - break; - - case "quote": - if (event.type === "QUOTE_RECEIVED") { - return { - ...ctx, - quote: event.quote, - request: event.request, - }; - } else if (event.type === "ERROR_OCCURRED") { - return { - ...ctx, - currentError: event.error, - retryState: "quote", - }; - } - break; - - case "preview": - if (event.type === "ERROR_OCCURRED") { - return { - ...ctx, - currentError: event.error, - retryState: "preview", - }; - } - break; - - case "execute": - if (event.type === "EXECUTION_COMPLETE") { - return { - ...ctx, - completedStatuses: event.completedStatuses, - }; - } else if (event.type === "ERROR_OCCURRED") { - return { - ...ctx, - currentError: event.error, - retryState: "execute", - }; - } - break; - - case "error": - if (event.type === "RETRY" || event.type === "RESET") { - return { - ...ctx, - currentError: undefined, - retryState: undefined, - }; - } - break; - - case "success": - if (event.type === "RESET") { - return { - adapters: ctx.adapters, - mode: ctx.mode, - }; - } - break; - - case "post-buy-transaction": - if (event.type === "RESET") { - return { - adapters: ctx.adapters, - mode: ctx.mode, - }; - } - break; - } - return ctx; - }); - - // State transitions - switch (state) { - case "init": - if (event.type === "DESTINATION_CONFIRMED") - return "methodSelection"; - if (event.type === "ERROR_OCCURRED") return "error"; - if (event.type === "CONTINUE_TO_TRANSACTION") - return "post-buy-transaction"; - break; - - case "methodSelection": - if (event.type === "PAYMENT_METHOD_SELECTED") return "quote"; - if (event.type === "BACK") return "init"; - if (event.type === "ERROR_OCCURRED") return "error"; - break; - - case "quote": - if (event.type === "QUOTE_RECEIVED") return "preview"; - if (event.type === "BACK") return "methodSelection"; - if (event.type === "ERROR_OCCURRED") return "error"; - break; - - case "preview": - if (event.type === "ROUTE_CONFIRMED") return "execute"; - if (event.type === "BACK") return "methodSelection"; - if (event.type === "ERROR_OCCURRED") return "error"; - break; - - case "execute": - if (event.type === "EXECUTION_COMPLETE") return "success"; - if (event.type === "BACK") return "preview"; - if (event.type === "ERROR_OCCURRED") return "error"; - break; - - case "success": - if (event.type === "CONTINUE_TO_TRANSACTION") - return "post-buy-transaction"; - if (event.type === "RESET") return "init"; - break; - - case "post-buy-transaction": - if (event.type === "RESET") return "init"; - break; - - case "error": - if (event.type === "RETRY") { - return context.retryState ?? "init"; - } - if (event.type === "RESET") { - return "init"; - } - break; - } - - return state; - }); - }, - [context.retryState], - ); - - return [ - { - context, - value: currentState, - }, - send, - ] as const; -} diff --git a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx index 21e2ae3f19c..387c51c46ad 100644 --- a/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx +++ b/packages/thirdweb/src/react/web/hooks/transaction/useSendTransaction.tsx @@ -138,6 +138,7 @@ export function useSendTransaction(config: SendTransactionConfig = {}) { setRootEl( ; - -export interface BridgeOrchestratorProps { - /** - * UI configuration and mode - */ - uiOptions: UIOptions; - - /** - * The receiver address, defaults to the connected wallet address - */ - receiverAddress: Address | undefined; - - /** - * ThirdwebClient for blockchain interactions - */ - client: ThirdwebClient; - - /** - * Called when the flow is completed successfully - */ - onComplete: (quote: BridgePrepareResult) => void; - - /** - * Called when the flow encounters an error - */ - onError: (error: Error, quote: BridgePrepareResult | undefined) => void; - - /** - * Called when the user cancels the flow - */ - onCancel: (quote: BridgePrepareResult | undefined) => void; - - /** - * Connect options for wallet connection - */ - connectOptions: PayEmbedConnectOptions | undefined; - - /** - * Locale for connect UI - */ - connectLocale: ConnectLocale | undefined; - - /** - * Optional purchase data for the payment - */ - purchaseData?: PurchaseData; - - /** - * Optional payment link ID for the payment - */ - paymentLinkId: string | undefined; - - /** - * Quick buy amounts - */ - presetOptions: [number, number, number] | undefined; - - /** - * Allowed payment methods - * @default ["crypto", "card"] - */ - paymentMethods?: ("crypto" | "card")[]; - - /** - * The user's ISO 3166 alpha-2 country code. This is used to determine onramp provider support. - */ - country: string | undefined; - - /** - * Whether to show thirdweb branding in the widget. - * @default true - */ - showThirdwebBranding?: boolean; - supportedTokens?: SupportedTokens; -} - -export function BridgeOrchestrator({ - client, - uiOptions, - receiverAddress, - onComplete, - onError, - onCancel, - connectOptions, - connectLocale, - purchaseData, - paymentLinkId, - presetOptions, - paymentMethods = ["crypto", "card"], - showThirdwebBranding = true, - supportedTokens, - country = "US", -}: BridgeOrchestratorProps) { - // Initialize adapters - const adapters = useMemo( - () => ({ - storage: webLocalStorage, - window: webWindowAdapter, - }), - [], - ); - - // Create modified connect options with branding setting - const modifiedConnectOptions = useMemo(() => { - if (!connectOptions) return undefined; - return { - ...connectOptions, - connectModal: { - ...connectOptions.connectModal, - showThirdwebBranding, - }, - }; - }, [connectOptions, showThirdwebBranding]); - - // Use the payment machine hook - const [state, send] = usePaymentMachine(adapters, uiOptions.mode); - - // Handle buy completion - const handleDoneOrContinueClick = useCallback(() => { - if (uiOptions.mode === "transaction") { - send({ type: "CONTINUE_TO_TRANSACTION" }); - } else { - send({ type: "RESET" }); - } - }, [send, uiOptions.mode]); - - // Handle post-buy transaction completion - const handlePostBuyTransactionComplete = useCallback(() => { - send({ type: "RESET" }); - }, [send]); - - // Handle errors - const handleError = useCallback( - (error: Error) => { - console.error(error); - onError?.(error, state.context.quote); - send({ error, type: "ERROR_OCCURRED" }); - }, - [onError, send, state.context.quote], - ); - - // Handle payment method selection - const handlePaymentMethodSelected = useCallback( - (paymentMethod: PaymentMethod) => { - send({ paymentMethod, type: "PAYMENT_METHOD_SELECTED" }); - }, - [send], - ); - - // Handle quote received - const handleQuoteReceived = useCallback( - (quote: BridgePrepareResult, request: BridgePrepareRequest) => { - send({ quote, request, type: "QUOTE_RECEIVED" }); - }, - [send], - ); - - // Handle route confirmation - const handleRouteConfirmed = useCallback(() => { - send({ type: "ROUTE_CONFIRMED" }); - }, [send]); - - // Handle execution complete - const handleExecutionComplete = useCallback( - ( - completedStatuses: CompletedStatusResult[], - quote: BridgePrepareResult, - ) => { - send({ completedStatuses, type: "EXECUTION_COMPLETE" }); - if (uiOptions.mode !== "transaction") { - onComplete?.(quote); - } - }, - [send, onComplete, uiOptions.mode], - ); - - // Handle retry - const handleRetry = useCallback(() => { - send({ type: "RETRY" }); - }, [send]); - - const quote = state.context.quote; - - // Handle requirements resolved from FundWallet and DirectPayment - const handleRequirementsResolved = useCallback( - (amount: string, token: TokenWithPrices, receiverAddress: Address) => { - send({ - destinationAmount: amount, - destinationToken: token, - receiverAddress, - type: "DESTINATION_CONFIRMED", - }); - }, - [send], - ); - - return ( - - {/* Error Banner */} - {state.value === "error" && state.context.currentError && ( - { - send({ type: "RESET" }); - onCancel?.(quote); - }} - onRetry={handleRetry} - /> - )} - - {/* Render current screen based on state */} - {state.value === "init" && uiOptions.mode === "fund_wallet" && ( - - )} - - {state.value === "init" && uiOptions.mode === "direct_payment" && ( - - )} - - {state.value === "init" && uiOptions.mode === "transaction" && ( - send({ type: "CONTINUE_TO_TRANSACTION" })} - showThirdwebBranding={showThirdwebBranding} - uiOptions={uiOptions} - /> - )} - - {state.value === "methodSelection" && - state.context.destinationToken && - state.context.destinationAmount && - state.context.receiverAddress && ( - { - send({ type: "BACK" }); - }} - onError={handleError} - onPaymentMethodSelected={handlePaymentMethodSelected} - paymentMethods={paymentMethods} - receiverAddress={state.context.receiverAddress} - currency={uiOptions.currency} - supportedTokens={supportedTokens} - country={country} - /> - )} - - {state.value === "quote" && - state.context.selectedPaymentMethod && - state.context.receiverAddress && - state.context.destinationToken && - state.context.destinationAmount && ( - { - send({ type: "BACK" }); - }} - onError={handleError} - onQuoteReceived={handleQuoteReceived} - paymentLinkId={paymentLinkId} - paymentMethod={state.context.selectedPaymentMethod} - purchaseData={purchaseData} - receiver={state.context.receiverAddress} - uiOptions={uiOptions} - /> - )} - - {state.value === "preview" && - state.context.selectedPaymentMethod && - state.context.quote && ( - { - send({ type: "BACK" }); - }} - onConfirm={handleRouteConfirmed} - onError={handleError} - paymentMethod={state.context.selectedPaymentMethod} - preparedQuote={state.context.quote} - uiOptions={uiOptions} - /> - )} - - {state.value === "execute" && quote && state.context.request && ( - { - send({ type: "BACK" }); - }} - onCancel={() => { - onCancel(quote); - }} - onComplete={(completedStatuses) => { - handleExecutionComplete(completedStatuses, quote); - }} - request={state.context.request} - wallet={state.context.selectedPaymentMethod?.payerWallet} - windowAdapter={webWindowAdapter} - /> - )} - - {state.value === "success" && - quote && - state.context.completedStatuses && ( - - )} - - {state.value === "post-buy-transaction" && - uiOptions.mode === "transaction" && - quote && - uiOptions.transaction && ( - { - onComplete?.(quote); - }} - tx={uiOptions.transaction} - windowAdapter={webWindowAdapter} - /> - )} - - ); -} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx index d8cb79943e5..71b3403bdbe 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx @@ -1,20 +1,15 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; import { trackPayEvent } from "../../../../analytics/track/pay.js"; -import type { Token } from "../../../../bridge/index.js"; +import type { TokenWithPrices } from "../../../../bridge/index.js"; import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js"; -import { getToken } from "../../../../pay/convert/get-token.js"; import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { PurchaseData } from "../../../../pay/types.js"; -import { - type Address, - checksumAddress, - isAddress, -} from "../../../../utils/address.js"; -import { stringify } from "../../../../utils/json.js"; +import type { Address } from "../../../../utils/address.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../wallets/smart/types.js"; import type { AppMetadata } from "../../../../wallets/types.js"; @@ -23,14 +18,28 @@ import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProv import type { Theme } from "../../../core/design-system/index.js"; import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js"; import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js"; -import type { BridgePrepareResult } from "../../../core/hooks/useBridgePrepare.js"; +import type { + BridgePrepareRequest, + BridgePrepareResult, +} from "../../../core/hooks/useBridgePrepare.js"; +import type { CompletedStatusResult } from "../../../core/hooks/useStepExecutor.js"; import type { SupportedTokens } from "../../../core/utils/defaultTokens.js"; +import { webWindowAdapter } from "../../adapters/WindowAdapter.js"; import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js"; +import type { ConnectLocale } from "../ConnectWallet/locale/types.js"; import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js"; import { DynamicHeight } from "../components/DynamicHeight.js"; import { Spinner } from "../components/Spinner.js"; import type { LocaleId } from "../types.js"; -import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js"; +import { useTokenQuery } from "./common/token-query.js"; +import { ErrorBanner } from "./ErrorBanner.js"; +import { FundWallet } from "./FundWallet.js"; +import { PaymentDetails } from "./payment-details/PaymentDetails.js"; +import { PaymentSelection } from "./payment-selection/PaymentSelection.js"; +import { SuccessScreen } from "./payment-success/SuccessScreen.js"; +import { QuoteLoader } from "./QuoteLoader.js"; +import { StepRunner } from "./StepRunner.js"; +import type { PaymentMethod, RequiredParams } from "./types.js"; import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js"; export type BuyOrOnrampPrepareResult = Extract< @@ -206,20 +215,6 @@ export type BuyWidgetProps = { receiverAddress?: Address; }; -// Enhanced UIOptions to handle unsupported token state -type UIOptionsResult = - | { type: "success"; data: UIOptions } - | { - type: "indexing_token"; - token: Token; - chain: Chain; - } - | { - type: "unsupported_token"; - tokenAddress: Address; - chain: Chain; - }; - /** * Widget is a prebuilt UI for purchasing a specific token. * @@ -331,8 +326,24 @@ type UIOptionsResult = * @bridge */ export function BuyWidget(props: BuyWidgetProps) { - const localeQuery = useConnectLocale(props.locale || "en_US"); - const theme = props.theme || "dark"; + return ( + + + + ); +} + +function BridgeWidgetContentWrapper(props: BuyWidgetProps) { + const localQuery = useConnectLocale(props.locale || "en_US"); + const tokenQuery = useTokenQuery({ + tokenAddress: props.tokenAddress, + chainId: props.chain.id, + client: props.client, + }); useQuery({ queryFn: () => { @@ -347,83 +358,22 @@ export function BuyWidget(props: BuyWidgetProps) { queryKey: ["buy_widget:render"], }); - const bridgeDataQuery = useQuery({ - queryFn: async (): Promise => { - if ( - !props.tokenAddress || - (isAddress(props.tokenAddress) && - checksumAddress(props.tokenAddress) === - checksumAddress(NATIVE_TOKEN_ADDRESS)) - ) { - const ETH = await getToken( - props.client, - NATIVE_TOKEN_ADDRESS, - props.chain.id, - ).catch((err) => { - err.message.includes("not supported") - ? undefined - : Promise.reject(err); - }); - if (!ETH) { - return { - chain: props.chain, - tokenAddress: props.tokenAddress || NATIVE_TOKEN_ADDRESS, - type: "unsupported_token", - }; - } - return { - data: { - destinationToken: ETH, - initialAmount: props.amount, - metadata: { - description: props.description, - image: props.image, - title: props.title, - }, - mode: "fund_wallet", - currency: props.currency || "USD", - buttonLabel: props.buttonLabel, - }, - type: "success", - }; - } - - const token = await getToken( - props.client, - props.tokenAddress, - props.chain.id, - ).catch((err) => { - err.message.includes("not supported") ? undefined : Promise.reject(err); - }); - if (!token) { - return { - chain: props.chain, - tokenAddress: props.tokenAddress, - type: "unsupported_token", - }; - } + // if branding is disabled for widget, disable it for connect options too + const connectOptions = useMemo(() => { + if (props.showThirdwebBranding === false) { return { - data: { - destinationToken: token, - initialAmount: props.amount, - metadata: { - description: props.description, - image: props.image, - title: props.title, - }, - mode: "fund_wallet", - currency: props.currency || "USD", - buttonLabel: props.buttonLabel, + ...props.connectOptions, + connectModal: { + ...props.connectOptions?.connectModal, + showThirdwebBranding: false, }, - type: "success", }; - }, - queryKey: ["bridgeData", stringify(props)], - }); + } + return props.connectOptions; + }, [props.connectOptions, props.showThirdwebBranding]); - let content = null; - if (!localeQuery.data || bridgeDataQuery.isLoading) { - content = ( + if (tokenQuery.isPending || !localQuery.data) { + return (
); - } else if (bridgeDataQuery.data?.type === "unsupported_token") { - // Show unsupported token screen - content = ( + } else if (tokenQuery.data?.type === "unsupported_token") { + return ( ); - } else if (bridgeDataQuery.data?.type === "success") { - // Show normal bridge orchestrator - content = ( - + ); + } else if (tokenQuery.error) { + return ( + { - // type guard - if (quote?.type === "buy" || quote?.type === "onramp") { - props.onCancel?.(quote); - } + error={tokenQuery.error} + onRetry={() => { + tokenQuery.refetch(); }} - onComplete={(quote) => { - // type guard - if (quote?.type === "buy" || quote?.type === "onramp") { - props.onSuccess?.(quote); - } + onCancel={() => { + props.onCancel?.(undefined); }} - onError={(err: Error, quote) => { - // type guard - if (quote?.type === "buy" || quote?.type === "onramp") { - props.onError?.(err, quote); - } + /> + ); + } + + return null; +} + +type BuyWidgetScreen = + | { id: "1:buy-ui" } + | { + id: "2:methodSelection"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + } + | { + id: "3:load-quote"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + paymentMethod: PaymentMethod; + } + | { + id: "4:preview"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + } + | { + id: "5:execute"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + } + | { + id: "6:success"; + completedStatuses: CompletedStatusResult[]; + preparedQuote: BridgePrepareResult; + } + | { + id: "error"; + error: Error; + preparedQuote: BridgePrepareResult | undefined; + }; + +function BridgeWidgetContent( + props: RequiredParams< + BuyWidgetProps, + "currency" | "presetOptions" | "showThirdwebBranding" | "paymentMethods" + > & { + connectLocale: ConnectLocale; + destinationToken: TokenWithPrices; + }, +) { + const [screen, setScreen] = useState({ id: "1:buy-ui" }); + + const handleError = useCallback( + (error: Error, quote: BridgePrepareResult | undefined) => { + console.error(error); + if (quote?.type === "buy" || quote?.type === "onramp") { + props.onError?.(error, quote); + } else { + props.onError?.(error, undefined); + } + setScreen({ + id: "error", + preparedQuote: quote, + error, + }); + }, + [props.onError], + ); + + const handleCancel = useCallback( + (preparedQuote: BridgePrepareResult | undefined) => { + if (preparedQuote?.type === "buy" || preparedQuote?.type === "onramp") { + props.onCancel?.(preparedQuote); + } else { + props.onCancel?.(undefined); + } + }, + [props.onCancel], + ); + + if (screen.id === "1:buy-ui") { + return ( + { + setScreen({ + id: "2:methodSelection", + destinationAmount, + destinationToken, + receiverAddress, + }); }} - paymentLinkId={props.paymentLinkId} - paymentMethods={props.paymentMethods} presetOptions={props.presetOptions} - purchaseData={props.purchaseData} receiverAddress={props.receiverAddress} - uiOptions={bridgeDataQuery.data.data} showThirdwebBranding={props.showThirdwebBranding} + metadata={{ + title: props.title, + description: props.description, + image: props.image, + }} + buttonLabel={props.buttonLabel} + currency={props.currency} + initialAmount={props.amount} + destinationToken={props.destinationToken} + /> + ); + } + + if (screen.id === "2:methodSelection") { + return ( + { + setScreen({ id: "1:buy-ui" }); + }} + onError={(error) => { + handleError(error, undefined); + }} + onPaymentMethodSelected={(paymentMethod) => { + setScreen({ + ...screen, + id: "3:load-quote", + paymentMethod, + }); + }} /> ); } + if (screen.id === "3:load-quote") { + return ( + { + setScreen({ + ...screen, + id: "2:methodSelection", + }); + }} + onError={(error) => { + handleError(error, undefined); + }} + onQuoteReceived={(preparedQuote, request) => { + setScreen({ + ...screen, + id: "4:preview", + preparedQuote, + request, + }); + }} + paymentMethod={screen.paymentMethod} + receiver={screen.receiverAddress} + /> + ); + } + + if (screen.id === "4:preview") { + return ( + { + setScreen({ + ...screen, + id: "2:methodSelection", + }); + }} + onConfirm={() => { + setScreen({ + ...screen, + id: "5:execute", + }); + }} + onError={(error) => { + handleError(error, screen.preparedQuote); + }} + paymentMethod={screen.paymentMethod} + preparedQuote={screen.preparedQuote} + modeInfo={{ + mode: "fund_wallet", + }} + /> + ); + } + + if (screen.id === "5:execute") { + return ( + { + setScreen({ + ...screen, + id: "4:preview", + }); + }} + onCancel={() => { + handleCancel(screen.preparedQuote); + }} + onComplete={(completedStatuses) => { + if ( + screen.preparedQuote.type === "buy" || + screen.preparedQuote.type === "onramp" + ) { + props.onSuccess?.(screen.preparedQuote); + } + setScreen({ + id: "6:success", + preparedQuote: screen.preparedQuote, + completedStatuses, + }); + }} + request={screen.request} + wallet={screen.paymentMethod.payerWallet} + windowAdapter={webWindowAdapter} + /> + ); + } + + if (screen.id === "6:success") { + return ( + { + setScreen({ id: "1:buy-ui" }); + }} + preparedQuote={screen.preparedQuote} + showContinueWithTx={false} + windowAdapter={webWindowAdapter} + /> + ); + } + + if (screen.id === "error") { + return ( + { + setScreen({ id: "1:buy-ui" }); + handleCancel(screen.preparedQuote); + }} + onRetry={() => { + setScreen({ id: "1:buy-ui" }); + }} + /> + ); + } + + return null; +} + +/** + * @internal + */ +function BridgeWidgetContainer(props: { + theme: BuyWidgetProps["theme"]; + className: string | undefined; + style?: React.CSSProperties | undefined; + children: React.ReactNode; +}) { return ( - + - {content} + {props.children} ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx index 360e27dcc4a..de1b62c939d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx @@ -1,16 +1,15 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; import { trackPayEvent } from "../../../../analytics/track/pay.js"; -import type { Token } from "../../../../bridge/index.js"; +import type { TokenWithPrices } from "../../../../bridge/index.js"; import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js"; -import { getToken } from "../../../../pay/convert/get-token.js"; import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { PurchaseData } from "../../../../pay/types.js"; -import { type Address, checksumAddress } from "../../../../utils/address.js"; -import { stringify } from "../../../../utils/json.js"; +import type { Address } from "../../../../utils/address.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../wallets/smart/types.js"; import type { AppMetadata } from "../../../../wallets/types.js"; @@ -19,16 +18,28 @@ import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProv import type { Theme } from "../../../core/design-system/index.js"; import type { SiweAuthOptions } from "../../../core/hooks/auth/useSiweAuth.js"; import type { ConnectButton_connectModalOptions } from "../../../core/hooks/connection/ConnectButtonProps.js"; +import type { + BridgePrepareRequest, + BridgePrepareResult, +} from "../../../core/hooks/useBridgePrepare.js"; +import type { CompletedStatusResult } from "../../../core/hooks/useStepExecutor.js"; import type { SupportedTokens } from "../../../core/utils/defaultTokens.js"; +import { webWindowAdapter } from "../../adapters/WindowAdapter.js"; import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js"; +import type { ConnectLocale } from "../ConnectWallet/locale/types.js"; import { EmbedContainer } from "../ConnectWallet/Modal/ConnectEmbed.js"; -import { Container } from "../components/basic.js"; -import { Button } from "../components/buttons.js"; import { DynamicHeight } from "../components/DynamicHeight.js"; import { Spinner } from "../components/Spinner.js"; -import { Text } from "../components/text.js"; import type { LocaleId } from "../types.js"; -import { BridgeOrchestrator, type UIOptions } from "./BridgeOrchestrator.js"; +import { useTokenQuery } from "./common/token-query.js"; +import { DirectPayment } from "./DirectPayment.js"; +import { ErrorBanner } from "./ErrorBanner.js"; +import { PaymentDetails } from "./payment-details/PaymentDetails.js"; +import { PaymentSelection } from "./payment-selection/PaymentSelection.js"; +import { SuccessScreen } from "./payment-success/SuccessScreen.js"; +import { QuoteLoader } from "./QuoteLoader.js"; +import { StepRunner } from "./StepRunner.js"; +import type { PaymentMethod } from "./types.js"; import { UnsupportedTokenScreen } from "./UnsupportedTokenScreen.js"; export type CheckoutWidgetProps = { @@ -151,11 +162,6 @@ export type CheckoutWidgetProps = { */ feePayer?: "user" | "seller"; - /** - * Preset fiat amounts to display in the UI. Defaults to [5, 10, 20]. - */ - presetOptions?: [number, number, number]; - /** * Arbitrary data to be included in the returned status and webhook events. */ @@ -164,17 +170,17 @@ export type CheckoutWidgetProps = { /** * Callback triggered when the purchase is successful. */ - onSuccess?: () => void; + onSuccess?: (quote: BridgePrepareResult) => void; /** * Callback triggered when the purchase encounters an error. */ - onError?: (error: Error) => void; + onError?: (error: Error, quote: BridgePrepareResult | undefined) => void; /** * Callback triggered when the user cancels the purchase. */ - onCancel?: () => void; + onCancel?: (quote: BridgePrepareResult | undefined) => void; /** * @hidden @@ -204,20 +210,6 @@ export type CheckoutWidgetProps = { buttonLabel?: string; }; -// Enhanced UIOptions to handle unsupported token state -type UIOptionsResult = - | { type: "success"; data: UIOptions } - | { - type: "indexing_token"; - token: Token; - chain: Chain; - } - | { - type: "unsupported_token"; - tokenAddress: Address; - chain: Chain; - }; - /** * Widget a prebuilt UI for purchasing a specific token. * @@ -321,8 +313,24 @@ type UIOptionsResult = * @bridge */ export function CheckoutWidget(props: CheckoutWidgetProps) { - const localeQuery = useConnectLocale(props.locale || "en_US"); - const theme = props.theme || "dark"; + return ( + + + + ); +} + +function CheckoutWidgetContentWrapper(props: CheckoutWidgetProps) { + const localQuery = useConnectLocale(props.locale || "en_US"); + const tokenQuery = useTokenQuery({ + tokenAddress: props.tokenAddress, + chainId: props.chain.id, + client: props.client, + }); useQuery({ queryFn: () => { @@ -337,50 +345,22 @@ export function CheckoutWidget(props: CheckoutWidgetProps) { queryKey: ["checkout_widget:render"], }); - const bridgeDataQuery = useQuery({ - queryFn: async (): Promise => { - const token = await getToken( - props.client, - checksumAddress(props.tokenAddress || NATIVE_TOKEN_ADDRESS), - props.chain.id, - ).catch((err) => - err.message.includes("not supported") ? undefined : Promise.reject(err), - ); - if (!token) { - return { - chain: props.chain, - tokenAddress: checksumAddress( - props.tokenAddress || NATIVE_TOKEN_ADDRESS, - ), - type: "unsupported_token", - }; - } + // if branding is disabled for widget, disable it for connect options too + const connectOptions = useMemo(() => { + if (props.showThirdwebBranding === false) { return { - data: { - metadata: { - description: props.description, - image: props.image, - title: props.name, - }, - mode: "direct_payment", - currency: props.currency || "USD", - buttonLabel: props.buttonLabel, - paymentInfo: { - amount: props.amount, - feePayer: props.feePayer === "seller" ? "receiver" : "sender", - sellerAddress: props.seller, - token, // User is sender, seller is receiver - }, + ...props.connectOptions, + connectModal: { + ...props.connectOptions?.connectModal, + showThirdwebBranding: false, }, - type: "success", }; - }, - queryKey: ["bridgeData", stringify(props)], - }); + } + return props.connectOptions; + }, [props.connectOptions, props.showThirdwebBranding]); - let content = null; - if (!localeQuery.data || bridgeDataQuery.isLoading) { - content = ( + if (tokenQuery.isPending || !localQuery.data) { + return (
); - } else if (bridgeDataQuery.data?.type === "unsupported_token") { - // Show unsupported token screen - content = ( + } else if (tokenQuery.data?.type === "unsupported_token") { + return ( ); - } else if (bridgeDataQuery.data?.type === "success") { - // Show normal bridge orchestrator - content = ( - + ); + } else if (tokenQuery.error) { + return ( + { + tokenQuery.refetch(); + }} onCancel={() => { - props.onCancel?.(); + props.onCancel?.(undefined); }} - onComplete={() => { - props.onSuccess?.(); + /> + ); + } + + return null; +} + +type CheckoutWidgetScreen = + | { id: "1:init-ui" } + | { + id: "2:methodSelection"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + } + | { + id: "3:load-quote"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + paymentMethod: PaymentMethod; + } + | { + id: "4:preview"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + } + | { + id: "5:execute"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + } + | { + id: "6:success"; + completedStatuses: CompletedStatusResult[]; + preparedQuote: BridgePrepareResult; + } + | { + id: "error"; + error: Error; + preparedQuote: BridgePrepareResult | undefined; + }; + +type RequiredParams = T & { + [K in keys]-?: T[K]; +}; + +function CheckoutWidgetContent( + props: RequiredParams< + CheckoutWidgetProps, + "currency" | "showThirdwebBranding" | "paymentMethods" + > & { + connectLocale: ConnectLocale; + destinationToken: TokenWithPrices; + }, +) { + const [screen, setScreen] = useState({ + id: "1:init-ui", + }); + + const mappedFeePayer: "receiver" | "sender" = + props.feePayer === "seller" ? "receiver" : "sender"; + + const handleError = useCallback( + (error: Error, quote: BridgePrepareResult | undefined) => { + console.error(error); + props.onError?.(error, quote); + setScreen({ + id: "error", + preparedQuote: quote, + error, + }); + }, + [props.onError], + ); + + const handleCancel = useCallback( + (preparedQuote: BridgePrepareResult | undefined) => { + props.onCancel?.(preparedQuote); + }, + [props.onCancel], + ); + + if (screen.id === "1:init-ui") { + return ( + { - props.onError?.(err); + showThirdwebBranding={props.showThirdwebBranding} + metadata={{ + title: props.name, + description: props.description, + image: props.image, }} - paymentLinkId={props.paymentLinkId} + currency={props.currency} + buttonLabel={props.buttonLabel} + // others + onContinue={(destinationAmount, destinationToken, receiverAddress) => { + setScreen({ + id: "2:methodSelection", + destinationAmount, + destinationToken, + receiverAddress, + }); + }} + /> + ); + } + + if (screen.id === "2:methodSelection") { + return ( + { + setScreen({ id: "1:init-ui" }); + }} + onError={(error) => { + handleError(error, undefined); + }} + onPaymentMethodSelected={(paymentMethod) => { + setScreen({ + ...screen, + id: "3:load-quote", + paymentMethod, + }); + }} + /> + ); + } + + if (screen.id === "3:load-quote") { + return ( + { + setScreen({ + ...screen, + id: "2:methodSelection", + }); + }} + onError={(error) => { + handleError(error, undefined); + }} + onQuoteReceived={(preparedQuote, request) => { + setScreen({ + ...screen, + id: "4:preview", + preparedQuote, + request, + }); + }} + paymentMethod={screen.paymentMethod} + receiver={screen.receiverAddress} + /> + ); + } + + if (screen.id === "4:preview") { + return ( + { + setScreen({ + ...screen, + id: "2:methodSelection", + }); + }} + onConfirm={() => { + setScreen({ + ...screen, + id: "5:execute", + }); + }} + onError={(error) => { + handleError(error, screen.preparedQuote); + }} + paymentMethod={screen.paymentMethod} + preparedQuote={screen.preparedQuote} + modeInfo={{ + mode: "direct_payment", + paymentInfo: { + amount: screen.destinationAmount, + feePayer: mappedFeePayer, + sellerAddress: props.seller, + token: screen.destinationToken, + }, + }} + /> + ); + } + + if (screen.id === "5:execute") { + return ( + { + setScreen({ + ...screen, + id: "4:preview", + }); + }} + onCancel={() => { + handleCancel(screen.preparedQuote); + }} + onComplete={(completedStatuses) => { + props.onSuccess?.(screen.preparedQuote); + setScreen({ + id: "6:success", + preparedQuote: screen.preparedQuote, + completedStatuses, + }); + }} + request={screen.request} + wallet={screen.paymentMethod.payerWallet} + windowAdapter={webWindowAdapter} /> ); } - if (bridgeDataQuery.isError) { - content = ( - - - Something went wrong. - - - + if (screen.id === "6:success") { + return ( + { + setScreen({ id: "1:init-ui" }); + }} + preparedQuote={screen.preparedQuote} + showContinueWithTx={false} + windowAdapter={webWindowAdapter} + /> ); } + if (screen.id === "error") { + return ( + { + setScreen({ id: "1:init-ui" }); + handleCancel(screen.preparedQuote); + }} + onRetry={() => { + setScreen({ id: "1:init-ui" }); + }} + /> + ); + } + + return null; +} + +/** + * @internal + */ +function CheckoutWidgetContainer(props: { + theme: CheckoutWidgetProps["theme"]; + className: string | undefined; + style?: React.CSSProperties | undefined; + children: React.ReactNode; +}) { return ( - + - {content} + {props.children} ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx index 3cfd3ff82fa..3a3b9417269 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx @@ -2,6 +2,7 @@ import type { TokenWithPrices } from "../../../../bridge/types/Token.js"; import { defineChain } from "../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { Address } from "../../../../utils/address.js"; import { PoweredByThirdweb } from "../ConnectWallet/PoweredByTW.js"; import { FiatValue } from "../ConnectWallet/screens/Buy/swap/FiatValue.js"; @@ -10,15 +11,19 @@ import { Button } from "../components/buttons.js"; import { ChainName } from "../components/ChainName.js"; import { Spacer } from "../components/Spacer.js"; import { Text } from "../components/text.js"; -import type { UIOptions } from "./BridgeOrchestrator.js"; import { ChainIcon } from "./common/TokenAndChain.js"; import { WithHeader } from "./common/WithHeader.js"; - -export interface DirectPaymentProps { - /** - * Payment information for the direct payment - */ - uiOptions: Extract; +import type { DirectPaymentInfo } from "./types.js"; + +type DirectPaymentProps = { + paymentInfo: DirectPaymentInfo; + currency: SupportedFiatCurrency; + metadata: { + title: string | undefined; + description: string | undefined; + image: string | undefined; + }; + buttonLabel: string | undefined; /** * ThirdwebClient for blockchain interactions @@ -36,29 +41,31 @@ export interface DirectPaymentProps { /** * Whether to show thirdweb branding in the widget. - * @default true */ - showThirdwebBranding?: boolean; -} + showThirdwebBranding: boolean; +}; export function DirectPayment({ - uiOptions, + paymentInfo, + metadata, client, onContinue, showThirdwebBranding = true, + buttonLabel, + currency, }: DirectPaymentProps) { - const chain = defineChain(uiOptions.paymentInfo.token.chainId); + const chain = defineChain(paymentInfo.token.chainId); const handleContinue = () => { onContinue( - uiOptions.paymentInfo.amount, - uiOptions.paymentInfo.token, - uiOptions.paymentInfo.sellerAddress, + paymentInfo.amount, + paymentInfo.token, + paymentInfo.sellerAddress, ); }; - const buyNow = uiOptions.buttonLabel ? ( + const buyNow = buttonLabel ? ( - {uiOptions.buttonLabel} + {buttonLabel} ) : ( @@ -66,13 +73,13 @@ export function DirectPayment({ Buy Now · ); @@ -80,8 +87,9 @@ export function DirectPayment({ return ( {/* Price section */} @@ -140,7 +148,7 @@ export function DirectPayment({ fontFamily: "monospace", }} > - {`${uiOptions.paymentInfo.amount} ${uiOptions.paymentInfo.token.symbol}`} + {`${paymentInfo.amount} ${paymentInfo.token.symbol}`} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx index f1d3171e3c2..6e5a32c3e7f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from "react"; import type { TokenWithPrices } from "../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import { type Address, getAddress } from "../../../../utils/address.js"; import { numberToPlainString } from "../../../../utils/formatNumber.js"; import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js"; @@ -24,20 +25,14 @@ import { Input } from "../components/formElements.js"; import { Spacer } from "../components/Spacer.js"; import { Text } from "../components/text.js"; import type { PayEmbedConnectOptions } from "../PayEmbed.js"; -import type { UIOptions } from "./BridgeOrchestrator.js"; import { TokenAndChain } from "./common/TokenAndChain.js"; import { WithHeader } from "./common/WithHeader.js"; -export interface FundWalletProps { - /** - * UI configuration and mode - */ - uiOptions: Extract; - +type FundWalletProps = { /** * The receiver address, defaults to the connected wallet address */ - receiverAddress?: Address; + receiverAddress: Address | undefined; /** * ThirdwebClient for price fetching */ @@ -55,33 +50,66 @@ export interface FundWalletProps { /** * Quick buy amounts */ - presetOptions?: [number, number, number]; + presetOptions: [number, number, number]; /** * Connect options for wallet connection */ - connectOptions?: PayEmbedConnectOptions; + connectOptions: PayEmbedConnectOptions | undefined; /** * Whether to show thirdweb branding in the widget. - * @default true */ - showThirdwebBranding?: boolean; -} + showThirdwebBranding: boolean; + + /** + * The initial amount to prefill the input with + */ + initialAmount: string | undefined; + + /** + * The destination token to buy + */ + destinationToken: TokenWithPrices; + + /** + * The currency to use for the payment. + */ + currency: SupportedFiatCurrency; + + /** + * Override label to display on the button + */ + buttonLabel: string | undefined; + + /** + * The metadata to display in the widget. + */ + metadata: { + title: string | undefined; + description: string | undefined; + image: string | undefined; + }; +}; export function FundWallet({ client, receiverAddress, - uiOptions, onContinue, - presetOptions = [5, 10, 20], + presetOptions, connectOptions, - showThirdwebBranding = true, + showThirdwebBranding, + initialAmount, + destinationToken, + currency, + buttonLabel, + metadata, }: FundWalletProps) { - const [amount, setAmount] = useState(uiOptions.initialAmount ?? ""); + const [amount, setAmount] = useState(initialAmount ?? ""); const theme = useCustomTheme(); const account = useActiveAccount(); const receiver = receiverAddress ?? account?.address; + const handleAmountChange = (inputValue: string) => { let processedValue = inputValue; @@ -120,8 +148,7 @@ export function FundWallet({ }; const handleQuickAmount = (usdAmount: number) => { - const price = - uiOptions.destinationToken.prices[uiOptions.currency || "USD"] || 0; + const price = destinationToken.prices[currency || "USD"] || 0; if (price === 0) { return; } @@ -137,8 +164,13 @@ export function FundWallet({ return ( {/* Token Info */} @@ -154,11 +186,7 @@ export function FundWallet({ flexWrap: "nowrap", }} > - + {/* Amount Input */} ≈{" "} {formatCurrencyAmount( - uiOptions.currency || "USD", + currency || "USD", Number(amount) * - (uiOptions.destinationToken.prices[ - uiOptions.currency || "USD" - ] || 0), + (destinationToken.prices[currency || "USD"] || 0), )} @@ -328,11 +354,7 @@ export function FundWallet({ fullWidth onClick={() => { if (isValidAmount) { - onContinue( - amount, - uiOptions.destinationToken, - getAddress(receiver), - ); + onContinue(amount, destinationToken, getAddress(receiver)); } }} style={{ @@ -341,16 +363,13 @@ export function FundWallet({ }} variant="primary" > - {uiOptions.buttonLabel || - `Buy ${amount} ${uiOptions.destinationToken.symbol}`} + {buttonLabel || `Buy ${amount} ${destinationToken.symbol}`} ) : ( void; - /** * Called when user wants to go back */ - onBack?: () => void; + onBack: (() => void) | undefined; /** * Optional purchase data for the payment */ - purchaseData?: PurchaseData; + purchaseData: PurchaseData | undefined; /** * Optional payment link ID for the payment */ - paymentLinkId?: string; + paymentLinkId: string | undefined; - /** - * UI options - */ - uiOptions: UIOptions; + feePayer: "sender" | "receiver" | undefined; + mode: "direct_payment" | "fund_wallet" | "transaction"; } export function QuoteLoader({ - uiOptions, destinationToken, paymentMethod, amount, @@ -96,14 +91,9 @@ export function QuoteLoader({ onError, purchaseData, paymentLinkId, + feePayer, + mode, }: QuoteLoaderProps) { - // For now, we'll use a simple buy operation - // This will be expanded to handle different bridge types based on the payment method - const feePayer = - uiOptions.mode === "direct_payment" - ? uiOptions.paymentInfo.feePayer - : undefined; - const mode = uiOptions.mode; const request: BridgePrepareRequest = getBridgeParams({ amount, client, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index f139c0ecd8b..ad4a9bf2d65 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -27,14 +27,14 @@ import { Spacer } from "../components/Spacer.js"; import { Spinner } from "../components/Spinner.js"; import { Text } from "../components/text.js"; -interface StepRunnerProps { - title?: string; +type StepRunnerProps = { + title: string | undefined; request: BridgePrepareRequest; /** * Wallet instance for executing transactions */ - wallet?: Wallet; + wallet: Wallet | undefined; /** * Thirdweb client for API calls @@ -49,7 +49,7 @@ interface StepRunnerProps { /** * Whether to automatically start the transaction process */ - autoStart?: boolean; + autoStart: boolean; /** * Called when all steps are completed - receives array of completed status results @@ -59,18 +59,18 @@ interface StepRunnerProps { /** * Called when user cancels the flow */ - onCancel?: () => void; + onCancel: (() => void) | undefined; /** * Called when user clicks the back button */ - onBack?: () => void; + onBack: () => void; /** * Prepared quote to use */ preparedQuote: BridgePrepareResult; -} +}; export function StepRunner({ title, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx index 70bef2e5c1b..67ab578fda5 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx @@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import type { TokenWithPrices } from "../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; +import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; import { type Address, getAddress, @@ -28,15 +30,21 @@ import { ChainName } from "../components/ChainName.js"; import { Spacer } from "../components/Spacer.js"; import { Text } from "../components/text.js"; import type { PayEmbedConnectOptions } from "../PayEmbed.js"; -import type { UIOptions } from "./BridgeOrchestrator.js"; import { ChainIcon } from "./common/TokenAndChain.js"; import { WithHeader } from "./common/WithHeader.js"; -export interface TransactionPaymentProps { +type TransactionPaymentProps = { /** * UI configuration and mode */ - uiOptions: Extract; + transaction: PreparedTransaction; + currency: SupportedFiatCurrency; + buttonLabel: string | undefined; + metadata: { + title: string | undefined; + description: string | undefined; + image: string | undefined; + }; /** * ThirdwebClient for blockchain interactions @@ -67,29 +75,32 @@ export interface TransactionPaymentProps { * @default true */ showThirdwebBranding?: boolean; -} +}; export function TransactionPayment({ - uiOptions, + transaction, client, onContinue, onExecuteTransaction, connectOptions, + currency, showThirdwebBranding = true, + buttonLabel: _buttonLabel, + metadata, }: TransactionPaymentProps) { const theme = useCustomTheme(); const activeAccount = useActiveAccount(); const wallet = useActiveWallet(); // Get chain metadata for native currency symbol - const chainMetadata = useChainMetadata(uiOptions.transaction.chain); + const chainMetadata = useChainMetadata(transaction.chain); // Use the extracted hook for transaction details const transactionDataQuery = useTransactionDetails({ client, - transaction: uiOptions.transaction, + transaction: transaction, wallet, - currency: uiOptions.currency, + currency: currency, }); // We can't use useWalletBalance here because erc20Value is a possibly async value @@ -99,12 +110,10 @@ export function TransactionPayment({ if (!activeAccount?.address) { return "0"; } - const erc20Value = await resolvePromisedValue( - uiOptions.transaction.erc20Value, - ); + const erc20Value = await resolvePromisedValue(transaction.erc20Value); const walletBalance = await getWalletBalance({ address: activeAccount?.address, - chain: uiOptions.transaction.chain, + chain: transaction.chain, tokenAddress: erc20Value?.tokenAddress.toLowerCase() !== NATIVE_TOKEN_ADDRESS ? erc20Value?.tokenAddress @@ -123,14 +132,15 @@ export function TransactionPayment({ transactionDataQuery.data?.functionInfo?.functionName || "Contract Call"; const isLoading = transactionDataQuery.isLoading || chainMetadata.isLoading; - const buttonLabel = uiOptions.buttonLabel || `Execute ${functionName}`; + const buttonLabel = _buttonLabel || `Execute ${functionName}`; if (isLoading) { return ( {/* Loading Header */} @@ -182,8 +192,9 @@ export function TransactionPayment({ return ( {/* Cost and Function Name section */} - {shortenAddress(uiOptions.transaction.to as string)} + {shortenAddress(transaction.to as string)} @@ -286,13 +297,9 @@ export function TransactionPayment({ Network - + void; + onSuccess?: (data: WaitForReceiptOptions) => void; /** * Callback triggered when the purchase encounters an error. @@ -202,20 +214,6 @@ export type TransactionWidgetProps = { buttonLabel?: string; }; -// Enhanced UIOptions to handle unsupported token state -type UIOptionsResult = - | { type: "success"; data: UIOptions } - | { - type: "indexing_token"; - token: Token; - chain: Chain; - } - | { - type: "unsupported_token"; - tokenAddress: Address; - chain: Chain; - }; - /** * Widget a prebuilt UI for purchasing a specific token. * @@ -328,10 +326,29 @@ type UIOptionsResult = * * @bridge */ + export function TransactionWidget(props: TransactionWidgetProps) { - const localeQuery = useConnectLocale(props.locale || "en_US"); - const theme = props.theme || "dark"; + return ( + + + + ); +} +type TransactionQueryResult = + | { + transaction: PreparedTransaction; + type: "success"; + } + | { + type: "unsupported_token"; + }; + +export function TransactionWidgetContentWrapper(props: TransactionWidgetProps) { useQuery({ queryFn: () => { trackPayEvent({ @@ -345,8 +362,10 @@ export function TransactionWidget(props: TransactionWidgetProps) { queryKey: ["transaction_widget:render"], }); - const bridgeDataQuery = useQuery({ - queryFn: async (): Promise => { + const localQuery = useConnectLocale(props.locale || "en_US"); + + const txQuery = useQuery({ + queryFn: async (): Promise => { let erc20Value = props.transaction.erc20Value; if (props.amount) { @@ -364,8 +383,6 @@ export function TransactionWidget(props: TransactionWidgetProps) { }); if (!token) { return { - chain: props.transaction.chain, - tokenAddress: checksumAddress(tokenAddress), type: "unsupported_token", }; } @@ -382,27 +399,31 @@ export function TransactionWidget(props: TransactionWidgetProps) { }); return { - data: { - currency: props.currency || "USD", - buttonLabel: props.buttonLabel, - metadata: { - description: props.description, - image: props.image, - title: props.title, - }, - mode: "transaction", - transaction, - }, + transaction, type: "success", }; }, - queryKey: ["bridgeData", stringify(props)], + + queryKey: ["transaction-query", stringify(props)], retry: 1, }); - let content = null; - if (!localeQuery.data || bridgeDataQuery.isLoading) { - content = ( + // if branding is disabled for widget, disable it for connect options too + const connectOptions = useMemo(() => { + if (props.showThirdwebBranding === false) { + return { + ...props.connectOptions, + connectModal: { + ...props.connectOptions?.connectModal, + showThirdwebBranding: props.showThirdwebBranding, + }, + }; + } + return props.connectOptions; + }, [props.connectOptions, props.showThirdwebBranding]); + + if (txQuery.isPending || !localQuery.data) { + return (
); - } else if (bridgeDataQuery.error) { - content = ( + } else if (txQuery.error) { + return (
- {bridgeDataQuery.error.message} + {txQuery.error.message}
); - } else if (bridgeDataQuery.data?.type === "unsupported_token") { - // Show unsupported token screen - content = ( + } else if (txQuery.data?.type === "unsupported_token") { + return ( ); - } else if (bridgeDataQuery.data?.type === "success") { - // Show normal bridge orchestrator - content = ( - + ); + } + + return null; +} + +type TransactionWidgetScreen = + | { id: "init-ui" } + | { + id: "buy:1.methodSelection"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + transaction: PreparedTransaction; + } + | { + id: "buy:2.load-quote"; + destinationAmount: string; + destinationToken: TokenWithPrices; + receiverAddress: Address; + paymentMethod: PaymentMethod; + transaction: PreparedTransaction; + } + | { + id: "buy:3.preview"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + transaction: PreparedTransaction; + } + | { + id: "buy:4.execute-buy"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + destinationAmount: string; + destinationToken: TokenWithPrices; + paymentMethod: PaymentMethod; + receiverAddress: Address; + transaction: PreparedTransaction; + } + | { + id: "buy:5.success"; + completedStatuses: CompletedStatusResult[]; + preparedQuote: BridgePrepareResult; + transaction: PreparedTransaction; + } + | { + id: "execute-tx"; + transaction: PreparedTransaction; + } + | { + id: "error"; + error: Error; + }; + +type RequiredParams = T & { + [K in keys]-?: T[K]; +}; + +function TransactionWidgetContent( + props: RequiredParams< + TransactionWidgetProps, + "currency" | "showThirdwebBranding" | "paymentMethods" + > & { + connectLocale: ConnectLocale; + }, +) { + const [screen, setScreen] = useState({ + id: "init-ui", + }); + + const handleError = useCallback( + (error: Error) => { + console.error(error); + props.onError?.(error); + setScreen({ + id: "error", + error, + }); + }, + [props.onError], + ); + + if (screen.id === "init-ui") { + return ( + { - props.onCancel?.(); + onContinue={(destinationAmount, destinationToken, receiverAddress) => { + setScreen({ + id: "buy:1.methodSelection", + destinationAmount, + destinationToken, + transaction: props.transaction, + receiverAddress, + }); }} - onComplete={() => { - props.onSuccess?.(); + onExecuteTransaction={() => { + setScreen({ + id: "execute-tx", + transaction: props.transaction, + }); }} - onError={(err: Error) => { - props.onError?.(err); + showThirdwebBranding={props.showThirdwebBranding} + currency={props.currency} + buttonLabel={props.buttonLabel} + transaction={props.transaction} + /> + ); + } + + if (screen.id === "buy:1.methodSelection") { + return ( + { + setScreen({ id: "init-ui" }); + }} + onError={(error) => { + handleError(error); + }} + onPaymentMethodSelected={(paymentMethod) => { + setScreen({ + ...screen, + id: "buy:2.load-quote", + paymentMethod, + }); }} + /> + ); + } + + if (screen.id === "buy:2.load-quote") { + return ( + { + setScreen({ + ...screen, + id: "buy:1.methodSelection", + }); + }} + onError={(error) => { + handleError(error); + }} + onQuoteReceived={(preparedQuote, request) => { + setScreen({ + ...screen, + id: "buy:3.preview", + preparedQuote, + request, + }); + }} + paymentMethod={screen.paymentMethod} + receiver={screen.receiverAddress} + /> + ); + } + + if (screen.id === "buy:3.preview") { + return ( + { + setScreen({ + ...screen, + id: "buy:1.methodSelection", + }); + }} + onConfirm={() => { + setScreen({ + ...screen, + id: "buy:4.execute-buy", + }); + }} + onError={(error) => { + handleError(error); + }} + paymentMethod={screen.paymentMethod} + preparedQuote={screen.preparedQuote} + modeInfo={{ + mode: "transaction", + transaction: screen.transaction, + }} /> ); } + if (screen.id === "buy:4.execute-buy") { + return ( + { + setScreen({ + ...screen, + id: "buy:3.preview", + }); + }} + onCancel={() => { + props.onCancel?.(); + }} + onComplete={(completedStatuses) => { + setScreen({ + ...screen, + id: "buy:5.success", + completedStatuses, + }); + }} + request={screen.request} + wallet={screen.paymentMethod.payerWallet} + windowAdapter={webWindowAdapter} + /> + ); + } + + if (screen.id === "buy:5.success") { + return ( + { + setScreen({ id: "execute-tx", transaction: screen.transaction }); + }} + preparedQuote={screen.preparedQuote} + showContinueWithTx={true} + windowAdapter={webWindowAdapter} + /> + ); + } + + if (screen.id === "error") { + return ( + { + setScreen({ id: "init-ui" }); + props.onCancel?.(); + }} + onRetry={() => { + setScreen({ id: "init-ui" }); + }} + /> + ); + } + + if (screen.id === "execute-tx") { + return ( + { + setScreen({ id: "init-ui" }); + }} + onTxSent={(data) => { + props.onSuccess?.(data); + }} + tx={screen.transaction} + windowAdapter={webWindowAdapter} + /> + ); + } + + return null; +} + +/** + * @internal + */ +function TransactionWidgetContainer(props: { + theme: TransactionWidgetProps["theme"]; + className: string | undefined; + style?: React.CSSProperties | undefined; + children: React.ReactNode; +}) { return ( - + - {content} + {props.children} ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx index ada040f9eab..2772a9c444a 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx @@ -9,14 +9,14 @@ import { Container } from "../components/basic.js"; import { Spacer } from "../components/Spacer.js"; import { Text } from "../components/text.js"; -export interface UnsupportedTokenScreenProps { +type UnsupportedTokenScreenProps = { /** * The chain the token is on */ chain: Chain; client: ThirdwebClient; - tokenAddress?: string; -} + tokenAddress: string; +}; /** * Screen displayed when a specified token is not supported by the Bridge API diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index e93a4c871cd..eaa41b97b33 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -5,31 +5,25 @@ import { radius } from "../../../../core/design-system/index.js"; import { Container } from "../../components/basic.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; -import type { UIOptions } from "../BridgeOrchestrator.js"; -export function WithHeader({ - children, - uiOptions, - defaultTitle, - client, -}: { +export function WithHeader(props: { children: React.ReactNode; - uiOptions: UIOptions; - defaultTitle: string; + title: string; + description: string | undefined; + image: string | undefined; client: ThirdwebClient; }) { const theme = useCustomTheme(); - const showTitle = uiOptions.metadata?.title !== ""; return ( {/* image */} - {uiOptions.metadata?.image && ( + {props.image && (
- {(showTitle || uiOptions.metadata?.description) && ( + {(props.title || props.description) && ( <> {/* title */} - {showTitle && ( + {props.title && ( - {uiOptions.metadata?.title || defaultTitle} + {props.title} )} {/* Description */} - {uiOptions.metadata?.description && ( + {props.description && ( <> - {uiOptions.metadata?.description} + {props.description} )} @@ -65,7 +59,7 @@ export function WithHeader({ )} - {children} + {props.children} ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/token-query.ts b/packages/thirdweb/src/react/web/ui/Bridge/common/token-query.ts new file mode 100644 index 00000000000..8c7c0bd7ae2 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/token-query.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import type { TokenWithPrices } from "../../../../../bridge/index.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getToken } from "../../../../../pay/convert/get-token.js"; + +type TokenQueryResult = + | { type: "success"; token: TokenWithPrices } + | { + type: "unsupported_token"; + }; + +export function useTokenQuery(params: { + tokenAddress: string | undefined; + chainId: number; + client: ThirdwebClient; +}) { + return useQuery({ + queryFn: async (): Promise => { + const tokenAddress = params.tokenAddress || NATIVE_TOKEN_ADDRESS; + const token = await getToken( + params.client, + tokenAddress, + params.chainId, + ).catch((err) => { + err.message.includes("not supported") ? undefined : Promise.reject(err); + }); + + if (!token) { + return { + type: "unsupported_token", + }; + } + + return { + token: token, + type: "success", + }; + }, + queryKey: ["bridge.getToken", params], + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index cd48413414f..d90249c20f0 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -4,11 +4,11 @@ import { useMemo } from "react"; import { trackPayEvent } from "../../../../../analytics/track/pay.js"; import { defineChain } from "../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { radius, spacing } from "../../../../core/design-system/index.js"; import { useChainsQuery } from "../../../../core/hooks/others/useChainQuery.js"; import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js"; -import type { PaymentMethod } from "../../../../core/machines/paymentMachine.js"; import { formatCurrencyAmount, formatTokenAmount, @@ -17,17 +17,20 @@ import { Container, ModalHeader } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; -import type { UIOptions } from "../BridgeOrchestrator.js"; +import type { ModeInfo, PaymentMethod } from "../types.js"; + import { PaymentOverview } from "./PaymentOverview.js"; -export interface PaymentDetailsProps { - title?: string; - confirmButtonLabel?: string; +type PaymentDetailsProps = { + metadata: { + title: string | undefined; + description: string | undefined; + }; - /** - * The UI mode to use - */ - uiOptions: UIOptions; + currency: SupportedFiatCurrency; + modeInfo: ModeInfo; + + confirmButtonLabel: string | undefined; /** * The client to use */ @@ -55,18 +58,19 @@ export interface PaymentDetailsProps { * Called when an error occurs */ onError: (error: Error) => void; -} +}; export function PaymentDetails({ - title, + metadata, confirmButtonLabel, - uiOptions, client, paymentMethod, preparedQuote, onConfirm, onBack, onError, + currency, + modeInfo, }: PaymentDetailsProps) { const theme = useCustomTheme(); @@ -267,7 +271,10 @@ export function PaymentDetails({ return ( - + @@ -276,6 +283,9 @@ export function PaymentDetails({ {displayData.destinationToken && ( )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentOverview.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentOverview.tsx index 8cd744a7394..0c6ee0f6df2 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentOverview.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentOverview.tsx @@ -1,21 +1,22 @@ import type { Token } from "../../../../../bridge/index.js"; import { defineChain } from "../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; +import type { PreparedTransaction } from "../../../../../transaction/prepare-transaction.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { radius, spacing } from "../../../../core/design-system/index.js"; import { useTransactionDetails } from "../../../../core/hooks/useTransactionDetails.js"; -import type { PaymentMethod } from "../../../../core/machines/paymentMachine.js"; import { getFiatCurrencyIcon } from "../../ConnectWallet/screens/Buy/fiat/currencies.js"; import { FiatValue } from "../../ConnectWallet/screens/Buy/swap/FiatValue.js"; import { StepConnectorArrow } from "../../ConnectWallet/screens/Buy/swap/StepConnector.js"; import { WalletRow } from "../../ConnectWallet/screens/Buy/swap/WalletRow.js"; import { Container } from "../../components/basic.js"; import { Text } from "../../components/text.js"; -import type { UIOptions } from "../BridgeOrchestrator.js"; import { TokenBalanceRow } from "../common/TokenBalanceRow.js"; +import type { ModeInfo, PaymentMethod } from "../types.js"; export function PaymentOverview(props: { - uiOptions: UIOptions; + currency: SupportedFiatCurrency; receiver: string; sender?: string; client: ThirdwebClient; @@ -23,6 +24,11 @@ export function PaymentOverview(props: { toToken: Token; fromAmount: string; toAmount: string; + metadata: { + title: string | undefined; + description: string | undefined; + }; + modeInfo: ModeInfo; }) { const theme = useCustomTheme(); const sender = @@ -32,6 +38,7 @@ export function PaymentOverview(props: { : undefined); const isDifferentRecipient = props.receiver.toLowerCase() !== sender?.toLowerCase(); + return ( {/* Sell */} @@ -63,7 +70,7 @@ export function PaymentOverview(props: { )} {props.paymentMethod.type === "wallet" && ( {}} @@ -134,7 +141,7 @@ export function PaymentOverview(props: { /> )} - {props.uiOptions.mode === "direct_payment" && ( + {props.modeInfo.mode === "direct_payment" && ( - {props.uiOptions.metadata?.title || "Payment"} + {props.metadata.title || "Payment"} - {props.uiOptions.metadata?.description && ( + {props.metadata.description && ( - {props.uiOptions.metadata.description} + {props.metadata.description} )} @@ -159,24 +166,24 @@ export function PaymentOverview(props: { style={{ alignItems: "flex-end" }} > - {props.uiOptions.paymentInfo.amount} {props.toToken.symbol} + {props.modeInfo.paymentInfo.amount} {props.toToken.symbol} )} - {props.uiOptions.mode === "fund_wallet" && ( + {props.modeInfo.mode === "fund_wallet" && ( {}} @@ -188,11 +195,13 @@ export function PaymentOverview(props: { token={props.toToken} /> )} - {props.uiOptions.mode === "transaction" && ( + {props.modeInfo.mode === "transaction" && ( )} @@ -201,16 +210,21 @@ export function PaymentOverview(props: { } const TransactionOverViewCompact = (props: { - uiOptions: Extract; + transaction: PreparedTransaction; + currency: SupportedFiatCurrency; paymentMethod: PaymentMethod; client: ThirdwebClient; + metadata: { + title: string | undefined; + description: string | undefined; + }; }) => { const theme = useCustomTheme(); const txInfo = useTransactionDetails({ client: props.client, - transaction: props.uiOptions.transaction, + transaction: props.transaction, wallet: props.paymentMethod.payerWallet, - currency: props.uiOptions.currency, + currency: props.currency, }); if (!txInfo.data) { @@ -234,7 +248,7 @@ const TransactionOverViewCompact = (props: { }} /> {/* Description skeleton - only if metadata exists */} - {props.uiOptions.metadata?.description && ( + {props.metadata.description && (
- {props.uiOptions.metadata?.title || "Transaction"} + {props.metadata.title || "Transaction"} - {props.uiOptions.metadata?.description && ( + {props.metadata.description && ( - {props.uiOptions.metadata.description} + {props.metadata.description} )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index db86c04755e..d71fce89e2e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -12,18 +12,18 @@ import type { Wallet } from "../../../../../wallets/interfaces/wallet.js"; import { usePaymentMethods } from "../../../../core/hooks/usePaymentMethods.js"; import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; import { useConnectedWallets } from "../../../../core/hooks/wallets/useConnectedWallets.js"; -import type { PaymentMethod } from "../../../../core/machines/paymentMachine.js"; import type { SupportedTokens } from "../../../../core/utils/defaultTokens.js"; import type { ConnectLocale } from "../../ConnectWallet/locale/types.js"; import { WalletSwitcherConnectionScreen } from "../../ConnectWallet/screens/WalletSwitcherConnectionScreen.js"; import { Container, ModalHeader } from "../../components/basic.js"; import { Spacer } from "../../components/Spacer.js"; import type { PayEmbedConnectOptions } from "../../PayEmbed.js"; +import type { PaymentMethod } from "../types.js"; import { FiatProviderSelection } from "./FiatProviderSelection.js"; import { TokenSelection } from "./TokenSelection.js"; import { WalletFiatSelection } from "./WalletFiatSelection.js"; -export interface PaymentSelectionProps { +type PaymentSelectionProps = { /** * The destination token to bridge to */ @@ -37,7 +37,7 @@ export interface PaymentSelectionProps { /** * The receiver address */ - receiverAddress?: Address; + receiverAddress: Address; /** * ThirdwebClient for API calls @@ -57,47 +57,41 @@ export interface PaymentSelectionProps { /** * Called when user wants to go back */ - onBack?: () => void; + onBack: () => void; /** * Connect options for wallet connection */ - connectOptions?: PayEmbedConnectOptions; + connectOptions: PayEmbedConnectOptions | undefined; /** * Locale for connect UI */ connectLocale: ConnectLocale; - /** - * Whether to include the destination token in the payment methods - */ - includeDestinationToken?: boolean; - /** * Allowed payment methods - * @default ["crypto", "card"] */ - paymentMethods?: ("crypto" | "card")[]; + paymentMethods: ("crypto" | "card")[]; /** * Fee payer */ - feePayer?: "sender" | "receiver"; + feePayer: "sender" | "receiver" | undefined; /** * The currency to use for the payment. * @default "USD" */ - currency?: SupportedFiatCurrency; + currency: SupportedFiatCurrency; /** * The user's ISO 3166 alpha-2 country code. This is used to determine onramp provider support. */ country: string | undefined; - supportedTokens?: SupportedTokens; -} + supportedTokens: SupportedTokens | undefined; +}; type Step = | { type: "walletSelection" } @@ -115,8 +109,7 @@ export function PaymentSelection({ onBack, connectOptions, connectLocale, - includeDestinationToken, - paymentMethods = ["crypto", "card"], + paymentMethods, supportedTokens, feePayer, currency, @@ -161,10 +154,6 @@ export function PaymentSelection({ client, destinationAmount, destinationToken, - includeDestinationToken: - includeDestinationToken || - receiverAddress?.toLowerCase() !== - payerWallet?.getAccount()?.address?.toLowerCase(), payerWallet, supportedTokens, }); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx index 61ed2566f6c..8e6c268e71e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx @@ -4,7 +4,6 @@ import type { ThirdwebClient } from "../../../../../client/client.js"; import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { radius, spacing } from "../../../../core/design-system/index.js"; -import type { PaymentMethod } from "../../../../core/machines/paymentMachine.js"; import { formatCurrencyAmount, formatTokenAmount, @@ -15,6 +14,7 @@ import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; import { TokenAndChain } from "../common/TokenAndChain.js"; +import type { PaymentMethod } from "../types.js"; interface TokenSelectionProps { paymentMethods: PaymentMethod[]; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx index 8d491066020..bcafd515e93 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx @@ -13,14 +13,13 @@ import { Container, ModalHeader } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; -import type { UIOptions } from "../BridgeOrchestrator.js"; import { PaymentReceipt } from "./PaymentReceipt.js"; -export interface SuccessScreenProps { +type SuccessScreenProps = { /** * UI options */ - uiOptions: UIOptions; + showContinueWithTx: boolean; /** * Prepared quote from Bridge.prepare */ @@ -46,19 +45,19 @@ export interface SuccessScreenProps { /** * Whether or not this payment is associated with a payment ID. If it does, we show a different message. */ - hasPaymentId?: boolean; -} + hasPaymentId: boolean; +}; type ViewState = "success" | "detail"; export function SuccessScreen({ - uiOptions, preparedQuote, completedStatuses, onDone, windowAdapter, client, hasPaymentId = false, + showContinueWithTx, }: SuccessScreenProps) { const theme = useCustomTheme(); const [viewState, setViewState] = useState("success"); @@ -137,7 +136,7 @@ export function SuccessScreen({ {hasPaymentId ? "You can now close this page and return to the application." - : uiOptions.mode === "transaction" + : showContinueWithTx ? "Click continue to execute your transaction." : "Your payment has been completed successfully."} @@ -158,7 +157,7 @@ export function SuccessScreen({ {!hasPaymentId && ( )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index f27e5a800ef..69752d88c2c 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -260,7 +260,7 @@ export function SwapWidget(props: SwapWidgetProps) { style={props.style} className={props.className} > - + ); } @@ -314,7 +314,11 @@ type SwapWidgetScreen = > | { id: "error"; error: Error; preparedQuote: SwapPreparedQuote }; -function SwapWidgetContent(props: SwapWidgetProps) { +function SwapWidgetContent( + props: SwapWidgetProps & { + currency: SupportedFiatCurrency; + }, +) { const [screen, setScreen] = useState({ id: "1:swap-ui" }); const activeWalletInfo = useActiveWalletInfo(); const isPersistEnabled = props.persistTokenSelections !== false; @@ -385,7 +389,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { client={props.client} theme={props.theme || "dark"} connectOptions={props.connectOptions} - currency={props.currency || "USD"} + currency={props.currency} activeWalletInfo={activeWalletInfo} buyToken={buyToken} sellToken={sellToken} @@ -412,7 +416,10 @@ function SwapWidgetContent(props: SwapWidgetProps) { if (screen.id === "2:preview") { return ( { @@ -434,10 +441,9 @@ function SwapWidgetContent(props: SwapWidgetProps) { action: screen.mode, }} preparedQuote={screen.preparedQuote} - uiOptions={{ - destinationToken: screen.buyToken, + currency={props.currency} + modeInfo={{ mode: "fund_wallet", - currency: props.currency, }} /> ); @@ -487,11 +493,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { }); }} preparedQuote={screen.preparedQuote} - uiOptions={{ - destinationToken: screen.buyToken, - mode: "fund_wallet", - currency: props.currency, - }} + showContinueWithTx={false} windowAdapter={webWindowAdapter} hasPaymentId={false} // TODO Question: Do we need to expose this as prop? /> diff --git a/packages/thirdweb/src/react/web/ui/Bridge/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/types.ts new file mode 100644 index 00000000000..2f5929a7368 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/types.ts @@ -0,0 +1,46 @@ +import type { Quote, TokenWithPrices } from "../../../../bridge/index.js"; +import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; +import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; + +export type DirectPaymentInfo = { + sellerAddress: `0x${string}`; + token: TokenWithPrices; + amount: string; + feePayer?: "sender" | "receiver"; +}; + +export type ModeInfo = + | { + mode: "direct_payment"; + paymentInfo: DirectPaymentInfo; + } + | { + mode: "transaction"; + transaction: PreparedTransaction; + } + | { + mode: "fund_wallet"; + }; + +export type RequiredParams = T & { + [K in keys]-?: T[K]; +}; + +/** + * Payment method types with their required data + */ +export type PaymentMethod = + | { + type: "wallet"; + action: "buy" | "sell"; + payerWallet: Wallet; + originToken: TokenWithPrices; + balance: bigint; + quote: Quote; + } + | { + type: "fiat"; + payerWallet?: Wallet; + currency: string; + onramp: "stripe" | "coinbase" | "transak"; + }; diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx index c816ba8e693..fa3a71cc051 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/DepositScreen.tsx @@ -75,7 +75,6 @@ const WaitingBadge = /* @__PURE__ */ StyledDiv(() => { * @internal */ export function DepositScreen(props: { - onBack: (() => void) | undefined; connectLocale: ConnectLocale; client: ThirdwebClient; tx: PreparedTransaction; @@ -144,7 +143,7 @@ export function DepositScreen(props: { return ( - + diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx index 293168eceab..b3c3663c075 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { trackPayEvent } from "../../../../analytics/track/pay.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { WaitForReceiptOptions } from "../../../../transaction/actions/wait-for-tx-receipt.js"; import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; import { resolvePromisedValue } from "../../../../utils/promise/resolve-promised-value.js"; @@ -13,7 +14,7 @@ import { useActiveWallet } from "../../../core/hooks/wallets/useActiveWallet.js" import type { SupportedTokens } from "../../../core/utils/defaultTokens.js"; import { webWindowAdapter } from "../../adapters/WindowAdapter.js"; import { LoadingScreen } from "../../wallets/shared/LoadingScreen.js"; -import { BridgeOrchestrator } from "../Bridge/BridgeOrchestrator.js"; +import { TransactionWidgetContentWrapper } from "../Bridge/TransactionWidget.js"; import { useConnectLocale } from "../ConnectWallet/locale/getConnectLocale.js"; import { Modal } from "../components/Modal.js"; import type { LocaleId } from "../types.js"; @@ -33,7 +34,8 @@ type ModalProps = { payOptions: PayUIOptions; onTxSent: (data: WaitForReceiptOptions) => void; modalMode: "buy" | "deposit"; - country?: string; + country: string | undefined; + currency: SupportedFiatCurrency | undefined; }; export function TransactionModal(props: ModalProps) { @@ -84,9 +86,45 @@ export function TransactionModal(props: ModalProps) { ); } -function TransactionModalContent(props: ModalProps & { onBack?: () => void }) { +function TransactionModalContent(props: ModalProps) { + if (props.modalMode === "deposit") { + return ; + } + + return ( + { + props.onTxSent(data); + }} + title={props.payOptions.metadata?.name} + description={props.payOptions.metadata?.description} + image={props.payOptions.metadata?.image} + paymentMethods={ + props.payOptions.buyWithCrypto === false + ? ["card"] + : props.payOptions.buyWithFiat === false + ? ["crypto"] + : ["crypto", "card"] + } + showThirdwebBranding={props.payOptions.showThirdwebBranding} + supportedTokens={props.supportedTokens} + onError={undefined} + paymentLinkId={undefined} + buttonLabel={undefined} + purchaseData={undefined} + /> + ); +} + +function DepositAndExecuteTx(props: ModalProps) { const localeQuery = useConnectLocale(props.localeId); - const [screen, setScreen] = useState<"buy" | "execute-tx">("buy"); + const [screen, setScreen] = useState<"deposit" | "execute-tx">("deposit"); if (!localeQuery.data) { return ; @@ -103,12 +141,11 @@ function TransactionModalContent(props: ModalProps & { onBack?: () => void }) { ); } - if (props.modalMode === "deposit") { + if (screen === "deposit") { return ( { setScreen("execute-tx"); }} @@ -117,25 +154,5 @@ function TransactionModalContent(props: ModalProps & { onBack?: () => void }) { ); } - return ( - { - setScreen("execute-tx"); - }} - onError={(_error) => {}} - paymentLinkId={undefined} - presetOptions={undefined} - purchaseData={undefined} - receiverAddress={undefined} - uiOptions={{ - mode: "transaction", - transaction: props.tx, - }} - /> - ); + return null; } diff --git a/packages/thirdweb/src/stories/Bridge/BridgeOrchestrator.stories.tsx b/packages/thirdweb/src/stories/Bridge/BridgeOrchestrator.stories.tsx deleted file mode 100644 index 598dc9a2aea..00000000000 --- a/packages/thirdweb/src/stories/Bridge/BridgeOrchestrator.stories.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import type { Theme } from "../../react/core/design-system/index.js"; -import { - BridgeOrchestrator, - type BridgeOrchestratorProps, -} from "../../react/web/ui/Bridge/BridgeOrchestrator.js"; -import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { - DIRECT_PAYMENT_UI_OPTIONS, - FUND_WALLET_UI_OPTIONS, - TRANSACTION_UI_OPTIONS, -} from "./fixtures.js"; - -/** - * BridgeOrchestrator is the main orchestrator component for the Bridge payment flow. - * It manages the complete state machine navigation between different screens and - * handles the coordination of payment methods, routes, and execution. - */ - -// Props interface for the wrapper component -interface BridgeOrchestratorWithThemeProps extends BridgeOrchestratorProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const BridgeOrchestratorWithTheme = ( - props: BridgeOrchestratorWithThemeProps, -) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { - args: { - client: storyClient, - onCancel: () => {}, - onComplete: () => {}, - onError: (error) => console.error("Bridge error:", error), - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.usdcDefault, - country: "US", - }, - argTypes: { - onCancel: { action: "flow cancelled" }, - onComplete: { action: "flow completed" }, - onError: { action: "error occurred" }, - presetOptions: { - control: "object", - description: "Quick buy options", - }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - }, - component: BridgeOrchestratorWithTheme, - parameters: { - docs: { - description: { - component: - "**BridgeOrchestrator** is the main orchestrator component that manages the complete Bridge payment flow using XState FSM.\n\n" + - "## Features\n" + - "- **State Machine Navigation**: Uses XState v5 for predictable state transitions\n" + - "- **Payment Method Selection**: Supports wallet and fiat payment methods\n" + - "- **Route Preview**: Shows detailed transaction steps and fees\n" + - "- **Step Execution**: Real-time progress tracking\n" + - "- **Error Handling**: Comprehensive error states with retry functionality\n" + - "- **Theme Support**: Works with both light and dark themes\n\n" + - "## State Flow\n" + - "1. **Resolve Requirements** → 2. **Method Selection** → 3. **Quote** → 4. **Preview** → 5. **Prepare** → 6. **Execute** → 7. **Success**\n\n" + - "Each state can transition to the **Error** state, which provides retry functionality.", - }, - }, - layout: "fullscreen", - }, - tags: ["autodocs"], - title: "Bridge/BridgeOrchestrator", -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -/** - * Default BridgeOrchestrator in light theme. - */ -export const Light: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.usdcDefault, - }, - parameters: { - backgrounds: { default: "light" }, - }, -}; - -/** - * BridgeOrchestrator in dark theme. - */ -export const Dark: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.usdcDefault, - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -/** - * Direct payment mode for purchasing a specific product/service. - */ -export const DirectPayment: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.digitalArt, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Direct payment mode shows a product purchase interface with the item image, price, seller address, and network information. The user can connect their wallet and proceed with the payment.", - }, - }, - }, -}; - -/** - * Direct payment mode in light theme. - */ -export const DirectPaymentLight: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.concertTicket, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of direct payment mode, showing a different product example with USDC payment.", - }, - }, - }, -}; - -/** - * Transaction mode showing a complex contract interaction. - */ -export const Transaction: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Transaction mode showing a complex contract interaction (claimTo function) with function name extraction from contract ABI and detailed cost breakdown.", - }, - }, - }, -}; - -/** - * Transaction mode in light theme showing an ERC20 token transfer. - */ -export const TransactionLight: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.erc20Transfer, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of transaction mode showing an ERC20 token transfer with proper token amount formatting and USD conversion.", - }, - }, - }, -}; - -/** - * Transaction mode in light theme showing an ERC20 token transfer. - */ -export const TransactionJPYCurrency: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: undefined, - purchaseData: undefined, - receiverAddress: undefined, - theme: "light", - uiOptions: { - ...TRANSACTION_UI_OPTIONS.erc20Transfer, - currency: "JPY", - }, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of transaction mode showing an ERC20 token transfer with proper token amount formatting and USD conversion.", - }, - }, - }, -}; - -export const CustompresetOptions: Story = { - args: { - connectLocale: undefined, - connectOptions: undefined, - onCancel: undefined, - onComplete: undefined, - onError: undefined, - paymentLinkId: undefined, - presetOptions: [1, 2, 3], - purchaseData: undefined, - receiverAddress: undefined, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.ethDefault, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Fund wallet mode with custom quick options showing ETH with [1, 2, 3] preset amounts.", - }, - }, - }, -}; diff --git a/packages/thirdweb/src/stories/Bridge/CheckoutWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/CheckoutWidget.stories.tsx new file mode 100644 index 00000000000..68d551b241e --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/CheckoutWidget.stories.tsx @@ -0,0 +1,147 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { polygon } from "../../chains/chain-definitions/polygon.js"; +import { defineChain } from "../../chains/utils.js"; +import { + CheckoutWidget, + type CheckoutWidgetProps, +} from "../../react/web/ui/Bridge/CheckoutWidget.js"; +import { storyClient } from "../utils.js"; +import { DIRECT_PAYMENT_UI_OPTIONS } from "./fixtures.js"; + +const meta = { + args: { + client: storyClient, + onCancel: () => {}, + onError: () => {}, + onSuccess: () => {}, + currency: "USD", + }, + component: StoryVariant, + title: "Bridge/Checkout/CheckoutWidget", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DigitalArt: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.buttonLabel, + }, +}; + +export const DigitalArtJPCurrency: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.token.chainId, + ), + currency: "JPY", + seller: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.digitalArt.buttonLabel, + }, +}; + +export const ConcertTicket: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.concertTicket.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.concertTicket.buttonLabel, + }, +}; + +export const SubscriptionService: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.subscription.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.subscription.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.subscription.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.subscription.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.subscription.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.subscription.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.subscription.buttonLabel, + }, +}; + +export const PhysicalProduct: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.sneakers.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.sneakers.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.sneakers.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.sneakers.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.sneakers.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.sneakers.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.sneakers.buttonLabel, + }, +}; + +export const NoImage: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.credits.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.credits.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.credits.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.credits.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.credits.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.credits.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.credits.buttonLabel, + }, +}; + +export const CustomButtonLabel: Story = { + args: { + amount: DIRECT_PAYMENT_UI_OPTIONS.customButton.paymentInfo.amount, + chain: defineChain( + DIRECT_PAYMENT_UI_OPTIONS.customButton.paymentInfo.token.chainId, + ), + seller: DIRECT_PAYMENT_UI_OPTIONS.customButton.paymentInfo.sellerAddress, + name: DIRECT_PAYMENT_UI_OPTIONS.customButton.metadata?.title, + description: DIRECT_PAYMENT_UI_OPTIONS.customButton.metadata?.description, + image: DIRECT_PAYMENT_UI_OPTIONS.customButton.metadata?.image, + buttonLabel: DIRECT_PAYMENT_UI_OPTIONS.customButton.buttonLabel, + }, +}; + +export const SmallAmount: Story = { + args: { + amount: "0.01", + chain: polygon, + seller: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425", + }, +}; + +function StoryVariant(props: CheckoutWidgetProps) { + return ( +
+ + +
+ ); +} diff --git a/packages/thirdweb/src/stories/Bridge/DirectPayment.stories.tsx b/packages/thirdweb/src/stories/Bridge/DirectPayment.stories.tsx deleted file mode 100644 index 8ea869758b0..00000000000 --- a/packages/thirdweb/src/stories/Bridge/DirectPayment.stories.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import type { Theme } from "../../react/core/design-system/index.js"; -import { - DirectPayment, - type DirectPaymentProps, -} from "../../react/web/ui/Bridge/DirectPayment.js"; -import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { DIRECT_PAYMENT_UI_OPTIONS } from "./fixtures.js"; - -// Props interface for the wrapper component -interface DirectPaymentWithThemeProps extends DirectPaymentProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const DirectPaymentWithTheme = (props: DirectPaymentWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { - args: { - client: storyClient, - onContinue: (_amount, _token, _receiverAddress) => {}, - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.digitalArt, - }, - argTypes: { - onContinue: { - action: "continue clicked", - description: "Called when user continues with the payment", - }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - uiOptions: { - description: - "UI configuration for direct payment mode including payment info and metadata", - }, - }, - component: DirectPaymentWithTheme, - parameters: { - docs: { - description: { - component: - "DirectPayment component displays a product/service purchase interface with payment details.\n\n" + - "## Features\n" + - "- **Product Display**: Shows product name, image, and pricing\n" + - "- **Payment Details**: Token amount, network information, and seller address\n" + - "- **Wallet Integration**: Connect button or continue with active wallet\n" + - "- **Responsive Design**: Adapts to different screen sizes and themes\n" + - "- **Fee Configuration**: Support for sender or receiver paying fees\n\n" + - "This component is used in the 'direct_payment' mode of BridgeOrchestrator for purchasing specific items or services. It now accepts uiOptions directly to configure payment info and metadata.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/DirectPayment", -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const DigitalArt: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.digitalArt, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example of purchasing a digital art NFT with ETH payment. Shows the product image, pricing in ETH, and seller information with sender paying fees.", - }, - }, - }, -}; - -export const DigitalArtLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.digitalArt, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version of the digital art purchase interface.", - }, - }, - }, -}; - -export const ConcertTicket: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.concertTicket, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example of purchasing a concert ticket with USDC payment. Shows different product type, stable token pricing, and receiver paying fees.", - }, - }, - }, -}; - -export const ConcertTicketLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.concertTicket, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version of the concert ticket purchase.", - }, - }, - }, -}; - -export const SubscriptionService: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.subscription, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example of a subscription service payment with detailed description. Shows how the component works for recurring service payments with comprehensive product information.", - }, - }, - }, -}; - -export const SubscriptionServiceLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.subscription, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of subscription service payment with full description text.", - }, - }, - }, -}; - -export const PhysicalProduct: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.sneakers, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example of purchasing physical products with crypto payments. Shows how the component adapts to different product types with ETH payment.", - }, - }, - }, -}; - -export const PhysicalProductLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.sneakers, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version of physical product purchase.", - }, - }, - }, -}; - -export const NoImage: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.credits, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example of purchasing digital credits without product image. Shows how the component handles text-only products with description fallback.", - }, - }, - }, -}; - -export const NoImageLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.credits, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version of credits purchase without image.", - }, - }, - }, -}; - -export const CustomButtonLabel: Story = { - args: { - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example showcasing custom button label functionality. The button shows 'Purchase Now' instead of the default 'Buy Now' text.", - }, - }, - }, -}; - -export const CustomButtonLabelLight: Story = { - args: { - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version with custom button label 'Purchase Now'.", - }, - }, - }, -}; diff --git a/packages/thirdweb/src/stories/Bridge/ErrorBanner.stories.tsx b/packages/thirdweb/src/stories/Bridge/ErrorBanner.stories.tsx index 9575ae9f614..281a3499faf 100644 --- a/packages/thirdweb/src/stories/Bridge/ErrorBanner.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/ErrorBanner.stories.tsx @@ -1,8 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { createThirdwebClient } from "../../client/client.js"; -import type { Theme } from "../../react/core/design-system/index.js"; import { ErrorBanner } from "../../react/web/ui/Bridge/ErrorBanner.js"; -import { ModalThemeWrapper } from "../utils.js"; +import { ModalThemeWrapper, storyClient } from "../utils.js"; const mockNetworkError = new Error( "Network connection failed. Please check your internet connection and try again.", @@ -13,135 +11,41 @@ const mockInsufficientFundsError = new Error( ); const mockGenericError = new Error("An unexpected error occurred."); -// Props interface for the wrapper component -interface ErrorBannerWithThemeProps { - error: Error; - onRetry: () => void; - onCancel?: () => void; - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const ErrorBannerWithTheme = (props: ErrorBannerWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { - error: mockNetworkError, onCancel: () => {}, onRetry: () => {}, - theme: "dark", + client: storyClient, }, - argTypes: { - onCancel: { action: "cancel clicked" }, - onRetry: { action: "retry clicked" }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - }, - component: ErrorBannerWithTheme, - parameters: { - docs: { - description: { - component: - "Error banner component that displays user-friendly error messages with retry functionality and optional cancel action.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/ErrorBanner", -} satisfies Meta; + component: ErrorBanner, + decorators: [ + (Story) => ( + + + + ), + ], + title: "Bridge/screens/ErrorBanner", +}; export default meta; type Story = StoryObj; -export const Light: Story = { - args: { - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - }, -}; - -export const Dark: Story = { - args: { - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - export const NetworkError: Story = { args: { error: mockNetworkError, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const NetworkErrorLight: Story = { - args: { - error: mockNetworkError, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; export const UserRejectedError: Story = { args: { error: mockUserRejectedError, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const UserRejectedErrorLight: Story = { - args: { - error: mockUserRejectedError, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; export const InsufficientFundsError: Story = { args: { error: mockInsufficientFundsError, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const InsufficientFundsErrorLight: Story = { - args: { - error: mockInsufficientFundsError, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; @@ -149,40 +53,11 @@ export const WithoutCancelButton: Story = { args: { error: mockGenericError, onCancel: undefined, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const WithoutCancelButtonLight: Story = { - args: { - error: mockGenericError, - onCancel: undefined, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; export const EmptyMessage: Story = { args: { error: new Error(""), - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const EmptyMessageLight: Story = { - args: { - error: new Error(""), - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; diff --git a/packages/thirdweb/src/stories/Bridge/FundWallet.stories.tsx b/packages/thirdweb/src/stories/Bridge/FundWallet.stories.tsx deleted file mode 100644 index 04c643753bd..00000000000 --- a/packages/thirdweb/src/stories/Bridge/FundWallet.stories.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import type { Theme } from "../../react/core/design-system/index.js"; -import type { FundWalletProps } from "../../react/web/ui/Bridge/FundWallet.js"; -import { FundWallet } from "../../react/web/ui/Bridge/FundWallet.js"; -import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { FUND_WALLET_UI_OPTIONS, RECEIVER_ADDRESSES } from "./fixtures.js"; - -// Props interface for the wrapper component -interface FundWalletWithThemeProps extends FundWalletProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const FundWalletWithTheme = (props: FundWalletWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { - args: { - client: storyClient, - onContinue: (amount, token, receiverAddress) => { - alert(`Continue with ${amount} ${token.symbol} to ${receiverAddress}`); - }, - receiverAddress: RECEIVER_ADDRESSES.primary, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.ethDefault, - }, - argTypes: { - onContinue: { action: "continue clicked" }, - receiverAddress: { - description: "Optional receiver address (defaults to connected wallet)", - }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - uiOptions: { - description: "UI configuration for fund wallet mode", - }, - }, - component: FundWalletWithTheme, - parameters: { - docs: { - description: { - component: - "FundWallet component allows users to specify the amount they want to add to their wallet. This is the first screen in the fund_wallet flow before method selection.\n\n" + - "## Features\n" + - "- **Token Selection**: Choose from different tokens (ETH, USDC, UNI)\n" + - "- **Amount Input**: Enter custom amount or use quick options\n" + - "- **Receiver Address**: Optional receiver address (defaults to connected wallet)\n" + - "- **Quick Options**: Preset amounts for faster selection\n" + - "- **Theme Support**: Works with both light and dark themes\n\n" + - "This component now accepts uiOptions directly to configure the destination token, initial amount, and quick options.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/FundWallet", -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Light: Story = { - args: { - receiverAddress: undefined, - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.ethDefault, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Default fund wallet interface in light theme with ETH token.", - }, - }, - }, -}; - -export const Dark: Story = { - args: { - receiverAddress: undefined, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.ethDefault, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: "Default fund wallet interface in dark theme with ETH token.", - }, - }, - }, -}; - -export const WithInitialAmount: Story = { - args: { - receiverAddress: RECEIVER_ADDRESSES.secondary, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.ethWithAmount, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Fund wallet with pre-filled amount and specified receiver address.", - }, - }, - }, -}; - -export const WithInitialAmountLight: Story = { - args: { - receiverAddress: RECEIVER_ADDRESSES.secondary, - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.ethWithAmount, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version with pre-filled amount and receiver address.", - }, - }, - }, -}; - -export const USDCToken: Story = { - args: { - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.usdcDefault, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: "Fund wallet configured for USDC token with initial amount.", - }, - }, - }, -}; - -export const USDCTokenLight: Story = { - args: { - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.usdcDefault, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version for USDC token funding.", - }, - }, - }, -}; - -export const LargeAmount: Story = { - args: { - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.uniLarge, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Fund wallet with UNI token and large pre-filled amount to test formatting.", - }, - }, - }, -}; - -export const LargeAmountLight: Story = { - args: { - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.uniLarge, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version with UNI token and large amount.", - }, - }, - }, -}; - -export const CustomButtonLabel: Story = { - args: { - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example showcasing custom button label functionality. The button shows 'Add Funds Now' instead of the default 'Buy [amount] [symbol]' text.", - }, - }, - }, -}; - -export const CustomButtonLabelLight: Story = { - args: { - theme: "light", - uiOptions: FUND_WALLET_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version with custom button label 'Add Funds Now'.", - }, - }, - }, -}; diff --git a/packages/thirdweb/src/stories/Bridge/PaymentDetails.stories.tsx b/packages/thirdweb/src/stories/Bridge/PaymentDetails.stories.tsx index f95893cae68..b721e0bac51 100644 --- a/packages/thirdweb/src/stories/Bridge/PaymentDetails.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/PaymentDetails.stories.tsx @@ -1,10 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { Theme } from "../../react/core/design-system/index.js"; -import type { PaymentMethod } from "../../react/core/machines/paymentMachine.js"; -import { - PaymentDetails, - type PaymentDetailsProps, -} from "../../react/web/ui/Bridge/payment-details/PaymentDetails.js"; +import { PaymentDetails } from "../../react/web/ui/Bridge/payment-details/PaymentDetails.js"; +import type { PaymentMethod } from "../../react/web/ui/Bridge/types.js"; import { stringify } from "../../utils/json.js"; import { ModalThemeWrapper, storyClient } from "../utils.js"; import { @@ -17,7 +13,6 @@ import { simpleBuyQuote, simpleOnrampQuote, TRANSACTION_UI_OPTIONS, - USDC, } from "./fixtures.js"; const fiatPaymentMethod: PaymentMethod = { @@ -63,324 +58,92 @@ const ethCryptoPaymentMethod: PaymentMethod = JSON.parse( }), ); -// Props interface for the wrapper component -interface PaymentDetailsWithThemeProps extends PaymentDetailsProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const PaymentDetailsWithTheme = (props: PaymentDetailsWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { onBack: () => {}, onConfirm: () => {}, onError: (error) => console.error("Error:", error), preparedQuote: simpleOnrampQuote, - theme: "dark", - uiOptions: { - destinationToken: USDC, + modeInfo: { mode: "fund_wallet", }, - }, - argTypes: { - onBack: { action: "back clicked" }, - onConfirm: { action: "route confirmed" }, - onError: { action: "error occurred" }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], + currency: "USD", + metadata: { + title: undefined, + description: undefined, }, + client: storyClient, + confirmButtonLabel: undefined, }, - component: PaymentDetailsWithTheme, - parameters: { - docs: { - description: { - component: - "Route preview screen that displays prepared quote details, fees, estimated time, and transaction steps for user confirmation.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/PaymentDetails", -} satisfies Meta; + decorators: [ + (Story) => ( + + + + ), + ], + component: PaymentDetails, + title: "Bridge/screens/PaymentDetails", +}; export default meta; type Story = StoryObj; export const OnrampSimple: Story = { args: { - client: storyClient, paymentMethod: fiatPaymentMethod, preparedQuote: simpleOnrampQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Simple onramp quote with no extra steps - direct fiat to crypto.", - }, - }, - }, -}; - -export const OnrampSimpleLight: Story = { - args: { - client: storyClient, - paymentMethod: fiatPaymentMethod, - preparedQuote: simpleOnrampQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Simple onramp quote with no extra steps (light theme).", - }, - }, }, }; export const OnrampSimpleDirectPayment: Story = { args: { - client: storyClient, - paymentMethod: fiatPaymentMethod, - preparedQuote: simpleOnrampQuote, - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.credits, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Simple onramp quote with no extra steps - direct fiat to crypto.", - }, - }, - }, -}; - -export const OnrampSimpleLightDirectPayment: Story = { - args: { - client: storyClient, paymentMethod: fiatPaymentMethod, preparedQuote: simpleOnrampQuote, - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.concertTicket, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Simple onramp quote with no extra steps (light theme).", - }, - }, + ...DIRECT_PAYMENT_UI_OPTIONS.credits, }, }; export const OnrampWithSwaps: Story = { args: { - client: storyClient, - paymentMethod: fiatPaymentMethod, - preparedQuote: onrampWithSwapsQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Onramp quote with 2 additional swap steps after the fiat purchase.", - }, - }, - }, -}; - -export const OnrampWithSwapsLight: Story = { - args: { - client: storyClient, paymentMethod: fiatPaymentMethod, preparedQuote: onrampWithSwapsQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Onramp quote with 2 additional swap steps (light theme).", - }, - }, }, }; export const BuySimple: Story = { args: { - client: storyClient, - paymentMethod: ethCryptoPaymentMethod, - preparedQuote: simpleBuyQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Simple buy quote with a single transaction (no approval needed).", - }, - }, - }, -}; - -export const BuySimpleLight: Story = { - args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: simpleBuyQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Simple buy quote with a single transaction (light theme).", - }, - }, }, }; export const BuySimpleDirectPayment: Story = { args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: simpleBuyQuote, - theme: "dark", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.digitalArt, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Simple buy quote with a single transaction (no approval needed).", - }, - }, - }, -}; - -export const BuySimpleLightDirectPayment: Story = { - args: { - client: storyClient, - paymentMethod: ethCryptoPaymentMethod, - preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: DIRECT_PAYMENT_UI_OPTIONS.subscription, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Simple buy quote with a single transaction (light theme).", - }, - }, + ...DIRECT_PAYMENT_UI_OPTIONS.digitalArt, }, }; export const BuyWithLongText: Story = { args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: longTextBuyQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: "Simple buy quote with a single transaction (light theme).", - }, - }, }, }; export const BuyWithApproval: Story = { args: { - client: storyClient, - paymentMethod: cryptoPaymentMethod, - preparedQuote: buyWithApprovalQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Buy quote requiring both approval and buy transactions in a single step.", - }, - }, - }, -}; - -export const BuyWithApprovalLight: Story = { - args: { - client: storyClient, paymentMethod: cryptoPaymentMethod, preparedQuote: buyWithApprovalQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Buy quote with approval and buy transactions (light theme).", - }, - }, }, }; export const BuyComplex: Story = { args: { - client: storyClient, - paymentMethod: ethCryptoPaymentMethod, - preparedQuote: complexBuyQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Complex buy quote with 3 steps, each requiring approval and execution transactions across multiple chains.", - }, - }, - }, -}; - -export const BuyComplexLight: Story = { - args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: complexBuyQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Complex multi-step buy quote spanning multiple chains (light theme).", - }, - }, }, }; @@ -388,114 +151,36 @@ export const BuyComplexLight: Story = { export const TransactionEthTransfer: Story = { args: { - client: storyClient, - paymentMethod: ethCryptoPaymentMethod, - preparedQuote: simpleBuyQuote, - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Transaction mode showing ETH transfer payment details with function name and contract information displayed in the PaymentDetails screen.", - }, - }, - }, -}; - -export const TransactionEthTransferLight: Story = { - args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of transaction mode for ETH transfer with detailed payment overview.", - }, + modeInfo: { + mode: "transaction", + transaction: TRANSACTION_UI_OPTIONS.ethTransfer.transaction, }, + ...TRANSACTION_UI_OPTIONS.ethTransfer, }, }; export const TransactionERC20Transfer: Story = { args: { - client: storyClient, - paymentMethod: cryptoPaymentMethod, - preparedQuote: simpleBuyQuote, - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.erc20Transfer, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Transaction mode for ERC20 token transfer showing token details and transfer function in payment preview.", - }, - }, - }, -}; - -export const TransactionERC20TransferLight: Story = { - args: { - client: storyClient, paymentMethod: cryptoPaymentMethod, preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.erc20Transfer, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of ERC20 token transfer transaction mode with payment details.", - }, + modeInfo: { + mode: "transaction", + transaction: TRANSACTION_UI_OPTIONS.erc20Transfer.transaction, }, + ...TRANSACTION_UI_OPTIONS.erc20Transfer, }, }; export const TransactionContractInteraction: Story = { args: { - client: storyClient, - paymentMethod: ethCryptoPaymentMethod, - preparedQuote: simpleBuyQuote, - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Transaction mode for complex contract interaction (claimTo function) showing detailed contract information and function details in payment preview.", - }, - }, - }, -}; - -export const TransactionContractInteractionLight: Story = { - args: { - client: storyClient, paymentMethod: ethCryptoPaymentMethod, preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version of contract interaction transaction mode with comprehensive payment details.", - }, + modeInfo: { + mode: "transaction", + transaction: TRANSACTION_UI_OPTIONS.contractInteraction.transaction, }, + ...TRANSACTION_UI_OPTIONS.contractInteraction, }, }; diff --git a/packages/thirdweb/src/stories/Bridge/PaymentSelection.stories.tsx b/packages/thirdweb/src/stories/Bridge/PaymentSelection.stories.tsx index 4dfcf50ee2f..46c030c4876 100644 --- a/packages/thirdweb/src/stories/Bridge/PaymentSelection.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/PaymentSelection.stories.tsx @@ -1,178 +1,50 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { Theme } from "../../react/core/design-system/index.js"; -import { - PaymentSelection, - type PaymentSelectionProps, -} from "../../react/web/ui/Bridge/payment-selection/PaymentSelection.js"; +import { PaymentSelection } from "../../react/web/ui/Bridge/payment-selection/PaymentSelection.js"; import en from "../../react/web/ui/ConnectWallet/locale/en.js"; import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { UNI, USDC } from "./fixtures.js"; +import { USDC } from "./fixtures.js"; -// Props interface for the wrapper component -interface PaymentSelectionWithThemeProps extends PaymentSelectionProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const PaymentSelectionWithTheme = (props: PaymentSelectionWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { client: storyClient, + onBack: () => { + alert("Back"); + }, connectLocale: en, destinationAmount: "1", destinationToken: USDC, onError: (error) => console.error("Error:", error), - onPaymentMethodSelected: (_paymentMethod) => {}, - theme: "dark", + onPaymentMethodSelected: () => {}, country: "US", - }, - argTypes: { - connectLocale: { - description: "Locale for connecting wallets", - }, - destinationAmount: { - description: "Amount of destination token to bridge", - }, - destinationToken: { - description: "The target token to bridge to", - }, - onBack: { - action: "back clicked", - description: "Called when user wants to go back (only shown in Step 1)", - }, - onError: { - action: "error occurred", - description: "Called when an error occurs during the flow", - }, - onPaymentMethodSelected: { - action: "payment method selected", - description: "Called when user selects a wallet token or fiat provider", - }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - }, - component: PaymentSelectionWithTheme, - parameters: { - docs: { - description: { - component: - "Payment method selection screen with a 2-step flow:\n\n" + - "**Step 1:** Choose payment method - shows connected wallets, connect wallet option, and pay with fiat option\n\n" + - "**Step 2a:** If wallet selected - shows available origin tokens for bridging to the destination token (fetches real routes data from the Bridge API)\n\n" + - "**Step 2b:** If fiat selected - shows onramp provider options (Coinbase, Stripe, Transak)\n\n" + - "The component intelligently manages wallet context and provides proper error handling for each step.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/PaymentSelection", -} satisfies Meta; + connectOptions: undefined, + currency: "USD", + paymentMethods: ["crypto", "card"], + receiverAddress: "0x0000000000000000000000000000000000000000", + feePayer: undefined, + supportedTokens: undefined, + }, + decorators: [ + (Story) => ( + + + + ), + ], + component: PaymentSelection, + title: "Bridge/screens/PaymentSelection", +}; export default meta; type Story = StoryObj; -export const Light: Story = { - args: { - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version showing the initial wallet selection step. Click on a connected wallet to see token selection, or click 'Pay with Fiat' to see provider selection.", - }, - }, - }, -}; - -export const Dark: Story = { +export const OnlyCryptoMethodEnabled: Story = { args: { - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Dark theme version of the payment selection flow. The component starts with wallet selection and provides navigation through the 2-step process.", - }, - }, - }, -}; - -export const WithBackButton: Story = { - args: { - onBack: () => {}, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Version with a back button in the header. The back behavior changes based on the current step - Step 1 calls onBack, Steps 2a/2b return to Step 1.", - }, - }, - }, -}; - -export const WithBackButtonLight: Story = { - args: { - onBack: () => {}, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Light theme version with back button functionality. Demonstrates the navigation flow between steps.", - }, - }, + paymentMethods: ["crypto"], }, }; -export const DifferentDestinationToken: Story = { +export const OnlyFiatMethodEnabled: Story = { args: { - destinationToken: UNI, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example with a different destination token (UNI). This will show different available origin tokens in Step 2a when a wallet is selected.", - }, - }, - }, -}; - -export const LargeAmount: Story = { - args: { - destinationAmount: "1000", - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example with a larger destination amount (1000 USDC). This may affect which origin tokens are available based on user balances.", - }, - }, + paymentMethods: ["card"], }, }; diff --git a/packages/thirdweb/src/stories/Bridge/StepRunner.stories.tsx b/packages/thirdweb/src/stories/Bridge/StepRunner.stories.tsx index bcab65c8a31..6d4cbd4cd8b 100644 --- a/packages/thirdweb/src/stories/Bridge/StepRunner.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/StepRunner.stories.tsx @@ -1,11 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; -import type { ThirdwebClient } from "../../client/client.js"; -import type { WindowAdapter } from "../../react/core/adapters/WindowAdapter.js"; -import type { Theme } from "../../react/core/design-system/index.js"; -import type { BridgePrepareRequest } from "../../react/core/hooks/useBridgePrepare.js"; import type { CompletedStatusResult } from "../../react/core/hooks/useStepExecutor.js"; +import { webWindowAdapter } from "../../react/web/adapters/WindowAdapter.js"; import { StepRunner } from "../../react/web/ui/Bridge/StepRunner.js"; -import type { Wallet } from "../../wallets/interfaces/wallet.js"; import { ModalThemeWrapper, storyClient } from "../utils.js"; import { STORY_MOCK_WALLET, @@ -13,80 +9,37 @@ import { simpleBuyRequest, } from "./fixtures.js"; -// Mock window adapter -const mockWindowAdapter: WindowAdapter = { - open: async (_url: string) => {}, -}; - -// Props interface for the wrapper component -interface StepRunnerWithThemeProps { - request: BridgePrepareRequest; - wallet: Wallet; - client: ThirdwebClient; - windowAdapter: WindowAdapter; - onComplete: (completedStatuses: CompletedStatusResult[]) => void; - onError: (error: Error) => void; - onCancel?: () => void; - onBack?: () => void; - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const StepRunnerWithTheme = (props: StepRunnerWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { client: storyClient, onCancel: () => {}, onComplete: (_completedStatuses: CompletedStatusResult[]) => {}, - onError: (error: Error) => console.error("Error:", error), - theme: "dark", wallet: STORY_MOCK_WALLET, - windowAdapter: mockWindowAdapter, - }, - argTypes: { - onCancel: { action: "execution cancelled" }, - onComplete: { action: "execution completed" }, - onError: { action: "error occurred" }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, + windowAdapter: webWindowAdapter, + title: undefined, + autoStart: true, + onBack: undefined, + preparedQuote: simpleBuyQuote, }, - component: StepRunnerWithTheme, + component: StepRunner, + decorators: [ + (Story) => ( + + + + ), + ], parameters: { layout: "centered", }, - title: "Bridge/StepRunner", -} satisfies Meta; + title: "Bridge/screens/StepRunner", +}; export default meta; type Story = StoryObj; -export const Light: Story = { - args: { - request: simpleBuyRequest, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - }, -}; - -export const Dark: Story = { +export const Basic: Story = { args: { request: simpleBuyRequest, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, }, }; diff --git a/packages/thirdweb/src/stories/Bridge/SuccessScreen.stories.tsx b/packages/thirdweb/src/stories/Bridge/SuccessScreen.stories.tsx index 49c5cfa7e9e..3913b59426b 100644 --- a/packages/thirdweb/src/stories/Bridge/SuccessScreen.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/SuccessScreen.stories.tsx @@ -1,19 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { stringify } from "viem"; -import type { Theme } from "../../react/core/design-system/index.js"; import type { CompletedStatusResult } from "../../react/core/hooks/useStepExecutor.js"; import { webWindowAdapter } from "../../react/web/adapters/WindowAdapter.js"; -import { - SuccessScreen, - type SuccessScreenProps, -} from "../../react/web/ui/Bridge/payment-success/SuccessScreen.js"; +import { SuccessScreen } from "../../react/web/ui/Bridge/payment-success/SuccessScreen.js"; import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { - FUND_WALLET_UI_OPTIONS, - simpleBuyQuote, - simpleOnrampQuote, - TRANSACTION_UI_OPTIONS, -} from "./fixtures.js"; +import { simpleBuyQuote, simpleOnrampQuote } from "./fixtures.js"; const mockBuyCompletedStatuses: CompletedStatusResult[] = JSON.parse( stringify([ @@ -75,164 +66,64 @@ const mockOnrampCompletedStatuses: CompletedStatusResult[] = JSON.parse( ]), ); -// Props interface for the wrapper component -interface SuccessScreenWithThemeProps extends SuccessScreenProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const SuccessScreenWithTheme = (props: SuccessScreenWithThemeProps) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { completedStatuses: mockBuyCompletedStatuses, onDone: () => {}, preparedQuote: simpleBuyQuote, - theme: "dark", - uiOptions: FUND_WALLET_UI_OPTIONS.ethDefault, + showContinueWithTx: false, windowAdapter: webWindowAdapter, - }, - argTypes: { - onDone: { action: "success screen closed" }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - }, - component: SuccessScreenWithTheme, - parameters: { - docs: { - description: { - component: - "Success screen that displays completion confirmation with transaction summary, payment details, and action buttons for next steps. Includes animated success icon and detailed transaction view.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/SuccessScreen", -} satisfies Meta; + client: storyClient, + hasPaymentId: false, + }, + component: SuccessScreen, + decorators: [ + (Story) => ( + + + + ), + ], + title: "Bridge/screens/SuccessScreen", +}; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - client: storyClient, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - }, -}; - -export const DefaultLight: Story = { - args: { - client: storyClient, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, - }, +export const Basic: Story = { + args: {}, }; export const OnrampPayment: Story = { args: { - client: storyClient, - completedStatuses: mockOnrampCompletedStatuses, - preparedQuote: simpleOnrampQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Success screen for onramp payments showing payment ID that can be copied to clipboard.", - }, - }, - }, -}; - -export const OnrampPaymentLight: Story = { - args: { - client: storyClient, completedStatuses: mockOnrampCompletedStatuses, preparedQuote: simpleOnrampQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; export const ComplexPayment: Story = { args: { - client: storyClient, - completedStatuses: [ - ...mockOnrampCompletedStatuses, - ...mockBuyCompletedStatuses, - ], - preparedQuote: simpleOnrampQuote, - theme: "dark", - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Success screen for onramp payments showing payment ID that can be copied to clipboard.", - }, - }, - }, -}; - -export const ComplexPaymentLight: Story = { - args: { - client: storyClient, completedStatuses: [ ...mockOnrampCompletedStatuses, ...mockBuyCompletedStatuses, ], preparedQuote: simpleOnrampQuote, - theme: "light", - }, - parameters: { - backgrounds: { default: "light" }, }, }; export const TransactionPayment: Story = { args: { - client: storyClient, completedStatuses: mockBuyCompletedStatuses, preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "light" }, + showContinueWithTx: true, }, }; export const PaymentId: Story = { args: { - client: storyClient, completedStatuses: mockBuyCompletedStatuses, hasPaymentId: true, preparedQuote: simpleBuyQuote, - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "light" }, + showContinueWithTx: true, }, }; diff --git a/packages/thirdweb/src/stories/Bridge/Transaction/TransactionWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Transaction/TransactionWidget.stories.tsx new file mode 100644 index 00000000000..3492abc7dd2 --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Transaction/TransactionWidget.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + TransactionWidget, + type TransactionWidgetProps, +} from "../../../react/web/ui/Bridge/TransactionWidget.js"; +import { storyClient } from "../../utils.js"; +import { TRANSACTION_UI_OPTIONS } from "../fixtures.js"; + +const meta: Meta = { + args: { + client: storyClient, + onSuccess: () => {}, + onError: () => {}, + onCancel: () => {}, + currency: "USD", + ...TRANSACTION_UI_OPTIONS.ethTransfer, + }, + component: StoryVariant, + title: "Bridge/Transaction/TransactionWidget", +}; + +export default meta; +type Story = StoryObj; + +export const EthereumTransfer: Story = { + args: { + ...TRANSACTION_UI_OPTIONS.ethTransfer, + }, +}; + +export const ERC20TokenTransfer: Story = { + args: { + ...TRANSACTION_UI_OPTIONS.erc20Transfer, + }, +}; + +export const ContractInteraction: Story = { + args: { + ...TRANSACTION_UI_OPTIONS.contractInteraction, + }, +}; + +export const CustomButtonLabel: Story = { + args: { + ...TRANSACTION_UI_OPTIONS.customButton, + }, +}; + +function StoryVariant(props: TransactionWidgetProps) { + return ( +
+ + +
+ ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Transaction/useSendTransactionModal.stories.tsx b/packages/thirdweb/src/stories/Bridge/Transaction/useSendTransactionModal.stories.tsx new file mode 100644 index 00000000000..010b28939fb --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Transaction/useSendTransactionModal.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta } from "@storybook/react"; +import { base } from "../../../chains/chain-definitions/base.js"; +import { sepolia } from "../../../chains/chain-definitions/sepolia.js"; +import { useActiveAccount } from "../../../react/core/hooks/wallets/useActiveAccount.js"; +import { useSendTransaction } from "../../../react/web/hooks/transaction/useSendTransaction.js"; +import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; +import { Button } from "../../../react/web/ui/components/buttons.js"; +import { Spinner } from "../../../react/web/ui/components/Spinner.js"; +import { + type PreparedTransaction, + prepareTransaction, +} from "../../../transaction/prepare-transaction.js"; +import { toWei } from "../../../utils/units.js"; +import { storyClient } from "../../utils.js"; + +const meta: Meta = { + component: Variant, + title: "Bridge/Transaction/useSendTransaction", +}; +export default meta; + +const sendBase = prepareTransaction({ + chain: base, + client: storyClient, + to: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425", + value: toWei("100"), +}); + +// using an unsupported chain to popup deposit screen +const sendSepolia = prepareTransaction({ + chain: sepolia, + client: storyClient, + to: "0x83Dd93fA5D8343094f850f90B3fb90088C1bB425", + value: toWei("100"), +}); + +export const BuyAndExecuteTx = { + args: { + transaction: sendBase, + }, +}; + +export const DepositAndExecuteTx = { + args: { + transaction: sendSepolia, + }, +}; + +function Variant(props: { transaction: PreparedTransaction }) { + const sendTx = useSendTransaction(); + const account = useActiveAccount(); + + if (!account) { + return ; + } + return ( + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/TransactionPayment.stories.tsx b/packages/thirdweb/src/stories/Bridge/TransactionPayment.stories.tsx deleted file mode 100644 index c9613223268..00000000000 --- a/packages/thirdweb/src/stories/Bridge/TransactionPayment.stories.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - TransactionPayment, - type TransactionPaymentProps, -} from "../../react/web/ui/Bridge/TransactionPayment.js"; -import { ModalThemeWrapper, storyClient } from "../utils.js"; -import { TRANSACTION_UI_OPTIONS } from "./fixtures.js"; - -// Props interface for the wrapper component -interface TransactionPaymentWithThemeProps extends TransactionPaymentProps { - theme: "light" | "dark"; -} - -// Wrapper component to provide theme context -const TransactionPaymentWithTheme = ( - props: TransactionPaymentWithThemeProps, -) => { - const { theme, ...componentProps } = props; - - return ( - -
- -
-
- ); -}; - -const meta = { - args: { - client: storyClient, - onExecuteTransaction: () => {}, - onContinue: (_amount, _token, _receiverAddress) => {}, - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer, - }, - argTypes: { - onContinue: { - action: "continue clicked", - description: "Called when user continues with the transaction", - }, - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - uiOptions: { - description: - "UI configuration for transaction mode including prepared transaction", - }, - }, - component: TransactionPaymentWithTheme, - parameters: { - docs: { - description: { - component: - "Transaction payment component that displays detailed transaction information including contract details, function names, transaction costs, and network fees.\n\n" + - "## Features\n" + - "- **Contract Information**: Shows contract name and clickable address\n" + - "- **Function Detection**: Extracts function names from transaction data using ABI\n" + - "- **Cost Calculation**: Displays transaction value and USD equivalent\n" + - "- **Network Fees**: Shows estimated gas costs with token amounts\n" + - "- **Chain Details**: Network name and logo with proper formatting\n" + - "- **Skeleton Loading**: Comprehensive loading states matching final layout\n\n" + - "This component now accepts uiOptions directly to configure the transaction and metadata. Supports both native token and ERC20 token transactions with proper function name extraction.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/TransactionPayment", -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const EthereumTransfer: Story = { - args: { - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Simple ETH transfer transaction showing native token value and network fees with USD conversion. Demonstrates function name extraction from contract ABI.", - }, - }, - }, -}; - -export const EthereumTransferLight: Story = { - args: { - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.ethTransfer, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Simple ETH transfer transaction in light theme with skeleton loading support.", - }, - }, - }, -}; - -export const ERC20TokenTransfer: Story = { - args: { - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.erc20Transfer, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "ERC20 token transaction showing token amount, USD value, and proper formatting using real token data. Displays transfer function details.", - }, - }, - }, -}; - -export const ERC20TokenTransferLight: Story = { - args: { - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.erc20Transfer, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "ERC20 token transaction in light theme with enhanced formatting.", - }, - }, - }, -}; - -export const ContractInteraction: Story = { - args: { - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Complex contract interaction showing function name extraction from ABI (claimTo), cost calculation, and network details with proper currency formatting.", - }, - }, - }, -}; - -export const ContractInteractionLight: Story = { - args: { - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.contractInteraction, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Contract interaction transaction in light theme with enhanced UX and skeleton loading.", - }, - }, - }, -}; - -export const CustomButtonLabel: Story = { - args: { - theme: "dark", - uiOptions: TRANSACTION_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Example showcasing custom button label functionality. The button shows 'Execute Now' instead of the default 'Execute [functionName]' text.", - }, - }, - }, -}; - -export const CustomButtonLabelLight: Story = { - args: { - theme: "light", - uiOptions: TRANSACTION_UI_OPTIONS.customButton, - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: "Light theme version with custom button label 'Execute Now'.", - }, - }, - }, -}; diff --git a/packages/thirdweb/src/stories/Bridge/UnsupportedTokenScreen.stories.tsx b/packages/thirdweb/src/stories/Bridge/UnsupportedTokenScreen.stories.tsx index a2dc7efe812..da9f0c8dcb8 100644 --- a/packages/thirdweb/src/stories/Bridge/UnsupportedTokenScreen.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/UnsupportedTokenScreen.stories.tsx @@ -1,58 +1,25 @@ import type { Meta, StoryObj } from "@storybook/react"; import { defineChain } from "../../chains/utils.js"; -import { createThirdwebClient } from "../../client/client.js"; -import type { Theme } from "../../react/core/design-system/index.js"; -import { - UnsupportedTokenScreen, - type UnsupportedTokenScreenProps, -} from "../../react/web/ui/Bridge/UnsupportedTokenScreen.js"; -import { ModalThemeWrapper } from "../utils.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; +import { UnsupportedTokenScreen } from "../../react/web/ui/Bridge/UnsupportedTokenScreen.js"; +import { ModalThemeWrapper, storyClient } from "../utils.js"; -const TEST_CLIENT = createThirdwebClient({ clientId: "test" }); - -// Props interface for the wrapper component -interface UnsupportedTokenScreenWithThemeProps - extends UnsupportedTokenScreenProps { - theme: "light" | "dark" | Theme; -} - -// Wrapper component to provide theme context -const UnsupportedTokenScreenWithTheme = ( - props: UnsupportedTokenScreenWithThemeProps, -) => { - const { theme, ...componentProps } = props; - return ( - - - - ); -}; - -const meta = { +const meta: Meta = { args: { - chain: defineChain(1), // Ethereum mainnet - theme: "dark", - }, - argTypes: { - theme: { - control: "select", - description: "Theme for the component", - options: ["light", "dark"], - }, - }, - component: UnsupportedTokenScreenWithTheme, - parameters: { - docs: { - description: { - component: - "Screen displayed when a token is being indexed or when using an unsupported testnet. Shows loading state for indexing tokens or error state for testnets.", - }, - }, - layout: "centered", - }, - tags: ["autodocs"], - title: "Bridge/UnsupportedTokenScreen", -} satisfies Meta; + client: storyClient, + chain: defineChain(1), + tokenAddress: NATIVE_TOKEN_ADDRESS, + }, + component: UnsupportedTokenScreen, + title: "Bridge/screens/UnsupportedTokenScreen", + decorators: [ + (Story) => ( + + + + ), + ], +}; export default meta; type Story = StoryObj; @@ -60,67 +27,11 @@ type Story = StoryObj; export const TokenNotSupported: Story = { args: { chain: defineChain(1), - client: TEST_CLIENT, - theme: "dark", // Ethereum mainnet - will show indexing spinner - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Shows the loading state when a token is being indexed by the Bridge on a mainnet chain.", - }, - }, - }, -}; - -export const TokenNotSupportedLight: Story = { - args: { - chain: defineChain(1), - client: TEST_CLIENT, - theme: "light", // Ethereum mainnet - will show indexing spinner - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Shows the loading state when a token is being indexed by the Bridge on a mainnet chain (light theme).", - }, - }, }, }; export const TestnetNotSupported: Story = { args: { - chain: defineChain(11155111), - client: TEST_CLIENT, - theme: "dark", // Sepolia testnet - will show error state - }, - parameters: { - backgrounds: { default: "dark" }, - docs: { - description: { - story: - "Shows the error state when trying to use the Bridge on a testnet chain (Sepolia in this example).", - }, - }, - }, -}; - -export const TestnetNotSupportedLight: Story = { - args: { - chain: defineChain(11155111), - client: TEST_CLIENT, - theme: "light", // Sepolia testnet - will show error state - }, - parameters: { - backgrounds: { default: "light" }, - docs: { - description: { - story: - "Shows the error state when trying to use the Bridge on a testnet chain (Sepolia in this example, light theme).", - }, - }, + chain: defineChain(11155111), // Sepolia testnet }, }; diff --git a/packages/thirdweb/src/stories/Bridge/fixtures.ts b/packages/thirdweb/src/stories/Bridge/fixtures.ts index ec547800151..51b8ec900b9 100644 --- a/packages/thirdweb/src/stories/Bridge/fixtures.ts +++ b/packages/thirdweb/src/stories/Bridge/fixtures.ts @@ -13,8 +13,11 @@ import type { BridgePrepareResult, } from "../../react/core/hooks/useBridgePrepare.js"; import { getDefaultToken } from "../../react/core/utils/defaultTokens.js"; -import type { UIOptions } from "../../react/web/ui/Bridge/BridgeOrchestrator.js"; -import { prepareTransaction } from "../../transaction/prepare-transaction.js"; +import type { DirectPaymentInfo } from "../../react/web/ui/Bridge/types.js"; +import { + type PreparedTransaction, + prepareTransaction, +} from "../../transaction/prepare-transaction.js"; import { toWei } from "../../utils/units.js"; import type { Account, Wallet } from "../../wallets/interfaces/wallet.js"; import { storyClient } from "../utils.js"; @@ -636,7 +639,7 @@ const contractInteractionTransaction = claimTo({ // ========== COMMON DUMMY DATA FOR STORYBOOK ========== // // Common receiver addresses for testing -export const RECEIVER_ADDRESSES = { +const RECEIVER_ADDRESSES = { physical: "0x5555666677778888999900001111222233334444" as const, primary: "0x2247d5d238d0f9d37184d8332aE0289d1aD9991b" as const, secondary: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD" as const, @@ -677,57 +680,24 @@ const PRODUCT_METADATA = { }, }; -// Type aliases for better type safety -type FundWalletUIOptions = Extract; -type DirectPaymentUIOptions = Extract; -type TransactionUIOptions = Extract; +type DirectPaymentUIOptions = { + metadata: { + description: string | undefined; + title: string | undefined; + image: string | undefined; + }; + paymentInfo: DirectPaymentInfo; + buttonLabel: string | undefined; +}; -// UI Options for FundWallet mode -export const FUND_WALLET_UI_OPTIONS: Record< - "ethDefault" | "ethWithAmount" | "usdcDefault" | "uniLarge" | "customButton", - FundWalletUIOptions -> = { - ethDefault: { - destinationToken: ETH, - metadata: { - description: "Add funds to your wallet", - title: "Fund Wallet", - }, - mode: "fund_wallet" as const, - }, - ethWithAmount: { - destinationToken: ETH, - initialAmount: "0.001", - metadata: { - description: "Add funds to your wallet", - title: "Fund Wallet", - }, - mode: "fund_wallet" as const, - }, - uniLarge: { - destinationToken: UNI, - initialAmount: "150000", - metadata: { - description: "Add UNI tokens to your wallet", - title: "Fund Wallet", - }, - mode: "fund_wallet" as const, - }, - usdcDefault: { - destinationToken: USDC, - initialAmount: "5", - mode: "fund_wallet" as const, - }, - customButton: { - destinationToken: ETH, - initialAmount: "0.01", - metadata: { - description: "Test custom button label for funding", - title: "Custom Fund Wallet", - }, - mode: "fund_wallet" as const, - buttonLabel: "Add Funds Now", - }, +type TransactionUIOptions = { + metadata: { + description: string | undefined; + title: string | undefined; + image: string | undefined; + }; + transaction: PreparedTransaction; + buttonLabel: string | undefined; }; // UI Options for DirectPayment mode @@ -746,7 +716,7 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< image: PRODUCT_METADATA.concertTicket.image, title: "Buy Concert Ticket", }, - mode: "direct_payment" as const, + buttonLabel: undefined, paymentInfo: { amount: "25.00", feePayer: "receiver" as const, @@ -758,8 +728,9 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< metadata: { description: PRODUCT_METADATA.credits.description, title: "Add Credits", + image: undefined, }, - mode: "direct_payment" as const, + buttonLabel: undefined, paymentInfo: { amount: "25", feePayer: "receiver" as const, @@ -773,7 +744,7 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< image: PRODUCT_METADATA.digitalArt.image, title: "Purchase Digital Art", }, - mode: "direct_payment" as const, + buttonLabel: undefined, paymentInfo: { amount: "0.1", feePayer: "sender" as const, @@ -787,7 +758,7 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< image: PRODUCT_METADATA.sneakers.image, title: "Buy Sneakers", }, - mode: "direct_payment" as const, + buttonLabel: undefined, paymentInfo: { amount: "0.05", feePayer: "receiver" as const, @@ -801,7 +772,7 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< image: PRODUCT_METADATA.subscription.image, title: "Subscribe to Premium", }, - mode: "direct_payment" as const, + buttonLabel: undefined, paymentInfo: { amount: "9.99", feePayer: "sender" as const, @@ -815,7 +786,6 @@ export const DIRECT_PAYMENT_UI_OPTIONS: Record< image: PRODUCT_METADATA.digitalArt.image, title: "Custom Button Test", }, - mode: "direct_payment" as const, buttonLabel: "Purchase Now", paymentInfo: { amount: "0.05", @@ -835,32 +805,35 @@ export const TRANSACTION_UI_OPTIONS: Record< metadata: { description: "Interact with smart contract", title: "Contract Interaction", + image: undefined, }, - mode: "transaction" as const, + buttonLabel: undefined, transaction: contractInteractionTransaction, }, erc20Transfer: { metadata: { description: "Transfer ERC20 tokens", title: "Token Transfer", + image: undefined, }, - mode: "transaction" as const, + buttonLabel: undefined, transaction: erc20Transaction, }, ethTransfer: { metadata: { description: "Review and execute transaction", title: "Execute Transaction", + image: undefined, }, - mode: "transaction" as const, + buttonLabel: undefined, transaction: ethTransferTransaction, }, customButton: { metadata: { description: "Test custom button label for transactions", title: "Custom Transaction", + image: undefined, }, - mode: "transaction" as const, buttonLabel: "Execute Now", transaction: ethTransferTransaction, }, diff --git a/packages/thirdweb/src/stories/BuyWidget.stories.tsx b/packages/thirdweb/src/stories/BuyWidget.stories.tsx index c4f09399ee0..be810debb6c 100644 --- a/packages/thirdweb/src/stories/BuyWidget.stories.tsx +++ b/packages/thirdweb/src/stories/BuyWidget.stories.tsx @@ -1,5 +1,6 @@ import type { Meta } from "@storybook/react-vite"; import { base } from "../chains/chain-definitions/base.js"; +import { ethereum } from "../chains/chain-definitions/ethereum.js"; import { defineChain } from "../chains/utils.js"; import { BuyWidget } from "../react/web/ui/Bridge/BuyWidget.js"; import { storyClient } from "./utils.js"; @@ -8,14 +9,51 @@ const meta = { parameters: { layout: "centered", }, - title: "Connect/BuyWidget", + title: "Bridge/Buy/BuyWidget", } satisfies Meta; export default meta; -export function BasicUsage() { +export function BuyBaseNativeToken() { return ; } +export function BuyBaseUSDC() { + return ( + + ); +} + +export function CustomTitleDescriptionAndButtonLabel() { + return ( + + ); +} + +export function HideTitle() { + return ( + + ); +} + export function UnsupportedChain() { return ( @@ -54,3 +92,14 @@ export function OnlyCryptoSupported() { /> ); } + +export function LargeAmount() { + return ( + + ); +} diff --git a/packages/thirdweb/src/stories/utils.tsx b/packages/thirdweb/src/stories/utils.tsx index 1b496a82f8a..d1ac15edc04 100644 --- a/packages/thirdweb/src/stories/utils.tsx +++ b/packages/thirdweb/src/stories/utils.tsx @@ -3,7 +3,6 @@ import { CustomThemeProvider, useCustomTheme, } from "../react/core/design-system/CustomThemeProvider.js"; -import type { Theme } from "../react/core/design-system/index.js"; import { radius } from "../react/native/design-system/index.js"; const clientId = process.env.STORYBOOK_CLIENT_ID; @@ -16,15 +15,23 @@ export const storyClient = createThirdwebClient({ clientId: clientId, }); -export const ModalThemeWrapper = (props: { - children: React.ReactNode; - theme: "light" | "dark" | Theme; -}) => { - const { theme } = props; +export const ModalThemeWrapper = (props: { children: React.ReactNode }) => { return ( - - {props.children} - +
+ + {props.children} + + + + {props.children} + +
); };