• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

decentraland / marketplace / 20164899792

12 Dec 2025 11:09AM UTC coverage: 66.467% (+0.3%) from 66.153%
20164899792

push

github

web-flow
feat: introduce use of credits for NAMEs (#2543)

* feat: introduce use of credits for NAMEs

* feat: bump dcl libs

* feat: update Payment Selector component

* test: update test missing prop

* feat: add tests and dev.json updated

* feat: add more tests and update old ones

* fix: build issue

* fix: test

* feat: add step to show coral scan link for the users (#2544)

* feat: add step to show coral scan link for the users

* fix: test

2825 of 5527 branches covered (51.11%)

Branch coverage included in aggregate %.

136 of 151 new or added lines in 10 files covered. (90.07%)

1 existing line in 1 file now uncovered.

8263 of 11155 relevant lines covered (74.07%)

75.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.14
/webapp/src/components/Modals/BuyWithCryptoModal/BuyWithCryptoModal.tsx
1
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1✔
2
import { useHistory, useLocation } from 'react-router-dom'
1✔
3
import classNames from 'classnames'
1✔
4
import { ethers } from 'ethers'
1✔
5
import compact from 'lodash/compact'
1✔
6
import { Network } from '@dcl/schemas'
1✔
7
import { getNetwork, ChainId } from '@dcl/schemas/dist/dapps/chain-id'
1✔
8
import Modal from 'decentraland-dapps/dist/containers/Modal'
1✔
9
import { getNetworkProvider } from 'decentraland-dapps/dist/lib/eth'
1✔
10
import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils'
1✔
11
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
1✔
12
import type { ChainData, Token, CrossChainProvider } from 'decentraland-transactions/crossChain'
13
import { ContractName, getContract } from 'decentraland-transactions'
1✔
14
import { Button, Icon, Loader, ModalNavigation } from 'decentraland-ui'
1✔
15
import { config } from '../../../config'
1✔
16
import { formatWeiMANA } from '../../../lib/mana'
1✔
17
import { isWearableOrEmote } from '../../../modules/asset/utils'
1✔
18
import * as events from '../../../utils/events'
1✔
19
import { AssetImage } from '../../AssetImage'
1✔
20
import { CardPaymentsExplanation } from '../../BuyPage/CardPaymentsExplanation'
1✔
21
import { isPriceTooLow } from '../../BuyPage/utils'
1✔
22
import { Mana } from '../../Mana'
1✔
23
import { ManaToFiat } from '../../ManaToFiat'
1✔
24
import ChainAndTokenSelector from './ChainAndTokenSelector/ChainAndTokenSelector'
1✔
25
import { useShouldUseCrossChainProvider, useTokenBalance } from './hooks'
1✔
26
import PaymentSelector from './PaymentSelector'
1✔
27
import PurchaseTotal from './PurchaseTotal'
1✔
28
import { getDefaultChains, getMANAToken, getShouldUseMetaTx, isToken } from './utils'
1✔
29
import { Props } from './BuyWithCryptoModal.types'
30
import styles from './BuyWithCryptoModal.module.css'
1✔
31

32
export const CANCEL_DATA_TEST_ID = 'confirm-buy-with-crypto-modal-cancel'
1✔
33
export const BUY_NOW_BUTTON_TEST_ID = 'buy-now-button'
7✔
34
export const SWITCH_NETWORK_BUTTON_TEST_ID = 'switch-network'
7✔
35
export const GET_MANA_BUTTON_TEST_ID = 'get-mana-button'
13✔
36
export const BUY_WITH_CARD_TEST_ID = 'buy-with-card-button'
13✔
37
export const PRICE_TOO_LOW_TEST_ID = 'price-too-low-label'
1✔
38

39
export type ProviderChain = ChainData
40
export type ProviderToken = Token
41

42
const squidURL = config.get('SQUID_API_URL')
1✔
43

44
export const CROSS_CHAIN_POLLING_TEST_ID = 'cross-chain-polling'
1✔
45
export const CORAL_SCAN_LINK_TEST_ID = 'coral-scan-link'
1✔
46

47
export const BuyWithCryptoModal = (props: Props) => {
27✔
48
  const {
49
    price,
50
    wallet,
51
    credits,
52
    useCredits,
53
    metadata: { asset },
54
    isBuyingAsset,
55
    isLoadingAuthorization,
56
    isSwitchingNetwork,
57
    isBuyWithCardPage,
58
    isUsingMagic,
59
    creditsClaimProgress,
60
    onSwitchNetwork,
61
    onGetGasCost,
62
    onGetCrossChainRoute,
63
    onBuyNatively,
64
    onBuyWithCard,
65
    onBuyCrossChain,
66
    onBuyWithCredits,
67
    onGetMana,
68
    onClose,
69
    onGoBack
70
  } = props
206✔
71

72
  const isPollingCrossChain = creditsClaimProgress?.status === 'polling'
205✔
73

74
  const crossChainSupportedChains = useRef<ChainId[]>([])
205✔
75
  const analytics = getAnalytics()
205✔
76
  const manaAddressOnAssetChain = getContract(ContractName.MANAToken, asset.chainId).address
205✔
77
  const abortControllerRef = useRef(new AbortController())
205✔
78

79
  // useStates
80
  const [providerChains, setProviderChains] = useState<ChainData[]>(getDefaultChains())
205✔
81
  const [providerTokens, setProviderTokens] = useState<Token[]>([])
205✔
82
  const [selectedChain, setSelectedChain] = useState(asset.chainId)
205✔
83
  const [selectedToken, setSelectedToken] = useState<Token>(getMANAToken(asset.chainId))
205✔
84
  const [canBuyAsset, setCanBuyAsset] = useState<boolean | undefined>()
205✔
85
  const [insufficientToken, setInsufficientToken] = useState<Token | undefined>()
205✔
86
  const [showChainSelector, setShowChainSelector] = useState(false)
205✔
87
  const [showTokenSelector, setShowTokenSelector] = useState(false)
205✔
88
  const [crossChainProvider, setCrossChainProvider] = useState<CrossChainProvider>()
205✔
89
  const manaTokenOnSelectedChain: Token | undefined = useMemo(() => {
205✔
90
    return providerTokens.find(t => t.symbol === 'MANA' && t.chainId === selectedChain.toString())
61✔
91
  }, [providerTokens, selectedChain])
92
  const manaTokenOnAssetChain: Token | undefined = useMemo(() => {
205✔
93
    return providerTokens.find(t => t.address.toLocaleLowerCase() === manaAddressOnAssetChain.toLocaleLowerCase())
53✔
94
  }, [providerTokens, manaAddressOnAssetChain])
95

96
  const selectedProviderChain = useMemo(() => {
205✔
97
    return providerChains.find(c => c.chainId.toString() === selectedChain.toString())
91✔
98
  }, [providerChains, selectedChain])
99

100
  const chainNativeToken = useMemo(() => {
205✔
101
    return providerTokens.find(
61✔
102
      t => t.chainId.toString() === selectedChain.toString() && t.symbol === selectedProviderChain?.nativeCurrency.symbol
136✔
103
    )
104
  }, [selectedChain, selectedProviderChain, providerTokens])
105

106
  const { gasCost, isFetchingGasCost } = onGetGasCost(selectedToken, chainNativeToken, wallet)
205✔
107

108
  const hasCredits = useMemo(() => credits && credits.totalCredits > 0, [credits])
205!
109

110
  // Calculate final price when using credits
111
  const finalPrice = useMemo(() => {
205✔
112
    if (!useCredits || !credits || !credits.totalCredits) {
27!
113
      return price
27✔
114
    }
NEW
115
    const totalCreditsAmount = ethers.BigNumber.from(credits.totalCredits.toString())
×
NEW
116
    const priceAmount = ethers.BigNumber.from(price)
×
NEW
117
    const finalAmount = priceAmount.sub(totalCreditsAmount.gt(priceAmount) ? priceAmount : totalCreditsAmount)
×
NEW
118
    return finalAmount.toString()
×
119
  }, [price, useCredits, credits])
120

121
  const isCreditsTransaction = useMemo(() => useCredits && hasCredits, [useCredits, hasCredits])
205!
122

123
  // For credits transactions, no route is fetched (it's calculated on backend when "Buy Now" is clicked)
124
  const { route, fromAmount, routeFeeCost, routeTotalUSDCost, isFetchingRoute, routeFailed } = onGetCrossChainRoute(
205✔
125
    selectedToken,
126
    selectedChain,
127
    providerTokens,
128
    crossChainProvider,
129
    wallet,
130
    !!isCreditsTransaction
131
  )
132

133
  useEffect(() => {
205✔
134
    const initializeCrossChainProvider = async () => {
27✔
135
      const { AxelarProvider, CROSS_CHAIN_SUPPORTED_CHAINS } = await import('decentraland-transactions/crossChain')
27✔
136
      const provider = new AxelarProvider(squidURL)
27✔
137
      crossChainSupportedChains.current = CROSS_CHAIN_SUPPORTED_CHAINS
27✔
138
      await provider.init() // init the provider on the mount
139
      setCrossChainProvider(provider)
27✔
140
    }
141

142
    void initializeCrossChainProvider()
27✔
143
  }, [])
144

145
  const { isFetchingBalance, tokenBalance: selectedTokenBalance } = useTokenBalance(selectedToken, selectedChain, wallet?.address)
205✔
146

147
  // if the tx should be done through the provider
148
  const shouldUseCrossChainProvider = useShouldUseCrossChainProvider(selectedToken, asset.network)
205✔
149

150
  // Compute if the process should use a meta tx (connected in ETH and buying a L2 NFT)
151
  const useMetaTx = useMemo(() => {
205✔
152
    return (
51✔
153
      !!selectedToken &&
153✔
154
      !!wallet &&
155
      getShouldUseMetaTx(asset.chainId, selectedChain, selectedToken.address, manaAddressOnAssetChain, wallet.network)
156
    )
157
  }, [asset, manaAddressOnAssetChain, selectedChain, selectedToken, wallet])
158

159
  // Compute if the price is too low for meta tx
160
  const hasLowPriceForMetaTx = useMemo(
205✔
161
    () => {
162
      if (useCredits && credits) {
27!
163
        return false
×
164
      }
165
      return (wallet?.chainId as ChainId) !== ChainId.MATIC_MAINNET && isPriceTooLow(price)
27✔
166
    }, // not connected to polygon AND has price < minimum for meta tx
167
    [price, wallet?.chainId, useCredits, credits]
168
  )
169

170
  // init lib if necessary and fetch chains & supported tokens
171
  useEffect(() => {
205✔
172
    void (async () => {
54✔
173
      try {
54✔
174
        if (crossChainProvider) {
54✔
175
          if (!crossChainProvider.isLibInitialized()) {
27!
176
            await crossChainProvider.init()
×
177
          }
178
          const defaultChains = getDefaultChains()
27✔
179
          const supportedTokens = crossChainProvider.getSupportedTokens()
27✔
180
          const supportedChains = [
27✔
181
            ...defaultChains,
182
            ...crossChainProvider.getSupportedChains().filter(c => defaultChains.every(dc => dc.chainId !== c.chainId))
1,134✔
183
          ] // keep the defaults since we support MANA on them natively
184
          setProviderChains(
27✔
185
            supportedChains.filter(
186
              c => crossChainSupportedChains.current.includes(+c.chainId) && defaultChains.find(t => t.chainId === c.chainId)
243✔
187
            )
188
          )
189
          setProviderTokens(supportedTokens.filter(t => crossChainSupportedChains.current.includes(+t.chainId)))
108✔
190
        }
191
      } catch (error) {
192
        console.log('error: ', error)
×
193
      }
194
    })()
195
  }, [crossChainProvider, wallet])
196

197
  // when providerTokens are loaded and there's no selected token or the token selected if from another network
198
  useEffect(() => {
205✔
199
    if (
104!
200
      crossChainProvider?.isLibInitialized() &&
335!
201
      ((!selectedToken && providerTokens.length) || // only run if not selectedToken, meaning the first render
202
        (selectedToken && selectedChain.toString() !== selectedToken.chainId)) // or if selectedToken is not from the selectedChain
203
    ) {
204
      try {
×
205
        setSelectedToken(manaTokenOnSelectedChain || getMANAToken(selectedChain)) // if it's not in the providerTokens, create the object manually with the right conectract address
×
206
      } catch (error) {
207
        const selectedChainTokens = providerTokens.filter(t => t.chainId === selectedChain.toString())
×
208
        setSelectedToken(selectedChainTokens[0])
×
209
      }
210
    }
211
  }, [crossChainProvider, providerTokens.length, manaTokenOnSelectedChain, selectedChain, selectedToken])
212

213
  // computes if the user can buy the item with the selected token
214
  useEffect(() => {
205✔
215
    void (async () => {
104✔
216
      if (
104✔
217
        selectedToken &&
328✔
218
        ((selectedToken.symbol === 'MANA' && !!wallet) ||
219
          (selectedToken.symbol !== 'MANA' && // MANA balance is calculated differently
220
            selectedTokenBalance))
221
      ) {
222
        let canBuy
223
        const priceToCheck = finalPrice // Use finalPrice (adjusted for credits)
104✔
224

225
        if (selectedToken.symbol === 'MANA' && wallet) {
104✔
226
          // wants to buy a L2 item with ETH MANA (through the provider)
227
          if (asset.network === Network.MATIC && getNetwork(selectedChain) === Network.ETHEREUM) {
88✔
228
            canBuy = wallet.networks[Network.ETHEREUM].mana >= +ethers.utils.formatEther(priceToCheck)
6✔
229
          } else {
230
            canBuy = wallet.networks[asset.network].mana >= +ethers.utils.formatEther(priceToCheck)
82✔
231
          }
232
          if (!canBuy) {
88✔
233
            setInsufficientToken(selectedToken)
73✔
234
          }
235
        } else if (selectedTokenBalance && routeFeeCost) {
16✔
236
          const balance = parseFloat(ethers.utils.formatUnits(selectedTokenBalance, selectedToken.decimals))
16✔
237

238
          if (manaTokenOnAssetChain && selectedToken && crossChainProvider && wallet) {
16✔
239
            // fee is paid with same token selected
240
            if (selectedToken.symbol === routeFeeCost.token.symbol) {
16!
241
              canBuy = balance > Number(fromAmount) + Number(routeFeeCost.totalCost)
16✔
242
              if (!canBuy) {
16✔
243
                setInsufficientToken(selectedToken)
8✔
244
              }
245
            } else {
246
              const networkProvider = await getNetworkProvider(Number(routeFeeCost.token.chainId))
×
247
              const provider = new ethers.providers.Web3Provider(networkProvider)
×
248
              const balanceNativeTokenWei = await provider.getBalance(wallet.address)
×
249
              const canPayForGas = balanceNativeTokenWei.gte(ethers.utils.parseEther(routeFeeCost.totalCost))
×
250
              canBuy = canPayForGas && balance > Number(fromAmount)
×
251
              if (!canBuy) {
×
252
                setInsufficientToken(balance < Number(fromAmount) ? selectedToken : routeFeeCost.token)
×
253
              }
254
            }
255
          }
256
        }
257
        setCanBuyAsset(canBuy)
104✔
258
      }
259
    })()
260
  }, [
261
    asset,
262
    crossChainProvider,
263
    fromAmount,
264
    price,
265
    finalPrice,
266
    useCredits,
267
    providerTokens,
268
    routeFeeCost,
269
    selectedChain,
270
    selectedToken,
271
    selectedTokenBalance,
272
    wallet
273
  ])
274

275
  const handleCrossChainBuy = useCallback(() => {
205✔
276
    if (route && crossChainProvider && crossChainProvider.isLibInitialized()) {
3✔
277
      onBuyCrossChain(route)
3✔
278
    }
279
  }, [crossChainProvider, onBuyCrossChain, route])
280

281
  const renderSwitchNetworkButton = useCallback(() => {
205✔
282
    return (
283
      <Button
284
        fluid
285
        inverted
286
        className={styles.switchNetworkButton}
287
        disabled={isSwitchingNetwork}
288
        data-testid={SWITCH_NETWORK_BUTTON_TEST_ID}
289
        onClick={() => onSwitchNetwork(selectedChain)}
5✔
290
      >
291
        {isSwitchingNetwork ? (
13!
292
          <>
293
            <Loader inline active size="tiny" />
294
            {isUsingMagic ? t('buy_with_crypto_modal.switching_network') : t('buy_with_crypto_modal.confirm_switch_network')}
×
295
          </>
296
        ) : (
297
          t('buy_with_crypto_modal.switch_network', {
298
            chain: selectedProviderChain?.networkName
299
          })
300
        )}
301
      </Button>
302
    )
303
  }, [isSwitchingNetwork, onSwitchNetwork, selectedProviderChain, selectedChain, isUsingMagic])
304

305
  const handleBuyWithCard = useCallback(() => {
205✔
306
    if (onBuyWithCard) {
×
307
      onBuyWithCard()
×
308
    }
309
  }, [onBuyWithCard])
310

311
  const renderGetMANAButton = useCallback(() => {
205✔
312
    return (
313
      <>
314
        <Button
315
          fluid
316
          primary
317
          data-testid={GET_MANA_BUTTON_TEST_ID}
318
          loading={isFetchingBalance || isBuyingAsset}
216✔
319
          onClick={() => {
320
            onGetMana()
×
321
            onClose()
×
322
          }}
323
        >
324
          {t('buy_with_crypto_modal.get_mana')}
325
        </Button>
326
        {onBuyWithCard && (
108✔
327
          <Button
328
            inverted
329
            fluid
330
            data-testid={BUY_WITH_CARD_TEST_ID}
331
            disabled={isBuyingAsset}
332
            loading={isBuyingAsset}
333
            onClick={handleBuyWithCard}
334
          >
335
            <Icon name="credit card outline" />
336
            {t(`buy_with_crypto_modal.buy_with_card`)}
337
          </Button>
338
        )}
339
      </>
340
    )
341
  }, [isFetchingBalance, isBuyingAsset, asset.chainId, isLoadingAuthorization, onBuyWithCard, handleBuyWithCard, onGetMana, onClose])
342

343
  const renderBuyNowButton = useCallback(() => {
205✔
344
    const onClick =
345
      useCredits && asset.data.ens && onBuyWithCredits
15!
346
        ? onBuyWithCredits
347
        : shouldUseCrossChainProvider
15✔
348
          ? handleCrossChainBuy
349
          : onBuyNatively
350

351
    let buttonText: string | null = null
15✔
352
    if (isFetchingRoute) {
15!
353
      buttonText = null
×
354
    } else if (isBuyingAsset) {
15!
355
      buttonText = isUsingMagic ? t('buy_with_crypto_modal.buying_asset') : t('buy_with_crypto_modal.confirm_transaction')
×
356
    } else if (isLoadingAuthorization) {
15!
357
      buttonText = t('buy_with_crypto_modal.authorizing_purchase')
×
358
    } else {
359
      buttonText = t('buy_with_crypto_modal.buy_now')
15✔
360
    }
361

362
    return (
363
      <>
364
        <Button
365
          fluid
366
          primary
367
          data-testid={BUY_NOW_BUTTON_TEST_ID}
368
          disabled={(selectedToken?.symbol !== 'MANA' && !route) || isFetchingRoute || isBuyingAsset || isLoadingAuthorization}
63✔
369
          loading={isFetchingBalance}
370
          onClick={onClick}
371
        >
372
          <>
373
            {isBuyingAsset || isLoadingAuthorization || isFetchingRoute ? <Loader inline active size="tiny" /> : null}
60!
374
            {buttonText}
375
          </>
376
        </Button>
377
      </>
378
    )
379
  }, [
380
    route,
381
    selectedToken,
382
    isFetchingRoute,
383
    isBuyingAsset,
384
    isLoadingAuthorization,
385
    isFetchingBalance,
386
    isUsingMagic,
387
    onBuyNatively,
388
    handleCrossChainBuy,
389
    shouldUseCrossChainProvider,
390
    useCredits,
391
    asset.data.ens,
392
    onBuyWithCredits
393
  ])
394

395
  const renderMainActionButton = useCallback(() => {
205✔
396
    const hasEnoughCredits =
397
      useCredits &&
178!
398
      credits &&
399
      credits.totalCredits &&
400
      ethers.BigNumber.from(credits.totalCredits.toString()).gte(ethers.BigNumber.from(price))
401

402
    if (wallet && selectedToken && canBuyAsset !== undefined) {
178✔
403
      // if can't buy Get Mana and Buy With Card buttons
404
      // BUT if using credits and has enough, allow checkout
405
      if (!canBuyAsset && !hasEnoughCredits) {
136✔
406
        return renderGetMANAButton()
108✔
407
      }
408

409
      // for any token other than MANA, it user needs to be connected on the origin chain
410
      if (selectedToken.symbol !== 'MANA') {
28✔
411
        return selectedChain === wallet.chainId ? renderBuyNowButton() : renderSwitchNetworkButton()
8✔
412
      }
413

414
      // for L1 NFTs
415
      if (asset.network === Network.ETHEREUM && !isCreditsTransaction) {
20✔
416
        // if tries to buy with ETH MANA and connected to other network, should switch to ETH network to pay directly
417
        return selectedToken.symbol === 'MANA' &&
8✔
418
          (wallet.network as Network) !== Network.ETHEREUM &&
419
          getNetwork(selectedChain) === Network.ETHEREUM
420
          ? renderSwitchNetworkButton()
421
          : renderBuyNowButton()
422
      }
423

424
      // for L2 NFTs paying with MANA
425
      // And connected to MATIC, should render the buy now button otherwise check if a meta tx is available
426
      if (getNetwork(selectedChain) === Network.MATIC) {
12✔
427
        return (wallet.network as Network) === Network.MATIC
12✔
428
          ? renderBuyNowButton()
429
          : hasLowPriceForMetaTx
8✔
430
            ? renderSwitchNetworkButton() // switch to MATIC to pay for the gas
431
            : renderBuyNowButton()
432
      }
433

434
      // can buy it with MANA from other chain through the provider
435
      return renderBuyNowButton()
×
436
    } else if (!route && routeFailed) {
42!
437
      // can't buy Get Mana and Buy With Card buttons
438
      return renderGetMANAButton()
×
439
    }
440
  }, [
441
    wallet,
442
    selectedToken,
443
    canBuyAsset,
444
    route,
445
    asset,
446
    price,
447
    finalPrice,
448
    routeFailed,
449
    selectedChain,
450
    hasLowPriceForMetaTx,
451
    renderBuyNowButton,
452
    renderSwitchNetworkButton,
453
    renderGetMANAButton,
454
    useCredits,
455
    credits,
456
    isCreditsTransaction
457
  ])
458

459
  const onTokenOrChainSelection = useCallback(
205✔
460
    (selectedOption: Token | ChainData) => {
461
      setShowChainSelector(false)
24✔
462
      setShowTokenSelector(false)
24✔
463

464
      if (isToken(selectedOption)) {
24✔
465
        abortControllerRef.current.abort()
16✔
466

467
        const selectedToken = providerTokens.find(
16✔
468
          t => t.address === selectedOption.address && t.chainId === selectedChain.toString()
60✔
469
        ) as Token
470
        // reset all fields
471
        setSelectedToken(selectedToken)
16✔
472
        setCanBuyAsset(undefined)
16✔
473
        abortControllerRef.current = new AbortController()
16✔
474
        analytics?.track(events.CROSS_CHAIN_TOKEN_SELECTION, {
16✔
475
          selectedToken,
476
          category: asset.category
477
        })
478
      } else {
479
        setSelectedChain(Number(selectedOption.chainId) as ChainId)
8✔
480
        const manaDestinyChain = providerTokens.find(t => t.symbol === 'MANA' && t.chainId === selectedOption.chainId)
10✔
481
        // set the selected token on the new chain selected to MANA or the first one found
482
        const selectedToken = providerTokens.find(t => t.chainId === selectedOption.chainId)
10✔
483
        const token = manaDestinyChain || selectedToken
8!
484
        if (token) {
8✔
485
          setSelectedToken(token)
8✔
486
        }
487

488
        analytics?.track(events.CROSS_CHAIN_CHAIN_SELECTION, {
8✔
489
          selectedChain: selectedOption.chainId
490
        })
491
      }
492
    },
493
    [analytics, providerTokens, selectedChain]
494
  )
495

496
  const renderModalNavigation = useCallback(() => {
205✔
497
    if (showChainSelector || showTokenSelector) {
205✔
498
      return (
499
        <ModalNavigation
500
          title={t(`buy_with_crypto_modal.token_and_chain_selector.select_${showChainSelector ? 'chain' : 'token'}`)}
24✔
501
          onBack={() => {
502
            setShowChainSelector(false)
×
503
            setShowTokenSelector(false)
×
504
          }}
505
        />
506
      )
507
    }
508

509
    // When polling cross-chain, show a different title and disable navigation
510
    if (isPollingCrossChain) {
181✔
511
      return (
512
        <ModalNavigation
513
          title={t('buy_with_crypto_modal.cross_chain_polling.title', {
514
            name: asset.name,
515
            b: (children: React.ReactChildren) => <b>{children}</b>
516
          })}
517
        />
518
      )
519
    }
520

521
    return (
522
      <ModalNavigation
523
        title={t('buy_with_crypto_modal.title', {
524
          name: asset.name,
525
          b: (children: React.ReactChildren) => <b>{children}</b>
526
        })}
527
        onBack={!isBuyingAsset ? onGoBack : undefined}
178!
528
        onClose={!isBuyingAsset ? onClose : undefined}
178!
529
      />
530
    )
531
  }, [asset.name, onClose, showChainSelector, showTokenSelector, isBuyingAsset, isPollingCrossChain])
532

533
  const renderCrossChainPollingContent = useCallback(() => {
205✔
534
    if (!creditsClaimProgress) return null
3!
535

536
    return (
537
      <div className={styles.crossChainPollingContainer} data-testid={CROSS_CHAIN_POLLING_TEST_ID}>
538
        <div className={styles.crossChainPollingContent}>
539
          <Loader active size="large" inline />
540
          <h3 className={styles.crossChainPollingTitle}>{t('buy_with_crypto_modal.cross_chain_polling.processing')}</h3>
541
          <p className={styles.crossChainPollingDescription}>{t('buy_with_crypto_modal.cross_chain_polling.description')}</p>
542
          <a
543
            href={creditsClaimProgress.coralScanUrl}
544
            target="_blank"
545
            rel="noopener noreferrer"
546
            className={styles.coralScanLink}
547
            data-testid={CORAL_SCAN_LINK_TEST_ID}
548
          >
549
            <Icon name="external alternate" />
550
            {t('buy_with_crypto_modal.cross_chain_polling.track_on_coral_scan')}
551
          </a>
552
          <p className={styles.crossChainPollingNote}>{t('buy_with_crypto_modal.cross_chain_polling.note')}</p>
553
        </div>
554
      </div>
555
    )
556
  }, [creditsClaimProgress])
557

558
  const translationPageDescriptorId = compact([
205✔
559
    'mint',
560
    isWearableOrEmote(asset) ? (isBuyWithCardPage ? 'with_card' : 'with_mana') : null,
410!
561
    'page'
562
  ]).join('_')
563

564
  const location = useLocation()
205✔
565
  const history = useHistory()
205✔
566

567
  const handleOnClose = useCallback(() => {
205✔
568
    const search = new URLSearchParams(location.search)
×
569
    const hasModalQueryParam = search.get('buyWithCrypto')
×
570
    if (hasModalQueryParam) {
×
571
      search.delete('buyWithCrypto')
×
572
      history.replace({
×
573
        search: search.toString()
574
      })
575
    }
576
    onClose()
×
577
  }, [history, location.search, onClose])
578

579
  const handleShowChainSelector = useCallback(() => {
205✔
580
    setShowChainSelector(true)
8✔
581
  }, [])
582

583
  const handleShowTokenSelector = useCallback(() => {
205✔
584
    setShowTokenSelector(true)
16✔
585
  }, [])
586

587
  const assetName = useMemo(() => {
205✔
588
    return asset.data.ens ? (
27!
589
      <>
590
        <strong>{asset.name}</strong>.dcl.eth
591
      </>
592
    ) : (
593
      asset.name
594
    )
595
  }, [asset])
596

597
  const assetDescription = useMemo(() => {
205✔
598
    if (asset.data.ens) {
27!
599
      return t('buy_with_crypto_modal.asset_description.ens')
×
600
    } else if (asset.data.emote) {
27!
601
      return t('buy_with_crypto_modal.asset_description.emotes')
×
602
    } else if (asset.data.wearable) {
27!
603
      return t('buy_with_crypto_modal.asset_description.wearables')
27✔
604
    } else if (asset.data.estate || asset.data.parcel) {
×
605
      return t('buy_with_crypto_modal.asset_description.land')
×
606
    } else {
607
      return t('buy_with_crypto_modal.asset_description.other')
×
608
    }
609
  }, [asset])
610

611
  return (
612
    <Modal size="tiny" onClose={isPollingCrossChain ? undefined : handleOnClose} className={styles.buyWithCryptoModal}>
205✔
613
      {renderModalNavigation()}
614
      <Modal.Content>
615
        <>
616
          {isPollingCrossChain ? (
617
            renderCrossChainPollingContent()
3✔
618
          ) : showChainSelector || showTokenSelector ? (
396✔
619
            <div>
24✔
620
              {showChainSelector && wallet ? (
56✔
621
                <ChainAndTokenSelector
622
                  wallet={wallet}
623
                  currentChain={selectedChain}
624
                  chains={providerChains}
625
                  onSelect={onTokenOrChainSelection}
626
                />
627
              ) : null}
628
              {showTokenSelector && wallet ? (
64✔
629
                <ChainAndTokenSelector
630
                  wallet={wallet}
631
                  currentChain={selectedChain}
632
                  tokens={providerTokens}
633
                  onSelect={onTokenOrChainSelection}
634
                />
635
              ) : null}
636
            </div>
637
          ) : (
638
            <>
639
              <div className={styles.assetContainer}>
640
                <AssetImage asset={asset} isSmall />
641
                <div className={styles.assetDetails}>
642
                  <span className={styles.assetName}>{assetName}</span>
643
                  <span className={styles.assetDescription}>{assetDescription}</span>
644
                </div>
645
                <div className={styles.priceContainer}>
646
                  <Mana network={asset.network} inline withTooltip>
647
                    {formatWeiMANA(finalPrice)}
648
                  </Mana>
649
                  <span className={styles.priceInUSD}>
650
                    <ManaToFiat mana={finalPrice} digits={4} />
651
                  </span>
652
                </div>
653
              </div>
654

655
              <PaymentSelector
656
                price={finalPrice}
657
                wallet={wallet}
658
                isBuyingAsset={isBuyingAsset}
659
                providerTokens={providerTokens}
660
                selectedToken={selectedToken}
661
                selectedChain={selectedChain}
662
                shouldUseCrossChainProvider={shouldUseCrossChainProvider}
663
                gasCost={gasCost}
664
                isFetchingGasCost={isFetchingGasCost}
665
                isFetchingBalance={isFetchingBalance}
666
                selectedProviderChain={selectedProviderChain}
667
                selectedTokenBalance={selectedTokenBalance}
668
                onShowChainSelector={handleShowChainSelector}
669
                onShowTokenSelector={handleShowTokenSelector}
670
                amountInSelectedToken={fromAmount}
671
                route={route}
672
                routeFeeCost={routeFeeCost}
673
                useCredits={useCredits}
674
                hasCredits={!!asset.data.ens && !!credits && credits.totalCredits > 0}
178!
675
              />
676

677
              <PurchaseTotal
678
                selectedToken={selectedToken}
679
                price={finalPrice}
680
                useMetaTx={useMetaTx}
681
                shouldUseCrossChainProvider={shouldUseCrossChainProvider}
682
                route={route}
683
                routeFeeCost={routeFeeCost}
684
                fromAmount={fromAmount}
685
                isLoading={isFetchingRoute || isFetchingGasCost || (shouldUseCrossChainProvider && !route)}
582✔
686
                gasCost={gasCost}
687
                manaTokenOnSelectedChain={manaTokenOnAssetChain}
688
                routeTotalUSDCost={routeTotalUSDCost}
689
              />
690

691
              {selectedToken && shouldUseCrossChainProvider ? (
534✔
692
                <div className={styles.durationAndExchangeContainer}>
693
                  <div>
694
                    <span>
695
                      <Icon name="clock outline" /> {t('buy_with_crypto_modal.durations.transaction_duration')}{' '}
696
                    </span>
697
                    {route && route.route?.estimate?.estimatedRouteDuration !== undefined ? (
96✔
698
                      t(
48✔
699
                        `buy_with_crypto_modal.durations.${
700
                          route.route.estimate.estimatedRouteDuration === 0
48!
701
                            ? 'fast'
702
                            : route.route.estimate.estimatedRouteDuration === 20
×
703
                              ? 'normal'
704
                              : 'slow'
705
                        }`
706
                      )
707
                    ) : (
708
                      <span className={classNames(styles.skeleton, styles.fromAmountUSDSkeleton)} />
709
                    )}
710
                  </div>
711
                  <div className={styles.exchangeContainer}>
712
                    <div className={styles.exchangeContainerLabel}>
713
                      <span className={styles.exchangeIcon} />
714
                      <span> {t('buy_with_crypto_modal.exchange_rate')} </span>
715
                    </div>
716
                    {route && route.route?.estimate?.exchangeRate && selectedToken ? (
144✔
717
                      <>
48✔
718
                        1 {selectedToken.symbol} = {route.route.estimate.exchangeRate.slice(0, 7)} MANA
719
                      </>
720
                    ) : (
721
                      <span className={classNames(styles.skeleton, styles.fromAmountUSDSkeleton)} />
722
                    )}
723
                  </div>
724
                </div>
725
              ) : null}
726

727
              {selectedToken &&
610✔
728
              shouldUseCrossChainProvider &&
729
              asset.network === Network.MATIC && // and it's buying a MATIC asset
730
              !isPriceTooLow(price) ? (
731
                <span className={styles.rememberFreeTxs}>
732
                  {t('buy_with_crypto_modal.remember_transaction_fee_covered', {
733
                    covered: <span className={styles.feeCoveredFree}>{t('buy_with_crypto_modal.covered_for_you_by_dao')}</span>
734
                  })}
735
                </span>
736
              ) : null}
737

738
              {hasLowPriceForMetaTx && !isBuyWithCardPage && useMetaTx ? (
386✔
739
                <span className={styles.warning} data-testid={PRICE_TOO_LOW_TEST_ID}>
740
                  {' '}
741
                  {t('buy_with_crypto_modal.price_too_low', {
742
                    learn_more: (
743
                      <a
744
                        href="https://docs.decentraland.org/player/blockchain-integration/transactions-in-polygon/#why-do-i-have-to-cover-the-tra[…]ems-that-cost-less-than-1-mana"
745
                        target="_blank"
746
                        rel="noreferrer"
747
                      >
748
                        {/* TODO: add this URL */}
749
                        <u> {t('buy_with_crypto_modal.learn_more')} </u>
750
                      </a>
751
                    )
752
                  })}
753
                </span>
754
              ) : null}
755
              {canBuyAsset === false && !isFetchingBalance && !isFetchingRoute ? (
572✔
756
                <span className={styles.warning}>
757
                  {t('buy_with_crypto_modal.insufficient_funds', {
758
                    token: insufficientToken?.symbol || 'MANA',
108!
759
                    orPayWithOtherToken: (text: string) => (!onBuyWithCard ? <span>{text}</span> : undefined),
108!
760
                    card: (text: string) => (onBuyWithCard ? <span>{text}</span> : undefined)
108!
761
                  })}
762
                </span>
763
              ) : null}
764
              {routeFailed && selectedToken ? (
356!
765
                <span className={styles.warning}>
766
                  {' '}
767
                  {t('buy_with_crypto_modal.route_unavailable', {
768
                    token: selectedToken.symbol
769
                  })}
770
                </span>
771
              ) : null}
772
            </>
773
          )}
774
        </>
775
      </Modal.Content>
776
      {showChainSelector || showTokenSelector || isPollingCrossChain ? null : (
610✔
777
        <Modal.Actions>
778
          <div className={classNames(styles.buttons, isWearableOrEmote(asset) && 'with-mana')}>{renderMainActionButton()}</div>
356✔
779
          {isWearableOrEmote(asset) && isBuyWithCardPage ? (
534!
780
            <CardPaymentsExplanation translationPageDescriptorId={translationPageDescriptorId} />
781
          ) : null}
782
        </Modal.Actions>
783
      )}
784
    </Modal>
785
  )
786
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc