diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..421c5477f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,44 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module' + }, + + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'prettier' + ], + + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-this-alias': 'off', + + 'import/no-unresolved': 'off', + 'import/no-default-export': 1, + 'import/no-named-as-default-member': 'off', + 'import/export': 'off' + + // 'import/order': [ + // 'warn', + // { + // 'groups': ['builtin', 'external', 'parent', 'sibling', 'index'], + // 'alphabetize': { + // 'order': 'asc', /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */ + // 'caseInsensitive': true /* ignore case. Options: [true, false] */ + // } + // }, + // ] + } +} diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 000000000..d78d39cbe --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,36 @@ +name: Setup Node and PNPM dependencies + +runs: + using: 'composite' + + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup PNPM + uses: pnpm/action-setup@v3 + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: | + ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + if: ${{ steps.pnpm-cache.outputs.cache-hit != 'true' }} diff --git a/.github/workflows/google.yml b/.github/workflows/google.yml new file mode 100644 index 000000000..3ab19274f --- /dev/null +++ b/.github/workflows/google.yml @@ -0,0 +1,116 @@ +# This workflow will build a docker container, publish it to Google Container +# Registry, and deploy it to GKE when there is a push to the "main" +# branch. +# +# To configure this workflow: +# +# 1. Enable the following Google Cloud APIs: +# +# - Artifact Registry (artifactregistry.googleapis.com) +# - Google Kubernetes Engine (container.googleapis.com) +# - IAM Credentials API (iamcredentials.googleapis.com) +# +# You can learn more about enabling APIs at +# https://support.google.com/googleapi/answer/6158841. +# +# 2. Ensure that your repository contains the necessary configuration for your +# Google Kubernetes Engine cluster, including deployment.yml, +# kustomization.yml, service.yml, etc. +# +# 3. Create and configure a Workload Identity Provider for GitHub: +# https://github.com/google-github-actions/auth#preferred-direct-workload-identity-federation. +# +# Depending on how you authenticate, you will need to grant an IAM principal +# permissions on Google Cloud: +# +# - Artifact Registry Administrator (roles/artifactregistry.admin) +# - Kubernetes Engine Developer (roles/container.developer) +# +# You can learn more about setting IAM permissions at +# https://cloud.google.com/iam/docs/manage-access-other-resources +# +# 5. Change the values in the "env" block to match your values. + +name: "Build and Deploy to GKE" +on: + push: + branches: + - '"main"' + +env: + PROJECT_ID: "my-project" # TODO: update to your Google Cloud project ID + GAR_LOCATION: "us-central1" # TODO: update to your region + GKE_CLUSTER: "cluster-1" # TODO: update to your cluster name + GKE_ZONE: "us-central1-c" # TODO: update to your cluster zone + DEPLOYMENT_NAME: "gke-test" # TODO: update to your deployment name + REPOSITORY: "samples" # TODO: update to your Artifact Registry docker repository name + IMAGE: "static-site" + WORKLOAD_IDENTITY_PROVIDER: "projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider" # TODO: update to your workload identity provider + +jobs: + setup-build-publish-deploy: + name: "Setup, Build, Publish, and Deploy" + runs-on: "ubuntu-latest" + environment: "production" + + permissions: + contents: "read" + id-token: "write" + + steps: + - name: "Checkout" + uses: "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332" # actions/checkout@v4 + + # Configure Workload Identity Federation and generate an access token. + # + # See https://github.com/google-github-actions/auth for more options, + # including authenticating via a JSON credentials file. + - id: "auth" + name: "Authenticate to Google Cloud" + uses: "google-github-actions/auth@f112390a2df9932162083945e46d439060d66ec2" # google-github-actions/auth@v2 + with: + workload_identity_provider: "${{ env.WORKLOAD_IDENTITY_PROVIDER }}" + + # Authenticate Docker to Google Cloud Artifact Registry + - name: "Docker Auth" + uses: "docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567" # docker/login-action@v3 + with: + username: "oauth2accesstoken" + password: "${{ steps.auth.outputs.auth_token }}" + registry: "${{ env.GAR_LOCATION }}-docker.pkg.dev" + + # Get the GKE credentials so we can deploy to the cluster + - name: "Set up GKE credentials" + uses: "google-github-actions/get-gke-credentials@6051de21ad50fbb1767bc93c11357a49082ad116" # google-github-actions/get-gke-credentials@v2 + with: + cluster_name: "${{ env.GKE_CLUSTER }}" + location: "${{ env.GKE_ZONE }}" + + # Build the Docker image + - name: "Build and push Docker container" + run: |- + DOCKER_TAG="${GAR_LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE}:${GITHUB_SHA}" + + docker build \ + --tag "${DOCKER_TAG}" \ + --build-arg GITHUB_SHA="${GITHUB_SHA}" \ + --build-arg GITHUB_REF="${GITHUB_REF}" \ + . + + docker push "${DOCKER_TAG}" + + # Set up kustomize + - name: "Set up Kustomize" + run: |- + curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz + chmod u+x ./kustomize + + # Deploy the Docker image to the GKE cluster + - name: "Deploy to GKE" + run: |- + # replacing the image name in the k8s template + ./kustomize edit set image LOCATION-docker.pkg.dev/PROJECT_ID/REPOSITORY/IMAGE:TAG=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/$IMAGE:$GITHUB_SHA + ./kustomize build . | kubectl apply -f - + kubectl rollout status deployment/$DEPLOYMENT_NAME + kubectl get services -o wide + diff --git a/.github/workflows/summary.yml b/.github/workflows/summary.yml new file mode 100644 index 000000000..9b07bb8f8 --- /dev/null +++ b/.github/workflows/summary.yml @@ -0,0 +1,34 @@ +name: Summarize new issues + +on: + issues: + types: [opened] + +jobs: + summary: + runs-on: ubuntu-latest + permissions: + issues: write + models: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run AI inference + id: inference + uses: actions/ai-inference@v1 + with: + prompt: | + Summarize the following GitHub issue in one paragraph: + Title: ${{ github.event.issue.title }} + Body: ${{ github.event.issue.body }} + + - name: Comment with AI summary + run: | + gh issue comment $ISSUE_NUMBER --body '${{ steps.inference.outputs.response }}' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RESPONSE: ${{ steps.inference.outputs.response }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6211e5e8a..5cfb8e102 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,3 +1,4 @@ + name: Run Tests on: @@ -7,6 +8,8 @@ on: jobs: test: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml new file mode 100644 index 000000000..3f266d373 --- /dev/null +++ b/.github/workflows/vercel.yml @@ -0,0 +1,84 @@ +# This workflow's name will appear on the GitHub Actions page. +name: Deploy to Vercel +permissions: + contents: read + +# This specifies when the workflow should run. +on: + # The workflow will run on every 'push' to the 'main' branch. + push: + branches: + - master + - main + + # This allows you to manually trigger the workflow from the GitHub Actions page. + workflow_dispatch: +# These are environment variables passed to the Vercel CLI. +# They must be set as GitHub Secrets for security. +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + # Add any other environment variables your project needs here, + # such as API keys for the Coinbase SDK. + # Example: VITE_COINBASE_API_KEY: ${{ secrets.VITE_COINBASE_API_KEY }} +# Defines a job named 'deploy'. +jobs: + deploy: + # Specifies the runner environment for this job. + + runs-on: ubuntu-latest + + # The steps in the 'deploy' job. + steps: + # 1. Check out the project code from the repository. + # This is the first essential step to get access to your code. + - name: Checkout project code + uses: actions/checkout@v3 + # 2. Set up Node.js. + # The version should match the one in your project. + steps: + - uses: actions/setup-node@v4 + - name: Setup Node.js + - uses: actions/setup-node@v4 + with: + node-version: 18.x,20.x,22.x + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + # 3. Install the Vercel CLI. + # The Vercel CLI is the main tool for interacting with the Vercel platform. + with: + node-version: 22.x + name: Install Vercel CLI + run: npm install --global vercel@latest + + # 4. Pull Vercel project settings. + # This fetches Vercel project links and Org ID. + with: + - name: Pull Vercel environment variables + run: vercel pull --yes --environment=production + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + + # 5. Build the project. + # This command builds your project for a production environment. + # It will automatically use the build command defined in your project's `package.json`. + with: + - name: Build project + run: vercel build + + # 6. Deploy the project to Vercel. + # This step deploys the pre-built code to Vercel. +strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + name: Deploy to Vercel + run: vercel deploy --prebuilt + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/.husky/pre-commit b/.husky/pre-commit index b20d77aeb..3ce94ddc2 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ pnpm run typecheck -pnpm run lint -pnpm run prettier:check +pnpm format \ No newline at end of file diff --git a/CNAME b/CNAME new file mode 100644 index 000000000..1889d9bae --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +sequence.app diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..034e84803 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/examples/components/src/Header.tsx b/examples/components/src/Header.tsx index fe36230b9..a9a2e3b5b 100644 --- a/examples/components/src/Header.tsx +++ b/examples/components/src/Header.tsx @@ -1,40 +1,28 @@ -import { networks, truncateAtIndex } from '@0xsequence/connect' import { Button, Card, - CheckmarkIcon, ChevronDownIcon, GradientAvatar, Image, - MoonIcon, NetworkImage, - SearchIcon, SignoutIcon, - SunIcon, Text, - TextInput, - useTheme + truncateAddress } from '@0xsequence/design-system' import * as PopoverPrimitive from '@radix-ui/react-popover' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { useChainId, useChains, useConnection, useDisconnect, useSwitchChain } from 'wagmi' export const Header = () => { - const { theme, setTheme } = useTheme() - const normalizedTheme: 'light' | 'dark' = theme === 'light' ? 'light' : 'dark' - return ( -
+
- Sequence Web SDK Logo + Sequence Web SDK Logo
-
@@ -51,14 +39,14 @@ const AccountMenu = () => {
- {truncateAtIndex(String(address), 8)} + {truncateAddress(String(address), 4)}
@@ -70,9 +58,9 @@ const AccountMenu = () => { {isOpen && ( - - - + + +
Account @@ -88,10 +76,14 @@ const AccountMenu = () => {
- +
@@ -106,54 +98,25 @@ const NetworkSelect = () => { const chainId = useChainId() const { switchChain } = useSwitchChain() const [isOpen, toggleOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - chains.forEach(chain => { + const modifiedChains = chains.map(chain => { if (chain.id === 8453) { - chain.name = 'Base' + return { ...chain, name: 'Base' }; } - }) - - const { mainnets, testnets } = useMemo(() => { - const filtered = chains.filter(chain => chain.name.toLowerCase().includes(searchQuery.toLowerCase())) - - const mainnets = filtered.filter(chain => { - const sequenceNetwork = networks[chain.id] - return !sequenceNetwork?.testnet - }) - - const testnets = filtered.filter(chain => { - const sequenceNetwork = networks[chain.id] - return sequenceNetwork?.testnet - }) - - return { - mainnets, - testnets - } - }, [chains, searchQuery]) - - const currentChain = chains.find(chain => chain.id === chainId) + return chain; + }); return ( - { - toggleOpen(open) - if (!open) { - setSearchQuery('') - } - }} - > +
- - {currentChain?.name || chainId} + + {chains.find(chain => chain.id === chainId)?.name || chainId}
@@ -164,98 +127,28 @@ const NetworkSelect = () => { {isOpen && ( - - -
- - All networks - -
- ) => setSearchQuery(e.target.value)} - placeholder="Search networks" - leftIcon={SearchIcon} - className="w-full pr-10" + + +
+ {chains.map(chain => ( +
-
- -
- {mainnets.length > 0 && ( -
- - Mainnets - -
- {mainnets.map(chain => ( - - ))} -
-
- )} - - {testnets.length > 0 && ( -
- - Testnets - -
- {testnets.map(chain => ( - - ))} -
-
- )} - - {mainnets.length === 0 && testnets.length === 0 && ( -
- - No networks found - -
- )} + ))}
diff --git a/examples/components/src/WalletListItem.tsx b/examples/components/src/WalletListItem.tsx index 37aa08b9e..34472ee0c 100644 --- a/examples/components/src/WalletListItem.tsx +++ b/examples/components/src/WalletListItem.tsx @@ -1,5 +1,5 @@ -import { truncateAtMiddle } from '@0xsequence/connect' import { Button, Card, cn, Text } from '@0xsequence/design-system' +import { truncateAtMiddle } from '@0xsequence/web-sdk-core' interface WalletListItemProps { id: string diff --git a/examples/next/next.config.mjs b/examples/next/next.config.mjs index 969508ef0..b7cbd2e97 100644 --- a/examples/next/next.config.mjs +++ b/examples/next/next.config.mjs @@ -1,3 +1,7 @@ +import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin' + +const withVanillaExtract = createVanillaExtractPlugin() + /** @type {import('next').NextConfig} */ const nextConfig = { webpack: config => { @@ -14,6 +18,7 @@ const nextConfig = { } return config } + // transpilePackages: ['@0xsequence/kit', '@0xsequence/kit-wallet', '@0xsequence/kit-connectors', '@0xsequence/checkout'] } -export default nextConfig +export default withVanillaExtract(nextConfig) diff --git a/examples/next/public/discord.svg b/examples/next/public/discord.svg new file mode 100644 index 000000000..b4ca26f48 --- /dev/null +++ b/examples/next/public/discord.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/examples/next/public/github.svg b/examples/next/public/github.svg new file mode 100644 index 000000000..5a9a1f46c --- /dev/null +++ b/examples/next/public/github.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/examples/next/public/kit-logo-text.svg b/examples/next/public/kit-logo-text.svg new file mode 100644 index 000000000..2d6def521 --- /dev/null +++ b/examples/next/public/kit-logo-text.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/next/public/kit-logo.svg b/examples/next/public/kit-logo.svg new file mode 100644 index 000000000..cc49f75de --- /dev/null +++ b/examples/next/public/kit-logo.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/next/public/next.svg b/examples/next/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/examples/next/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/next/public/twitter.svg b/examples/next/public/twitter.svg new file mode 100644 index 000000000..46a33e5f7 --- /dev/null +++ b/examples/next/public/twitter.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/examples/next/public/youtube.svg b/examples/next/public/youtube.svg new file mode 100644 index 000000000..4f8014f94 --- /dev/null +++ b/examples/next/public/youtube.svg @@ -0,0 +1,11 @@ + + + + Layer 1 + + + \ No newline at end of file diff --git a/examples/next/src/app/components/EnvironmentSetter.tsx b/examples/next/src/app/components/EnvironmentSetter.tsx new file mode 100644 index 000000000..b576faf69 --- /dev/null +++ b/examples/next/src/app/components/EnvironmentSetter.tsx @@ -0,0 +1,11 @@ +'use client' + +import { isDevSequenceApis } from '@0xsequence/react-connect' + +export const EnvironmentSetter = () => { + globalThis.__WEB_SDK_DEV_GLOBAL__ = false + + console.log('is dev environment: ', isDevSequenceApis()) + + return null +} diff --git a/examples/next/src/app/components/Footer.tsx b/examples/next/src/app/components/Footer.tsx new file mode 100644 index 000000000..889937ef3 --- /dev/null +++ b/examples/next/src/app/components/Footer.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import { Box, Button, Image, Text, useMediaQuery, useTheme } from '@0xsequence/design-system' + +interface BottomPageLink { + label: string + url: string +} + +export const bottomPageLinks: BottomPageLink[] = [ + { + label: 'Terms', + url: 'https://sequence.xyz/terms' + }, + { + label: 'About', + url: 'https://github.com/0xsequence/kit' + }, + { + label: 'Blog', + url: 'https://sequence.xyz/blog' + }, + { + label: 'Builder', + url: 'https://sequence.build' + }, + { + label: 'Docs', + url: 'https://docs.sequence.xyz/wallet/connectors/kit/kit/overview' + } +] + +interface SocialLinks { + id: string + url: string + icon: string +} + +export const socialLinks: SocialLinks[] = [ + { + id: 'discord', + url: 'https://discord.gg/sequence', + icon: 'discord.svg' + }, + { + id: 'twitter', + url: 'https://www.twitter.com/0xsequence', + icon: 'twitter.svg' + }, + { + id: 'youtube', + url: 'https://www.youtube.com/channel/UC1zHgUyV-doddTcnFNqt62Q', + icon: 'youtube.svg' + }, + { + id: 'github', + url: 'https://github.com/0xsequence', + icon: 'github.svg' + } +] + +export const Footer = () => { + const { theme } = useTheme() + const isMobile = useMediaQuery('isMobile') + + const onClickLinkUrl = (url: string) => { + if (typeof window !== 'undefined') { + window.open(url) + } + } + + const Links = () => { + return ( + + {bottomPageLinks.map((link, index) => ( + - * ) - * } - * ``` - */ -export const useAddFundsModal = (): UseAddFundsModalReturnType => { - const { isAddFundsModalOpen, triggerAddFunds, closeAddFunds, addFundsSettings } = useAddFundsModalContext() - - return { isAddFundsModalOpen, triggerAddFunds, closeAddFunds, addFundsSettings } -} diff --git a/packages/checkout/src/hooks/useCheckoutModal.ts b/packages/checkout/src/hooks/useCheckoutModal.ts deleted file mode 100644 index b749e1e60..000000000 --- a/packages/checkout/src/hooks/useCheckoutModal.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useCheckoutModalContext, type CheckoutSettings } from '../contexts/CheckoutModal.js' - -/** - * Return type for the useCheckoutModal hook. - * - * @property {function(settings: CheckoutSettings): void} triggerCheckout - Function to open the Checkout modal - * @property {function(): void} closeCheckout - Function to close the Checkout modal - * @property {CheckoutSettings|undefined} settings - Current settings for the Checkout modal - */ -type UseCheckoutModalReturnType = { - triggerCheckout: (settings: CheckoutSettings) => void - closeCheckout: () => void - settings: CheckoutSettings | undefined -} - -/** - * Hook to manage the Checkout modal that allows users to complete purchases using various payment methods. - * - * This hook provides methods to open and close the checkout modal, and access its current settings. - * Checkout supports credit card payments and crypto payments for purchasing digital assets. - * - * Go to {@link https://docs.sequence.xyz/sdk/web/checkout-sdk/hooks/useCheckoutModal} for more detailed documentation. - * - * @returns An object containing functions and settings for the Checkout modal {@link UseCheckoutModalReturnType} - * - * @example - * ```tsx - * import { useCheckoutModal } from '@0xsequence/checkout' - * import { ChainId } from '@0xsequence/connect' - * import { getOrderbookCalldata } from '../utils' - * - * const YourComponent = () => { - * const { address } = useConnection() - * const { triggerCheckout } = useCheckoutModal() - * - * const handleCheckout = () => { - * // NFT purchase settings - * const chainId = ChainId.POLYGON - * const orderbookAddress = '0xB537a160472183f2150d42EB1c3DD6684A55f74c' - * const nftQuantity = '1' - * const orderId = 'your-order-id' - * const tokenContractAddress = '0xabcdef...' // NFT contract address - * const tokenId = '123' // NFT token ID - * - * triggerCheckout({ - * creditCardCheckout: { - * chainId, - * contractAddress: orderbookAddress, - * recipientAddress: address || '', - * currencyQuantity: '100000', - * currencySymbol: 'USDC', - * currencyAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', - * currencyDecimals: '6', - * nftId: tokenId, - * nftAddress: tokenContractAddress, - * nftQuantity, - * approvedSpenderAddress: orderbookAddress, - * calldata: getOrderbookCalldata({ - * orderId, - * quantity: nftQuantity, - * recipient: address || '' - * }), - * onSuccess: (txHash) => console.log('Success!', txHash) - * }, - * orderSummaryItems: [ - * { - * title: 'NFT #' + tokenId, - * subtitle: 'Your Collection', - * imageUrl: 'https://example.com/nft.png' - * } - * ] - * }) - * } - * - * return ( - * - * ) - * } - * ``` - */ -export const useCheckoutModal = (): UseCheckoutModalReturnType => { - const { triggerCheckout, closeCheckout, settings } = useCheckoutModalContext() - - return { triggerCheckout, closeCheckout, settings } -} diff --git a/packages/checkout/src/hooks/useCheckoutOptionsSalesContract.ts b/packages/checkout/src/hooks/useCheckoutOptionsSalesContract.ts deleted file mode 100644 index 36c106f39..000000000 --- a/packages/checkout/src/hooks/useCheckoutOptionsSalesContract.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { CheckoutOptionsSalesContractArgs } from '@0xsequence/marketplace' -import { useQuery } from '@tanstack/react-query' - -import { useMarketplaceClient } from './useMarketplaceClient.js' - -export interface UseGenerateBuyTransactionOptions { - disabled?: boolean -} - -export const useCheckoutOptionsSalesContract = ( - chain: number | string, - args: CheckoutOptionsSalesContractArgs, - options?: UseGenerateBuyTransactionOptions -) => { - const marketplaceClient = useMarketplaceClient({ chain }) - - return useQuery({ - queryKey: ['useCheckoutOptionsSalesContract', args], - queryFn: async () => { - const res = await marketplaceClient.checkoutOptionsSalesContract(args) - - return res - }, - retry: false, - staleTime: 360 * 1000, - enabled: !options?.disabled && !!args.wallet - }) -} diff --git a/packages/checkout/src/hooks/useCheckoutWhitelistStatus.ts b/packages/checkout/src/hooks/useCheckoutWhitelistStatus.ts new file mode 100644 index 000000000..2378bd13a --- /dev/null +++ b/packages/checkout/src/hooks/useCheckoutWhitelistStatus.ts @@ -0,0 +1,24 @@ +import { useProjectAccessKey, useEnvironment } from '@0xsequence/kit' +import { useQuery } from '@tanstack/react-query' + +import { checkSardineWhitelistStatus, CheckSardineWhitelistStatusArgs } from '../utils' + +export const useCheckoutWhitelistStatus = (args: CheckSardineWhitelistStatusArgs, disabled?: boolean) => { + const prodProjectAccessKey = useProjectAccessKey() + + const { isEnabledDevSardine, devProjectAccessKey } = useEnvironment() + + const projectAccessKey = isEnabledDevSardine ? devProjectAccessKey : prodProjectAccessKey + + return useQuery({ + queryKey: ['useCheckoutWhitelistStatus', args, projectAccessKey, isEnabledDevSardine], + queryFn: async () => { + const res = await checkSardineWhitelistStatus(args, projectAccessKey, isEnabledDevSardine) + + return res + }, + retry: false, + staleTime: 1800 * 1000, + enabled: !disabled + }) +} diff --git a/packages/checkout/src/hooks/useCreditCardCheckoutModal.ts b/packages/checkout/src/hooks/useCreditCardCheckoutModal.ts new file mode 100644 index 000000000..d5ded1f94 --- /dev/null +++ b/packages/checkout/src/hooks/useCreditCardCheckoutModal.ts @@ -0,0 +1,76 @@ +import { useCreditCardCheckoutModalContext, type CreditCardCheckoutSettings } from '../contexts/CreditCardCheckout.js' + +/** + * Return type for the useCreditCardCheckoutModal hook. + * + * @property {function(settings: CreditCardCheckoutSettings): void} initiateCreditCardCheckout - Function to open the Checkout modal + * @property {function(): void} closeCheckout - Function to close the Checkout modal + * @property {CreditCardCheckoutSettings|undefined} settings - Current settings for the Checkout modal + */ +type UseCheckoutModalReturnType = { + initiateCreditCardCheckout: (settings: CreditCardCheckoutSettings) => void + closeCreditCardCheckout: () => void + settings: CreditCardCheckoutSettings | undefined +} + +/** + * Hook to manage the Credit Card Checkout modal that allows users to complete purchases using credit card. + * + * This hook provides methods to open and close the checkout modal, and access its current settings. + * + * Go to {@link https://docs.sequence.xyz/sdk/web/checkout-sdk/hooks/useCreditCardCheckoutModal} for more detailed documentation. + * + * @returns An object containing functions and settings for the Checkout modal {@link UseCreditCardCheckoutModalReturnType} + * + * @example + * ```tsx + * import { useCreditCardCheckoutModal } from '@0xsequence/checkout' + * import { ChainId } from '@0xsequence/network' + * import { getOrderbookCalldata } from '../utils' + * + * const YourComponent = () => { + * const { address } = useAccount() + * const { initiateCreditCardCheckout } = useCreditCardCheckoutModal() + * + * const handleCheckout = () => { + * // NFT purchase settings + * const chainId = ChainId.POLYGON + * const orderbookAddress = '0xB537a160472183f2150d42EB1c3DD6684A55f74c' + * const nftQuantity = '1' + * const orderId = 'your-order-id' + * const tokenContractAddress = '0xabcdef...' // NFT contract address + * const tokenId = '123' // NFT token ID + * + * initiateCreditCardCheckout({ + * chainId, + * contractAddress: orderbookAddress, + * recipientAddress: address || '', + * currencyQuantity: '100000', + * currencySymbol: 'USDC', + * currencyAddress: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + * currencyDecimals: '6', + * nftId: tokenId, + * nftAddress: tokenContractAddress, + * nftQuantity, + * approvedSpenderAddress: orderbookAddress, + * calldata: getOrderbookCalldata({ + * orderId, + * quantity: nftQuantity, + * recipient: address || '' + + * }) + * } + * + * return ( + * + * ) + * } + * ``` + */ +export const useCreditCardCheckoutModal = (): UseCheckoutModalReturnType => { + const { initiateCreditCardCheckout, closeCreditCardCheckout, settings } = useCreditCardCheckoutModalContext() + + return { initiateCreditCardCheckout, closeCreditCardCheckout, settings } +} diff --git a/packages/checkout/src/hooks/useERC1155SaleContractCheckout.ts b/packages/checkout/src/hooks/useERC1155SaleContractCheckout.ts deleted file mode 100644 index 243d6ee5c..000000000 --- a/packages/checkout/src/hooks/useERC1155SaleContractCheckout.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { findSupportedNetwork } from '@0xsequence/connect' -import { useDetectContractVersion } from '@0xsequence/hooks' -import { type CheckoutOptionsSalesContractArgs } from '@0xsequence/marketplace' -import { encodeFunctionData, toHex, zeroAddress, type Hex } from 'viem' -import { useReadContract, useReadContracts } from 'wagmi' - -import { ERC_1155_SALE_CONTRACT } from '../constants/abi.js' -import type { SelectPaymentSettings } from '../contexts/SelectPaymentModal.js' - -import { useCheckoutOptionsSalesContract } from './useCheckoutOptionsSalesContract.js' -import { useSelectPaymentModal } from './useSelectPaymentModal.js' - -/** - * Return type for the useERC1155SaleContractCheckout hook. - * - * @property Function to open the checkout modal `openCheckoutModal` - * @property Function to close the checkout modal `closeCheckoutModal` - * @property Current payment settings for the modal `selectPaymentSettings` - * @property Whether the contract data is still loading `isLoading` - * @property Whether there was an error loading the contract data `isError` - */ -interface UseERC1155SaleContractCheckoutReturnType { - openCheckoutModal: () => void - closeCheckoutModal: () => void - selectPaymentSettings?: SelectPaymentSettings - isLoading: boolean - isError: boolean -} - -type SaleContractSettings = Omit< - SelectPaymentSettings, - 'txData' | 'collectibles' | 'price' | 'currencyAddress' | 'recipientAddress' | 'targetContractAddress' -> - -export const getERC1155SaleContractConfig = ({ - chain, - price, - currencyAddress = zeroAddress, - recipientAddress, - collectibles, - collectionAddress, - ...restProps -}: Omit): SelectPaymentSettings => { - const purchaseTransactionData = encodeFunctionData({ - abi: ERC_1155_SALE_CONTRACT, - functionName: 'mint', - // [to, tokenIds, amounts, data, expectedPaymentToken, maxTotal, proof] - args: [ - recipientAddress, - collectibles.map(c => BigInt(c.tokenId || '')), - collectibles.map(c => BigInt(c.quantity)), - toHex(0), - currencyAddress, - price, - [toHex(0, { size: 32 })] - ] - }) - - return { - chain, - price, - currencyAddress, - recipientAddress, - collectibles, - collectionAddress, - txData: purchaseTransactionData, - ...restProps - } -} - -/** - * Hook for enabling ERC-1155 NFT purchases using a standard sale contract. - * - * This hook simplifies the process of purchasing ERC-1155 tokens by automatically: - * - Fetching price information from the sale contract - * - Determining payment options (crypto, credit card, etc.) - * - Generating the proper transaction data - * - Opening and managing the checkout modal - * - * @see {@link https://docs.sequence.xyz/sdk/web/checkout-sdk/hooks/useERC1155SaleContractCheckout} for more detailed documentation. - * - * @param {object} params - Configuration options for the ERC-1155 sale contract checkout - * @param {number} params.chain - Chain ID where the sale contract is deployed - * @param {string} params.contractAddress - Address of the ERC-1155 sale contract - * @param {string} params.wallet - Address of the wallet that will receive the NFTs - * @param {string} params.collectionAddress - Address of the ERC-1155 token contract - * @param {Array<{tokenId: string, quantity: string}>} params.items - Array of token IDs and quantities to purchase - * @param {function} [params.onSuccess] - Callback function when the transaction is successful - * @param {function} [params.onError] - Callback function when an error occurs - * @param {function} [params.onClose] - Callback function when the modal is closed - * - * @returns Object containing functions to control the checkout modal and state {@link UseERC1155SaleContractCheckoutReturnType} - * - * @example - * ```tsx - * import { useERC1155SaleContractCheckout } from "@0xsequence/checkout"; - * import { useConnection } from "wagmi"; - * - * const MyComponent = () => { - * const { address: userAddress } = useConnection(); - * const { openCheckoutModal } = useERC1155SaleContractCheckout({ - * chain: 80001, // chainId of the chain the collectible is on - * contractAddress: "0x0327b2f274e04d292e74a06809bcd687c63a4ba4", // address of the contract handling the minting function - * wallet: userAddress!, // address of the recipient - * collectionAddress: "0x888a322db4b8033bac3ff84412738c096f87f9d0", // address of the collection contract - * items: [ - * // array of collectibles to purchase - * { - * tokenId: "0", - * quantity: "1", - * }, - * ], - * onSuccess: (txnHash: string) => { - * console.log("success!", txnHash); - * }, - * onError: (error: Error) => { - * console.error(error); - * }, - * }); - * - * const onClick = () => { - * if (!userAddress) { - * return; - * } - * openCheckoutModal(); - * }; - * - * return ; - * }; - * ``` - */ -export const useERC1155SaleContractCheckout = ({ - chain, - contractAddress, - wallet, - collectionAddress, - items, - ...restArgs -}: Omit & SaleContractSettings): UseERC1155SaleContractCheckoutReturnType => { - const { openSelectPaymentModal, closeSelectPaymentModal, selectPaymentSettings } = useSelectPaymentModal() - const { - data: checkoutOptions, - isLoading: isLoadingCheckoutOptions, - isError: isErrorCheckoutOptions - } = useCheckoutOptionsSalesContract(chain, { - chainId: chain.toString(), - contractAddress, - wallet, - collectionAddress, - items - }) - const network = findSupportedNetwork(chain) - const chainId = network?.chainId || 137 - - const { - data: saleConfigData, - isLoading: isLoadingSaleConfig, - isError: isErrorSaleConfig - } = useSaleContractConfig({ chainId, contractAddress, tokenIds: items.map(i => i.tokenId) }) - - const isLoading = isLoadingCheckoutOptions || isLoadingSaleConfig - const error = isErrorCheckoutOptions || isErrorSaleConfig - - const openCheckoutModal = () => { - if (isLoading) { - throw new Error('Checkout options are still loading. Please wait and try again.') - } - if (error) { - throw new Error( - 'Failed to load checkout options or sale configuration. Please check your network connection and try again.', - { - cause: error - } - ) - } - - openSelectPaymentModal( - getERC1155SaleContractConfig({ - collectibles: items.map(item => ({ - tokenId: item.tokenId, - quantity: item.quantity - })), - chain: chainId, - price: items - .reduce((acc, item) => { - const price = BigInt(saleConfigData?.saleConfigs.find(sale => sale.tokenId === item.tokenId)?.price || 0) - - return acc + BigInt(item.quantity) * price - }, BigInt(0)) - .toString(), - currencyAddress: saleConfigData?.currencyAddress || '', - recipientAddress: wallet, - collectionAddress, - targetContractAddress: contractAddress, - creditCardProviders: checkoutOptions?.options.nftCheckout || [], - onRampProvider: checkoutOptions?.options.onRamp?.[0], - ...restArgs - }) - ) - } - - return { - openCheckoutModal, - closeCheckoutModal: closeSelectPaymentModal, - selectPaymentSettings, - isLoading, - isError: error - } -} - -interface UseSaleContractConfigArgs { - chainId: number - contractAddress: string - tokenIds: string[] -} - -interface SaleConfig { - tokenId: string - price: string -} - -interface UseSaleContractConfigData { - currencyAddress: string - saleConfigs: SaleConfig[] -} - -interface UseSaleContractConfigReturn { - data?: UseSaleContractConfigData - isLoading: boolean - isError: boolean -} - -export const useSaleContractConfig = ({ - chainId, - contractAddress, - tokenIds -}: UseSaleContractConfigArgs): UseSaleContractConfigReturn => { - const { - data: versionData, - isLoading: isLoadingVersion, - isError: isErrorVersion - } = useDetectContractVersion({ contractAddress, chainId }) - - const getAbi = () => { - if (isErrorVersion) { - return ERC_1155_SALE_CONTRACT - } - - const versionAbi = versionData?.version?.sourceData?.abi - if (!versionAbi) { - return ERC_1155_SALE_CONTRACT - } - - if (typeof versionAbi === 'string') { - try { - return JSON.parse(versionAbi) - } catch { - return ERC_1155_SALE_CONTRACT - } - } - - return versionAbi - } - - const abi = getAbi() - - const { - data: paymentTokenERC1155, - isLoading: isLoadingPaymentTokenERC1155, - isError: isErrorPaymentTokenERC1155 - } = useReadContract({ - chainId, - abi, - address: contractAddress as Hex, - functionName: 'paymentToken', - query: { - enabled: !!versionData || isErrorVersion - } - }) - - interface SaleDetailsERC1155 { - cost: bigint - startTime: bigint - endTime: bigint - supplyCap: bigint - merkleRoot: string - } - - const { - data: globalSaleDetailsERC1155, - isLoading: isLoadingGlobalSaleDetailsERC1155, - isError: isErrorGlobalSaleDetailsERC1155 - } = useReadContract({ - chainId, - abi, - address: contractAddress as Hex, - functionName: 'globalSaleDetails', - query: { - enabled: !!versionData || isErrorVersion - } - }) - - const baseTokenSaleContract = { - chainId, - abi, - address: contractAddress as Hex, - functionName: 'tokenSaleDetails' - } - - const tokenSaleContracts = tokenIds.map(tokenId => ({ - ...baseTokenSaleContract, - args: [BigInt(tokenId)] - })) - - const { - data: tokenSaleDetailsERC1155, - isLoading: isLoadingTokenSaleDetailsERC1155, - isError: isErrorTokenSaleDetailsERC1155 - } = useReadContracts({ - contracts: tokenSaleContracts - }) - - const isLoadingERC1155 = - isLoadingPaymentTokenERC1155 || isLoadingGlobalSaleDetailsERC1155 || isLoadingTokenSaleDetailsERC1155 || isLoadingVersion - - const isErrorERC1155 = isErrorPaymentTokenERC1155 || isErrorGlobalSaleDetailsERC1155 || isErrorTokenSaleDetailsERC1155 - - if (isLoadingERC1155 || isErrorERC1155) { - return { - data: undefined, - isLoading: isLoadingERC1155, - isError: isErrorERC1155 - } - } - - const getSaleConfigs = (): SaleConfig[] => { - let saleInfos: SaleConfig[] = [] - - if (isLoadingERC1155 || isErrorERC1155 || isLoadingVersion) { - return saleInfos - } - - const { cost: globalCost } = globalSaleDetailsERC1155 as SaleDetailsERC1155 - - saleInfos = tokenIds.map((tokenId, index) => { - const tokenSaleDetails = tokenSaleDetailsERC1155?.[index].result as SaleDetailsERC1155 - const tokenPrice = tokenSaleDetails['cost'] || BigInt(0) - const startTime = tokenSaleDetails['startTime'] || BigInt(0) - const endTime = tokenSaleDetails['endTime'] || BigInt(0) - - // In the sale contract, the token sale has priority over the global sale - // So we need to check if the token sale is set, and if it is, use that - // Otherwise, we use the global sale - - const isTokenSaleInvalid = - endTime === BigInt(0) || - BigInt(Math.floor(Date.now() / 1000)) <= startTime || - BigInt(Math.floor(Date.now() / 1000)) >= endTime - - const effectivePrice = isTokenSaleInvalid ? globalCost : tokenPrice - - return { - tokenId, - price: effectivePrice.toString() - } - }) - - return saleInfos - } - - return { - data: { - currencyAddress: paymentTokenERC1155 as string, - saleConfigs: getSaleConfigs() - }, - isLoading: isLoadingERC1155, - isError: isErrorERC1155 - } -} - -/** - * @deprecated use useERC1155SaleContractPaymentModal instead - */ -export const useERC1155SaleContractPaymentModal = useERC1155SaleContractCheckout diff --git a/packages/checkout/src/hooks/useForteAccessToken.ts b/packages/checkout/src/hooks/useForteAccessToken.ts new file mode 100644 index 000000000..b69ce62d0 --- /dev/null +++ b/packages/checkout/src/hooks/useForteAccessToken.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchForteAccessToken } from '../api/data.js' +import { useEnvironmentContext } from '../contexts/Environment.js' + +export const useForteAccessToken = () => { + const { fortePaymentUrl } = useEnvironmentContext() + + return useQuery({ + queryKey: ['useForteAccessToken'], + queryFn: async () => { + const res = await fetchForteAccessToken(fortePaymentUrl) + + return res + }, + retry: false, + staleTime: 60 * 1000, + refetchOnWindowFocus: false + }) +} diff --git a/packages/checkout/src/hooks/useFortePaymentIntent.ts b/packages/checkout/src/hooks/useFortePaymentIntent.ts index 0a472a40e..d24db32ce 100644 --- a/packages/checkout/src/hooks/useFortePaymentIntent.ts +++ b/packages/checkout/src/hooks/useFortePaymentIntent.ts @@ -1,20 +1,19 @@ -import { useConfig } from '@0xsequence/hooks' import { useQuery } from '@tanstack/react-query' import { createFortePaymentIntent, type CreateFortePaymentIntentArgs } from '../api/data.js' +import { useEnvironmentContext } from '../contexts/Environment.js' interface UseFortePaymentIntentOptions { disabled?: boolean } export const useFortePaymentIntent = (args: CreateFortePaymentIntentArgs, options?: UseFortePaymentIntentOptions) => { - const { env, projectAccessKey } = useConfig() - const apiUrl = env.apiUrl + const { fortePaymentUrl } = useEnvironmentContext() return useQuery({ queryKey: ['useFortePaymentIntent', args], queryFn: async () => { - const res = await createFortePaymentIntent(apiUrl, projectAccessKey, args) + const res = await createFortePaymentIntent(fortePaymentUrl, args) return res }, diff --git a/packages/checkout/src/hooks/useModalTheme.ts b/packages/checkout/src/hooks/useModalTheme.ts deleted file mode 100644 index ecc2ba6d2..000000000 --- a/packages/checkout/src/hooks/useModalTheme.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useCheckoutModalContext } from '../contexts/CheckoutModal.js' - -export const useModalTheme = () => { - const { theme } = useCheckoutModalContext() - - return theme -} diff --git a/packages/checkout/src/hooks/useSardineClientToken.ts b/packages/checkout/src/hooks/useSardineClientToken.ts new file mode 100644 index 000000000..5f7a6c142 --- /dev/null +++ b/packages/checkout/src/hooks/useSardineClientToken.ts @@ -0,0 +1,19 @@ +import { useEnvironment } from '@0xsequence/kit' +import { useQuery } from '@tanstack/react-query' + +import { FetchSardineClientTokenArgs, fetchSardineClientToken } from '../api/data' + +export const useSardineClientToken = (args: FetchSardineClientTokenArgs, disabled?: boolean) => { + return useQuery({ + queryKey: ['useSardineClientToken', args], + queryFn: async () => { + const res = await fetchSardineClientToken(args) + + return res + }, + retry: false, + staleTime: 0, + enabled: !disabled, + refetchOnWindowFocus: false + }) +} diff --git a/packages/checkout/src/hooks/useSardineOnRampLink.ts b/packages/checkout/src/hooks/useSardineOnRampLink.ts new file mode 100644 index 000000000..692718074 --- /dev/null +++ b/packages/checkout/src/hooks/useSardineOnRampLink.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' + +import { SardineLinkOnRampArgs, fetchSardineOnRampLink } from '../api/data' + +export const useSardineOnRampLink = (args: SardineLinkOnRampArgs, disabled?: boolean) => { + return useQuery({ + queryKey: ['useSardineOnRampLink', args], + queryFn: async () => { + const res = await fetchSardineOnRampLink(args) + + return res + }, + retry: false, + staleTime: 0, + enabled: !disabled, + refetchOnWindowFocus: false + }) +} diff --git a/packages/checkout/src/hooks/useSwapModal.ts b/packages/checkout/src/hooks/useSwapModal.ts deleted file mode 100644 index 5a1ecc166..000000000 --- a/packages/checkout/src/hooks/useSwapModal.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useSwapModalContext, type SwapModalSettings } from '../contexts/SwapModal.js' - -/** - * Return type for the useSwapModal hook. - * - * @property {function(settings: SwapModalSettings): void} openSwapModal - Function to open the Swap modal - * @property {function(): void} closeSwapModal - Function to close the Swap modal - * @property {SwapModalSettings|undefined} swapModalSettings - Current settings for the Swap modal - */ -type UseSwapModalReturnType = { - isSwapModalOpen: boolean - openSwapModal: (settings: SwapModalSettings) => void - closeSwapModal: () => void - swapModalSettings: SwapModalSettings | undefined -} - -/** - * Hook to manage the Swap modal that allows users to swap tokens in their wallet to a target currency. - * - * This hook provides methods to open and close the swap modal, and access its current settings. - * The Swap modal allows users to select tokens from their wallet to swap to a specified target token, - * with the option to execute additional transactions after the swap completes. - * - * @see {@link https://docs.sequence.xyz/sdk/web/checkout-sdk/hooks/useSwapModal} for more detailed documentation. - * - * @returns An object containing functions to control the Swap modal and its state {@link UseSwapModalReturnType} - * - * @example - * ```tsx - * import { useSwapModal } from '@0xsequence/checkout' - * import { encodeFunctionData, parseAbi } from 'viem' - * - * const YourComponent = () => { - * const { openSwapModal } = useSwapModal() - * - * const handleSwap = () => { - * // Target token information - * const chainId = 137 // Polygon - * const currencyAddress = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' // USDC on Polygon - * const currencyAmount = '20000' // 0.02 USDC (in smallest units) - * - * // Optional: Transaction to execute after swap is completed - * const data = encodeFunctionData({ - * abi: parseAbi(['function demo()']), - * functionName: 'demo', - * args: [] - * }) - * - * // Open the swap modal - * openSwapModal({ - * onSuccess: () => { - * console.log('swap successful!') - * }, - * chainId, - * currencyAddress, - * currencyAmount, - * postSwapTransactions: [ - * { - * to: '0x37470dac8a0255141745906c972e414b1409b470', - * data - * } - * ], - * title: 'Swap and Pay', - * description: 'Select a token in your wallet to swap to 0.2 USDC.' - * }) - * } - * - * return ( - * - * ) - * } - * ``` - */ -export const useSwapModal = (): UseSwapModalReturnType => { - const { isSwapModalOpen, openSwapModal, closeSwapModal, swapModalSettings } = useSwapModalContext() - - return { isSwapModalOpen, openSwapModal, closeSwapModal, swapModalSettings } -} diff --git a/packages/checkout/src/index.ts b/packages/checkout/src/index.ts index 30beb273e..075972219 100644 --- a/packages/checkout/src/index.ts +++ b/packages/checkout/src/index.ts @@ -6,8 +6,8 @@ export { useCheckoutModal } from './hooks/useCheckoutModal.js' export { useAddFundsModal } from './hooks/useAddFundsModal.js' export { useSelectPaymentModal } from './hooks/useSelectPaymentModal.js' export { useTransferFundsModal } from './hooks/useTransferFundsModal.js' +export { useCheckoutWhitelistStatus } from './hooks/useCheckoutWhitelistStatus.js' export { useSwapModal } from './hooks/useSwapModal.js' -export { useERC1155SaleContractCheckout, useERC1155SaleContractPaymentModal } from './hooks/useERC1155SaleContractCheckout.js' export { useCheckoutUI } from './hooks/useCheckoutUI/index.js' export { type ForteConfig } from './contexts/CheckoutModal.js' @@ -21,7 +21,7 @@ export { type TransactionStatusSettings } from './contexts/TransactionStatusModa export { useTransactionStatusModal } from './hooks/useTransactionStatusModal.js' // utils -export { fetchTransakSupportedCountries } from './utils/transak.js' +export { fetchTransakSupportedCountries, getTransakLink } from './utils/transak.js' // OnRampProvider export { TransactionOnRampProvider } from '@0xsequence/marketplace' diff --git a/packages/checkout/src/shared/components/KitCheckoutProvider.tsx b/packages/checkout/src/shared/components/KitCheckoutProvider.tsx new file mode 100644 index 000000000..c3cfa2808 --- /dev/null +++ b/packages/checkout/src/shared/components/KitCheckoutProvider.tsx @@ -0,0 +1,118 @@ +import { Box, Modal, ThemeProvider } from '@0xsequence/design-system' +import { getModalPositionCss } from '@0xsequence/kit' +import { useTheme } from '@0xsequence/kit/hooks' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AnimatePresence } from 'framer-motion' +import React, { useState, useEffect } from 'react' + +import { History, Navigation, NavigationContextProvider, CheckoutModalContextProvider, CheckoutSettings } from '../../contexts' +import { NavigationHeader } from '../../shared/components/NavigationHeader' +import { PendingTransaction, TransactionError, TransactionSuccess, CheckoutSelection } from '../../views' + +import '@0xsequence/design-system/styles.css' + +export type KitCheckoutProvider = { + children: React.ReactNode +} + +export const DEFAULT_LOCATION: Navigation = { + location: 'select-method-checkout' +} + +export const KitCheckoutProvider = (props: KitCheckoutProvider) => { + const queryClient = new QueryClient() + + return ( + + + + ) +} + +export const KitCheckoutContent = ({ children }: KitCheckoutProvider) => { + const { theme, position } = useTheme() + const [openCheckoutModal, setOpenCheckoutModal] = useState(false) + const [settings, setSettings] = useState() + const [history, setHistory] = useState([]) + const navigation = history.length > 0 ? history[history.length - 1] : DEFAULT_LOCATION + + const triggerCheckout = (settings: CheckoutSettings) => { + setSettings(settings) + setOpenCheckoutModal(true) + } + + const closeCheckout = () => { + setOpenCheckoutModal(false) + } + + const getContent = () => { + const { location } = navigation + switch (location) { + case 'select-method-checkout': + return + case 'transaction-pending': + return + case 'transaction-success': + return + case 'transaction-error': + return + case 'transaction-form': + default: + return + } + } + + const getHeader = () => { + const { location } = navigation + switch (location) { + case 'select-method-checkout': + return + case 'transaction-success': + case 'transaction-error': + case 'transaction-pending': + return + case 'transaction-form': + default: + return + } + } + + useEffect(() => { + if (openCheckoutModal) { + setHistory([]) + } + }, [openCheckoutModal]) + + return ( + + +
+ + + {openCheckoutModal && ( + setOpenCheckoutModal(false)} + > + + {getHeader()} + {getContent()} + + + )} + + +
+ {children} +
+
+ ) +} diff --git a/packages/checkout/src/shared/components/PaperTransaction.tsx b/packages/checkout/src/shared/components/PaperTransaction.tsx new file mode 100644 index 000000000..9f78373fe --- /dev/null +++ b/packages/checkout/src/shared/components/PaperTransaction.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect, ChangeEvent } from 'react' +import { Box, Button, Card, Spinner, Text, TextInput, EditIcon, CheckmarkIcon } from '@0xsequence/design-system' +import { CheckoutWithCard } from '@paperxyz/react-client-sdk' +import { useNavigation } from '../../hooks' +import { fetchPaperSecret } from '../../api' +import { CheckoutSettings } from '../../contexts/CheckoutModal' +import { useAPIClient } from '@0xsequence/kit' +export interface PaperTransactionProps { + settings: CheckoutSettings +} + +export const PaperTransaction = ({ settings }: PaperTransactionProps) => { + const apiClient = useAPIClient() + const [emailEditState, setEmailEditState] = useState(true) + const [email, setEmail] = useState(settings.creditCardCheckout?.email || '') + const [inputEmailAddress, setInputEmailAddress] = useState(email) + const [paperSecret, setPaperSecret] = useState(null) + const [paperSecretLoading, setPaperSecretLoading] = useState(false) + const { setNavigation } = useNavigation() + + const onClickEditEmail = () => { + if (emailEditState) { + setEmail(inputEmailAddress || '') + } + if (!emailEditState) { + setInputEmailAddress(email) + } + setEmailEditState(!emailEditState) + } + + const fetchSecret = async () => { + setPaperSecretLoading(true) + + try { + if (!email) { + throw new Error('No email address found') + } + + if (!settings.creditCardCheckout) { + throw new Error('No credit card checkout settings found') + } + + const secret = await fetchPaperSecret({ + apiClient, + email, + ...settings.creditCardCheckout + }) + + setPaperSecret(secret) + } catch (e) { + console.error('Failed to fetch paper secret', e) + setNavigation({ + location: 'transaction-error', + params: { + error: e as Error + } + }) + } + + setPaperSecretLoading(false) + } + + useEffect(() => { + const timer = setInterval(() => { + // This is a workaround for the KYC modal not becoming clickable + // The alternative of using the onReview callback does not work due + // to race conditions + const paperJsSdkModal = document.getElementById('paper-js-sdk-modal') + if (paperJsSdkModal) { + paperJsSdkModal.style.pointerEvents = 'visible' + } + }, 100) + return () => { + clearInterval(timer) + } + }, []) + + useEffect(() => { + if (email !== '') { + fetchSecret() + } + }, [email]) + + const isValidEmailAddress = () => { + const emailRegEx = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ + const isValidEmail = emailRegEx.test(inputEmailAddress || '') + return isValidEmail + } + + const emailAddressOnChange = (ev: ChangeEvent) => { + setInputEmailAddress(ev.target.value) + } + + const onPending = (transactionId: string) => { + setNavigation({ + location: 'transaction-pending', + params: { + transactionId + } + }) + } + + const onError = (error: Error) => { + setNavigation({ + location: 'transaction-error', + params: { + error + } + }) + } + + const getEmailInput = () => { + if (emailEditState) { + return ( + + + + Receipt email address + + + + -
- ) : ( -
- {addFundsSettings?.windowedOnRampMessage || 'Funds will be added from another window.'} -
- )} -
- ) - } - - if (isErrorTransakLink) { + if (errorTransakLink) { return ( -
+
An error has occurred
) diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/PaymentProviderOption.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/PaymentProviderOption.tsx deleted file mode 100644 index 9867918fa..000000000 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/PaymentProviderOption.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Card, cn, Text } from '@0xsequence/design-system' -import type { JSX } from 'react' - -interface PaymentProviderOptionProps { - name: string - onClick: () => void - isSelected: boolean - isRecommended: boolean - logo: JSX.Element -} - -export const PaymentProviderOption = ({ name, onClick, isSelected, isRecommended, logo }: PaymentProviderOptionProps) => { - return ( - -
-
-
{logo}
- - {name} - -
-
- {isRecommended && ( - - Recommended - - )} -
-
-
- ) -} diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/index.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/index.tsx index 2cbbc32f7..069eb3242 100644 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/index.tsx +++ b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCreditCard/index.tsx @@ -6,7 +6,7 @@ import { useConnection } from 'wagmi' import type { CheckoutSettings } from '../../../../contexts/CheckoutModal.js' import { useCheckoutModal, useSelectPaymentModal } from '../../../../hooks/index.js' -type BasePaymentProviderOptions = 'transak' +type BasePaymentProviderOptions = 'sardine' | 'transak' interface PayWithCreditCardTabProps { skipOnCloseCallback: () => void @@ -22,6 +22,7 @@ export const PayWithCreditCardTab = ({ skipOnCloseCallback }: PayWithCreditCardT txData, collectibles, collectionAddress, + sardineConfig, onSuccess = () => {}, onError = () => {}, onClose = () => {}, @@ -51,6 +52,7 @@ export const PayWithCreditCardTab = ({ skipOnCloseCallback }: PayWithCreditCardT onClickCustomProvider() } return + case 'sardine': case 'transak': case 'forte': onPurchase() @@ -94,8 +96,7 @@ export const PayWithCreditCardTab = ({ skipOnCloseCallback }: PayWithCreditCardT nftDecimals: collectible.decimals === undefined ? undefined : String(collectible.decimals), provider: selectedPaymentProvider as BasePaymentProviderOptions, calldata: txData, - onSuccessChecker: selectPaymentSettings?.onSuccessChecker, - approvedSpenderAddress, + approvedSpenderAddress: sardineConfig?.approvedSpenderAddress || approvedSpenderAddress, ...rest } } @@ -115,6 +116,8 @@ export const PayWithCreditCardTab = ({ skipOnCloseCallback }: PayWithCreditCardT const getProviderName = () => { switch (selectedPaymentProvider) { + case 'sardine': + return 'Sardine' case 'transak': return 'Transak' case 'forte': diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx index 30860eebb..7a3b8f27d 100644 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx +++ b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx @@ -2,12 +2,10 @@ import { compareAddress, ContractVerificationStatus, formatDisplay, - isTxRejected, sendTransactions, TRANSACTION_CONFIRMATIONS_DEFAULT, useAnalyticsContext } from '@0xsequence/connect' -import { findSupportedNetwork } from '@0xsequence/connect' import { AddIcon, Button, ChevronDownIcon, Spinner, Text, TokenImage, WarningIcon } from '@0xsequence/design-system' import { DEFAULT_SLIPPAGE_BPS, @@ -19,6 +17,7 @@ import { useIndexerClient } from '@0xsequence/hooks' import { TransactionOnRampProvider } from '@0xsequence/marketplace' +import { findSupportedNetwork } from '@0xsequence/connect' import { useEffect, useState, type RefObject } from 'react' import { encodeFunctionData, formatUnits, zeroAddress, type Hex } from 'viem' import { useChainId, useConnection, usePublicClient, useReadContract, useSwitchChain, useWalletClient } from 'wagmi' @@ -27,9 +26,10 @@ import { ERC_20_CONTRACT_ABI } from '../../../../constants/abi.js' import { EVENT_SOURCE } from '../../../../constants/index.js' import { type PaymentMethodSelectionParams } from '../../../../contexts/NavigationCheckout.js' import type { SelectPaymentSettings } from '../../../../contexts/SelectPaymentModal.js' -import { useAddFundsModal, useTransactionCounter } from '../../../../hooks/index.js' +import { useAddFundsModal } from '../../../../hooks/index.js' import { useSelectPaymentModal, useTransactionStatusModal } from '../../../../hooks/index.js' import { useNavigationCheckout } from '../../../../hooks/useNavigationCheckout.js' +import { TRANSAK_ONRAMP_URL } from '../../../../utils/transak.js' import { useInitialBalanceCheck } from './useInitialBalanceCheck.js' @@ -38,8 +38,6 @@ interface PayWithCryptoTabProps { isSwitchingChainRef: RefObject } -type ErrorCause = 'generic' | 'user-rejection' - export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: PayWithCryptoTabProps) => { const connectedChainId = useChainId() const { switchChain } = useSwitchChain() @@ -49,16 +47,8 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P const { openTransactionStatusModal } = useTransactionStatusModal() const { selectPaymentSettings = {} as SelectPaymentSettings, closeSelectPaymentModal } = useSelectPaymentModal() const { analytics } = useAnalyticsContext() - const [error, setError] = useState(null) + const [isError, setIsError] = useState(false) const { navigation, setNavigation } = useNavigationCheckout() - const { - initializeTransactionCounter, - incrementTransactionCount, - currentTransactionNumber, - maxTransactions, - isTransactionCounterInitialized, - resetTransactionCounter - } = useTransactionCounter() const { chain, @@ -80,7 +70,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P onSuccessChecker } = selectPaymentSettings - const isFree = BigInt(price) === 0n + const isFree = Number(price) == 0 const network = findSupportedNetwork(chain) const chainId = network?.chainId || 137 @@ -165,28 +155,12 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } ) - const isTargetWalletClientReady = !!walletClient - const isTargetPublicClientReady = publicClient?.chain?.id === chainId - useEffect(() => { - if ( - isSwitchingChainRef.current && - connectedChainId === chainId && - !isLoadingWalletClient && - isTargetWalletClientReady && - isTargetPublicClientReady - ) { + if (isSwitchingChainRef.current && connectedChainId == Number(chainId) && !isLoadingWalletClient) { isSwitchingChainRef.current = false onClickPurchase() } - }, [ - connectedChainId, - chainId, - isLoadingWalletClient, - isSwitchingChainRef, - isTargetWalletClientReady, - isTargetPublicClientReady - ]) + }, [connectedChainId, chainId, isLoadingWalletClient, isSwitchingChainRef.current]) const isNotEnoughBalanceError = typeof swapQuoteError?.cause === 'string' && swapQuoteError?.cause?.includes('not enough balance for swap') @@ -220,16 +194,16 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P compareAddress(balance.contractAddress, selectedCurrency.address) ) - const userBalance = BigInt(tokenBalance?.balance || '0') - const requiredBalance = BigInt(selectedCurrencyPrice) - const isInsufficientBalance = !isFree && userBalance < requiredBalance + const isInsufficientBalance = + tokenBalance === undefined || + (tokenBalance?.balance && tokenBalance.balance !== '' && BigInt(tokenBalance.balance) < BigInt(selectedCurrencyPrice)) useInitialBalanceCheck({ userAddress: userAddress || '', buyCurrencyAddress, price, chainId, - isInsufficientBalance, + isInsufficientBalance: isInsufficientBalance as boolean, tokenBalancesIsLoading }) @@ -246,15 +220,26 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P const priceFiat = (fiatExchangeRate * Number(formattedPrice)).toFixed(2) const onPurchaseMainCurrency = async () => { + if (!walletClient || isErrorWalletClient || errorWalletClient) { + throw new Error('Wallet client is not available. Please ensure your wallet is connected.', { + cause: errorWalletClient + }) + } if (!userAddress) { throw new Error('User address is not available. Please ensure your wallet is connected.') } + if (!publicClient) { + throw new Error('Public client is not available. Please check your network connection.') + } + if (!indexerClient) { + throw new Error('Indexer client is not available. Please check your network connection.') + } if (!connector) { throw new Error('Wallet connector is not available. Please ensure your wallet is properly connected.') } setIsPurchasing(true) - setError(null) + setIsError(false) try { if (connectedChainId != chainId) { @@ -263,18 +248,6 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P return } - if (!walletClient || isErrorWalletClient || errorWalletClient) { - throw new Error('Wallet client is not available. Please ensure your wallet is connected.', { - cause: errorWalletClient - }) - } - if (!publicClient || publicClient.chain?.id !== chainId) { - throw new Error('Public client is not ready for the selected network. Please try again.') - } - if (!indexerClient) { - throw new Error('Indexer client is not available. Please check your network connection.') - } - const approveTxData = encodeFunctionData({ abi: ERC_20_CONTRACT_ABI, functionName: 'approve', @@ -303,7 +276,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } ] - const txs = await sendTransactions({ + const txHash = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -315,29 +288,6 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P waitConfirmationForLastTransaction: false }) - if (txs.length === 0) { - throw new Error('No transactions to send') - } - - initializeTransactionCounter(txs.length) - - let txHash: string | undefined - for (const [index, tx] of txs.entries()) { - const currentTxHash = await tx() - incrementTransactionCount() - - const isLastTransaction = index === txs.length - 1 - - if (isLastTransaction) { - onSuccess?.(currentTxHash) - txHash = currentTxHash - } - } - - if (!txHash) { - throw new Error('Transaction hash is not available') - } - analytics?.track({ event: 'SEND_TRANSACTION_REQUEST', props: { @@ -388,18 +338,22 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } catch (e) { console.error('Failed to purchase...', e) onError(e as Error) - const isRejected = isTxRejected(e as Error) - setError(isRejected ? 'user-rejection' : 'generic') + setIsError(true) } - resetTransactionCounter() setIsPurchasing(false) } const onClickPurchaseSwap = async () => { + if (!walletClient) { + throw new Error('Wallet client is not available. Please ensure your wallet is connected.') + } if (!userAddress) { throw new Error('User address is not available. Please ensure your wallet is connected.') } + if (!publicClient) { + throw new Error('Public client is not available. Please check your network connection.') + } if (!connector) { throw new Error('Wallet connector is not available. Please ensure your wallet is properly connected.') } @@ -408,7 +362,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } setIsPurchasing(true) - setError(null) + setIsError(false) try { if (connectedChainId != chainId) { @@ -417,18 +371,6 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P return } - if (!walletClient || isErrorWalletClient || errorWalletClient) { - throw new Error('Wallet client is not available. Please ensure your wallet is connected.', { - cause: errorWalletClient - }) - } - if (!publicClient || publicClient.chain?.id !== chainId) { - throw new Error('Public client is not ready for the selected network. Please try again.') - } - if (!indexerClient) { - throw new Error('Indexer client is not available. Please check your network connection.') - } - const approveTxData = encodeFunctionData({ abi: ERC_20_CONTRACT_ABI, functionName: 'approve', @@ -482,7 +424,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } ] - const txs = await sendTransactions({ + const txHash = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -494,29 +436,6 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P waitConfirmationForLastTransaction: false }) - if (txs.length === 0) { - throw new Error('No transactions to send') - } - - initializeTransactionCounter(txs.length) - - let txHash: string | undefined - for (const [index, tx] of txs.entries()) { - const currentTxHash = await tx() - incrementTransactionCount() - - const isLastTransaction = index === txs.length - 1 - - if (isLastTransaction) { - onSuccess?.(currentTxHash) - txHash = currentTxHash - } - } - - if (!txHash) { - throw new Error('Transaction hash is not available') - } - analytics?.track({ event: 'SEND_TRANSACTION_REQUEST', props: { @@ -567,12 +486,10 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } catch (e) { console.error('Failed to purchase...', e) onError(e as Error) - const isRejected = isTxRejected(e as Error) - setError(isRejected ? 'user-rejection' : 'generic') + setIsError(true) } setIsPurchasing(false) - resetTransactionCounter() } const onClickPurchase = () => { @@ -584,23 +501,24 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } const onClickAddFunds = () => { + if (!onRampProvider || onRampProvider === TransactionOnRampProvider.unknown) { + window.open(TRANSAK_ONRAMP_URL, '_blank') + return + } + const getNetworks = (): string | undefined => { const network = findSupportedNetwork(chainId) return network?.name?.toLowerCase() } - const useWindowedOnRamp = !onRampProvider || onRampProvider == TransactionOnRampProvider.unknown - skipOnCloseCallback() closeSelectPaymentModal() triggerAddFunds({ walletAddress: userAddress || '', - provider: onRampProvider || TransactionOnRampProvider.transak, + provider: onRampProvider, networks: getNetworks(), defaultCryptoCurrency: dataCurrencyInfo?.symbol || '', - onClose: selectPaymentSettings?.onClose, - transakOnRampKind: useWindowedOnRamp ? 'windowed' : 'default', - windowedOnRampMessage: "Once you've added funds, you can close this window and try buying with crypto again." + onClose: selectPaymentSettings?.onClose }) } @@ -620,7 +538,12 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P }} className="flex flex-row gap-2 justify-between items-center p-2 bg-button-glass rounded-full cursor-pointer select-none" > - + {selectedCurrencyInfo?.symbol} @@ -680,34 +603,19 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P
- {onRampProvider !== TransactionOnRampProvider.unknown && ( - - )} +
) } const PriceSection = () => { - if (isTransactionCounterInitialized) { - const descriptionText = - maxTransactions > 1 - ? `Confirming transaction ${currentTransactionNumber} of ${maxTransactions}` - : `Confirming transaction` - return ( -
-
- - {descriptionText} - -
- -
- ) - } - if (isFree) { return (
@@ -791,35 +699,27 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P return 'Confirm payment' } - const getErrorText = () => { - if (error == 'user-rejection') { - return 'The transaction was rejected.' - } - return 'An error occurred. Please try again.' - } - return (
- {!!error && ( + {isError && (
- {getErrorText()} + An error occurred. Please try again.
)} + >
) diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/useInitialBalanceCheck.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/useInitialBalanceCheck.tsx index 321635db2..61a786c8e 100644 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/useInitialBalanceCheck.tsx +++ b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/useInitialBalanceCheck.tsx @@ -26,16 +26,11 @@ export const useInitialBalanceCheck = ({ isInsufficientBalance, tokenBalancesIsLoading }: UseInitialBalanceCheckArgs) => { - const isFree = BigInt(price) === 0n const { navigation, setNavigation } = useNavigationCheckout() const isInitialBalanceChecked = (navigation.params as PaymentMethodSelectionParams).isInitialBalanceChecked - const { - data: swapRoutes = [], - isLoading: swapRoutesIsLoading, - isError: isErrorSwapRoutes - } = useGetSwapRoutes( + const { data: swapRoutes = [], isLoading: swapRoutesIsLoading } = useGetSwapRoutes( { walletAddress: userAddress ?? '', toTokenAddress: buyCurrencyAddress, @@ -43,15 +38,11 @@ export const useInitialBalanceCheck = ({ chainId: chainId }, { - disabled: isInitialBalanceChecked || !isInsufficientBalance || isFree + disabled: isInitialBalanceChecked || !isInsufficientBalance } ) - const { - data: swapRoutesTokenBalancesData, - isLoading: swapRoutesTokenBalancesIsLoading, - isError: isErrorSwapRoutesTokenBalances - } = useGetTokenBalancesSummary( + const { data: swapRoutesTokenBalancesData, isLoading: swapRoutesTokenBalancesIsLoading } = useGetTokenBalancesSummary( { chainIds: [chainId], filter: { @@ -66,7 +57,7 @@ export const useInitialBalanceCheck = ({ omitMetadata: true }, { - disabled: isInitialBalanceChecked || !isInsufficientBalance || swapRoutesIsLoading || isFree + disabled: isInitialBalanceChecked || !isInsufficientBalance || swapRoutesIsLoading } ) @@ -89,18 +80,6 @@ export const useInitialBalanceCheck = ({ } } - if (!validSwapRoute) { - setNavigation({ - location: 'payment-method-selection', - params: { - ...navigation.params, - isInitialBalanceChecked: true - } - }) - - return - } - setNavigation({ location: 'payment-method-selection', params: { @@ -116,15 +95,7 @@ export const useInitialBalanceCheck = ({ useEffect(() => { if (!isInitialBalanceChecked && !tokenBalancesIsLoading && !swapRoutesIsLoading && !swapRoutesTokenBalancesIsLoading) { - if (isErrorSwapRoutes || isErrorSwapRoutesTokenBalances) { - setNavigation({ - location: 'payment-method-selection', - params: { - ...navigation.params, - isInitialBalanceChecked: true - } - }) - } else if (isInsufficientBalance) { + if (isInsufficientBalance) { findSwapQuote() } else { setNavigation({ @@ -141,8 +112,6 @@ export const useInitialBalanceCheck = ({ isInsufficientBalance, tokenBalancesIsLoading, swapRoutesIsLoading, - swapRoutesTokenBalancesIsLoading, - isErrorSwapRoutes, - isErrorSwapRoutesTokenBalances + swapRoutesTokenBalancesIsLoading ]) } diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/index.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/index.tsx index 3dce0770a..915d0790f 100644 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/index.tsx +++ b/packages/checkout/src/views/Checkout/PaymentMethodSelect/index.tsx @@ -29,13 +29,21 @@ type Tab = 'crypto' | 'credit-card' export const PaymentSelectionContent = () => { const { selectPaymentSettings = {} as SelectPaymentSettings } = useSelectPaymentModal() - const isSwitchingChainRef = useRef(false) + + const { price } = selectPaymentSettings + + const isFree = Number(price) == 0 const isFirstRender = useRef(true) - const { collectibles, creditCardProviders = [], onClose = () => {}, price } = selectPaymentSettings + const { collectibles, creditCardProviders = [], onClose = () => {} } = selectPaymentSettings const { skipOnCloseCallback } = useSkipOnCloseCallback(onClose) - const isFree = Number(price) == 0 + const validCreditCardProviders = creditCardProviders.filter(provider => { + if (provider === 'transak') { + return !!selectPaymentSettings?.transakConfig + } + return true + }) const [selectedTab, setSelectedTab] = useState('crypto') const { clearCachedBalances } = useClearCachedBalances() @@ -46,23 +54,21 @@ export const PaymentSelectionContent = () => { const isTokenIdUnknown = collectibles.some(collectible => !collectible.tokenId) - const showCreditCardPayment = creditCardProviders.length > 0 && !isTokenIdUnknown && !isFree + const showCreditCardPayment = validCreditCardProviders.length > 0 && !isTokenIdUnknown && !isFree const tabs: { label: string; value: Tab }[] = [ { label: 'Crypto', value: 'crypto' as Tab }, ...(showCreditCardPayment ? [{ label: 'Credit Card', value: 'credit-card' as Tab }] : []) ] - const isSingleOption = tabs.length == 1 - const TabWrapper = ({ children }: { children: React.ReactNode }) => { - return
{children}
+ return
{children}
} return ( <>
{
- {`Pay with${isSingleOption ? ' ' + tabs[0].label : ''}`} + Pay with
@@ -92,10 +98,10 @@ export const PaymentSelectionContent = () => { } }} > - {!isSingleOption && } + - + diff --git a/packages/checkout/src/views/Checkout/index.tsx b/packages/checkout/src/views/Checkout/index.tsx deleted file mode 100644 index 48c49fb1b..000000000 --- a/packages/checkout/src/views/Checkout/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './TokenSelection/index.js' -export * from './PaymentMethodSelect/index.js' diff --git a/packages/checkout/src/views/CheckoutSelection/component/OrderSummaryItem.tsx b/packages/checkout/src/views/CheckoutSelection/component/OrderSummaryItem.tsx index a98b71d1c..ee49c7aef 100644 --- a/packages/checkout/src/views/CheckoutSelection/component/OrderSummaryItem.tsx +++ b/packages/checkout/src/views/CheckoutSelection/component/OrderSummaryItem.tsx @@ -1,7 +1,9 @@ -import { formatDisplay } from '@0xsequence/connect' -import { Card, Image, NetworkImage, Skeleton, Text, TokenImage } from '@0xsequence/design-system' -import { useGetContractInfo, useGetTokenMetadata } from '@0xsequence/hooks' -import { formatUnits } from 'viem' +import { Box, Card, Image, Text, Skeleton, TokenImage, NetworkImage } from '@0xsequence/design-system' +import { useContractInfo, useTokenMetadata } from '@0xsequence/kit/hooks' +import { ethers } from 'ethers' +import React from 'react' + +import { formatDisplay } from '../../../utils' interface OrderSummaryItem { contractAddress: string @@ -11,18 +13,11 @@ interface OrderSummaryItem { } export const OrderSummaryItem = ({ contractAddress, tokenId, quantityRaw, chainId }: OrderSummaryItem) => { - const { data: tokenMetadata, isLoading: isLoadingTokenMetadata } = useGetTokenMetadata({ - chainID: String(chainId), - contractAddress, - tokenIDs: [tokenId] - }) - const { data: contractInfo, isLoading: isLoadingContractInfo } = useGetContractInfo({ - chainID: String(chainId), - contractAddress - }) - const isLoading = isLoadingTokenMetadata || isLoadingContractInfo + const { data: tokenMetadata, isPending: isPendingTokenMetadata } = useTokenMetadata(chainId, contractAddress, [tokenId]) + const { data: contractInfo, isPending: isPendingContractInfo } = useContractInfo(chainId, contractAddress) + const isPending = isPendingTokenMetadata || isPendingContractInfo - if (isLoading) { + if (isPending) { return } @@ -30,52 +25,54 @@ export const OrderSummaryItem = ({ contractAddress, tokenId, quantityRaw, chainI const { logoURI: collectionLogoURI, name: collectionName = 'Unknown Collection' } = contractInfo || {} - const balanceFormatted = formatUnits(BigInt(quantityRaw), decimals) + const balanceFormatted = ethers.utils.formatUnits(quantityRaw, decimals) return ( - -
-
- -
-
-
+ + + + + + + - + {collectionName} -
-
+ - + {name} - {`#${tokenId}`} -
-
-
-
- {`x${formatDisplay(balanceFormatted)}`} -
+ {`#${tokenId}`} + + + + + {`x${formatDisplay(balanceFormatted)}`} +
) } export const OrderSummarySkeleton = () => { return ( - -
+ + -
+ -
-
+ +
) diff --git a/packages/checkout/src/views/PaymentSelection/FundWithFiat.tsx b/packages/checkout/src/views/PaymentSelection/FundWithFiat.tsx new file mode 100644 index 000000000..c9e0d3a88 --- /dev/null +++ b/packages/checkout/src/views/PaymentSelection/FundWithFiat.tsx @@ -0,0 +1,58 @@ +import { findSupportedNetwork } from '@0xsequence/network' +import { ArrowRightIcon, Box, Card, CurrencyIcon, Text } from '@0xsequence/design-system' +import { TransactionOnRampProvider } from '@0xsequence/marketplace' + +import { useSelectPaymentModal, useAddFundsModal } from '../../hooks' + +interface FundWithFiatProps { + walletAddress: string + provider: TransactionOnRampProvider + chainId?: number +} + +export const FundWithFiat = ({ walletAddress, provider, chainId }: FundWithFiatProps) => { + const { triggerAddFunds } = useAddFundsModal() + const { closeSelectPaymentModal, selectPaymentSettings } = useSelectPaymentModal() + + const getNetworks = (): string | undefined => { + const chain = selectPaymentSettings?.chain + if (!chain) return + + const network = findSupportedNetwork(chain) + return network?.name?.toLowerCase() + } + + const onClick = () => { + closeSelectPaymentModal() + triggerAddFunds({ + walletAddress, + provider, + networks: getNetworks() + }) + } + + return ( + + + + + Fund wallet with credit card + + + + + + + ) +} diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/OrderSummary/Price.tsx b/packages/checkout/src/views/PaymentSelection/OrderSummary/Price.tsx similarity index 100% rename from packages/checkout/src/views/Checkout/PaymentMethodSelect/OrderSummary/Price.tsx rename to packages/checkout/src/views/PaymentSelection/OrderSummary/Price.tsx diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/OrderSummary/index.tsx b/packages/checkout/src/views/PaymentSelection/OrderSummary/index.tsx similarity index 100% rename from packages/checkout/src/views/Checkout/PaymentMethodSelect/OrderSummary/index.tsx rename to packages/checkout/src/views/PaymentSelection/OrderSummary/index.tsx diff --git a/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx b/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx index 91027839e..0069390c6 100644 --- a/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx +++ b/packages/checkout/src/views/PaymentSelection/PayWithCreditCard/index.tsx @@ -14,7 +14,7 @@ interface PayWithCreditCardProps { skipOnCloseCallback: () => void } -type BasePaymentProviderOptions = 'transak' | 'forte' +type BasePaymentProviderOptions = 'sardine' | 'transak' | 'forte' type CustomPaymentProviderOptions = 'custom' type PaymentProviderOptions = BasePaymentProviderOptions | CustomPaymentProviderOptions @@ -27,6 +27,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac txData, collectibles, collectionAddress, + sardineConfig, onSuccess = () => {}, onError = () => {}, onClose = () => {}, @@ -62,6 +63,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac onClickCustomProvider() } return + case 'sardine': case 'transak': case 'forte': onPurchase() @@ -106,7 +108,7 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac nftDecimals: collectible.decimals === undefined ? undefined : String(collectible.decimals), provider: selectedPaymentProvider as BasePaymentProviderOptions, calldata: txData, - approvedSpenderAddress: settings.approvedSpenderAddress, + approvedSpenderAddress: sardineConfig?.approvedSpenderAddress || settings.approvedSpenderAddress, supplementaryAnalyticsInfo, transakConfig, forteConfig @@ -121,35 +123,46 @@ export const PayWithCreditCard = ({ settings, disableButtons, skipOnCloseCallbac const Options = () => { return (
- {creditCardProviders.slice(0, 1).map(creditCardProvider => { - switch (creditCardProvider) { - case 'transak': - case 'forte': - case 'custom': - return ( - { - setSelectedPaymentProvider(creditCardProvider) - }} - disabled={disableButtons} - > -
- - - Pay with credit or debit card - -
-
- -
-
- ) - default: - return null - } - })} + {/* Only 1 option will be displayed, even if multiple providers are passed */} + {creditCardProviders + .slice(0, 1) + .filter(provider => { + // cannot display transak checkout if the settings aren't provided + if (provider === 'transak' && !settings.transakConfig) { + return false + } + return true + }) + .map(creditCardProvider => { + switch (creditCardProvider) { + case 'sardine': + case 'transak': + case 'forte': + case 'custom': + return ( + { + setSelectedPaymentProvider(creditCardProvider) + }} + disabled={disableButtons} + > +
+ + + Pay with credit or debit card + +
+
+ +
+
+ ) + default: + return null + } + })}
) } diff --git a/packages/checkout/src/views/PaymentSelection/PayWithCrypto/index.tsx b/packages/checkout/src/views/PaymentSelection/PayWithCrypto/index.tsx new file mode 100644 index 000000000..f5a448086 --- /dev/null +++ b/packages/checkout/src/views/PaymentSelection/PayWithCrypto/index.tsx @@ -0,0 +1,356 @@ +import { + compareAddress, + ContractVerificationStatus, + CryptoOption, + formatDisplay, + useClearCachedBalances, + useGetContractInfo, + useGetSwapRoutes, + useGetTokenBalancesSummary +} from '@0xsequence/connect' +import { AddIcon, Button, Spinner, SubtractIcon, Text } from '@0xsequence/design-system' +import { findSupportedNetwork } from '@0xsequence/network' +import { motion } from 'motion/react' +import { Fragment, useEffect, useMemo, useState, type SetStateAction } from 'react' +import { formatUnits, zeroAddress } from 'viem' +import { useAccount } from 'wagmi' + +import type { SelectPaymentSettings } from '../../../contexts' + +interface PayWithCryptoProps { + settings: SelectPaymentSettings + disableButtons: boolean + selectedCurrency: string | undefined + setSelectedCurrency: React.Dispatch> + isLoading: boolean +} + +const MAX_OPTIONS = 3 + +export const PayWithCrypto = ({ + settings, + disableButtons, + selectedCurrency, + setSelectedCurrency, + isLoading +}: PayWithCryptoProps) => { + const [showMore, setShowMore] = useState(false) + const { enableSwapPayments = true, enableMainCurrencyPayment = true } = settings + + const { chain, currencyAddress, price, skipNativeBalanceCheck, nativeTokenAddress } = settings + const { address: userAddress } = useAccount() + const { clearCachedBalances } = useClearCachedBalances() + const network = findSupportedNetwork(chain) + const chainId = network?.chainId || 137 + + const { data: currencyInfoData, isLoading: isLoadingCurrencyInfo } = useGetContractInfo({ + chainID: String(chainId), + contractAddress: currencyAddress + }) + + const { data: swapRoutes = [], isLoading: swapRoutesIsLoading } = useGetSwapRoutes( + { + walletAddress: userAddress ?? '', + chainId, + toTokenAmount: price, + toTokenAddress: currencyAddress + }, + { disabled: !enableSwapPayments || !userAddress } + ) + + const tokenAddressesToFetch = useMemo(() => { + const addresses = new Set() + if (enableMainCurrencyPayment && currencyAddress) { + addresses.add(currencyAddress) + } + swapRoutes + .flatMap(route => route.fromTokens) + .forEach(fromToken => { + if (fromToken.address) { + addresses.add(fromToken.address) + } + }) + return Array.from(addresses) + .filter(addr => !!addr) + .map(addr => addr.toLowerCase()) + }, [currencyAddress, swapRoutes, enableMainCurrencyPayment]) + + const balanceHookOptions = useMemo( + () => ({ + disabled: !userAddress || tokenAddressesToFetch.length === 0 + }), + [userAddress, tokenAddressesToFetch.length] + ) + + const { + data: tokenBalancesData, + isLoading: tokenBalancesIsLoading, + fetchNextPage: fetchNextTokenBalances, + hasNextPage: hasNextTokenBalances, + isFetchingNextPage: isFetchingNextTokenBalances + } = useGetTokenBalancesSummary( + { + chainIds: [chainId], + filter: { + accountAddresses: userAddress ? [userAddress] : [], + contractStatus: ContractVerificationStatus.ALL, + contractWhitelist: tokenAddressesToFetch, + omitNativeBalances: skipNativeBalanceCheck ?? false + }, + omitMetadata: true, + page: { pageSize: 40 } + }, + balanceHookOptions + ) + + const tokenBalancesMap = useMemo(() => { + const map = new Map() + tokenBalancesData?.pages?.forEach(page => { + page.balances?.forEach(balanceData => { + if (balanceData.contractAddress && balanceData.balance) { + map.set(balanceData.contractAddress.toLowerCase(), BigInt(balanceData.balance)) + } + }) + }) + return map + }, [tokenBalancesData]) + + useEffect(() => { + if (hasNextTokenBalances && !isFetchingNextTokenBalances) { + fetchNextTokenBalances() + } + }, [hasNextTokenBalances, isFetchingNextTokenBalances, fetchNextTokenBalances]) + + const isLoadingOptions = (tokenBalancesIsLoading && !balanceHookOptions.disabled) || isLoadingCurrencyInfo || isLoading + const swapsAreLoading = swapRoutesIsLoading && enableSwapPayments + + interface TokenPayOption { + index: number + name: string + symbol: string + currencyAddress: string + price?: number + decimals?: number + logoUri?: string + } + + const tokenPayOptions: TokenPayOption[] = useMemo(() => { + const initialCoins = [ + ...(enableMainCurrencyPayment && currencyInfoData && currencyAddress + ? [ + { + index: 0, + name: currencyInfoData.name || 'Unknown', + symbol: currencyInfoData.symbol || '', + currencyAddress: currencyAddress, + price: Number(price), + decimals: currencyInfoData.decimals, + logoUri: currencyInfoData.logoURI + } + ] + : []), + ...swapRoutes + .flatMap(route => route.fromTokens) + .map((fromToken, index) => { + return { + index: enableMainCurrencyPayment && currencyAddress ? index + 1 : index, + name: fromToken.name || 'Unknown', + symbol: fromToken.symbol || '', + currencyAddress: fromToken.address || '', + price: Number(fromToken.price || 0), + decimals: fromToken.decimals || 0, + logoUri: fromToken.logoUri + } + }) + ] + return initialCoins + .filter(coin => !!coin.currencyAddress) + .map(coin => ({ ...coin, currencyAddress: coin.currencyAddress.toLowerCase() })) + }, [enableMainCurrencyPayment, currencyInfoData, swapRoutes, currencyAddress]) + + useEffect(() => { + if (selectedCurrency || tokenPayOptions.length === 0 || (tokenBalancesIsLoading && !balanceHookOptions.disabled)) { + return + } + + const lowerCaseCurrencyAddress = currencyAddress?.toLowerCase() + + const mainCurrencyBalance = tokenBalancesMap.get(lowerCaseCurrencyAddress || '') ?? 0n + const priceBigInt = BigInt(price || '0') + const mainCurrencySufficient = priceBigInt <= mainCurrencyBalance + + if (enableMainCurrencyPayment && lowerCaseCurrencyAddress && mainCurrencySufficient) { + setSelectedCurrency(lowerCaseCurrencyAddress) + } else { + const firstSwapCoin = tokenPayOptions.find(c => c.currencyAddress !== lowerCaseCurrencyAddress) + if (firstSwapCoin) { + setSelectedCurrency(firstSwapCoin.currencyAddress) + } else if (enableMainCurrencyPayment && lowerCaseCurrencyAddress) { + setSelectedCurrency(lowerCaseCurrencyAddress) + } + } + }, [ + tokenPayOptions, + selectedCurrency, + enableMainCurrencyPayment, + currencyAddress, + price, + tokenBalancesMap, + setSelectedCurrency, + tokenBalancesIsLoading, + balanceHookOptions.disabled + ]) + + const priceDisplay = useMemo(() => { + const priceBigInt = BigInt(price || '0') + const decimals = currencyInfoData?.decimals || 0 + if (decimals <= 0) { + return '0' + } + const priceFormatted = formatUnits(priceBigInt, decimals) + return formatDisplay(priceFormatted, { + disableScientificNotation: true, + disableCompactNotation: true, + significantDigits: 6 + }) + }, [price, currencyInfoData]) + + useEffect(() => { + clearCachedBalances() + }, [clearCachedBalances]) + + const Options = () => { + const lowerSelectedCurrency = selectedCurrency?.toLowerCase() + const lowerCurrencyAddress = currencyAddress?.toLowerCase() + + return ( +
+ {tokenPayOptions.map(swapOption => { + const isMainCurrency = swapOption.currencyAddress === lowerCurrencyAddress + const currentBalance = tokenBalancesMap.get(swapOption.currencyAddress) ?? 0n + const isNative = compareAddress(swapOption.currencyAddress, nativeTokenAddress || zeroAddress) + const isNativeBalanceCheckSkipped = isNative && skipNativeBalanceCheck + + if (isMainCurrency) { + const priceBigInt = BigInt(swapOption.price || 0) + const hasInsufficientFunds = priceBigInt > currentBalance + + return ( + + { + setSelectedCurrency(swapOption.currencyAddress) + }} + price={priceDisplay} + disabled={disableButtons} + isSelected={lowerSelectedCurrency === swapOption.currencyAddress} + showInsufficientFundsWarning={isNativeBalanceCheckSkipped ? undefined : hasInsufficientFunds} + /> + + ) + } else { + if (!swapOption || !enableSwapPayments) { + return null + } + + const hasInsufficientFunds = BigInt(swapOption.price || 0) > currentBalance + const swapQuotePriceDisplay = formatUnits(BigInt(swapOption.price || 0), swapOption.decimals || 18) + const formattedPrice = formatDisplay(swapQuotePriceDisplay, { + disableScientificNotation: true, + disableCompactNotation: true, + significantDigits: 6 + }) + + return ( + { + setSelectedCurrency(swapOption.currencyAddress) + }} + price={formattedPrice} + disabled={disableButtons} + isSelected={lowerSelectedCurrency === swapOption.currencyAddress} + showInsufficientFundsWarning={isNativeBalanceCheckSkipped ? undefined : hasInsufficientFunds} + /> + ) + } + })} +
+ ) + } + + const gutterHeight = 8 + const optionHeight = 72 + const displayedOptionsAmount = Math.min(tokenPayOptions.length, MAX_OPTIONS) + const displayedGuttersAmount = Math.max(0, displayedOptionsAmount - 1) + const collapsedOptionsHeight = useMemo(() => { + return `${optionHeight * displayedOptionsAmount + gutterHeight * displayedGuttersAmount}px` + }, [tokenPayOptions.length]) + + const ShowMoreButton = () => { + return ( +
+
+ ) + } + + return ( +
+
+ + Pay with crypto + +
+
+ {isLoadingOptions ? ( +
+ +
+ ) : ( + <> + + + + {swapsAreLoading && ( +
+ +
+ )} + {!swapsAreLoading && tokenPayOptions.length > MAX_OPTIONS && } + + )} +
+
+ ) +} diff --git a/packages/checkout/src/views/PaymentSelection/TransferFunds.tsx b/packages/checkout/src/views/PaymentSelection/TransferFunds.tsx new file mode 100644 index 000000000..82316165e --- /dev/null +++ b/packages/checkout/src/views/PaymentSelection/TransferFunds.tsx @@ -0,0 +1,84 @@ +import { useClipboard } from '@0xsequence/connect' +import { Card, CheckmarkIcon, CopyIcon, IconButton, Text, truncateAddress } from '@0xsequence/design-system' +import { QRCodeCanvas } from 'qrcode.react' +import { useAccount } from 'wagmi' + +import { useSelectPaymentModal, useTransferFundsModal } from '../../hooks' + +export const TransferFunds = () => { + const { openTransferFundsModal } = useTransferFundsModal() + const { openSelectPaymentModal, closeSelectPaymentModal, selectPaymentSettings } = useSelectPaymentModal() + const { address: userAddress } = useAccount() + const [isCopied, setCopied] = useClipboard({ timeout: 4000 }) + + const onClickQrCode = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (!selectPaymentSettings) { + return + } + + closeSelectPaymentModal() + + setTimeout(() => { + openTransferFundsModal({ + walletAddress: userAddress || '', + onClose: () => { + setTimeout(() => { + openSelectPaymentModal(selectPaymentSettings) + }, 500) + } + }) + }, 500) + } + + return ( +
+
+ + Receive funds to your connected wallet + +
+ +
+
+ +
+
+
+ + Receive Funds + +
+
+ + {truncateAddress(userAddress || '', 12, 4)} + +
+
+
+
{ + e.stopPropagation() + e.preventDefault() + }} + > + : () => } + onClick={() => setCopied(userAddress || '')} + /> +
+
+
+ ) +} diff --git a/packages/checkout/src/views/PaymentSelection/index.tsx b/packages/checkout/src/views/PaymentSelection/index.tsx new file mode 100644 index 000000000..5941cb36a --- /dev/null +++ b/packages/checkout/src/views/PaymentSelection/index.tsx @@ -0,0 +1,515 @@ +import type { LifiToken } from '@0xsequence/api' +import { compareAddress, sendTransactions, TRANSACTION_CONFIRMATIONS_DEFAULT, useAnalyticsContext } from '@0xsequence/connect' +import { Button, Divider, Spinner, Text } from '@0xsequence/design-system' +import { + useClearCachedBalances, + useGetContractInfo, + useGetSwapQuote, + useGetSwapRoutes, + useIndexerClient +} from '@0xsequence/hooks' +import { findSupportedNetwork } from '@0xsequence/network' +import { useEffect, useState } from 'react' +import { encodeFunctionData, zeroAddress, type Hex } from 'viem' +import { useAccount, usePublicClient, useReadContract, useWalletClient } from 'wagmi' + +import { NavigationHeader } from '../../components/NavigationHeader' +import { HEADER_HEIGHT, EVENT_SOURCE } from '../../constants' +import { ERC_20_CONTRACT_ABI } from '../../constants/abi' +import type { SelectPaymentSettings } from '../../contexts/SelectPaymentModal' +import { useSelectPaymentModal, useSkipOnCloseCallback, useTransactionStatusModal } from '../../hooks' + +import { Footer } from './Footer' +import { FundWithFiat } from './FundWithFiat' +import { OrderSummary } from './OrderSummary' +import { PayWithCreditCard } from './PayWithCreditCard' +import { PayWithCrypto } from './PayWithCrypto/index' +import { TransferFunds } from './TransferFunds' + +export const PaymentSelection = () => { + return ( + <> + + + + ) +} + +export const PaymentSelectionHeader = () => { + return +} + +export const PaymentSelectionContent = () => { + const { openTransactionStatusModal } = useTransactionStatusModal() + const { selectPaymentSettings = {} as SelectPaymentSettings } = useSelectPaymentModal() + const { analytics } = useAnalyticsContext() + + const [disableButtons, setDisableButtons] = useState(false) + const [isError, setIsError] = useState(false) + const [isSendingTransaction, setIsSendingTransaction] = useState(false) + + const { + chain, + collectibles, + collectionAddress, + currencyAddress, + targetContractAddress, + approvedSpenderAddress, + price, + txData, + enableTransferFunds = true, + enableMainCurrencyPayment = true, + enableSwapPayments = true, + creditCardProviders = [], + transactionConfirmations = TRANSACTION_CONFIRMATIONS_DEFAULT, + onRampProvider, + onSuccess = () => {}, + onError = () => {}, + onClose = () => {}, + supplementaryAnalyticsInfo, + slippageBps + } = selectPaymentSettings + + const isNativeToken = compareAddress(currencyAddress, zeroAddress) + + const [selectedCurrency, setSelectedCurrency] = useState() + const network = findSupportedNetwork(chain) + const chainId = network?.chainId || 137 + const { address: userAddress, connector } = useAccount() + const { data: walletClient } = useWalletClient({ + chainId + }) + const publicClient = usePublicClient({ + chainId + }) + const indexerClient = useIndexerClient(chainId) + const { clearCachedBalances } = useClearCachedBalances() + const { closeSelectPaymentModal } = useSelectPaymentModal() + const { skipOnCloseCallback } = useSkipOnCloseCallback(onClose) + + const { data: allowanceData, isLoading: allowanceIsLoading } = useReadContract({ + abi: ERC_20_CONTRACT_ABI, + functionName: 'allowance', + chainId: chainId, + address: currencyAddress as Hex, + args: [userAddress, approvedSpenderAddress || targetContractAddress], + query: { + enabled: !!userAddress && !isNativeToken + } + }) + + const { data: _currencyInfoData, isLoading: isLoadingCurrencyInfo } = useGetContractInfo({ + chainID: String(chainId), + contractAddress: currencyAddress + }) + + const buyCurrencyAddress = currencyAddress + + const { data: swapRoutes = [], isLoading: swapRoutesIsLoading } = useGetSwapRoutes( + { + walletAddress: userAddress ?? '', + chainId, + toTokenAmount: price, + toTokenAddress: currencyAddress + }, + { disabled: !enableSwapPayments } + ) + + const disableSwapQuote = !selectedCurrency || compareAddress(selectedCurrency, buyCurrencyAddress) + + const { data: swapQuote, isLoading: isLoadingSwapQuote } = useGetSwapQuote( + { + params: { + walletAddress: userAddress ?? '', + toTokenAddress: buyCurrencyAddress, + fromTokenAddress: selectedCurrency || '', + toTokenAmount: price, + chainId: chainId, + includeApprove: true, + slippageBps: slippageBps || 100 + } + }, + { + disabled: disableSwapQuote + } + ) + + const isLoading = (allowanceIsLoading && !isNativeToken) || isLoadingCurrencyInfo + + const isApproved: boolean = (allowanceData as bigint) >= BigInt(price) || isNativeToken + + useEffect(() => { + clearCachedBalances() + }, []) + + const onPurchaseMainCurrency = async () => { + if (!walletClient || !userAddress || !publicClient || !connector) { + return + } + setIsError(false) + setDisableButtons(true) + + setIsSendingTransaction(true) + try { + const walletClientChainId = await walletClient.getChainId() + if (walletClientChainId !== chainId) { + await walletClient.switchChain({ id: chainId }) + } + + const approveTxData = encodeFunctionData({ + abi: ERC_20_CONTRACT_ABI, + functionName: 'approve', + args: [approvedSpenderAddress || targetContractAddress, price] + }) + + const transactions = [ + ...(isApproved + ? [] + : [ + { + to: currencyAddress as Hex, + data: approveTxData, + chainId + } + ]), + { + to: targetContractAddress as Hex, + data: txData, + chainId, + ...(isNativeToken + ? { + value: BigInt(price) + } + : {}) + } + ] + + const txHash = await sendTransactions({ + chainId, + senderAddress: userAddress, + publicClient, + walletClient, + indexerClient, + connector, + transactions, + transactionConfirmations, + waitConfirmationForLastTransaction: false + }) + + analytics?.track({ + event: 'SEND_TRANSACTION_REQUEST', + props: { + ...supplementaryAnalyticsInfo, + type: 'crypto', + source: EVENT_SOURCE, + chainId: String(chainId), + listedCurrency: currencyAddress, + purchasedCurrency: currencyAddress, + origin: window.location.origin, + from: userAddress, + to: targetContractAddress, + item_ids: JSON.stringify(collectibles.map(c => c.tokenId)), + item_quantities: JSON.stringify(collectibles.map(c => c.quantity)), + txHash + } + }) + + setIsSendingTransaction(false) + closeSelectPaymentModal() + + skipOnCloseCallback() + + openTransactionStatusModal({ + chainId, + currencyAddress, + collectionAddress, + txHash, + items: collectibles.map(collectible => ({ + tokenId: collectible.tokenId, + quantity: collectible.quantity, + decimals: collectible.decimals, + price: collectible.price || price + })), + onSuccess: () => { + clearCachedBalances() + onSuccess(txHash) + }, + onClose, + statusOverride: 'success' + }) + } catch (e) { + console.error('Failed to purchase...', e) + setIsSendingTransaction(false) + onError(e as Error) + setIsError(true) + } + + setDisableButtons(false) + } + + const onClickPurchaseSwap = async (swapTokenOption: LifiToken) => { + if (!walletClient || !userAddress || !publicClient || !connector || !swapQuote) { + return + } + + setIsError(false) + setDisableButtons(true) + setIsSendingTransaction(true) + try { + const walletClientChainId = await walletClient.getChainId() + if (walletClientChainId !== chainId) { + await walletClient.switchChain({ id: chainId }) + } + + const approveTxData = encodeFunctionData({ + abi: ERC_20_CONTRACT_ABI, + functionName: 'approve', + args: [approvedSpenderAddress || targetContractAddress, price] + }) + + const isSwapNativeToken = compareAddress(zeroAddress, swapTokenOption.address) + + const transactions = [ + // Swap quote optional approve step + ...(swapQuote?.approveData && !isSwapNativeToken + ? [ + { + to: swapTokenOption.address as Hex, + data: swapQuote.approveData as Hex, + chain: chainId + } + ] + : []), + // Swap quote tx + { + to: swapQuote.to as Hex, + data: swapQuote.transactionData as Hex, + chain: chainId, + ...(isSwapNativeToken + ? { + value: BigInt(swapQuote.transactionValue) + } + : {}) + }, + // Actual transaction optional approve step + ...(isApproved || isNativeToken + ? [] + : [ + { + to: currencyAddress as Hex, + data: approveTxData as Hex, + chainId: chainId + } + ]), + // transaction on the contract + { + to: targetContractAddress as Hex, + data: txData as Hex, + chainId, + ...(isNativeToken + ? { + value: BigInt(price) + } + : {}) + } + ] + + const txHash = await sendTransactions({ + chainId, + senderAddress: userAddress, + publicClient, + walletClient, + indexerClient, + connector, + transactions, + transactionConfirmations, + waitConfirmationForLastTransaction: false + }) + + analytics?.track({ + event: 'SEND_TRANSACTION_REQUEST', + props: { + ...supplementaryAnalyticsInfo, + type: 'crypto', + source: EVENT_SOURCE, + chainId: String(chainId), + listedCurrency: swapTokenOption.address, + purchasedCurrency: currencyAddress, + origin: window.location.origin, + from: userAddress, + to: targetContractAddress, + item_ids: JSON.stringify(collectibles.map(c => c.tokenId)), + item_quantities: JSON.stringify(collectibles.map(c => c.quantity)), + txHash + } + }) + + setIsSendingTransaction(false) + closeSelectPaymentModal() + + skipOnCloseCallback() + + openTransactionStatusModal({ + chainId, + currencyAddress, + collectionAddress, + txHash, + items: collectibles.map(collectible => ({ + tokenId: collectible.tokenId, + quantity: collectible.quantity, + decimals: collectible.decimals, + price: collectible.price || price + })), + onSuccess: () => { + clearCachedBalances() + onSuccess(txHash) + }, + onClose, + statusOverride: 'success' + }) + } catch (e) { + console.error('Failed to purchase...', e) + setIsSendingTransaction(false) + onError(e as Error) + setIsError(true) + } + + setDisableButtons(false) + } + + const onClickPurchase = async () => { + if (compareAddress(selectedCurrency || '', currencyAddress)) { + await onPurchaseMainCurrency() + } else { + const foundSwap = swapRoutes + .flatMap(route => route.fromTokens) + .find(fromToken => fromToken.address.toLowerCase() === selectedCurrency?.toLowerCase()) + if (foundSwap) { + await onClickPurchaseSwap(foundSwap) + } + } + } + + const cryptoSymbol = isNativeToken ? network?.nativeToken.symbol : _currencyInfoData?.symbol + + const validCreditCardProviders = creditCardProviders.filter(provider => { + if (provider === 'transak') { + return !!selectPaymentSettings?.transakConfig + } + return true + }) + + return ( + <> +
+
+ +
+ {(enableMainCurrencyPayment || enableSwapPayments) && ( + <> + + + + )} + {validCreditCardProviders.length > 0 && ( + <> + + + + )} + {onRampProvider && ( + <> + + {isLoadingCurrencyInfo && !isNativeToken ? ( +
+ +
+ ) : ( + { + skipOnCloseCallback() + }} + /> + )} + + )} + {enableTransferFunds && ( + <> + + + + )} + {(enableMainCurrencyPayment || enableSwapPayments) && ( + <> + {isError && ( +
+ + A problem occurred while executing the transaction. + +
+ )} +
+
+ ) : ( + 'Complete Purchase' + ) + } + /> +
+ {/* Replace by icon from design-system once new release is out */} + + + + + Secure Checkout + +
+
+ + )} +
+ +