diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 2930ffd6..894328d4 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -1,5 +1,5 @@ 'use client' -import GeneralRecipientInput from '@/components/Global/GeneralRecipientInput' +import GeneralRecipientInput, { GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' import * as _consts from '../Claim.consts' import { useContext, useEffect, useState } from 'react' import Icon from '@/components/Global/Icon' @@ -9,7 +9,6 @@ import useClaimLink from '../useClaimLink' import * as context from '@/context' import Loading from '@/components/Global/Loading' import * as consts from '@/constants' -import * as interfaces from '@/interfaces' import * as utils from '@/utils' import MoreInfo from '@/components/Global/MoreInfo' import TokenSelectorXChain from '@/components/Global/TokenSelector/TokenSelectorXChain' @@ -126,17 +125,6 @@ export const InitialClaimLinkView = ({ } } - const _estimatePoints = async () => { - const USDValue = Number(claimLinkData.tokenAmount) * (tokenPrice ?? 0) - const estimatedPoints = await estimatePoints({ - address: recipient.address ?? address ?? '', - chainId: claimLinkData.chainId, - amountUSD: USDValue, - actionType: ActionType.CLAIM, - }) - setEstimatedPoints(estimatedPoints) - } - const handleIbanRecipient = async () => { try { setErrorState({ @@ -255,10 +243,24 @@ export const InitialClaimLinkView = ({ } useEffect(() => { - if (recipient) { - _estimatePoints() + let isMounted = true + if (recipient?.address && isValidRecipient) { + const amountUSD = Number(claimLinkData.tokenAmount) * (tokenPrice ?? 0) + estimatePoints({ + address: recipient.address, + chainId: claimLinkData.chainId, + amountUSD, + actionType: ActionType.CLAIM, + }).then((points) => { + if (isMounted) { + setEstimatedPoints(points) + } + }) + } + return () => { + isMounted = false } - }, [recipient]) + }, [recipient.address, isValidRecipient, claimLinkData.tokenAmount, claimLinkData.chainId, tokenPrice]) useEffect(() => { if (recipient.address) return @@ -463,42 +465,16 @@ export const InitialClaimLinkView = ({ { - setRecipient({ name, address }) - setInputChanging(false) - }} - _setIsValidRecipient={({ isValid, error }: { isValid: boolean; error?: string }) => { - setIsValidRecipient(isValid) - if (error) { - setErrorState({ - showError: true, - errorMessage: error, - }) - } else { - setErrorState({ - showError: false, - errorMessage: '', - }) - } - setInputChanging(false) - }} - setIsValueChanging={() => { - setInputChanging(true) - }} - setRecipientType={(type: interfaces.RecipientType) => { - setRecipientType(type) - }} - onDeleteClick={() => { - setRecipientType('address') - setRecipient({ - name: undefined, - address: '', - }) + recipient={recipient} + onUpdate={(update: GeneralRecipientUpdate) => { + setRecipient(update.recipient) + setRecipientType(update.type) + setIsValidRecipient(update.isValid) setErrorState({ - showError: false, - errorMessage: '', + showError: !update.isValid, + errorMessage: update.errorMessage, }) + setInputChanging(update.isChanging) }} /> {recipient && isValidRecipient && recipientType !== 'iban' && recipientType !== 'us' && ( diff --git a/src/components/Global/AddressInput/index.tsx b/src/components/Global/AddressInput/index.tsx index 0eef5cf0..0ae8adeb 100644 --- a/src/components/Global/AddressInput/index.tsx +++ b/src/components/Global/AddressInput/index.tsx @@ -1,150 +1,41 @@ 'use client' -import { useEffect, useState } from 'react' -import Icon from '@/components/Global/Icon' -import * as utils from '@/utils' -import { ethers } from 'ethers' +import { isAddress } from 'viem' + +import { resolveFromEnsName } from '@/utils' +import ValidatedInput, { InputUpdate } from '@/components/Global/ValidatedInput' type AddressInputProps = { - className?: string placeholder: string value: string - onSubmit: any - _setIsValidRecipient: any - setIsValueChanging?: any - onDeleteClick: any + onUpdate: (update: InputUpdate) => void + className?: string } -const AddressInput = ({ - placeholder, - value, - onSubmit, - _setIsValidRecipient, - setIsValueChanging, - onDeleteClick, -}: AddressInputProps) => { - const [userInput, setUserInput] = useState(value) - const [recipient, setRecipient] = useState(value) - const [debouncedRecipient, setDebouncedRecipient] = useState('') - const [isValidRecipient, setIsValidRecipient] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [type, setType] = useState<'address' | 'ens'>('address') - - async function checkAddress(recipient: string) { +const AddressInput = ({ placeholder = 'Enter a valid address', value, onUpdate, className }: AddressInputProps) => { + async function checkAddress(recipient: string): Promise { try { if (recipient.toLowerCase().endsWith('.eth')) { - const resolvedAddress = await utils.resolveFromEnsName(recipient.toLowerCase()) - if (resolvedAddress) { - setRecipient(recipient) - setIsValidRecipient(true) - setType('ens') - onSubmit(recipient) - } else { - setIsValidRecipient(false) - } - } else if (ethers.utils.isAddress(recipient)) { - setRecipient(recipient) - setIsValidRecipient(true) - setType('address') - onSubmit(recipient) + const resolvedAddress = await resolveFromEnsName(recipient.toLowerCase()) + return !!resolvedAddress } else { - setIsValidRecipient(false) + return isAddress(recipient, { strict: false }) } } catch (error) { console.error('Error while validating recipient input field:', error) - setIsValidRecipient(false) - } finally { - setIsLoading(false) + return false } } - useEffect(() => { - _setIsValidRecipient(isValidRecipient) - }, [isValidRecipient]) - - useEffect(() => { - if (recipient && isValidRecipient) { - onSubmit(recipient) - } - }, [recipient]) - - useEffect(() => { - setIsLoading(true) - const handler = setTimeout(() => { - setDebouncedRecipient(userInput) - }, 750) - return () => { - clearTimeout(handler) - } - }, [userInput]) - - useEffect(() => { - if (debouncedRecipient) { - checkAddress(debouncedRecipient) - } - }, [debouncedRecipient]) - - useEffect(() => { - setUserInput(value) - }, [value]) - return ( -
-
- To: -
- { - e.preventDefault() - }} - onChange={(e) => { - setIsValueChanging(true) - if (e.target.value) { - setUserInput(e.target.value) - } else { - setIsValidRecipient(false) - setUserInput('') - } - }} - spellCheck="false" - /> - {userInput?.length > 0 ? ( - isLoading ? ( -
-
-
- ) : ( - userInput && ( - - ) - ) - ) : null} -
+ ) } diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx index c4696ecd..ac3d1c51 100644 --- a/src/components/Global/GeneralRecipientInput/index.tsx +++ b/src/components/Global/GeneralRecipientInput/index.tsx @@ -1,183 +1,103 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useRef } from 'react' import { isIBAN } from 'validator' -import Icon from '@/components/Global/Icon' -import * as interfaces from '@/interfaces' +import ValidatedInput, { InputUpdate } from '@/components/Global/ValidatedInput' import * as utils from '@/utils' -import { ethers } from 'ethers' +import { isAddress } from 'viem' +import * as interfaces from '@/interfaces' type GeneralRecipientInputProps = { className?: string placeholder: string - value: string - onSubmit: any - _setIsValidRecipient: any - setIsValueChanging?: any - setRecipientType: any - onDeleteClick: any + recipient: { name: string | undefined; address: string } + onUpdate: (update: GeneralRecipientUpdate) => void +} + +export type GeneralRecipientUpdate = { + recipient: { name: string | undefined; address: string } + type: interfaces.RecipientType + isValid: boolean + isChanging: boolean + errorMessage: string } -const GeneralRecipientInput = ({ - placeholder, - value, - onSubmit, - _setIsValidRecipient, - setIsValueChanging, - setRecipientType, - onDeleteClick, -}: GeneralRecipientInputProps) => { - const [userInput, setUserInput] = useState(value) - const [recipient, setAddress] = useState(value) - const [deboundedRecipient, setDeboundedRecipient] = useState('') - const [isValidRecipient, setIsValidRecipient] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [type, setType] = useState('address') +const GeneralRecipientInput = ({ placeholder, recipient, onUpdate, className }: GeneralRecipientInputProps) => { + const recipientType = useRef('address') + const errorMessage = useRef('') + const resolvedAddress = useRef('') - async function checkAddress(recipient: string) { + const checkAddress = useCallback(async (recipient: string): Promise => { try { + let isValid = false + let type: interfaces.RecipientType = 'address' if (isIBAN(recipient)) { - const validAccount = await utils.validateBankAccount(recipient) - if (validAccount) { - setIsValidRecipient(true) - _setIsValidRecipient({ isValid: true }) - setRecipientType('iban') - setType('iban') - setAddress(recipient) - onSubmit(userInput, recipient) - } else { - setIsValidRecipient(false) - _setIsValidRecipient({ isValid: false, error: 'Invalid IBAN, country not supported' }) - } + type = 'iban' + isValid = await utils.validateBankAccount(recipient) + if (!isValid) errorMessage.current = 'Invalid IBAN, country not supported' } else if (/^[0-9]{6,17}$/.test(recipient)) { - const validateBankAccount = await utils.validateBankAccount(recipient) - if (validateBankAccount) { - setIsValidRecipient(true) - _setIsValidRecipient({ isValid: true }) - setRecipientType('us') - setType('us') - setAddress(recipient) - onSubmit(userInput, recipient) - } else { - setIsValidRecipient(false) - _setIsValidRecipient({ isValid: false, error: 'Invalid US account number' }) - } + type = 'us' + isValid = await utils.validateBankAccount(recipient) + if (!isValid) errorMessage.current = 'Invalid US account number' } else if (recipient.toLowerCase().endsWith('.eth')) { - const resolvedAddress = await utils.resolveFromEnsName(recipient.toLowerCase()) - if (resolvedAddress) { - recipient = resolvedAddress - setIsValidRecipient(true) - _setIsValidRecipient({ isValid: true }) - setAddress(recipient) - setRecipientType('ens') - setType('ens') - onSubmit(userInput, recipient) + type = 'ens' + const address = await utils.resolveFromEnsName(recipient.toLowerCase()) + if (address) { + resolvedAddress.current = address + isValid = true } else { - setIsValidRecipient(false) - _setIsValidRecipient({ isValid: false }) + errorMessage.current = 'ENS not found' + isValid = false } - } else if (ethers.utils.isAddress(recipient)) { - setAddress(recipient) - setIsValidRecipient(true) - _setIsValidRecipient({ isValid: true }) - setRecipientType('address') - setType('address') - onSubmit(undefined, recipient) } else { - setIsValidRecipient(false) - _setIsValidRecipient({ isValid: false }) + type = 'address' + isValid = isAddress(recipient, { strict: false }) + if (!isValid) errorMessage.current = 'Invalid address' } + recipientType.current = type + return isValid } catch (error) { console.error('Error while validating recipient input field:', error) - setIsValidRecipient(false) - _setIsValidRecipient({ isValid: false }) - } finally { - setIsLoading(false) + return false } - } + }, []) - useEffect(() => { - if (recipient && isValidRecipient) { - _setIsValidRecipient({ isValid: true }) - } - }, [recipient]) - - useEffect(() => { - setIsLoading(true) - const handler = setTimeout(() => { - setDeboundedRecipient(userInput) - }, 750) - return () => { - clearTimeout(handler) - } - }, [userInput]) - - useEffect(() => { - if (deboundedRecipient) { - checkAddress(deboundedRecipient) + const onInputUpdate = useCallback((update: InputUpdate) => { + let _update: GeneralRecipientUpdate + if (update.isValid) { + errorMessage.current = '' + _update = { + recipient: + 'ens' === recipientType.current + ? { address: resolvedAddress.current, name: update.value } + : { address: update.value, name: undefined }, + type: recipientType.current, + isValid: true, + isChanging: update.isChanging, + errorMessage: '', + } + } else { + resolvedAddress.current = '' + _update = { + recipient: { address: update.value, name: undefined }, + type: recipientType.current, + isValid: false, + isChanging: update.isChanging, + errorMessage: errorMessage.current, + } } - }, [deboundedRecipient]) - - useEffect(() => { - setUserInput(value) - }, [value]) + onUpdate(_update) + }, []) return ( -
-
- To: -
- { - e.preventDefault() - }} - onChange={(e) => { - setIsValueChanging(true) - if (e.target.value) { - setUserInput(e.target.value) - } else { - _setIsValidRecipient({ isValid: false }) - setUserInput('') - } - }} - spellCheck="false" - /> - {userInput.length > 0 ? ( - isLoading ? ( -
-
-
- ) : ( - userInput && ( - - ) - ) - ) : null} -
+ ) } diff --git a/src/components/Global/ValidatedInput/index.tsx b/src/components/Global/ValidatedInput/index.tsx new file mode 100644 index 00000000..40bbfe9c --- /dev/null +++ b/src/components/Global/ValidatedInput/index.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect, ChangeEvent, useRef } from 'react' +import Icon from '@/components/Global/Icon' +type ValidatedInputProps = { + label: string + value: string + placeholder?: string + debounceTime?: number + validate: (value: string) => Promise + onUpdate: (update: InputUpdate) => void + className?: string +} +export type InputUpdate = { + value: string + isValid: boolean + isChanging: boolean +} +const ValidatedInput = ({ + label, + placeholder = '', + value, + debounceTime = 300, + onUpdate, + validate, + className, +}: ValidatedInputProps) => { + const [isValid, setIsValid] = useState(false) + const [isValidating, setIsValidating] = useState(false) + const [debouncedValue, setDebouncedValue] = useState(value) + const previousValueRef = useRef(value) + + useEffect(() => { + if ('' === debouncedValue || debouncedValue === previousValueRef.current) { + return + } + let isStale = false + previousValueRef.current = debouncedValue + setIsValidating(true) + validate(debouncedValue) + .then((isValid) => { + if (isStale) return + setIsValid(isValid) + onUpdate({ value: debouncedValue, isValid, isChanging: false }) + }) + .catch((error) => { + if (isStale) return + console.error('Unexpected error while validating recipient input field:', error) + setIsValid(false) + onUpdate({ value: debouncedValue, isValid: false, isChanging: false }) + }) + .finally(() => { + if (isStale) return + setIsValidating(false) + }) + return () => { + isStale = true + } + }, [debouncedValue]) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, debounceTime) + return () => { + clearTimeout(handler) + } + }, [value, debounceTime]) + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value + onUpdate({ value: newValue, isValid: false, isChanging: !!newValue }) + } + return ( +
+ + + {value && + (isValidating ? ( +
+
+
+ ) : ( + + ))} +
+ ) +} +export default ValidatedInput diff --git a/src/components/Request/Create/Views/Initial.view.tsx b/src/components/Request/Create/Views/Initial.view.tsx index c695b3bb..f42621aa 100644 --- a/src/components/Request/Create/Views/Initial.view.tsx +++ b/src/components/Request/Create/Views/Initial.view.tsx @@ -7,6 +7,7 @@ import Loading from '@/components/Global/Loading' import TokenSelector from '@/components/Global/TokenSelector/TokenSelector' import { peanut } from '@squirrel-labs/peanut-sdk' import AddressInput from '@/components/Global/AddressInput' +import { InputUpdate } from '@/components/Global/ValidatedInput' import { getTokenDetails } from '@/components/Create/Create.utils' import { useBalance } from '@/hooks/useBalance' import * as utils from '@/utils' @@ -130,23 +131,13 @@ export const InitialView = ({ { - setIsValidRecipient(valid) - setInputChanging(false) - }} - onDeleteClick={() => { - setRecipientAddress('') - setInputChanging(false) - }} - onSubmit={(recipient: string) => { - setRecipientAddress(recipient) - setInputChanging(false) + onUpdate={(update: InputUpdate) => { + setRecipientAddress(update.value) + setInputChanging(update.isChanging) + setIsValidRecipient(update.isValid) }} - setIsValueChanging={(value: boolean) => { - setInputChanging(value) - }} - placeholder="Enter recipient address" className="w-full" />