diff --git a/apps/frontend/src/components/Avenia/AveniaField/index.tsx b/apps/frontend/src/components/Avenia/AveniaField/index.tsx index 297710cd3..207c6d3b3 100644 --- a/apps/frontend/src/components/Avenia/AveniaField/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaField/index.tsx @@ -1,3 +1,4 @@ +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; import { motion } from "motion/react"; import { FC } from "react"; import { useFormContext, useFormState } from "react-hook-form"; @@ -69,21 +70,12 @@ export const AveniaField: FC = ({ id, label, index, validation - +
+ + {id === ExtendedAveniaFieldOptions.BIRTHDATE && ( + + )} +
{errorMessage && {errorMessage}} ); diff --git a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx index e2f674c85..e961280a3 100644 --- a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx +++ b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx @@ -161,11 +161,13 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, label: string, onChange: React.ChangeEventHandler | undefined, valid: boolean, - Icon: React.ComponentType> + Icon: React.ComponentType>, + fileName?: string ) => ( @@ -190,13 +192,15 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, t("components.documentUpload.fields.rgFront"), e => handleFileChange(e, setFront, setFrontValid), frontValid, - DocumentTextIcon + DocumentTextIcon, + front?.name )} {renderField( t("components.documentUpload.fields.rgBack"), e => handleFileChange(e, setBack, setBackValid), backValid, - DocumentTextIcon + DocumentTextIcon, + back?.name )} )} @@ -205,7 +209,8 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, t("components.documentUpload.fields.cnhDocument"), e => handleFileChange(e, setFront, setFrontValid), frontValid, - DocumentTextIcon + DocumentTextIcon, + front?.name )} diff --git a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx index b2916c480..cb3ac4fd5 100644 --- a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx +++ b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx @@ -106,7 +106,7 @@ export const QuoteSubmitButton: FC = ({ className, disab return (
diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index 4f33d2c1c..ea8691ba8 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -177,7 +177,7 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro ]); }; -export const RampSubmitButton = ({ className }: { className?: string }) => { +export const RampSubmitButton = ({ className, hasValidationErrors }: { className?: string; hasValidationErrors?: boolean }) => { const rampActor = useRampActor(); const { onRampConfirm } = useRampSubmission(); const stellarData = useStellarKycSelector(); @@ -208,6 +208,10 @@ export const RampSubmitButton = ({ className }: { className?: string }) => { const toToken = isOnramp ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken) : getAnyFiatTokenDetails(fiatToken); const submitButtonDisabled = useMemo(() => { + if (hasValidationErrors) { + return true; + } + if ( walletLocked && (isOfframp || quote?.from === "sepa") && @@ -242,6 +246,7 @@ export const RampSubmitButton = ({ className }: { className?: string }) => { return false; }, [ + hasValidationErrors, executionInput, isQuoteExpired, isOfframp, diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx index 0f8a38039..5c9cb283a 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx @@ -1,4 +1,6 @@ import { Networks } from "@vortexfi/shared"; +import { useFormContext } from "react-hook-form"; +import { RampFormValues } from "../../../hooks/ramp/schema"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { ConnectWalletSection } from "../../ConnectWalletSection"; import { RampSubmitButton } from "../../RampSubmitButton/RampSubmitButton"; @@ -10,12 +12,38 @@ export interface DetailsStepActionsProps { requiresConnection: boolean; className?: string; forceNetwork?: Networks; + isBrazilLanding: boolean; } -export const DetailsStepActions = ({ signingState, className, requiresConnection, forceNetwork }: DetailsStepActionsProps) => { +export const DetailsStepActions = ({ + signingState, + className, + requiresConnection, + forceNetwork, + isBrazilLanding +}: DetailsStepActionsProps) => { const { shouldDisplay: signingBoxVisible, signatureState, confirmations } = signingState; const { isConnected } = useVortexAccount(forceNetwork); + const { + formState: { errors }, + watch + } = useFormContext(); + const formValues = watch(); + + const hasFormErrors = Object.keys(errors).length > 0; + + let hasEmptyForm = false; + + if (isBrazilLanding) { + const allRelevantFieldsEmpty = !formValues.taxId || !formValues.walletAddress; + hasEmptyForm = allRelevantFieldsEmpty; + } else { + hasEmptyForm = !formValues.walletAddress; + } + + const hasValidationErrors = hasFormErrors || hasEmptyForm; + if (signingBoxVisible) { return (
@@ -28,7 +56,7 @@ export const DetailsStepActions = ({ signingState, className, requiresConnection return (
{requiresConnection && } - {displayRampSubmitButton && } + {displayRampSubmitButton && }
); }; diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index fdbfd6d2f..a2291a00a 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -1,10 +1,11 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { FiatToken, Networks } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useRampActor } from "../../../contexts/rampState"; +import { RampFormValues } from "../../../hooks/ramp/schema"; import { useRampForm } from "../../../hooks/ramp/useRampForm"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useSigningBoxState } from "../../../hooks/useSigningBoxState"; @@ -32,6 +33,7 @@ export interface FormData { taxId?: string; moneriumWalletAddress?: string; walletAddress?: string; + fiatToken?: FiatToken; } export const DetailsStep = ({ className }: DetailsStepProps) => { @@ -58,6 +60,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const walletForm = walletLockedFromState || address || undefined; const { form } = useRampForm({ + fiatToken: quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken), moneriumWalletAddress: evmAddress, pixId, taxId, @@ -72,12 +75,18 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { if (isMoneriumToAssethubRamp && substrateAddress) { form.setValue("walletAddress", substrateAddress); - } else if (walletLockedFromState) { - form.setValue("walletAddress", walletLockedFromState); } else if (!isMoneriumToAssethubRamp && address) { form.setValue("walletAddress", address); + } else if (walletLockedFromState) { + form.setValue("walletAddress", walletLockedFromState); } - }, [form, evmAddress, isMoneriumRamp, address, walletLockedFromState, isMoneriumToAssethubRamp, substrateAddress]); + + const fiatToken = quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken); + form.setValue("fiatToken", fiatToken); + }, [form, evmAddress, isMoneriumRamp, address, walletLockedFromState, isMoneriumToAssethubRamp, substrateAddress, quote]); + + const previousValues = useRef({}); + const currentValues = form.watch(); const { onRampConfirm } = useRampSubmission(); @@ -118,7 +127,12 @@ export const DetailsStep = ({ className }: DetailsStepProps) => {
)} - + diff --git a/apps/frontend/src/hooks/brla/useKYCForm/index.tsx b/apps/frontend/src/hooks/brla/useKYCForm/index.tsx index 47d881848..ca90fa158 100644 --- a/apps/frontend/src/hooks/brla/useKYCForm/index.tsx +++ b/apps/frontend/src/hooks/brla/useKYCForm/index.tsx @@ -60,12 +60,19 @@ const createKycFormSchema = (t: (key: string) => string) => [ExtendedAveniaFieldOptions.BIRTHDATE]: yup .date() - .transform((value, originalValue) => { + .transform((value: Date | undefined, originalValue: any) => { return originalValue === "" ? undefined : value; }) .required(t("components.brlaExtendedForm.validation.birthdate.required")) .max(new Date(), t("components.brlaExtendedForm.validation.birthdate.future")) - .min(new Date(1900, 0, 1), t("components.brlaExtendedForm.validation.birthdate.tooOld")), + .min(new Date(1900, 0, 1), t("components.brlaExtendedForm.validation.birthdate.tooOld")) + .test("is-18-or-older", t("components.brlaExtendedForm.validation.birthdate.tooYoung"), value => { + if (!value) return true; + const birthDate = new Date(value); + const ageDate = new Date(birthDate); + ageDate.setFullYear(ageDate.getFullYear() + 18); + return ageDate <= new Date(); + }), [ExtendedAveniaFieldOptions.COMPANY_NAME]: yup .string() @@ -73,7 +80,7 @@ const createKycFormSchema = (t: (key: string) => string) => [ExtendedAveniaFieldOptions.START_DATE]: yup .date() - .transform((value, originalValue) => { + .transform((value: Date | undefined, originalValue: any) => { return originalValue === "" ? undefined : value; }) .max(new Date(), t("components.brlaExtendedForm.validation.startDate.future")) diff --git a/apps/frontend/src/hooks/ramp/schema.ts b/apps/frontend/src/hooks/ramp/schema.ts index 59e772fba..106a4931a 100644 --- a/apps/frontend/src/hooks/ramp/schema.ts +++ b/apps/frontend/src/hooks/ramp/schema.ts @@ -11,6 +11,7 @@ export type RampFormValues = { pixId?: string; walletAddress?: string; moneriumWalletAddress?: string; + fiatToken?: FiatToken; }; export const PHONE_REGEX = /^\+[1-9][0-9]\d{1,14}$/; diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 82c002043..49e380969 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -4,6 +4,7 @@ import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, OnChainTokenDetails, + QuoteError, QuoteResponse, RampDirection } from "@vortexfi/shared"; @@ -17,7 +18,7 @@ import { TrackableEvent, useEventsContext } from "../../contexts/events"; import { useNetwork } from "../../contexts/network"; import { multiplyByPowerOfTen, stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; import { useQuoteFormStore } from "../../stores/quote/useQuoteFormStore"; -import { useQuote, useQuoteError } from "../../stores/quote/useQuoteStore"; +import { useQuote, useQuoteError, useQuoteLoading } from "../../stores/quote/useQuoteStore"; import { useRampDirection } from "../../stores/rampDirectionStore"; import { useOnchainTokenBalance } from "../useOnchainTokenBalance"; import { useVortexAccount } from "../useVortexAccount"; @@ -35,30 +36,20 @@ function validateOnramp( } ): string | null { const maxAmountUnits = multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); - // Set minimum amount for EURC to 1 unit as an arbitrary limit. - const minAmountUnits = - fromToken.assetSymbol === "EURC" ? new Big(1) : multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); - if (inputAmount && maxAmountUnits.lt(inputAmount)) { - trackEvent({ - error_message: "more_than_maximum_withdrawal", - event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" - }); - return t("pages.swap.error.moreThanMaximumWithdrawal.buy", { - assetSymbol: fromToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2) - }); - } + const isTooHigh = inputAmount && maxAmountUnits.lt(inputAmount); + const isTooLow = inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount); - if (inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount)) { + if (isTooHigh || isTooLow) { trackEvent({ - error_message: "less_than_minimum_withdrawal", + error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", input_amount: inputAmount ? inputAmount.toString() : "0" }); - return t("pages.swap.error.lessThanMinimumWithdrawal.buy", { + return t("pages.swap.error.amountOutOfRange.buy", { assetSymbol: fromToken.fiat.symbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); } @@ -74,6 +65,7 @@ function validateOfframp( toToken, quote, userInputTokenBalance, + isDisconnected, trackEvent }: { inputAmount: Big; @@ -81,56 +73,45 @@ function validateOfframp( toToken: FiatTokenDetails; quote: QuoteResponse; userInputTokenBalance: string | null; + isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; } ): string | null { - if (typeof userInputTokenBalance === "string") { - const isNativeToken = fromToken.isNative; - if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { - trackEvent({ - error_message: "insufficient_balance", - event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" - }); - return t("pages.swap.error.insufficientFunds", { - assetSymbol: fromToken?.assetSymbol, - userInputTokenBalance - }); - // If the user chose the max amount, show a warning for native tokens due to gas fees - } else if (isNativeToken && Big(userInputTokenBalance).eq(inputAmount)) { - return t("pages.swap.error.gasWarning"); - } - } - const maxAmountUnits = multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); + const amountOut = quote ? Big(quote.outputAmount) : Big(0); + + const isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountOut); + const isTooLow = !amountOut.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut); - if (inputAmount && quote && maxAmountUnits.lt(Big(quote.outputAmount))) { + if (isTooHigh || isTooLow) { trackEvent({ - error_message: "more_than_maximum_withdrawal", + error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", input_amount: inputAmount ? inputAmount.toString() : "0" }); - return t("pages.swap.error.moreThanMaximumWithdrawal.sell", { + return t("pages.swap.error.amountOutOfRange.sell", { assetSymbol: toToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2) + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); } - const amountOut = quote ? Big(quote.outputAmount) : Big(0); - - if (!amountOut.eq(0)) { - if (!config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut)) { + if (typeof userInputTokenBalance === "string" && !isDisconnected) { + const isNativeToken = fromToken.isNative; + if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { trackEvent({ - error_message: "less_than_minimum_withdrawal", + error_message: "insufficient_balance", event: "form_error", input_amount: inputAmount ? inputAmount.toString() : "0" }); - - return t("pages.swap.error.lessThanMinimumWithdrawal.sell", { - assetSymbol: toToken.fiat.symbol, - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) + return t("pages.swap.error.insufficientFunds", { + assetSymbol: fromToken?.assetSymbol, + userInputTokenBalance }); + // If the user chose the max amount, show a warning for native tokens due to gas fees + } else if (isNativeToken && Big(userInputTokenBalance).eq(inputAmount)) { + return t("pages.swap.error.gasWarning"); } } @@ -158,6 +139,7 @@ export const useRampValidation = () => { const { inputAmount: inputAmountString, onChainToken, fiatToken } = useQuoteFormStore(); const quote = useQuote(); + const quoteLoading = useQuoteLoading(); const quoteError = useQuoteError(); const { selectedNetwork } = useNetwork(); const { trackEvent } = useEventsContext(); @@ -178,16 +160,36 @@ export const useRampValidation = () => { }); const getCurrentErrorMessage = useCallback(() => { - if (quoteError) return t(quoteError); - - if (isDisconnected) return; + if (quoteLoading) return null; // First check if the fiat token is enabled const tokenAvailabilityError = validateTokenAvailability(t, fiatToken, trackEvent); if (tokenAvailabilityError) return tokenAvailabilityError; - let validationError = null; + // For offramps, we must also show a valid error message, when backend refuses to calculate a quote + // due to limits. + + const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); + if (quoteError?.includes(QuoteError.BelowLowerLimitSell)) { + const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxSellAmountRaw), -toToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minSellAmountRaw), -toToken.decimals); + return t("pages.swap.error.amountOutOfRange.sell", { + assetSymbol: toToken.assetSymbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) + }); + } else if (quoteError?.includes(QuoteError.BelowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow)) { + const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxBuyAmountRaw), -fromToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minBuyAmountRaw), -fromToken.decimals); + return t("pages.swap.error.amountOutOfRange.buy", { + assetSymbol: fromToken.assetSymbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) + }); + } else if (quoteError) return t(quoteError); + + let validationError = null; if (isOnramp) { validationError = validateOnramp(t, { fromToken: fromToken as FiatTokenDetails, @@ -198,6 +200,7 @@ export const useRampValidation = () => { validationError = validateOfframp(t, { fromToken: fromToken as OnChainTokenDetails, inputAmount, + isDisconnected, quote: quote as QuoteResponse, toToken: toToken as FiatTokenDetails, trackEvent, @@ -218,6 +221,7 @@ export const useRampValidation = () => { trackEvent, toToken, quote, + quoteLoading, userInputTokenBalance?.balance, fiatToken ]); diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 76fd47f89..4ef47cf17 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -75,7 +75,8 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountForSwapMustBeGreaterThanZero]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLow]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", - + [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context + [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 296daf20b..dfa1ea71a 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -106,7 +106,8 @@ "birthdate": { "future": "Birthdate cannot be in the future", "required": "Birthdate is required", - "tooOld": "Invalid birthdate" + "tooOld": "Invalid birthdate", + "tooYoung": "You must be at least 18 years old" }, "cep": { "minLength": "CEP must be at least 3 characters", @@ -219,6 +220,7 @@ "rgFront": "RG Front", "uploadSelfie": "Upload Selfie" }, + "helperText": "Click here to upload", "title": "Fast-Track Verification", "uploadBug": "There was an error uploading the files for verification. Please try again later.", "uploadFailed": "Upload failed. Please try again.", @@ -542,7 +544,6 @@ "titlePart2": "inside your APP", "widgetIntegration": "Widget integration" }, - "whyVortexApi": { "cta": { "npmPackage": "NPM package", @@ -919,6 +920,10 @@ "developedBy": "Developed by", "error": { "ARS_tokenUnavailable": "Improving your ARS exit - back shortly! ", + "amountOutOfRange": { + "buy": "{{assetSymbol}} orders must be between {{minAmountUnits}} and {{maxAmountUnits}}", + "sell": "{{assetSymbol}} orders must be between {{minAmountUnits}} and {{maxAmountUnits}}" + }, "BRL_tokenUnavailable": "Improving your BRL exit - back shortly! ", "EURC_tokenUnavailable": "Improving your EUR exit - back shortly! ", "feeComponents": "Failed to calculate the fees. Please try again with a different amount.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 7154762a4..bc2e67ad7 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -107,7 +107,8 @@ "birthdate": { "future": "Data de nascimento não pode ser no futuro", "required": "Data de nascimento é obrigatória", - "tooOld": "Data de nascimento inválida" + "tooOld": "Data de nascimento inválida", + "tooYoung": "Você deve ter pelo menos 18 anos de idade" }, "cep": { "minLength": "CEP deve ter pelo menos 3 caracteres", @@ -220,6 +221,7 @@ "rgFront": "Frente do RG", "uploadSelfie": "Enviar selfie" }, + "helperText": "Clique aqui para enviar", "title": "Verificação Rápida", "uploadBug": "Ocorreu um erro ao enviar os arquivos para verificação. Por favor, tente novamente mais tarde.", "uploadFailed": "Falha no envio. Por favor, tente novamente.", @@ -912,6 +914,10 @@ "developedBy": "Desenvolvido por", "error": { "ARS_tokenUnavailable": "Ajustando sua saída ARS - em breve!", + "amountOutOfRange": { + "buy": "Pedidos em {{assetSymbol}} devem estar entre {{minAmountUnits}} e {{maxAmountUnits}}", + "sell": "Pedidos em {{assetSymbol}} devem estar entre {{minAmountUnits}} e {{maxAmountUnits}}" + }, "BRL_tokenUnavailable": "Ajustando sua saída BRL - em breve!", "EURC_tokenUnavailable": "Ajustando sua saída EUR - em breve!", "feeComponents": "Falha ao calcular as taxas. Por favor, tente novamente com um valor diferente.", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 924b72c61..265fe0550 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,6 +95,8 @@ export enum QuoteError { InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", + BelowLowerLimitSell = "Output amount below minimum SELL limit of", + BelowLowerLimitBuy = "Input amount below minimum BUY limit of", // Token/calculation errors UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", diff --git a/packages/shared/src/services/brla/helpers.ts b/packages/shared/src/services/brla/helpers.ts index 6b70b4104..18df512fa 100644 --- a/packages/shared/src/services/brla/helpers.ts +++ b/packages/shared/src/services/brla/helpers.ts @@ -18,10 +18,64 @@ export function generateReferenceLabel(quote: Quote): string { export const CPF_REGEX = /^\d{3}(\.\d{3}){2}-\d{2}$|^\d{11}$/; export const CNPJ_REGEX = /^(\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2})$/; +/** + * Checks if all digits in a string are the same (e.g., "11111111111") + */ +function hasAllSameDigits(digits: string): boolean { + if (digits.length === 0) return false; + const firstDigit = digits[0]; + return digits.split("").every(d => d === firstDigit); +} + +/** + * Checks if digits form an ascending sequence (e.g., "12345678901" or "01234567890") + */ +function isAscendingSequence(digits: string): boolean { + for (let i = 1; i < digits.length; i++) { + const prev = parseInt(digits[i - 1], 10); + const curr = parseInt(digits[i], 10); + // Allow wrap-around from 9 to 0 + if (curr !== (prev + 1) % 10) { + return false; + } + } + return true; +} + +/** + * Checks if digits form a descending sequence (e.g., "98765432109" or "10987654321") + */ +function isDescendingSequence(digits: string): boolean { + for (let i = 1; i < digits.length; i++) { + const prev = parseInt(digits[i - 1], 10); + const curr = parseInt(digits[i], 10); + // Allow wrap-around from 0 to 9 + if (curr !== (prev - 1 + 10) % 10) { + return false; + } + } + return true; +} + +/** + * Checks if the input contains a trivial pattern (all same digits or sequential) + */ +function isTrivialPattern(input: string): boolean { + // Extract only digits + const digits = input.replace(/\D/g, ""); + if (digits.length === 0) return false; + + return hasAllSameDigits(digits) || isAscendingSequence(digits) || isDescendingSequence(digits); +} + export function isValidCnpj(cnpj: string): boolean { - return CNPJ_REGEX.test(cnpj); + if (!CNPJ_REGEX.test(cnpj)) return false; + if (isTrivialPattern(cnpj)) return false; + return true; } export function isValidCpf(cpf: string): boolean { - return CPF_REGEX.test(cpf); + if (!CPF_REGEX.test(cpf)) return false; + if (isTrivialPattern(cpf)) return false; + return true; }