diff --git a/examples/react/src/components/Connected.tsx b/examples/react/src/components/Connected.tsx index 8ecfde5a0..0964ba309 100644 --- a/examples/react/src/components/Connected.tsx +++ b/examples/react/src/components/Connected.tsx @@ -15,7 +15,7 @@ import { useOpenConnectModal, useWallets } from '@0xsequence/connect' -import { Button, Card, Modal, Select, Switch, Text, TextInput, cn } from '@0xsequence/design-system' +import { Button, Card, Modal, Scroll, Select, Switch, Text, TextInput, cn } from '@0xsequence/design-system' import { allNetworks, ChainId } from '@0xsequence/network' import { useOpenWalletModal } from '@0xsequence/wallet-widget' import { CardButton, Header, WalletListItem } from 'example-shared-components' @@ -30,13 +30,15 @@ import { ERC_1155_SALE_CONTRACT } from '../constants/erc1155-sale-contract' import { abi } from '../constants/nft-abi' import { delay, getCheckoutSettings, getOrderbookCalldata } from '../utils' +import { CustomCheckout } from './CustomCheckout' + // append ?debug to url to enable debug mode const searchParams = new URLSearchParams(location.search) const isDebugMode = searchParams.has('debug') export const Connected = () => { + const [isOpenCustomCheckout, setIsOpenCustomCheckout] = React.useState(false) const { setOpenConnectModal } = useOpenConnectModal() - const { address } = useAccount() const { openSwapModal } = useSwapModal() const { setOpenWalletModal } = useOpenWalletModal() @@ -637,6 +639,11 @@ export const Connected = () => { description="Set orderbook order id, token contract address and token id to test checkout (on Polygon)" onClick={onClickCheckout} /> + setIsOpenCustomCheckout(true)} + /> )} { )} + + {isOpenCustomCheckout && ( + setIsOpenCustomCheckout(false)} + > + + + + + )} + ) } diff --git a/examples/react/src/components/CustomCheckout/index.tsx b/examples/react/src/components/CustomCheckout/index.tsx new file mode 100644 index 000000000..5eba11baf --- /dev/null +++ b/examples/react/src/components/CustomCheckout/index.tsx @@ -0,0 +1,202 @@ +import { useCheckoutUI, CreditCardProviders } from '@0xsequence/checkout' +import { CryptoOption } from '@0xsequence/connect' +import { Text, Button, Spinner, NetworkImage, Image } from '@0xsequence/design-system' +import { useState } from 'react' +import { encodeFunctionData, toHex } from 'viem' +import { useAccount } from 'wagmi' + +import { ERC_1155_SALE_CONTRACT } from '../../constants/erc1155-sale-contract' + +export const CustomCheckout = () => { + const { address } = useAccount() + + // NATIVE token sale + // const currencyAddress = zeroAddress + // const salesContractAddress = '0xf0056139095224f4eec53c578ab4de1e227b9597' + // const collectionAddress = '0x92473261f2c26f2264429c451f70b0192f858795' + // const price = '200000000000000' + // const contractId = '674eb55a3d739107bbd18ecb' + + // // ERC-20 contract + const currencyAddress = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' + const salesContractAddress = '0xe65b75eb7c58ffc0bf0e671d64d0e1c6cd0d3e5b' + const collectionAddress = '0xdeb398f41ccd290ee5114df7e498cf04fac916cb' + const price = '20000' + const contractId = '674eb5613d739107bbd18ed2' + + const chainId = 137 + + const collectible = { + tokenId: '1', + quantity: '1' + } + + const purchaseTransactionData = encodeFunctionData({ + abi: ERC_1155_SALE_CONTRACT, + functionName: 'mint', + // [to, tokenIds, amounts, data, expectedPaymentToken, maxTotal, proof] + args: [ + address, + [BigInt(collectible.tokenId)], + [BigInt(collectible.quantity)], + toHex(0), + currencyAddress, + price, + [toHex(0, { size: 32 })] + ] + }) + + const checkoutUIParams = { + collectible, + chain: chainId, + totalPriceRaw: price, + targetContractAddress: salesContractAddress, + recipientAddress: address || '', + currencyAddress, + collectionAddress, + creditCardProvider: 'transak' as CreditCardProviders, + transakConfig: { + contractId, + apiKey: '5911d9ec-46b5-48fa-a755-d59a715ff0cf' + }, + onSuccess: (txnHash: string) => { + console.log('success!', txnHash) + }, + onError: (error: Error) => { + console.error(error) + }, + txData: purchaseTransactionData + } + + const { orderSummary, creditCardPayment, cryptoPayment } = useCheckoutUI(checkoutUIParams) + + const OrderSummary = () => { + if (orderSummary.isLoading) { + return + } + + if (orderSummary.error) { + return Error loading order summary + } + + return ( +
+
+
+
+ +
+
+ + {orderSummary.data?.collectibleItem?.collectionName} + + + {`${orderSummary.data?.collectibleItem?.collectibleName} x${orderSummary.data?.collectibleItem?.quantityFormatted}`} + +
+
+
+
+
+ + {`${orderSummary?.data?.formattedCryptoPrice} ${orderSummary?.data?.cryptoSymbol}`} +
+
+ + {`$${orderSummary?.data?.totalPriceFiat} estimated total`} + +
+
+
+ ) + } + + const CreditCardPayment = () => { + const [showCreditCardPayment, setShowCreditCardPayment] = useState(false) + if (creditCardPayment.isLoading) { + return + } + + if (creditCardPayment.error) { + return Error loading credit card payment + } + + const CreditCardIframe = creditCardPayment.data?.CreditCardIframe + const EventListener = creditCardPayment.data?.EventListener + + if (showCreditCardPayment) { + return ( +
+ + +
+ ) + } + + return + } + + const CryptoPayment = () => { + if (cryptoPayment.cryptoOptions.data.length === 0) { + return + } + + if (cryptoPayment.cryptoOptions.error) { + return Error loading crypto payment + } + + return ( +
+ {cryptoPayment.cryptoOptions.data.map(option => ( + { + cryptoPayment.purchaseAction.setSelectedCurrencyAddress(option.currencyAddress) + }} + isSelected={option.isSelected} + showInsufficientFundsWarning={option.isInsufficientFunds} + disabled={option.isInsufficientFunds} + /> + ))} + {cryptoPayment.cryptoOptions.isLoading && } + +
+ ) + } + + return ( +
+ The following data is generated by the useCheckoutUI hook + + Order Summary section + + + + Crypto Payment section + + + + Credit Card Payment section + + +
+ ) +} + +export default CustomCheckout diff --git a/packages/checkout/README.md b/packages/checkout/README.md index 744f6ddeb..1d9775a98 100644 --- a/packages/checkout/README.md +++ b/packages/checkout/README.md @@ -254,3 +254,56 @@ const MyComponent = () => { return } ``` + +## Custom Checkout UIs + +Fully customized checkout UIs can be created with the `useCheckoutUI` hook. The hook will return three objects `orderSummary`, `creditCardPayment` and `cryptoPayment` which serve to create the order summary section, credit card payment section and crypto payment section of a checkout UI. API calls are done within the hook and all that if left to build is placing the information and actions. + +Each section comes with its own loading, error and data states. + +```js +import { useCheckoutUI } from '@0xsequence/checkout' + +const CustomCheckoutUI = () => { + const checkoutUIParams = { + collectible, + chain: chainId, + totalPriceRaw: price, + targetContractAddress: salesContractAddress, + recipientAddress: address || '', + currencyAddress, + collectionAddress, + creditCardProvider: 'transak' as CreditCardProviders, + transakConfig: { + contractId, + apiKey: '5911d9ec-46b5-48fa-a755-d59a715ff0cf' + }, + onSuccess: (txnHash: string) => { + console.log('success!', txnHash) + }, + onError: (error: Error) => { + console.error(error) + }, + txData: purchaseTransactionData + } + + const { orderSummary, creditCardPayment, cryptoPayment } = useCheckoutUI(checkoutUIParams) + + const isLoading = orderSummary.isLoading || creditCardPayment.isLoading || cryptoPayment.isLoading + + const error = orderSummary.error || creditCardPayment.error || cryptoPayment.error + + if (isLoading) { + return
loading...
+ } + if (error) { + return
an error has occurred
+ } + + return ( + + + + ) +} +``` diff --git a/packages/checkout/src/hooks/useCheckoutUI/index.tsx b/packages/checkout/src/hooks/useCheckoutUI/index.tsx new file mode 100644 index 000000000..ef0ec6122 --- /dev/null +++ b/packages/checkout/src/hooks/useCheckoutUI/index.tsx @@ -0,0 +1,149 @@ +import { useGetTokenMetadata, useGetContractInfo } from '@0xsequence/hooks' +import { findSupportedNetwork } from '@0xsequence/network' +import { Hex } from 'viem' + +import { TransakConfig } from '../../contexts/CheckoutModal' +import { Collectible, CreditCardProviders } from '../../contexts/SelectPaymentModal' + +import { useCreditCardPayment, type UseCreditCardPaymentReturn } from './useCreditCardPayment' +import { useCryptoPayment, type UseCryptoPaymentReturn } from './useCryptoPayment' +import { useOrderSummary, type UseOrderSummaryReturn } from './useOrderSummary' + +interface UseCheckoutUIArgs { + chain: string | number + currencyAddress: string + totalPriceRaw: string + collectible: Collectible + collectionAddress: string + recipientAddress: string + targetContractAddress: string + txData: Hex + transactionConfirmations?: number + creditCardProvider?: CreditCardProviders + transakConfig?: TransakConfig + onSuccess?: (txHash: string) => void + onError?: (error: Error) => void +} + +interface UseCheckoutUIReturn { + orderSummary: UseOrderSummaryReturn + creditCardPayment: UseCreditCardPaymentReturn + cryptoPayment: UseCryptoPaymentReturn +} + +export const useCheckoutUI = ({ + chain, + currencyAddress, + totalPriceRaw, + collectible, + collectionAddress, + recipientAddress, + targetContractAddress, + txData, + transactionConfirmations, + creditCardProvider, + transakConfig, + onSuccess, + onError +}: UseCheckoutUIArgs): UseCheckoutUIReturn => { + const network = findSupportedNetwork(chain) + const chainId = network?.chainId || 137 + + const { + data: tokenMetadatas, + isLoading: isLoadingTokenMetadatas, + error: errorTokenMetadata + } = useGetTokenMetadata({ + chainID: String(chainId), + contractAddress: collectionAddress, + tokenIDs: [collectible.tokenId] + }) + + const { + data: dataCollectionInfo, + isLoading: isLoadingCollectionInfo, + error: errorCollectionInfo + } = useGetContractInfo({ + chainID: String(chainId), + contractAddress: collectionAddress + }) + + const { + data: currencyInfo, + isLoading: isLoadingCurrencyInfo, + error: errorCurrencyInfo + } = useGetContractInfo({ + chainID: String(chainId), + contractAddress: currencyAddress + }) + + const orderSummary = useOrderSummary({ + chain, + currencyAddress, + totalPriceRaw, + collectible, + collectionAddress, + currencyInfo, + tokenMetadatas, + dataCollectionInfo, + isLoadingCollectionInfo, + errorCollectionInfo, + isLoadingCurrencyInfo, + isLoadingTokenMetadatas, + errorTokenMetadata, + errorCurrencyInfo + }) + + const creditCardPayment = useCreditCardPayment({ + chain, + currencyAddress, + totalPriceRaw, + collectible, + collectionAddress, + recipientAddress, + targetContractAddress, + txData, + creditCardProvider, + transakConfig, + onSuccess, + onError, + currencyInfo, + tokenMetadatas, + dataCollectionInfo, + isLoadingCollectionInfo, + errorCollectionInfo, + isLoadingTokenMetadatas, + errorTokenMetadata, + isLoadingCurrencyInfo, + errorCurrencyInfo + }) + + const cryptoPayment = useCryptoPayment({ + chain, + currencyAddress, + totalPriceRaw, + collectible, + collectionAddress, + recipientAddress, + targetContractAddress, + txData, + transactionConfirmations, + onSuccess, + onError, + currencyInfo, + tokenMetadatas, + dataCollectionInfo, + isLoadingCollectionInfo, + errorCollectionInfo, + isLoadingTokenMetadatas, + errorTokenMetadata, + isLoadingCurrencyInfo, + errorCurrencyInfo + }) + + return { + orderSummary, + creditCardPayment, + cryptoPayment + } +} diff --git a/packages/checkout/src/hooks/useCheckoutUI/useCreditCardPayment.tsx b/packages/checkout/src/hooks/useCheckoutUI/useCreditCardPayment.tsx new file mode 100644 index 000000000..97c49ff2b --- /dev/null +++ b/packages/checkout/src/hooks/useCheckoutUI/useCreditCardPayment.tsx @@ -0,0 +1,334 @@ +import { useProjectAccessKey } from '@0xsequence/connect' +import { compareAddress } from '@0xsequence/connect' +import { useConfig } from '@0xsequence/hooks' +import { ContractInfo, TokenMetadata } from '@0xsequence/metadata' +import { findSupportedNetwork } from '@0xsequence/network' +import pako from 'pako' +import React, { useEffect, useRef } from 'react' +import { Hex, formatUnits, zeroAddress } from 'viem' + +import { fetchSardineOrderStatus } from '../../api' +import { useEnvironmentContext } from '../../contexts' +import { TransakConfig } from '../../contexts/CheckoutModal' +import { Collectible, CreditCardProviders } from '../../contexts/SelectPaymentModal' +import { TRANSAK_PROXY_ADDRESS } from '../../utils/transak' +import { useSardineClientToken } from '../useSardineClientToken' + +const POLLING_TIME = 10 * 1000 +const TRANSAK_IFRAME_ID = 'credit-card-payment-transak-iframe' +const SARDINE_IFRAME_ID = 'credit-card-payment-sardine-iframe' + +export interface UseCreditCardPaymentArgs { + chain: string | number + currencyAddress: string + totalPriceRaw: string + collectible: Collectible + collectionAddress: string + recipientAddress: string + targetContractAddress: string + txData: Hex + creditCardProvider?: CreditCardProviders + transakConfig?: TransakConfig + onSuccess?: (txHash: string) => void + onError?: (error: Error) => void + currencyInfo: ContractInfo | undefined + tokenMetadatas: TokenMetadata[] | undefined + dataCollectionInfo: ContractInfo | undefined + isLoadingCollectionInfo: boolean + errorCollectionInfo: Error | null + isLoadingTokenMetadatas: boolean + errorTokenMetadata: Error | null + isLoadingCurrencyInfo: boolean + errorCurrencyInfo: Error | null +} + +interface UseCreditCardPaymentData { + iframeId: string + paymentUrl?: string + CreditCardIframe: React.ComponentType + EventListener: React.ComponentType +} + +export interface UseCreditCardPaymentReturn { + error: Error | null + data: UseCreditCardPaymentData + isLoading: boolean +} + +export const useCreditCardPayment = ({ + chain, + currencyAddress, + totalPriceRaw, + collectible, + collectionAddress, + recipientAddress, + targetContractAddress, + txData, + creditCardProvider, + transakConfig, + onSuccess, + onError, + currencyInfo, + tokenMetadatas, + dataCollectionInfo, + isLoadingCollectionInfo, + errorCollectionInfo, + isLoadingTokenMetadatas, + errorTokenMetadata, + isLoadingCurrencyInfo, + errorCurrencyInfo +}: UseCreditCardPaymentArgs): UseCreditCardPaymentReturn => { + const projectAccessKey = useProjectAccessKey() + const { env } = useConfig() + const disableSardineClientTokenFetch = + isLoadingTokenMetadatas || isLoadingCurrencyInfo || isLoadingCollectionInfo || creditCardProvider !== 'sardine' + const { transakApiUrl, sardineCheckoutUrl: sardineProxyUrl } = useEnvironmentContext() + const network = findSupportedNetwork(chain) + const error = errorCollectionInfo || errorTokenMetadata || errorCurrencyInfo + const isLoading = isLoadingCollectionInfo || isLoadingTokenMetadatas || isLoadingCurrencyInfo + const isNativeCurrency = compareAddress(currencyAddress, zeroAddress) + const currencySymbol = isNativeCurrency ? network?.nativeToken.symbol : currencyInfo?.symbol || 'POL' + const currencyDecimals = isNativeCurrency ? network?.nativeToken.decimals : currencyInfo?.decimals || 18 + const iframeRef = useRef(null) + const tokenMetadata = tokenMetadatas?.[0] + + const { + data: dataClientToken, + isLoading: isLoadingClientToken, + error: errorClientToken + } = useSardineClientToken( + { + order: { + chainId: network?.chainId || 137, + contractAddress: targetContractAddress, + recipientAddress, + currencyQuantity: totalPriceRaw, + currencySymbol: currencyInfo?.symbol || 'POL', + currencyDecimals: String(currencyDecimals || 18), + currencyAddress, + nftId: collectible.tokenId, + nftAddress: collectionAddress, + nftQuantity: collectible.quantity, + nftDecimals: String(dataCollectionInfo?.decimals || 18), + calldata: txData + }, + projectAccessKey: projectAccessKey, + apiClientUrl: env.apiUrl, + tokenMetadata: tokenMetadata + }, + disableSardineClientTokenFetch + ) + + const missingCreditCardProvider = !creditCardProvider + const missingTransakConfig = !transakConfig && creditCardProvider === 'transak' + + if (missingCreditCardProvider || missingTransakConfig) { + return { + error: new Error('Missing credit card provider or transak config'), + data: { + iframeId: '', + CreditCardIframe: () => null, + EventListener: () => null + }, + isLoading: false + } + } + + if (error || isLoading) { + return { + error, + data: { + iframeId: '', + CreditCardIframe: () => null, + EventListener: () => null + }, + isLoading + } + } + + if (creditCardProvider === 'transak') { + // Transak requires the recipient address to be the proxy address + // so we need to replace the recipient address with the proxy address in the calldata + // this is a weird hack so that credit card integrations are as simple as possible and should work 99% of the time + // If an issue arises, the user can override the calldata in the transak settings + + const calldataWithProxy = + transakConfig?.callDataOverride ?? + txData.replace(recipientAddress.toLowerCase().substring(2), TRANSAK_PROXY_ADDRESS.toLowerCase().substring(2)) + + const pakoData = Array.from(pako.deflate(calldataWithProxy)) + + const transakCallData = encodeURIComponent(btoa(String.fromCharCode.apply(null, pakoData))) + + const price = Number(formatUnits(BigInt(totalPriceRaw), Number(currencyDecimals || 18))) + + const transakNftDataJson = JSON.stringify([ + { + imageURL: tokenMetadata?.image || '', + nftName: tokenMetadata?.name || 'collectible', + collectionAddress: collectionAddress, + tokenID: [collectible.tokenId], + price: [price], + quantity: Number(collectible.quantity), + nftType: dataCollectionInfo?.type || 'ERC721' + } + ]) + + const transakNftData = encodeURIComponent(btoa(transakNftDataJson)) + + const estimatedGasLimit = '500000' + + const partnerOrderId = `${recipientAddress}-${new Date().getTime()}` + + // Note: the network name might not always line up with Transak. A conversion function might be necessary + const network = findSupportedNetwork(chain) + const networkName = network?.name.toLowerCase() + const transakLink = `${transakApiUrl}?apiKey=${transakConfig?.apiKey}&isNFT=true&calldata=${transakCallData}&contractId=${transakConfig?.contractId}&cryptoCurrencyCode=${currencySymbol}&estimatedGasLimit=${estimatedGasLimit}&nftData=${transakNftData}&walletAddress=${recipientAddress}&disableWalletAddressForm=true&partnerOrderId=${partnerOrderId}&network=${networkName}` + + return { + error: null, + data: { + iframeId: TRANSAK_IFRAME_ID, + paymentUrl: transakLink, + CreditCardIframe: () => ( +
+