Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/app/(mobile-ui)/qr-pay/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { generateMetadata } from '@/app/metadata'
import PageContainer from '@/components/0_Bruddle/PageContainer'
import React from 'react'

export const metadata = generateMetadata({
title: 'QR Payment | Peanut',
description: 'Use Peanut to pay Argentinian MercadoPago and Brazilian Pix QR codes',
})

export default function QRPayLayout({ children }: { children: React.ReactNode }) {
return <PageContainer>{children}</PageContainer>
}
375 changes: 375 additions & 0 deletions src/app/(mobile-ui)/qr-pay/page.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/app/[...recipient]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props)
? {
code: currencyCode,
symbol: currencySymbol!,
price: currencyPrice!,
price: currencyPrice!.buy,
}
: undefined
}
Expand Down
78 changes: 34 additions & 44 deletions src/app/actions/currency.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,45 @@
'use server'
import { unstable_cache } from 'next/cache'
import { fetchWithSentry } from '@/utils'
import { getExchangeRate } from './exchange-rate'
import { AccountType } from '@/interfaces'
import { mantecaApi } from '@/services/manteca'

const MANTECA_CURRENCIES = ['ARS', 'BRL', 'COP', 'CRC', 'PUSD', 'GTQ', 'PHP', 'BOB']

export const getCurrencyPrice = unstable_cache(
async (currencyCode: string): Promise<number> => {
let price: number
async (currencyCode: string): Promise<{ buy: number; sell: number }> => {
let buy: number
let sell: number
currencyCode = currencyCode.toUpperCase()
switch (currencyCode) {
case 'USD':
price = 1
break
case 'EUR':
case 'MXN':
{
let accountType: AccountType
if (currencyCode === 'EUR') {
accountType = AccountType.IBAN
} else if (currencyCode === 'MXN') {
accountType = AccountType.CLABE
} else {
throw new Error('Invalid currency code')
}
const { data, error } = await getExchangeRate(accountType)
if (error) {
throw new Error('Failed to fetch exchange rate from bridge')
}
if (!data) {
throw new Error('No data returned from exchange rate API')
}
price = parseFloat(data.buy_rate)
}
break
case 'ARS':
{
const response = await fetchWithSentry('https://dolarapi.com/v1/dolares/cripto')
const data = await response.json()

if (!data.compra || !data.venta) {
throw new Error('Invalid response from dolarapi')
}

// Average between buy and sell price
price = (data.compra + data.venta) / 2
}
break
default:
throw new Error('Unsupported currency')
if (currencyCode === 'USD') {
buy = 1
sell = 1
} else if (['EUR', 'MXN'].includes(currencyCode)) {
let accountType: AccountType
if (currencyCode === 'EUR') {
accountType = AccountType.IBAN
} else if (currencyCode === 'MXN') {
accountType = AccountType.CLABE
} else {
throw new Error('Invalid currency code')
}
const { data, error } = await getExchangeRate(accountType)
if (error) {
throw new Error('Failed to fetch exchange rate from bridge')
}
if (!data) {
throw new Error('No data returned from exchange rate API')
}
buy = parseFloat(data.buy_rate)
sell = parseFloat(data.sell_rate)
} else if (MANTECA_CURRENCIES.includes(currencyCode)) {
const response = await mantecaApi.getPrices({ asset: 'USDC', against: currencyCode })
buy = Number(response.effectiveBuy)
sell = Number(response.effectiveSell)
} else {
throw new Error('Invalid currency code')
}
return price
return { buy, sell }
},
['getCurrencyPrice'],
{
Expand Down
2 changes: 1 addition & 1 deletion src/app/actions/onramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export async function createOnrampForGuest(
try {
const { currency, paymentRail } = getCurrencyConfig(params.country.id, 'onramp')
const price = await getCurrencyPrice(currency)
const amount = (Number(params.amount) * price).toFixed(2)
const amount = (Number(params.amount) * price.buy).toFixed(2)

const response = await fetchWithSentry(`${apiUrl}/bridge/onramp/create-for-guest`, {
method: 'POST',
Expand Down
1 change: 1 addition & 0 deletions src/assets/payment-apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export { default as GOOGLE_PAY } from './google-pay.svg'
export { default as MERCADO_PAGO } from './mercado-pago.svg'
export { default as PAYPAL } from './paypal.svg'
export { default as SATISPAY } from './satispay.svg'
export { default as PIX } from './pix.svg'

9 changes: 1 addition & 8 deletions src/assets/payment-apps/mercado-pago.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/payment-apps/pix.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/components/0_Bruddle/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const Card = ({ children, className, shadowSize, color = 'primary', ...props }:
<div
// Tailwind merge makes sure classes added through className by component caller are merged and overrides the default classes
className={twMerge(
`flex flex-col border border-n-1 bg-white dark:border-white dark:bg-n-1`,
`flex flex-col rounded-sm border border-n-1 bg-white dark:border-white dark:bg-n-1`,
shadowClass,
className
)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/AddMoney/components/InputAmountStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const InputAmountStep = ({
? {
code: currencyData.code!,
symbol: currencyData.symbol!,
price: currencyData.price!,
price: currencyData.price!.buy,
}
: undefined
}
Expand Down
9 changes: 4 additions & 5 deletions src/components/Claim/Link/views/Confirm.bank-claim.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { formatUnits } from 'viem'
import ExchangeRate from '@/components/ExchangeRate'
import { AccountType } from '@/interfaces'
import { useCurrency } from '@/hooks/useCurrency'
import { getCurrencySymbol } from '@/utils/bridge.utils'

interface ConfirmBankClaimViewProps {
onConfirm: () => void
Expand Down Expand Up @@ -72,23 +71,23 @@ export function ConfirmBankClaimView({

// fallback if conversion fails
const failedConversion = useMemo(() => {
return currencyCode !== 'USD' && !isLoadingCurrency && (!price || isNaN(price))
return currencyCode !== 'USD' && !isLoadingCurrency && (!price?.sell || isNaN(price.sell))
}, [currencyCode, isLoadingCurrency, price])

// display amount in local currency
const displayAmount = useMemo(() => {
if (currencyCode === 'USD') return usdAmount
if (isLoadingCurrency) return '-'
if (!price || isNaN(price)) return usdAmount
const converted = (Number(usdAmount) * price).toFixed(2)
if (!price?.sell || isNaN(price.sell)) return usdAmount
const converted = (Number(usdAmount) * price.sell).toFixed(2)
return converted
}, [price, usdAmount, currencyCode, isLoadingCurrency])

const displaySymbol = useMemo(() => {
if (currencyCode === 'USD') return '$'
// fallback to $ if conversion fails
if (failedConversion) return '$'
return resolvedSymbol ?? getCurrencySymbol(currencyCode)
return resolvedSymbol ?? currencyCode
}, [currencyCode, resolvedSymbol, failedConversion])

return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/Common/ActionListDaimoPayButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const ActionListDaimoPayButton = () => {
? {
code: currencyCode,
symbol: currencySymbol || '',
price: currencyPrice || 0,
price: currencyPrice?.buy || 0,
}
: undefined,
currencyAmount: usdAmount,
Expand Down
8 changes: 7 additions & 1 deletion src/components/Global/DirectSendQR/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,15 @@ export default function DirectSendQr({
}
break
case EQrType.MERCADO_PAGO:
case EQrType.PIX:
{
const timestamp = Date.now()
// Casing matters, so send original instead of normalized
redirectUrl = `/qr-pay?qrCode=${originalData}&t=${timestamp}`
}
break
case EQrType.BITCOIN_ONCHAIN:
case EQrType.BITCOIN_INVOICE:
case EQrType.PIX:
case EQrType.TRON_ADDRESS:
case EQrType.SOLANA_ADDRESS:
case EQrType.XRP_ADDRESS: {
Expand Down
20 changes: 19 additions & 1 deletion src/components/Global/DirectSendQR/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ const MP_AR_REGEX =
/^000201((?!6304).)*(?:(?:26|27|28|29|30|31|35|43)\d{2}(?:0015com\.mercadopago|0016com\.mercadolibre)).*5303032.*5802AR((?!6304).)*6304[0-9A-F]{4}$/i

/* PIX is also a emvco qr code */
const PIX_REGEX = /^.*00020126.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i
const PIX_REGEX = /^.*000201.*0014br\.gov\.bcb\.pix.*5303986.*5802BR.*$/i

export const PAYMENT_PROCESSOR_REGEXES: { [key in QrType]?: RegExp } = {
[EQrType.MERCADO_PAGO]: MP_AR_REGEX,
[EQrType.PIX]: PIX_REGEX,
}

const EIP_681_REGEX = /^ethereum:(?:pay-)?([^@/?]+)(?:@([^/?]+))?(?:\/([^?]+))?(?:\?(.*))?$/i

Expand Down Expand Up @@ -88,6 +93,19 @@ export function recognizeQr(data: string): QrType | null {
return null
}

/**
* Returns true if the given string is a payment processor QR code.
* For example, Mercado Pago, Pix, etc.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx

export const isPaymentProcessorQR = (data: string): boolean => {
for (const [_type, regex] of Object.entries(PAYMENT_PROCESSOR_REGEXES)) {
if (regex.test(data)) {
return true
}
}
return false
}

/**
* Extracts EIP-681 parameters from an Ethereum URI
* @param data The Ethereum URI string (e.g. "ethereum:0x123...?value=1e18")
Expand Down
101 changes: 58 additions & 43 deletions src/components/Global/TokenAmountInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ const TokenAmountInput = ({
[displayMode, currency?.price, selectedTokenData?.price, calculateAlternativeValue]
)

const showConversion = useMemo(() => {
return !hideCurrencyToggle && (displayMode === 'TOKEN' || displayMode === 'FIAT')
}, [hideCurrencyToggle, displayMode])

// This is needed because if we change the token we selected the value
// should change. This only depends on the price on purpose!! we don't want
// to change when we change the display mode or the value (we already call
Expand Down Expand Up @@ -152,11 +156,11 @@ const TokenAmountInput = ({
}
case 'FIAT': {
if (isInputUsd) {
setDisplaySymbol('$')
setDisplaySymbol('USD')
setAlternativeDisplaySymbol(currency?.symbol || '')
} else {
setDisplaySymbol(currency?.symbol || '')
setAlternativeDisplaySymbol('$')
setAlternativeDisplaySymbol('USD')
}
break
}
Expand Down Expand Up @@ -196,48 +200,62 @@ const TokenAmountInput = ({
return (
<form
ref={formRef}
className={`relative cursor-text rounded-none border border-n-1 bg-white px-2 py-4 dark:border-white ${className}`}
className={`relative cursor-text rounded-sm border border-n-1 bg-white p-2 dark:border-white ${className}`}
action=""
onClick={handleContainerClick}
>
<div className="flex h-14 w-full flex-row items-center justify-center gap-1">
<label className={`text-h1 ${displayValue ? 'text-black' : 'text-gray-2'}`}>{displaySymbol}</label>
<input
className={`h-12 max-w-80 bg-transparent text-center text-h1 outline-none transition-colors placeholder:text-h1 focus:border-primary-1 dark:border-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75 dark:focus:border-primary-1`}
placeholder={'0.00'}
onChange={(e) => {
const value = formatAmountWithoutComma(e.target.value)
onChange(value, isInputUsd)
}}
ref={inputRef}
inputMode="decimal"
type={inputType}
value={displayValue}
step="any"
min="0"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (onSubmit) onSubmit()
}
}}
onBlur={() => {
if (onBlur) onBlur()
}}
disabled={disabled}
/>
</div>
{walletBalance && !hideBalance && (
<div className="mt-0.5 text-center text-xs text-grey-1">
Your balance: {displayMode === 'FIAT' && currency ? 'US$' : '$'}
{walletBalance}
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<div className="flex items-center gap-1 font-bold">
<label className={`text-2xl ${displayValue ? 'text-black' : 'text-gray-2'}`}>{displaySymbol}</label>

{/* Input */}
<input
className={`h-12 w-[4ch] max-w-80 bg-transparent text-6xl font-black outline-none transition-colors placeholder:text-h1 focus:border-primary-1 dark:border-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75 dark:focus:border-primary-1`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really think, in general, we have wayyy too little reuse and too much component specific code. This applies to both styling (as you can see with 100000 million tailwind classses in FE code) and with logic

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is literally the TokenAmountInput which is reused in a lot of place and is a building block of the application. But in general, I agree

placeholder={'0.00'}
onChange={(e) => {
const value = formatAmountWithoutComma(e.target.value)
onChange(value, isInputUsd)
}}
ref={inputRef}
inputMode="decimal"
type={inputType}
value={displayValue}
step="any"
min="0"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (onSubmit) onSubmit()
}
}}
onBlur={() => {
if (onBlur) onBlur()
}}
disabled={disabled}
/>
</div>
)}
{/* Show conversion line and toggle */}
{!hideCurrencyToggle && (displayMode === 'TOKEN' || displayMode === 'FIAT') && (

{/* Conversion */}
{showConversion && (
<label className="text-lg font-bold">
≈ {alternativeDisplayValue} {alternativeDisplaySymbol}
</label>
)}

{/* Balance */}
{walletBalance && !hideBalance && (
<div className="text-center text-lg text-grey-1">
Balance: {displayMode === 'FIAT' && currency ? 'USD ' : '$ '}
{walletBalance}
</div>
)}
</div>

{/* Conversion toggle */}
{showConversion && (
<div
className={`flex w-full cursor-pointer flex-row items-center justify-center gap-1`}
className="absolute right-0 top-1/2 -translate-x-1/2 -translate-y-1/2 transform cursor-pointer"
onClick={(e) => {
e.preventDefault()
const currentValue = displayValue
Expand All @@ -246,10 +264,7 @@ const TokenAmountInput = ({
setIsInputUsd(!isInputUsd)
}}
>
<label className="text-base text-grey-1">
{alternativeDisplaySymbol} {alternativeDisplayValue}
</label>
<Icon name={'switch'} className="rotate-90 cursor-pointer fill-grey-1" />
<Icon name={'switch'} className="ml-5 rotate-90 cursor-pointer" width={32} height={32} />
</div>
)}
</form>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Request/views/ReqFulfillBankFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const ReqFulfillBankFlowManager = ({ parsedPaymentData }: { parsedPayment
const usdAmount = chargeDetails.tokenAmount
const minAmount = getMinimumAmount(selectedCountry.id)
getCurrencyPrice(currency).then((price) => {
const currencyAmount = Number(usdAmount) * price
const currencyAmount = Number(usdAmount) * price.buy
if (currencyAmount < minAmount) {
setErrorMessage(`Minimum amount is ${minAmount.toFixed(2)} ${currency}`)
} else {
Expand Down
Loading
Loading