diff --git a/.changeset/silver-falcons-stick.md b/.changeset/silver-falcons-stick.md new file mode 100644 index 0000000000..2f516583c0 --- /dev/null +++ b/.changeset/silver-falcons-stick.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +✨ scan pix qr codes diff --git a/cspell.json b/cspell.json index 739f8cb9e1..fc833f4528 100644 --- a/cspell.json +++ b/cspell.json @@ -46,6 +46,7 @@ "cloudkms", "cloudrun", "cloudrunv2", + "CNPJ", "codegen", "codepoint", "colocating", diff --git a/src/components/send-funds/NewRecipient.tsx b/src/components/send-funds/NewRecipient.tsx index 34bc45a069..75b4862277 100644 --- a/src/components/send-funds/NewRecipient.tsx +++ b/src/components/send-funds/NewRecipient.tsx @@ -3,10 +3,11 @@ import { useTranslation } from "react-i18next"; import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; -import { ArrowLeft, ArrowRight, CircleHelp, Info, Landmark, Zap } from "@tamagui/lucide-icons"; +import { ArrowLeft, ArrowRight, CircleHelp, Eye, EyeOff, Info, Landmark, Zap } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, XStack, YStack } from "tamagui"; +import { getPixKeyType, isCNPJ, isCPF, PixKeyType } from "@pix.js/qrcode"; import { useForm, useStore } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; @@ -15,7 +16,6 @@ import { addressFields, brlReference, clabe, - documentNumber, eurReference, Field, FieldInput, @@ -33,9 +33,11 @@ import { wireReference, type FieldConfig, } from "./recipientForm"; +import Scanner from "./Scanner"; import TransferTypeSheet from "./TransferTypeSheet"; import { bridgeRails, isValidCurrency } from "../../utils/currencies"; import { presentArticle } from "../../utils/intercom"; +import { isPixKey, parseBRCode, pixAccount, taxDocument } from "../../utils/pix"; import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import { APIError, createExternalAccount } from "../../utils/server"; @@ -49,12 +51,14 @@ import View from "../shared/View"; export default function NewRecipient() { const { t } = useTranslation(); const router = useRouter(); - const { currency, provider } = useLocalSearchParams(); + const { currency, provider, scan } = useLocalSearchParams(); const toast = useToastController(); const [step, setStep] = useState(1); const [openSelect, setOpenSelect] = useState(); const [openInfo, setOpenInfo] = useState(false); + const [scanning, setScanning] = useState(scan === "1"); + const [amount, setAmount] = useState(); const currencyKey = typeof currency === "string" ? currency : ""; const build = forms[currencyKey]; @@ -87,7 +91,7 @@ export default function NewRecipient() { }); router.push({ pathname: "/send-funds/send-amount", - params: { currency, provider, contactId: newAccount.id }, + params: { currency, provider, contactId: newAccount.id, ...(amount && { amount }) }, }); }, onError: (error) => { @@ -117,6 +121,15 @@ export default function NewRecipient() { }); const activePaths = new Set(fields.filter((f) => !f.transient).map((f) => f.path)); const stripped = Object.fromEntries(Object.entries(value).filter(([k, v]) => v !== "" && activePaths.has(k))); + if (stripped.account_pixKey && parseBRCode(stripped.account_pixKey)) { + stripped.account_brCode = stripped.account_pixKey; + delete stripped.account_pixKey; + } else if (stripped.account_pixKey && (isCPF(stripped.account_pixKey) || isCNPJ(stripped.account_pixKey))) { + stripped.account_pixKey = stripped.account_pixKey.replaceAll(/\D/g, ""); + } + if (stripped.account_documentNumber) { + stripped.account_documentNumber = stripped.account_documentNumber.replaceAll(/\D/g, ""); + } const payload = { currency: currencyKey, ...nest(stripped) }; createMutation.mutate(payload as Parameters[0]); }, @@ -146,6 +159,48 @@ export default function NewRecipient() { return ; } + function fill(path: string, value?: string) { + if (!value) return false; + const field = allFields.find((f) => f.path === path); + if (!field || validator(field)({ value })) return false; + form.setFieldValue(path, value); + return true; + } + + if (scanning) { + return ( + { + if (router.canGoBack()) router.back(); + else setScanning(false); + }} + onScan={(data) => { + const code = parseBRCode(data); + if (!code) { + toast.show(t("Couldn't read this QR code. Make sure it's a PIX code."), { + duration: 3000, + burntOptions: { haptic: "error", preset: "error" }, + }); + return false; + } + if (!(code.type === "static" && fill("account_pixKey", code.key))) fill("account_pixKey", code.brCode); + for (const [path, value] of Object.entries({ + accountOwnerName: code.ownerName, + address_city: code.city, + address_country: code.country, + address_postalCode: code.postalCode, + ...(code.type === "static" && { account_documentNumber: code.key, reference: code.txId }), + })) { + fill(path, value); + } + setAmount(code.type === "static" && code.value ? code.value.toFixed(2) : undefined); + setScanning(false); + return true; + }} + /> + ); + } + const openField = openSelect ? currentStep.fields.find((f) => f.path === openSelect) : undefined; const info = currentStep.fields.some((field) => field.info); @@ -222,15 +277,21 @@ export default function NewRecipient() { {currentStep.fields.map((field) => ( {({ state: { value, meta }, handleChange }) => { - const input = ( + function change(next: string) { + handleChange(next); + if (next === value) return; + if (field.kind === "option" || field.path === "account_pixKey") form.resetField("reference"); + if (field.path === "account_pixKey") setAmount(undefined); + } + const code = field.path === "account_pixKey" ? parseBRCode(value) : undefined; + const input = code ? ( + + ) : ( { - handleChange(next); - if (field.kind === "option" && next !== value) form.resetField("reference"); - }} + onChange={change} onOpen={() => { setOpenSelect(field.path); }} @@ -245,6 +306,7 @@ export default function NewRecipient() { error={meta.isTouched && typeof meta.errors[0] === "string" ? meta.errors[0] : undefined} > {input} + {field.path === "account_pixKey" && } ); }} @@ -306,6 +368,14 @@ export default function NewRecipient() { type Step = { fields: FieldConfig[]; subtitle?: string; title: string }; +const pixKeyLabels: Record = { + [PixKeyType.Cpf]: "CPF", + [PixKeyType.Cnpj]: "CNPJ", + [PixKeyType.Email]: "Email", + [PixKeyType.Phone]: "Phone number", + [PixKeyType.Evp]: "Random key", +}; + const errorMessages: Record = { "not approved": "Your KYC isn't approved for this currency", "not started": "Bridge setup incomplete", @@ -366,6 +436,69 @@ function nameFields(ownerType?: string): FieldConfig[] { return ownerType === "business" ? [businessName] : [firstName, lastName]; } +function AccountHint({ value, code }: { code: ReturnType; value: string }) { + const { t } = useTranslation(); + const trimmed = value.trim(); + if (!code) { + if (!isPixKey(trimmed)) return null; + return ( + + {t("PIX key · {{type}}", { type: t(pixKeyLabels[getPixKeyType(trimmed)]) })} + + ); + } + if (code.type !== "dynamic" || !code.oneTime) return null; + return ( + + + {t("One-time charge")} + + + {t("The saved contact may stop working once it's paid.")} + + + ); +} + +function BRCodeField({ value, name }: { name?: string; value: string }) { + const { t } = useTranslation(); + const [revealed, setRevealed] = useState(false); + return ( + + {revealed ? ( + + {value} + + ) : ( + + {name ? t("BR Code · {{name}}", { name }) : t("BR Code")} + + )} + { + setRevealed(!revealed); + }} + > + {revealed ? : } + + + ); +} + function referenceField(validate: FieldConfig["validate"]): FieldConfig { return { path: "reference", @@ -463,30 +596,22 @@ const forms: Record Fie ...addressFields(), referenceField(gbpReference), ], - BRL: ({ variant }) => [ + BRL: () => [ ownerName, { - path: "method", - label: "Account type", - placeholder: "Select", - kind: "select", - transient: true, - variant: true, - options: [ - { value: "pixKey", label: "PIX Key" }, - { value: "brCode", label: "BR Code" }, - ], + path: "account_pixKey", + label: "PIX key or BR Code", + placeholder: "Enter a key or paste a BR Code", + kind: "text", + validate: pixAccount, }, - variant === "brCode" - ? { path: "account_brCode", label: "BR Code", placeholder: "Paste BR Code", kind: "text", validate: text } - : { path: "account_pixKey", label: "PIX key", placeholder: "Enter PIX key", kind: "text", validate: text }, { path: "account_documentNumber", label: "Document number", placeholder: "Enter beneficiary's document number", kind: "text", optional: true, - validate: documentNumber, + validate: taxDocument, }, bankName, ...addressFields(), diff --git a/src/components/send-funds/QR.tsx b/src/components/send-funds/QR.tsx index 8f7c5a287a..fa3cba4fc1 100644 --- a/src/components/send-funds/QR.tsx +++ b/src/components/send-funds/QR.tsx @@ -1,192 +1,40 @@ -import React, { useCallback, useState } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; -import { Linking, StyleSheet } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { CameraView, useCameraPermissions } from "expo-camera"; -import { useFocusEffect, useRouter } from "expo-router"; +import { useRouter } from "expo-router"; -import { ArrowLeft, BoxSelect, SwitchCamera } from "@tamagui/lucide-icons"; -import { useWindowDimensions, XStack, YStack } from "tamagui"; +import { useToastController } from "@tamagui/toast"; import { parse, safeParse } from "valibot"; import { zeroAddress } from "viem"; import { Address } from "@exactly/common/validation"; -import reportError from "../../utils/reportError"; -import IconButton from "../shared/IconButton"; -import Button from "../shared/StyledButton"; -import Text from "../shared/Text"; -import View from "../shared/View"; +import Scanner from "./Scanner"; export default function QR() { - const { top, bottom } = useSafeAreaInsets(); - const { height, width } = useWindowDimensions(); - const router = useRouter(); - - const [active, setActive] = useState(true); - const [cameraFacing, setCameraFacing] = useState<"back" | "front">("back"); - const [permission, requestPermission] = useCameraPermissions(); const { t } = useTranslation(); + const toast = useToastController(); - useFocusEffect( - useCallback(() => { - setActive(true); - return () => { - setActive(false); - }; - }, []), - ); - - if (!permission) return ; - if (!permission.granted) { - if (!permission.canAskAgain) { - return ( - - { - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/send-funds"); - } - }} - gap="$s2" - > - - {t("Back")} - - - - - {t( - "Camera access is currently disabled for Exa App. In order to continue, enable camera access for Exa App from your device settings.", - )} - - - - - - ); - } - return ( - - { - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/send-funds"); - } - }} - gap="$s2" - > - - {t("Back")} - - - - - {t( - "Before we continue, we need your permission to access the camera. The camera will only be used for scanning valid addresses.", - )} - - - {t("Press “Continue” to proceed or “Back” to cancel.")} - - - - - - ); - } return ( - - {active && ( - { - const result = safeParse(Address, receiver); - if (!result.success) return; - if (result.output === parse(Address, zeroAddress)) return; - router.dismissTo({ pathname: "/send-funds/asset", params: { receiver: result.output } }); - }} - facing={cameraFacing} - style={styles.cameraView} - /> - )} - - - - - - { - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/send-funds"); - } - }} - /> - - + { + if (router.canGoBack()) router.back(); + else router.replace("/send-funds"); + }} + onScan={(data) => { + const result = safeParse(Address, data); + if (!result.success || result.output === parse(Address, zeroAddress)) { + toast.show(t("Couldn't read this QR code. Make sure it's a valid address."), { + duration: 3000, + burntOptions: { haptic: "error", preset: "error" }, + }); + return false; + } + router.dismissTo({ pathname: "/send-funds/asset", params: { receiver: result.output } }); + return true; + }} + /> ); } - -const styles = StyleSheet.create({ cameraView: { flex: 1 } }); diff --git a/src/components/send-funds/Recipients.tsx b/src/components/send-funds/Recipients.tsx index e52425430d..f589f3a36b 100644 --- a/src/components/send-funds/Recipients.tsx +++ b/src/components/send-funds/Recipients.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; -import { ArrowLeft, CircleHelp, Contact, PencilLine, Settings, TriangleAlert } from "@tamagui/lucide-icons"; +import { ArrowLeft, CircleHelp, Contact, PencilLine, QrCode, Settings, TriangleAlert } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, XStack, YStack } from "tamagui"; @@ -93,14 +93,30 @@ export default function Recipients() { - } - title={t("Transfer to a new beneficiary")} - subtitle={method ? t("Via {{method}}", { method }) : t("Add new beneficiary")} - onPress={() => { - router.push({ pathname: "/send-funds/new-recipient", params: { currency, provider } }); - }} - /> + + } + title={t("Transfer to a new beneficiary")} + subtitle={method ? t("Via {{method}}", { method }) : t("Add new beneficiary")} + onPress={() => { + router.push({ pathname: "/send-funds/new-recipient", params: { currency, provider } }); + }} + /> + {currency === "BRL" && ( + <> + + {t("or")} + + } + title={t("Scan QR code")} + onPress={() => { + router.push({ pathname: "/send-funds/new-recipient", params: { currency, provider, scan: "1" } }); + }} + /> + + )} + diff --git a/src/components/send-funds/Scanner.tsx b/src/components/send-funds/Scanner.tsx new file mode 100644 index 0000000000..ec7751db31 --- /dev/null +++ b/src/components/send-funds/Scanner.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Linking, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useFocusEffect } from "expo-router"; + +import { ArrowLeft, BoxSelect, SwitchCamera } from "@tamagui/lucide-icons"; +import { useWindowDimensions, XStack, YStack } from "tamagui"; + +import reportError from "../../utils/reportError"; +import IconButton from "../shared/IconButton"; +import Button from "../shared/StyledButton"; +import Text from "../shared/Text"; +import View from "../shared/View"; + +export default function Scanner({ onClose, onScan }: { onClose: () => void; onScan: (data: string) => boolean }) { + const { top, bottom } = useSafeAreaInsets(); + const { height, width } = useWindowDimensions(); + + const [active, setActive] = useState(true); + const [cameraFacing, setCameraFacing] = useState<"back" | "front">("back"); + const [permission, requestPermission] = useCameraPermissions(); + const { t } = useTranslation(); + const lastRef = useRef(undefined); + + useFocusEffect( + useCallback(() => { + setActive(true); + return () => { + setActive(false); + }; + }, []), + ); + + if (!permission) return ; + if (!permission.granted) { + if (!permission.canAskAgain) { + return ( + + + + + + {t( + "Camera access is currently disabled for Exa App. In order to continue, enable camera access for Exa App from your device settings.", + )} + + + + + + ); + } + return ( + + + + + + {t( + "Before we continue, we need your permission to access the camera. The camera will only be used for scanning QR codes.", + )} + + + {t("Press “Continue” to proceed or “Back” to cancel.")} + + + + + + ); + } + return ( + + {active && ( + { + if (lastRef.current === data) return; + lastRef.current = data; + if (onScan(data)) setActive(false); + }} + facing={cameraFacing} + style={styles.cameraView} + /> + )} + + + + + + + + + ); +} + +function BackControl({ onPress }: { onPress: () => void }) { + const { top } = useSafeAreaInsets(); + const { t } = useTranslation(); + return ( + + + {t("Back")} + + ); +} + +const styles = StyleSheet.create({ cameraView: { flex: 1 } }); diff --git a/src/components/send-funds/SendAmount.tsx b/src/components/send-funds/SendAmount.tsx index 6a6a48e5d6..f7dca8a2aa 100644 --- a/src/components/send-funds/SendAmount.tsx +++ b/src/components/send-funds/SendAmount.tsx @@ -54,7 +54,7 @@ export default function SendAmount() { i18n: { language }, } = useTranslation(); const router = useRouter(); - const { currency, provider, contactId } = useLocalSearchParams(); + const { currency, provider, contactId, amount: scannedAmount } = useLocalSearchParams(); const currencyString = typeof currency === "string" ? currency : ""; const contactString = typeof contactId === "string" ? contactId : ""; const fiatCurrency = isFiatCurrency(currencyString) ? currencyString : undefined; @@ -85,7 +85,7 @@ export default function SendAmount() { const symbol = getSymbol(currencyString); const form = useForm({ - defaultValues: { amount: "" }, + defaultValues: { amount: typeof scannedAmount === "string" ? scannedAmount : "" }, onSubmit: ({ value }) => { router.push({ pathname: "/send-funds/review", @@ -249,7 +249,7 @@ export default function SendAmount() { } label={t("Delivery time")} - value={t(rail ? bridgeRails[rail].time : DELIVERY_TIME)} + value={t(rail ? bridgeRails[rail].time : currencyString === "BRL" ? "Instant" : DELIVERY_TIME)} /> } diff --git a/src/components/send-funds/recipientForm.tsx b/src/components/send-funds/recipientForm.tsx index dbb11af3b2..c732cad979 100644 --- a/src/components/send-funds/recipientForm.tsx +++ b/src/components/send-funds/recipientForm.tsx @@ -53,7 +53,6 @@ export const routing = pipe(string(), regex(/^\d{9}$/, "Must be 9 numbers")); export const clabe = pipe(string(), regex(/^\d{18}$/, "Must be 18 numbers")); export const ukAccount = pipe(string(), regex(/^\d{8}$/, "Must be 8 numbers")); export const sortCode = pipe(string(), regex(/^\d{6}$/, "Must be 6 numbers")); -export const documentNumber = pipe(string(), regex(/^\d+$/, "Numbers only")); export const achReference = pipe( string(), maxLength(10, "Must be 10 characters or less"), diff --git a/src/i18n/es-AR.json b/src/i18n/es-AR.json index f7b862df30..870604aa57 100644 --- a/src/i18n/es-AR.json +++ b/src/i18n/es-AR.json @@ -36,11 +36,14 @@ "Couldn't load the exchange rate. Please try again.": "No se pudo cargar el tipo de cambio. Intentá de nuevo.", "Couldn't load this activity. Please try again.": "No se pudo cargar esta actividad. Intentá de nuevo.", "Couldn't load your contacts. Please try again.": "No se pudieron cargar tus contactos. Intentá de nuevo.", + "Couldn't read this QR code. Make sure it's a PIX code.": "No se pudo leer este código QR. Asegurate de que sea un código PIX.", + "Couldn't read this QR code. Make sure it's a valid address.": "No se pudo leer este código QR. Asegurate de que sea una dirección válida.", "Couldn't update the contact. Please try again.": "No se pudo actualizar el contacto. Intentá de nuevo.", "Deposit {{symbol}} directly to an external wallet": "Depositá {{symbol}} directamente en una billetera externa", "Deposit {{symbol}} into your Exa App wallet": "Depositá {{symbol}} en tu billetera de Exa App", "Deposit assets to start swapping.": "Depositá activos para empezar a intercambiar.", "Double-check your address before sending funds to avoid losing them.": "Verificá tu dirección antes de enviar fondos para evitar perderlos.", + "Enter a key or paste a BR Code": "Ingresá una clave o un Código BR", "Enter a lower amount to swap": "Ingresá una cantidad menor para intercambiar", "Enter a purchase amount": "Ingresá un monto de compra", "Enter address": "Ingresá la dirección", diff --git a/src/i18n/es.json b/src/i18n/es.json index afd944ac5e..f8fc1f8863 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -119,7 +119,7 @@ "Bank name not accepted, try a different one": "Nombre de banco no aceptado, prueba con otro", "Bank transfers": "Transferencias bancarias", "Because servers only keep public keys, servers are less valuable targets for hackers.": "Como los servidores solo guardan claves públicas, son objetivos menos valiosos para los hackers.", - "Before we continue, we need your permission to access the camera. The camera will only be used for scanning valid addresses.": "Antes de continuar, necesitamos tu permiso para acceder a la cámara. La cámara solo se usará para escanear direcciones válidas.", + "Before we continue, we need your permission to access the camera. The camera will only be used for scanning QR codes.": "Antes de continuar, necesitamos tu permiso para acceder a la cámara. La cámara solo se usará para escanear códigos QR.", "Begin verifying": "Comenzar verificación", "Beneficiary address": "Dirección del beneficiario", "Beneficiary name": "Nombre del beneficiario", @@ -130,6 +130,7 @@ "Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings": "Se debe habilitar la biometría para usar llaves de acceso. Habilita la biometría en la configuración de tu dispositivo", "Borrow_home": "Préstamo", "BR Code": "Código BR", + "BR Code · {{name}}": "Código BR · {{name}}", "Bridge {{symbol}}": "Hacer bridge de {{symbol}}", "Bridge and swap it to a supported asset on {{chain}}.": "Haz bridge y swap a un activo soportado en {{chain}}.", "Bridge and swap needed after receiving": "Bridge y swap necesarios después de recibir", @@ -215,6 +216,8 @@ "Couldn't load the exchange rate. Please try again.": "No se pudo cargar el tipo de cambio. Inténtalo de nuevo.", "Couldn't load this activity. Please try again.": "No se pudo cargar esta actividad. Inténtalo de nuevo.", "Couldn't load your contacts. Please try again.": "No se pudieron cargar tus contactos. Inténtalo de nuevo.", + "Couldn't read this QR code. Make sure it's a PIX code.": "No se pudo leer este código QR. Asegúrate de que sea un código PIX.", + "Couldn't read this QR code. Make sure it's a valid address.": "No se pudo leer este código QR. Asegúrate de que sea una dirección válida.", "Couldn't update the contact. Please try again.": "No se pudo actualizar el contacto. Inténtalo de nuevo.", "Country": "País", "Create account": "Crear cuenta", @@ -266,7 +269,9 @@ "Early repayment discount": "Descuento por pago anticipado", "Edit contact": "Editar contacto", "Edit beneficiary": "Editar beneficiario", + "Email": "Email", "Enter {{chain}} address": "Ingresa la dirección de {{chain}}", + "Enter a key or paste a BR Code": "Ingresa una clave o pega un Código BR", "Enter a lower amount to swap": "Ingresa una cantidad menor para intercambiar", "Enter a purchase amount": "Ingresa un monto de compra", "Enter address": "Ingresa la dirección", @@ -367,6 +372,7 @@ "Here you’ll find integrations with decentralized services powered by our partners. Exa App never controls your assets or how you use them when connected to the integrations provided by our partners.": "Aquí encontrarás integraciones con servicios descentralizados impulsados por nuestros socios. Exa App nunca controla tus activos ni cómo los usas cuando te conectas a las integraciones de nuestros socios.", "Hi! I'd like help recovering an asset on a network that isn't supported yet.\n\nNetwork: {{chainName}} (chain ID {{chainId}})\nAsset: {{symbol}}\nToken address: {{address}}\nAmount: {{amount}} {{symbol}}\nCurrent value: ${{usdValue}}": "¡Hola! Quisiera ayuda para recuperar un activo en una red que aún no está soportada.\n\nRed: {{chainName}} (ID de red {{chainId}})\nActivo: {{symbol}}\nDirección del token: {{address}}\nMonto: {{amount}} {{symbol}}\nValor actual: ${{usdValue}}", "Hi! I'm setting up my Exa Card and it's taking longer than expected. Could you check on its status?": "¡Hola! Estoy configurando mi Exa Card y está tardando más de lo esperado. ¿Podrían revisar su estado?", + "Hide BR Code": "Ocultar Código BR", "Hide PIN": "Ocultar PIN", "Hide sensitive": "Ocultar sensibles", "Home": "Inicio", @@ -404,12 +410,16 @@ "Installments": "Cuotas", "INSTALLMENTS": "CUOTAS", "INSTANT PAY ({{asset}})": "PAGAR AHORA ({{asset}})", + "Instant": "Instantáneo", "Insufficient balance": "Saldo insuficiente", "insufficient funds": "fondos insuficientes", "Introducing the first onchain credit card": "Presentamos la primera tarjeta de crédito on-chain", "Invalid address": "Dirección inválida", "Invalid asset address": "Dirección del activo inválida", + "Invalid BR Code": "Código BR inválido", "Invalid country code": "Código de país inválido", + "Invalid CPF or CNPJ": "CPF o CNPJ inválido", + "Invalid PIX key": "Clave PIX inválida", "Invalid receiver address": "Dirección del receptor inválida", "invalid pin": "pin inválido", "IS ENABLED": "ESTÁ HABILITADO", @@ -520,6 +530,7 @@ "Only your USDC balance counts toward your spending limit.": "Solo tu saldo en USDC cuenta para tu límite de gasto.", "Open Exa Discord": "Abrir Exa en Discord", "Open Exa on X": "Abrir Exa en X", + "One-time charge": "Cobro de un solo uso", "Open in browser": "Abrir en el navegador", "Open your {{provider}} virtual account": "Abre tu cuenta virtual de {{provider}}", "Operation ID copied!": "¡ID de operación copiado!", @@ -567,7 +578,10 @@ "Pesos": "Pesos", "Pesos, dollars, or euros": "Pesos, dólares o euros", "PIX key": "Clave PIX", + "Phone number": "Teléfono", "PIX Key": "Clave PIX", + "PIX key or BR Code": "Clave PIX o Código BR", + "PIX key · {{type}}": "Clave PIX · {{type}}", "Please check your internet connection and try again in a moment. If the problem persists, reinstalling the app may help.": "Revisa tu conexión a internet e inténtalo de nuevo en unos momentos. Si el problema persiste, reinstalar la aplicación puede ayudar.", "Please wait...": "Por favor, espera...", "Portfolio balance": "Saldo del portafolio", @@ -585,6 +599,7 @@ "Processing": "Procesando", "Protocol borrow": "Préstamo del protocolo", "Purchase details": "Detalles de la compra", + "Random key": "Clave aleatoria", "Reach out to our support team and we’ll get you back on track.": "Ponte en contacto con nuestro equipo de soporte y te ayudaremos a resolverlo.", "Reals": "Reales", "Receive on": "Recibir en", @@ -672,6 +687,7 @@ "Settings": "Configuración", "Share {{chain}} address": "Compartir dirección de {{chain}}", "Share": "Compartir", + "Show BR Code": "Mostrar Código BR", "Show PIN": "Mostrar PIN", "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", @@ -745,6 +761,7 @@ "The maximum amount you can spend using Pay Now. Each purchase is deducted from your USDC balance immediately.": "El monto máximo que puedes gastar usando Pagar ahora. Cada compra se descuenta de tu balance de USDC al instante.", "The operation ID has been copied to the clipboard.": "El ID de operación se ha copiado al portapapeles.", "The reference is saved with this contact. To use a different one, delete the contact and add it again.": "La referencia se guarda con este contacto. Para usar otra, elimina el contacto y vuelve a agregarlo.", + "The saved contact may stop working once it's paid.": "El contacto guardado puede dejar de funcionar después de realizar el pago.", "The transaction hash has been copied to the clipboard.": "El hash de la transacción ha sido copiado al portapapeles.", "The transfer details saved for this contact aren't available. To send to them, delete the contact and add their account details again.": "Los datos de transferencia guardados para este contacto no están disponibles. Para enviarle, elimina el contacto y vuelve a agregar los datos de su cuenta.", "There are no pending requests!": "¡No hay solicitudes pendientes!", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e81fbe8292..ca3c6a063a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -119,7 +119,7 @@ "Bank name not accepted, try a different one": "Nome do banco não aceito, tente outro", "Bank transfers": "Transferências bancárias", "Because servers only keep public keys, servers are less valuable targets for hackers.": "Como os servidores guardam apenas chaves públicas, são alvos menos valiosos para hackers.", - "Before we continue, we need your permission to access the camera. The camera will only be used for scanning valid addresses.": "Antes de continuar, precisamos da sua permissão para acessar a câmera. A câmera será usada apenas para escanear endereços válidos.", + "Before we continue, we need your permission to access the camera. The camera will only be used for scanning QR codes.": "Antes de continuar, precisamos da sua permissão para acessar a câmera. A câmera será usada apenas para escanear códigos QR.", "Begin verifying": "Iniciar verificação", "Beneficiary address": "Endereço do beneficiário", "Beneficiary name": "Nome do beneficiário", @@ -130,6 +130,7 @@ "Biometrics must be enabled to use passkeys. Please enable biometrics in your device settings": "A biometria deve estar ativada para usar chaves de acesso. Ative a biometria nas configurações do seu dispositivo", "Borrow_home": "Empréstimo", "BR Code": "Código BR", + "BR Code · {{name}}": "Código BR · {{name}}", "Bridge {{symbol}}": "Fazer bridge de {{symbol}}", "Bridge and swap it to a supported asset on {{chain}}.": "Faça bridge e swap para um ativo suportado em {{chain}}.", "Bridge and swap needed after receiving": "Bridge e swap necessários após o recebimento", @@ -215,6 +216,8 @@ "Couldn't load the exchange rate. Please try again.": "Não foi possível carregar a taxa de câmbio. Tente novamente.", "Couldn't load this activity. Please try again.": "Não foi possível carregar esta atividade. Tente novamente.", "Couldn't load your contacts. Please try again.": "Não foi possível carregar seus contatos. Tente novamente.", + "Couldn't read this QR code. Make sure it's a PIX code.": "Não foi possível ler este código QR. Verifique se é um código PIX.", + "Couldn't read this QR code. Make sure it's a valid address.": "Não foi possível ler este código QR. Verifique se é um endereço válido.", "Couldn't update the contact. Please try again.": "Não foi possível atualizar o contato. Tente novamente.", "Country": "País", "Create account": "Criar conta", @@ -266,7 +269,9 @@ "Early repayment discount": "Desconto por pagamento antecipado", "Edit contact": "Editar contato", "Edit beneficiary": "Editar beneficiário", + "Email": "Email", "Enter {{chain}} address": "Insira o endereço de {{chain}}", + "Enter a key or paste a BR Code": "Digite uma chave ou cole um Código BR", "Enter a lower amount to swap": "Insira um valor menor para trocar", "Enter a purchase amount": "Insira um valor de compra", "Enter address": "Insira o endereço", @@ -367,6 +372,7 @@ "Here you’ll find integrations with decentralized services powered by our partners. Exa App never controls your assets or how you use them when connected to the integrations provided by our partners.": "Aqui você encontrará integrações com serviços descentralizados impulsionados pelos nossos parceiros. O Exa App nunca controla seus ativos nem como você os usa quando conectado às integrações dos nossos parceiros.", "Hi! I'd like help recovering an asset on a network that isn't supported yet.\n\nNetwork: {{chainName}} (chain ID {{chainId}})\nAsset: {{symbol}}\nToken address: {{address}}\nAmount: {{amount}} {{symbol}}\nCurrent value: ${{usdValue}}": "Olá! Gostaria de ajuda para recuperar um ativo em uma rede que ainda não é suportada.\n\nRede: {{chainName}} (ID da rede {{chainId}})\nAtivo: {{symbol}}\nEndereço do token: {{address}}\nValor: {{amount}} {{symbol}}\nValor atual: ${{usdValue}}", "Hi! I'm setting up my Exa Card and it's taking longer than expected. Could you check on its status?": "Olá! Estou configurando meu Exa Card e está demorando mais que o esperado. Poderiam verificar o status?", + "Hide BR Code": "Ocultar Código BR", "Hide PIN": "Ocultar PIN", "Hide sensitive": "Ocultar sensíveis", "Home": "Início", @@ -404,12 +410,16 @@ "Installments": "Parcelas", "INSTALLMENTS": "PARCELAS", "INSTANT PAY ({{asset}})": "PAGAR AGORA ({{asset}})", + "Instant": "Instantâneo", "Insufficient balance": "Saldo insuficiente", "insufficient funds": "fundos insuficientes", "Introducing the first onchain credit card": "Apresentamos o primeiro cartão de crédito on-chain", "Invalid address": "Endereço inválido", "Invalid asset address": "Endereço do ativo inválido", + "Invalid BR Code": "Código BR inválido", "Invalid country code": "Código de país inválido", + "Invalid CPF or CNPJ": "CPF ou CNPJ inválido", + "Invalid PIX key": "Chave PIX inválida", "Invalid receiver address": "Endereço do destinatário inválido", "invalid pin": "pin inválido", "IS ENABLED": "ESTÁ ATIVADO", @@ -520,6 +530,7 @@ "Only your USDC balance counts toward your spending limit.": "Apenas seu saldo em USDC conta para seu limite de gastos.", "Open Exa Discord": "Abrir Exa no Discord", "Open Exa on X": "Abrir Exa no X", + "One-time charge": "Cobrança única", "Open in browser": "Abrir no navegador", "Open your {{provider}} virtual account": "Abra sua conta virtual da {{provider}}", "Operation ID copied!": "ID da operação copiado!", @@ -567,7 +578,10 @@ "Pesos": "Pesos", "Pesos, dollars, or euros": "Pesos, dólares ou euros", "PIX key": "Chave PIX", + "Phone number": "Telefone", "PIX Key": "Chave PIX", + "PIX key or BR Code": "Chave PIX ou Código BR", + "PIX key · {{type}}": "Chave PIX · {{type}}", "Please check your internet connection and try again in a moment. If the problem persists, reinstalling the app may help.": "Verifique sua conexão com a internet e tente novamente em instantes. Se o problema persistir, reinstalar o aplicativo pode ajudar.", "Please wait...": "Por favor, aguarde...", "Portfolio balance": "Saldo do portfólio", @@ -585,6 +599,7 @@ "Processing": "Processando", "Protocol borrow": "Empréstimo do protocolo", "Purchase details": "Detalhes da compra", + "Random key": "Chave aleatória", "Reach out to our support team and we’ll get you back on track.": "Entre em contato com nossa equipe de suporte e vamos te ajudar a resolver isso.", "Reals": "Reais", "Receive on": "Receber em", @@ -672,6 +687,7 @@ "Settings": "Configurações", "Share {{chain}} address": "Compartilhar endereço de {{chain}}", "Share": "Compartilhar", + "Show BR Code": "Mostrar Código BR", "Show PIN": "Mostrar PIN", "Show QR": "Mostrar QR", "Show QR Code": "Mostrar código QR", @@ -745,6 +761,7 @@ "The maximum amount you can spend using Pay Now. Each purchase is deducted from your USDC balance immediately.": "O valor máximo que você pode gastar usando Pagar agora. Cada compra é descontada do seu saldo de USDC imediatamente.", "The operation ID has been copied to the clipboard.": "O ID da operação foi copiado para a área de transferência.", "The reference is saved with this contact. To use a different one, delete the contact and add it again.": "A referência é salva com este contato. Para usar outra, exclua o contato e adicione-o novamente.", + "The saved contact may stop working once it's paid.": "O contato salvo pode deixar de funcionar após o pagamento.", "The transaction hash has been copied to the clipboard.": "O hash da transação foi copiado para a área de transferência.", "The transfer details saved for this contact aren't available. To send to them, delete the contact and add their account details again.": "Os dados de transferência salvos para este contato não estão disponíveis. Para enviar a ele, exclua o contato e adicione os dados da conta novamente.", "There are no pending requests!": "Não há solicitações pendentes!", diff --git a/src/utils/pix.ts b/src/utils/pix.ts new file mode 100644 index 0000000000..9faaf02967 --- /dev/null +++ b/src/utils/pix.ts @@ -0,0 +1,92 @@ +import { + isCNPJ, + isCPF, + isDynamicPix, + isStaticPix, + keyDetector, + parseDynamicPix, + parseStaticPix, + PointOfInitiationMethod, +} from "@pix.js/qrcode"; +import { alpha2ToAlpha3 } from "i18n-iso-countries/index"; +import { check, pipe, string } from "valibot"; + +export function parseBRCode(payload: string) { + const code = payload.trim(); + if (isStaticPix(code)) { + let parsed: Static; + try { + parsed = parseStaticPix(code) as Static; + } catch { + return; + } + const key = parsed.merchantAccountInfo?.key; + if (!key || !parsed.merchantName) return; + return { + type: "static", + brCode: code, + key, + ownerName: parsed.merchantName, + city: parsed.merchantCity, + country: parsed.countryCode && alpha2ToAlpha3(parsed.countryCode), + postalCode: parsed.postalCode, + value: parsed.value, + txId: parsed.additionalData?.txId === "***" ? undefined : parsed.additionalData?.txId, + } as const; + } + if (isDynamicPix(code)) { + let parsed: Dynamic; + try { + parsed = parseDynamicPix(code) as Dynamic; + } catch { + return; + } + if (!parsed.merchantName) return; + return { + type: "dynamic", + brCode: code, + ownerName: parsed.merchantName, + city: parsed.merchantCity, + country: parsed.countryCode && alpha2ToAlpha3(parsed.countryCode), + postalCode: parsed.postalCode, + oneTime: parsed.pointOfInitiationMethod === PointOfInitiationMethod.OnTimeOnly, + } as const; + } +} + +function isBRCode(value: string) { + return value.trimStart().startsWith("000201"); +} + +export function isPixKey(value: string) { + return Object.values(keyDetector).some((detect) => detect(value)); +} + +export const pixAccount = pipe( + string(), + check((value) => isPixKey(value) || !isBRCode(value) || !!parseBRCode(value), "Invalid BR Code"), + check((value) => isPixKey(value) || !!parseBRCode(value), "Invalid PIX key"), +); + +export const taxDocument = pipe( + string(), + check((value) => isCPF(value) || isCNPJ(value), "Invalid CPF or CNPJ"), +); + +type Static = { + additionalData?: { txId?: string }; + countryCode?: string; + merchantAccountInfo?: { key?: string }; + merchantCity?: string; + merchantName?: string; + postalCode?: string; + value?: number; +}; + +type Dynamic = { + countryCode?: string; + merchantCity?: string; + merchantName?: string; + pointOfInitiationMethod?: PointOfInitiationMethod; + postalCode?: string; +};