From a9136088ce2936c4de0d5a3a2c7c61c8905ae093 Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:22:02 +0200 Subject: [PATCH 01/17] feat: handle send link claims to bank account for peanut users (#1078) * reafactor: create reusable country list component and use it for all the flows * feat: reusable user accounts components * feat: handle different cases based on kyc status for bank claim * fix: account creation * chore: add docstring to hooks * chore: better comments for bank flow manager * fix: kyc modal closing after tos acceptance issue * fix: remove bank acc caching from withdraw flow * fix: update confirm claim modal copy * fix: remove bank acc caching from claim flow * fix: navheader title --- src/app/(mobile-ui)/home/page.tsx | 8 +- .../AddMoney/components/DepositMethodList.tsx | 3 +- .../AddWithdraw/AddWithdrawCountriesList.tsx | 54 +- .../AddWithdraw/AddWithdrawRouterView.tsx | 223 ++------ .../AddWithdraw/DynamicBankAccountForm.tsx | 27 +- src/components/Claim/Link/Initial.view.tsx | 90 ++- .../Claim/Link/Onchain/Confirm.view.tsx | 2 +- .../Claim/Link/Onchain/Success.view.tsx | 4 +- .../Claim/Link/views/BankFlowManager.view.tsx | 530 +++++++++++++----- .../Link/views/ClaimCountryList.view.tsx | 103 ---- .../Link/views/Confirm.bank-claim.view.tsx | 6 +- src/components/Common/ActionList.tsx | 165 ++++++ src/components/Common/CountryList.tsx | 150 +++++ src/components/Common/CountryListRouter.tsx | 60 ++ src/components/Common/CountryListSkeleton.tsx | 25 + src/components/Common/SavedAccountsView.tsx | 128 +++++ src/components/GuestActions/MethodList.tsx | 149 ----- ...owContext.tsx => ClaimBankFlowContext.tsx} | 88 ++- src/context/contextProvider.tsx | 6 +- src/hooks/useDetermineBankClaimType.ts | 76 +++ src/hooks/useGeoLocaion.ts | 34 ++ src/hooks/useKycFlow.ts | 41 +- src/hooks/useSavedAccounts.tsx | 23 + 23 files changed, 1252 insertions(+), 743 deletions(-) delete mode 100644 src/components/Claim/Link/views/ClaimCountryList.view.tsx create mode 100644 src/components/Common/ActionList.tsx create mode 100644 src/components/Common/CountryList.tsx create mode 100644 src/components/Common/CountryListRouter.tsx create mode 100644 src/components/Common/CountryListSkeleton.tsx create mode 100644 src/components/Common/SavedAccountsView.tsx delete mode 100644 src/components/GuestActions/MethodList.tsx rename src/context/{GuestFlowContext.tsx => ClaimBankFlowContext.tsx} (54%) create mode 100644 src/hooks/useDetermineBankClaimType.ts create mode 100644 src/hooks/useGeoLocaion.ts create mode 100644 src/hooks/useSavedAccounts.tsx diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 195e41fee..295bad12d 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -38,8 +38,8 @@ import { AccountType } from '@/interfaces' import { formatUnits } from 'viem' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' -import { useGuestFlow } from '@/context/GuestFlowContext' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds @@ -48,7 +48,7 @@ export default function Home() { const { balance, address, isFetchingBalance, isFetchingRewardBalance } = useWallet() const { rewardWalletBalance } = useWalletStore() const [isRewardsModalOpen, setIsRewardsModalOpen] = useState(false) - const { resetGuestFlow } = useGuestFlow() + const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() const { resetWithdrawFlow } = useWithdrawFlow() const [isBalanceHidden, setIsBalanceHidden] = useState(() => { const prefs = getUserPreferences() @@ -84,9 +84,9 @@ export default function Home() { const isLoading = isFetchingUser && !username useEffect(() => { - resetGuestFlow() + resetClaimBankFlow() resetWithdrawFlow() - }, [resetGuestFlow, resetWithdrawFlow]) + }, [resetClaimBankFlow, resetWithdrawFlow]) useEffect(() => { // We have some users that didn't have the peanut wallet created diff --git a/src/components/AddMoney/components/DepositMethodList.tsx b/src/components/AddMoney/components/DepositMethodList.tsx index 1839f2edc..838085590 100644 --- a/src/components/AddMoney/components/DepositMethodList.tsx +++ b/src/components/AddMoney/components/DepositMethodList.tsx @@ -2,7 +2,6 @@ import { CardPosition } from '@/components/Global/Card' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' -import { IconName } from '@/components/Global/Icons/Icon' import Image from 'next/image' import { twMerge } from 'tailwind-merge' import { countryCodeMap } from '../consts' @@ -38,7 +37,7 @@ export const DepositMethodList = ({ methods, onItemClick, isAllMethodsView = fal if (isSingleOverall) { determinedPosition = 'single' } else if (isFirstOverall) { - determinedPosition = 'first' + determinedPosition = isCryptoAtSlot0 && isAllMethodsView ? 'single' : 'first' } else if (isCryptoAtSlot0 && isCurrentMethodCountry && index === 1 && isAllMethodsView) { // if crypto card is at methods[0], and this is the country card at methods[1], // treat this country card as 'first' in its own group. diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 24ce2f325..edce3df1e 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -15,12 +15,10 @@ import { useEffect, useRef, useState } from 'react' import { InitiateKYCModal } from '@/components/Kyc' import { DynamicBankAccountForm, IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount, updateUserById } from '@/app/actions/users' -import { jsonParse, jsonStringify } from '@/utils/general.utils' import { KYCStatus } from '@/utils/bridge-accounts.utils' import { AddBankAccountPayload } from '@/app/actions/types/users.types' import { useWebSocket } from '@/hooks/useWebSocket' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { useOnrampFlow } from '@/context/OnrampFlowContext' import { Account } from '@/interfaces' import PeanutLoading from '../Global/PeanutLoading' @@ -33,10 +31,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const params = useParams() const { user, fetchUser } = useAuth() const { setSelectedBankAccount, amountToWithdraw } = useWithdrawFlow() - const { setFromBankSelected } = useOnrampFlow() const [view, setView] = useState<'list' | 'form'>('list') const [isKycModalOpen, setIsKycModalOpen] = useState(false) - const [cachedBankDetails, setCachedBankDetails] = useState | null>(null) const formRef = useRef<{ handleSubmit: () => void }>(null) const [liveKycStatus, setLiveKycStatus] = useState(user?.user?.kycStatus as KYCStatus) @@ -66,18 +62,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { (country) => country.type === 'country' && country.path === countrySlugFromUrl ) - useEffect(() => { - if (user?.user.userId) { - const item = sessionStorage.getItem(`temp-bank-account-${user.user.userId}`) - const data = item ? jsonParse(item) : null - const currentStatus = liveKycStatus || user.user.kycStatus - if (data && currentStatus === 'approved' && !cachedBankDetails) { - setCachedBankDetails(data) - setView('form') - } - } - }, [user, liveKycStatus, cachedBankDetails]) - const handleFormSubmit = async ( payload: AddBankAccountPayload, rawData: IBankAccountDetails @@ -124,9 +108,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { setSelectedBankAccount(newAccountFromResponse) } - if (user?.user.userId) { - sessionStorage.removeItem(`temp-bank-account-${user.user.userId}`) - } if (currentCountry) { router.push(`/withdraw/${currentCountry.path}/bank`) } @@ -151,10 +132,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet if (!isUserKycVerified) { - const { firstName, lastName, email, ...detailsToSave } = rawData - if (user?.user.userId) { - sessionStorage.setItem(`temp-bank-account-${user.user.userId}`, jsonStringify(detailsToSave)) - } setIsKycModalOpen(true) } @@ -162,9 +139,10 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } const handleKycSuccess = () => { - setIsKycModalOpen(false) - if (formRef.current) { - formRef.current.handleSubmit() + // only transition to form if this component initiated the KYC modal + if (isKycModalOpen) { + setIsKycModalOpen(false) + setView('form') } } @@ -207,16 +185,20 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (view === 'form') { return (
- setView('list')} /> - -
- -
+ { + // ensure kyc modal isn't open so late success events don't flip view + setIsKycModalOpen(false) + setView('list') + }} + /> + setIsKycModalOpen(false)} diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index b531748ff..3dcb30afb 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -1,29 +1,19 @@ 'use client' import { Button } from '@/components/0_Bruddle' import { DepositMethod, DepositMethodList } from '@/components/AddMoney/components/DepositMethodList' -import { countryData as ALL_METHODS_DATA, countryCodeMap } from '@/components/AddMoney/consts' -import EmptyState from '@/components/Global/EmptyStates/EmptyState' import NavHeader from '@/components/Global/NavHeader' -import { SearchInput } from '@/components/SearchUsers/SearchInput' -import { - RecentMethod, - getUserPreferences, - updateUserPreferences, - shortenAddressLong, - formatIban, -} from '@/utils/general.utils' +import { RecentMethod, getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { useRouter } from 'next/navigation' -import { FC, useEffect, useMemo, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useUserStore } from '@/redux/hooks' import { AccountType, Account } from '@/interfaces' -import Image from 'next/image' -import { Icon } from '@/components/Global/Icons/Icon' -import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useOnrampFlow } from '@/context/OnrampFlowContext' -import Divider from '@/components/0_Bruddle/Divider' import Card from '@/components/Global/Card' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' +import { CountryList } from '../Common/CountryList' +import PeanutLoading from '../Global/PeanutLoading' +import SavedAccountsView from '../Common/SavedAccountsView' interface AddWithdrawRouterViewProps { flow: 'add' | 'withdraw' @@ -53,7 +43,6 @@ export const AddWithdrawRouterView: FC = ({ // determine if we should show the full list of methods (countries/crypto) instead of the default view const shouldShowAllMethods = flow === 'withdraw' ? showAllWithdrawMethods : localShowAllMethods const setShouldShowAllMethods = flow === 'withdraw' ? setShowAllWithdrawMethods : setLocalShowAllMethods - const [searchTerm, setSearchTerm] = useState('') const [isLoadingPreferences, setIsLoadingPreferences] = useState(true) const baseRoute = flow === 'add' ? '/add-money' : '/withdraw' @@ -129,66 +118,14 @@ export const AddWithdrawRouterView: FC = ({ } } - const allMethodsTransformed: DepositMethod[] = useMemo(() => { - let methods = ALL_METHODS_DATA.map((method) => { - let path = `${baseRoute}/${method.path}` - return { - ...method, - path: path, - type: method.type as 'crypto' | 'country', - } - }) - - return methods - }, [baseRoute, flow]) - - const filteredAllMethods = useMemo(() => { - let methodsToShow - if (!searchTerm) { - methodsToShow = [...allMethodsTransformed] - } else { - methodsToShow = allMethodsTransformed.filter( - (method) => - method.title.toLowerCase().includes(searchTerm.toLowerCase()) || - method.currency?.toLowerCase().includes(searchTerm.toLowerCase()) || - method.description?.toLowerCase().includes(searchTerm.toLowerCase()) - ) - } - - const transformedMethods = methodsToShow.map((method) => { - if (method.type === 'crypto') { - return { - ...method, - title: flow === 'add' ? 'Crypto Deposit' : 'Crypto', - description: flow === 'add' ? 'Use an exchange or your wallet' : 'Withdraw to a wallet or exchange', - } - } - return method - }) - - return transformedMethods.sort((a, b) => { - if (a.type === 'crypto' && b.type !== 'crypto') { - return -1 - } - if (b.type === 'crypto' && a.type !== 'crypto') { - return 1 - } - return a.title.toLowerCase().localeCompare(b.title.toLowerCase()) - }) - }, [searchTerm, allMethodsTransformed, flow]) - - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchTerm(e.target.value) - } - - const handleClearSearch = () => { - setSearchTerm('') - } - const defaultBackNavigation = () => router.push('/home') if (isLoadingPreferences) { - return null + return ( +
+ +
+ ) } if (flow === 'withdraw' && savedAccounts.length === 0 && !shouldShowAllMethods) { @@ -218,25 +155,16 @@ export const AddWithdrawRouterView: FC = ({ // Render saved accounts for withdraw flow if they exist and we're not in 'showAll' mode if (flow === 'withdraw' && !shouldShowAllMethods && savedAccounts.length > 0) { return ( -
- -
-
-

Saved accounts

- { - setSelectedBankAccount(account) - router.push(path) - }} - /> -
- - -
-
+ { + setSelectedBankAccount(account) + router.push(path) + }} + onSelectNewMethodClick={() => setShouldShowAllMethods(true)} + /> ) } @@ -289,104 +217,19 @@ export const AddWithdrawRouterView: FC = ({ }} /> -
-

{mainHeading}

- - - {searchTerm && filteredAllMethods.length === 0 ? ( - - ) : ( -
- -
- )} -
-
- ) -} - -// component to render saved bank accounts -const SavedAccountsList: FC<{ accounts: Account[]; onItemClick: (account: Account, path: string) => void }> = ({ - accounts, - onItemClick, -}) => { - return ( -
- {accounts.map((account, index) => { - let details: { countryCode?: string; countryName?: string; country?: string } = {} - if (typeof account.details === 'string') { - try { - details = JSON.parse(account.details) - } catch (error) { - console.error('Failed to parse account_details:', error) - } - } else if (typeof account.details === 'object' && account.details !== null) { - details = account.details as { country?: string } - } - - const threeLetterCountryCode = (details.countryCode ?? '').toUpperCase() - const twoLetterCountryCode = countryCodeMap[threeLetterCountryCode] ?? threeLetterCountryCode - - const countryCodeForFlag = twoLetterCountryCode.toLowerCase() ?? '' - - let countryInfo - if (account.type === AccountType.US) { - countryInfo = ALL_METHODS_DATA.find((c) => c.id === 'US') - } else { - countryInfo = details.countryName - ? ALL_METHODS_DATA.find((c) => c.title.toLowerCase() === details.countryName?.toLowerCase()) - : ALL_METHODS_DATA.find((c) => c.id === threeLetterCountryCode) - } - - const path = countryInfo ? `/withdraw/${countryInfo.path}/bank` : '/withdraw' - const isSingle = accounts.length === 1 - const isFirst = index === 0 - const isLast = index === accounts.length - 1 - - let position: 'first' | 'last' | 'middle' | 'single' = 'middle' - if (isSingle) position = 'single' - else if (isFirst) position = 'first' - else if (isLast) position = 'last' - - return ( - onItemClick(account, path)} - className="p-4 py-2.5" - leftIcon={ -
- {countryCodeForFlag && ( - {`${details.countryName - )} -
- -
-
- } - /> - ) - })} + { + const countryPath = `${baseRoute}/${country.path}` + router.push(countryPath) + }} + onCryptoClick={() => { + const cryptoPath = `${baseRoute}/crypto` + router.push(cryptoPath) + }} + flow={flow} + />
) } diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 5cb050530..8401d8dce 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -37,6 +37,7 @@ export type IBankAccountDetails = { interface DynamicBankAccountFormProps { country: string + countryName?: string onSuccess: (payload: AddBankAccountPayload, rawData: IBankAccountDetails) => Promise<{ error?: string }> initialData?: Partial flow?: 'claim' | 'withdraw' @@ -44,7 +45,10 @@ interface DynamicBankAccountFormProps { } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( - ({ country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { + ( + { country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps, countryName: countryNameFromProps }, + ref + ) => { const { user } = useAuth() const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) @@ -121,7 +125,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D accountType, accountNumber: accountNumber.replace(/\s/g, ''), countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: countryName as string, + countryName: (countryName ?? countryNameFromProps) as string, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -151,6 +155,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D }) if (result.error) { setSubmissionError(result.error) + setIsSubmitting(false) } } catch (error: any) { setSubmissionError(error.message) @@ -225,9 +230,23 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D }} className="space-y-4" > + {flow === 'claim' && !user?.user.userId && ( +
+ {renderInput('firstName', 'First Name', { required: 'First name is required' })} + {renderInput('lastName', 'Last Name', { required: 'Last name is required' })} +
+ )} + {flow === 'claim' && user?.user.userId && !user.user.fullName && ( +
+ {renderInput('firstName', 'First Name', { required: 'First name is required' })} + {renderInput('lastName', 'Last Name', { required: 'Last name is required' })} +
+ )} {flow === 'claim' && - renderInput('name', 'Full Name', { - required: 'Full name is required', + user?.user.userId && + !user.user.email && + renderInput('email', 'E-mail', { + required: 'Email is required', })} {flow !== 'claim' && !user?.user?.fullName && (
diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 4fa90ebc8..f63f7c9f3 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -1,6 +1,5 @@ 'use client' -import { Button } from '@/components/0_Bruddle' import GeneralRecipientInput, { GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' @@ -42,15 +41,16 @@ import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { formatUnits } from 'viem' import type { Address } from 'viem' import { IClaimScreenProps } from '../Claim.consts' +import ActionList from '@/components/Common/ActionList' +import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import useClaimLink from '../useClaimLink' -import GuestActionList from '@/components/GuestActions/MethodList' -import { useGuestFlow } from '@/context/GuestFlowContext' import ActionModal from '@/components/Global/ActionModal' import { Slider } from '@/components/Slider' -import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' import { BankFlowManager } from './views/BankFlowManager.view' import { type PeanutCrossChainRoute, getRoute } from '@/services/swap' +import { Button } from '@/components/0_Bruddle' +import Image from 'next/image' +import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' export const InitialClaimLinkView = (props: IClaimScreenProps) => { const { @@ -86,13 +86,12 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { const { claimToExternalWallet, - resetGuestFlow, - showGuestActionsList, - guestFlowStep, + flowStep: claimBankFlowStep, showVerificationModal, setShowVerificationModal, setClaimToExternalWallet, - } = useGuestFlow() + resetFlow: resetClaimBankFlow, + } = useClaimBankFlow() const { setLoadingState, isLoading } = useContext(loadingStateContext) const { selectedChainID, @@ -229,8 +228,8 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { ) useEffect(() => { - if (isPeanutWallet) resetSelectedToken() - }, [resetSelectedToken, isPeanutWallet]) + if (isPeanutWallet && !claimToExternalWallet) resetSelectedToken() + }, [resetSelectedToken, isPeanutWallet, claimToExternalWallet]) const handleIbanRecipient = async () => { try { @@ -471,10 +470,10 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { ) useEffect(() => { - if (guestFlowStep?.startsWith('bank-')) { + if (claimBankFlowStep) { resetSelectedToken() } - }, [guestFlowStep, resetSelectedToken]) + }, [claimBankFlowStep, resetSelectedToken]) useEffect(() => { let isMounted = true @@ -560,7 +559,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { ]) const getButtonText = () => { - if (isPeanutWallet) { + if (isPeanutWallet && !claimToExternalWallet) { return (
Receive on
@@ -571,6 +570,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
) } + if (selectedRoute || (isXChain && hasFetchedRoute)) { return 'Review' } @@ -595,46 +595,19 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { } } - const guestAction = () => { - if (!!user?.user.userId || claimToExternalWallet) return null - return ( -
- {!showGuestActionsList && ( - - )} - {!isPeanutClaimOnlyMode && } -
- ) - } - - if (guestFlowStep?.startsWith('bank-')) { + if (claimBankFlowStep) { return } return ( -
- {!!user?.user.userId || showGuestActionsList ? ( +
+ {!!user?.user.userId || claimBankFlowStep || claimToExternalWallet ? (
{ if (claimToExternalWallet) { setClaimToExternalWallet(false) - } else if (showGuestActionsList) { - resetGuestFlow() } else { router.push('/home') } @@ -667,11 +640,10 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { {/* Token Selector * We don't want to show this if we're claiming to peanut wallet. Else its okay */} - {!isPeanutWallet && - recipientType !== 'iban' && + {recipientType !== 'iban' && recipientType !== 'us' && !isPeanutClaimOnlyMode && - guestFlowStep !== 'bank-country-selection' && + claimBankFlowStep !== ClaimBankFlowStep.BankCountryList && !!claimToExternalWallet && ( )} @@ -681,7 +653,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { {!isPeanutClaimOnlyMode && ( <> {/* Manual Input Section - Always visible in non-peanut-only mode */} - {!isPeanutWallet && !!claimToExternalWallet && ( + {!!claimToExternalWallet && ( { )}
-
- {guestAction()} - {(!!claimToExternalWallet || !!user?.user.userId) && ( +
+ {!!(claimToExternalWallet || !!user?.user.userId) && ( )} + {!isPeanutClaimOnlyMode && !claimToExternalWallet && ( + + )}
{ description={

Only claim to an address that support the selected network and token.

-

Incorrect transfers may be lost.

+

Incorrect transfers may be lost. If you're unsure, do not proceed.

} icon="alert" iconContainerClassName="bg-yellow-400" footer={ -
+
{ if (!v) return @@ -762,6 +736,16 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { } }} /> +
} preventClose={false} diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 59f64379f..e4fd56b3e 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -133,7 +133,7 @@ export const ConfirmClaimLinkView = ({ } return ( -
+
diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index 32a128f20..0d33ea28c 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -3,7 +3,7 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { TRANSACTIONS } from '@/constants/query.consts' import { useAuth } from '@/context/authContext' -import { useGuestFlow } from '@/context/GuestFlowContext' +import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useUserStore } from '@/redux/hooks' import { ESendLinkStatus, sendLinksApi } from '@/services/sendLinks' import { formatTokenAmount, getTokenDetails, printableAddress, shortenAddressLong } from '@/utils' @@ -25,7 +25,7 @@ export const SuccessClaimLinkView = ({ const { fetchUser } = useAuth() const router = useRouter() const queryClient = useQueryClient() - const { offrampDetails, claimType, bankDetails } = useGuestFlow() + const { offrampDetails, claimType, bankDetails } = useClaimBankFlow() useEffect(() => { queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 7d98e6f3b..aed3f45e9 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -2,115 +2,158 @@ import { IClaimScreenProps } from '../../Claim.consts' import { DynamicBankAccountForm, IBankAccountDetails } from '@/components/AddWithdraw/DynamicBankAccountForm' -import { useGuestFlow } from '@/context/GuestFlowContext' -import { ClaimCountryListView } from './ClaimCountryList.view' -import { useCallback, useContext, useState } from 'react' +import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' +import { useCallback, useContext, useState, useRef, useEffect } from 'react' import { loadingStateContext } from '@/context' import { createBridgeExternalAccountForGuest } from '@/app/actions/external-accounts' -import { confirmOfframp, createOfframpForGuest } from '@/app/actions/offramp' +import { confirmOfframp, createOfframp, createOfframpForGuest } from '@/app/actions/offramp' import { Address, formatUnits } from 'viem' import { ErrorHandler, formatTokenAmount } from '@/utils' import * as Sentry from '@sentry/nextjs' import useClaimLink from '../../useClaimLink' -import { ConfirmBankClaimView } from './Confirm.bank-claim.view' import { AddBankAccountPayload } from '@/app/actions/types/users.types' +import { useAuth } from '@/context/authContext' import { TCreateOfframpRequest, TCreateOfframpResponse } from '@/services/services.types' import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import peanut from '@squirrel-labs/peanut-sdk' -import { getUserById } from '@/app/actions/users' +import { addBankAccount, getUserById, updateUserById } from '@/app/actions/users' +import SavedAccountsView from '../../../Common/SavedAccountsView' +import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' +import useSavedAccounts from '@/hooks/useSavedAccounts' +import { ConfirmBankClaimView } from './Confirm.bank-claim.view' +import { CountryListRouter } from '@/components/Common/CountryListRouter' import NavHeader from '@/components/Global/NavHeader' +import { InitiateKYCModal } from '@/components/Kyc' +import { useWebSocket } from '@/hooks/useWebSocket' +import { KYCStatus } from '@/utils/bridge-accounts.utils' +/** + * @name BankFlowManager + * @description This component manages the entire bank claim flow, acting as a state machine. + * It determines which view to show based on the user's KYC status, saved accounts, and progress. + * It handles creating off-ramps, adding bank accounts, and orchestrating the KYC process. + */ export const BankFlowManager = (props: IClaimScreenProps) => { + // props and basic setup const { onCustom, claimLinkData, setTransactionHash } = props - const { guestFlowStep, setGuestFlowStep, selectedCountry, setClaimType, setBankDetails } = useGuestFlow() + const { user, fetchUser } = useAuth() + + // state from the centralized context + const { + flowStep: claimBankFlowStep, + setFlowStep: setClaimBankFlowStep, + selectedCountry, + setClaimType, + setBankDetails, + justCompletedKyc, + setJustCompletedKyc, + showVerificationModal: isKycModalOpen, + setShowVerificationModal: setIsKycModalOpen, + } = useClaimBankFlow() + + // hooks for business logic and data fetching + const { claimType: bankClaimType } = useDetermineBankClaimType(claimLinkData.sender?.userId ?? '') + const savedAccounts = useSavedAccounts() const { isLoading, setLoadingState } = useContext(loadingStateContext) const { claimLink } = useClaimLink() - const [offrampDetails, setOfframpDetails] = useState(null) + + // local states for this component const [localBankDetails, setLocalBankDetails] = useState(null) const [receiverFullName, setReceiverFullName] = useState('') const [error, setError] = useState(null) + const formRef = useRef<{ handleSubmit: () => void }>(null) + const [liveKycStatus, setLiveKycStatus] = useState(user?.user?.kycStatus as KYCStatus) + const [isProcessingKycSuccess, setIsProcessingKycSuccess] = useState(false) + const [offrampData, setOfframpData] = useState(null) - const handleSuccess = async (payload: AddBankAccountPayload, rawData: IBankAccountDetails) => { - if (!selectedCountry) { - const err = 'Country not selected' - setError(err) - return { error: err } + // websocket for real-time KYC status updates + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: !!user?.user.username, + onKycStatusUpdate: (newStatus) => { + setLiveKycStatus(newStatus as KYCStatus) + }, + }) + + // effect to update live KYC status from user object + useEffect(() => { + if (user?.user.kycStatus) { + setLiveKycStatus(user.user.kycStatus as KYCStatus) } + }, [user?.user.kycStatus]) + + /** + * @name handleConfirmClaim + * @description claims the link to the deposit address provided by the off-ramp api and confirms the transfer. + */ + const handleConfirmClaim = useCallback( + async (details: TCreateOfframpResponse) => { + try { + const claimTx = await claimLink({ + address: details.depositInstructions.toAddress, + link: claimLinkData.link, + }) + setTransactionHash(claimTx) + await confirmOfframp(details.transferId, claimTx) + if (setClaimType) setClaimType('claim-bank') + onCustom('SUCCESS') + } catch (e: any) { + const errorString = ErrorHandler(e) + setError(errorString) + Sentry.captureException(e) + throw e + } + }, + [claimLink, claimLinkData.link, setTransactionHash, setClaimType, onCustom] + ) + /** + * @name handleCreateOfframpAndClaim + * @description creates an off-ramp transfer for the user, either as a guest or a logged-in user. + */ + const handleCreateOfframpAndClaim = async (account: IBankAccountDetails) => { try { - if (!claimLinkData.sender?.userId) return { error: 'Sender details not found' } setLoadingState('Executing transaction') setError(null) - const userResponse = await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) - - if (!userResponse || ('error' in userResponse && userResponse.error)) { - const errorMessage = - (userResponse && typeof userResponse.error === 'string' && userResponse.error) || - 'Failed to get user info' - setError(errorMessage) - return { error: errorMessage } - } - if (userResponse.kycStatus !== 'approved') { - setError('User not KYC approved') - return { error: 'User not KYC approved' } + // determine user for offramp based on the bank claim type + const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim + const userForOfframp = isGuestFlow + ? await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) + : user?.user + + // handle error if user for offramp is not found + if (!userForOfframp || ('error' in userForOfframp && userForOfframp.error)) { + throw new Error( + (userForOfframp && typeof userForOfframp.error === 'string' && userForOfframp.error) || + 'Failed to get user info' + ) } - setReceiverFullName(rawData.name ?? '') - sessionStorage.setItem('receiverFullName', rawData.name ?? '') + // handle error if user is not KYC approved + if (userForOfframp.kycStatus !== 'approved') throw new Error('User not KYC approved') + if (!userForOfframp?.bridgeCustomerId) throw new Error('User bridge customer ID not found') - const [firstName, ...lastNameParts] = rawData.name?.split(' ') ?? ['', ''] - const lastName = lastNameParts.join(' ') + setReceiverFullName(userForOfframp.fullName ?? '') + // get payment rail and currency for the offramp const paymentRail = getBridgeChainName(claimLinkData.chainId) const currency = getBridgeTokenName(claimLinkData.chainId, claimLinkData.tokenAddress) + if (!paymentRail || !currency) throw new Error('Chain or token not supported for bank withdrawal') - if (!paymentRail || !currency) { - const err = 'Chain or token not supported for bank withdrawal' - setError(err) - return { error: err } - } - - const payloadWithCountry = { - ...payload, - country: selectedCountry.id, - accountOwnerName: { - firstName: firstName, - lastName: lastName, - }, - } - - if (!userResponse?.bridgeCustomerId) { - setError('Sender details not found') - return { error: 'Sender details not found' } - } - - const externalAccountResponse = await createBridgeExternalAccountForGuest( - userResponse.bridgeCustomerId, - payloadWithCountry - ) - - if ('error' in externalAccountResponse && externalAccountResponse.error) { - setError(externalAccountResponse.error) - return { error: externalAccountResponse.error } - } - - if (!('id' in externalAccountResponse)) { - setError('Failed to create external account') - return { error: 'Failed to create external account' } - } - - // note: we pass peanut contract address to offramp as the funds go from, user -> peanut contract -> bridge + // get params from send link const params = peanut.getParamsFromLink(claimLinkData.link) const { address: pubKey } = peanut.generateKeysFromString(params.password) const chainId = params.chainId const contractVersion = params.contractVersion const peanutContractAddress = peanut.getContractAddress(chainId, contractVersion) as Address + // handle offramp request creation const offrampRequestParams: TCreateOfframpRequest = { + onBehalfOf: userForOfframp.bridgeCustomerId, amount: formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals), - userId: userResponse.userId, + userId: userForOfframp.userId, sendLinkPubKey: pubKey, source: { paymentRail: paymentRail, @@ -118,112 +161,299 @@ export const BankFlowManager = (props: IClaimScreenProps) => { fromAddress: peanutContractAddress, }, destination: { - ...getOfframpCurrencyConfig(selectedCountry.id), - externalAccountId: externalAccountResponse.id, - }, - features: { - allowAnyFromAddress: true, + ...getOfframpCurrencyConfig(account.country ?? selectedCountry!.id), + externalAccountId: (account as any).bridgeAccountId ?? (account as any).id, }, + features: { allowAnyFromAddress: true }, } - const offrampResponse = await createOfframpForGuest(offrampRequestParams) + const offrampResponse = isGuestFlow + ? await createOfframpForGuest(offrampRequestParams) + : await createOfframp(offrampRequestParams) if (offrampResponse.error || !offrampResponse.data) { - setError(offrampResponse.error || 'Failed to create offramp') - return { error: offrampResponse.error || 'Failed to create offramp' } + throw new Error(offrampResponse.error || 'Failed to create offramp') } + const offrampData = offrampResponse.data as TCreateOfframpResponse + setLocalBankDetails(account) + setBankDetails(account) + setOfframpData(offrampData) - setOfframpDetails(offrampResponse.data as TCreateOfframpResponse) - - setLocalBankDetails(rawData) - setBankDetails(rawData) - setGuestFlowStep('bank-confirm-claim') - return {} + // claim send link to deposit address received from offramp response + await handleConfirmClaim(offrampData) } catch (e: any) { const errorString = ErrorHandler(e) setError(errorString) Sentry.captureException(e) - return { error: errorString } } finally { setLoadingState('Idle') } } - const handleConfirmClaim = useCallback(async () => { - try { - setLoadingState('Executing transaction') - setError(null) + /** + * @name handleSuccess + * @description Callback for when the DynamicBankAccountForm is successfully submitted. + * It handles different logic based on the bank claim type (guest, user, kyc needed). + */ + const handleSuccess = async ( + payload: AddBankAccountPayload, + rawData: IBankAccountDetails + ): Promise<{ error?: string }> => { + // scenario 1: receiver needs KYC + if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { + // update user's name and email if they are not present + const hasNameOnLoad = !!user?.user.fullName + const hasEmailOnLoad = !!user?.user.email + if (!hasNameOnLoad || !hasEmailOnLoad) { + if (user?.user.userId && rawData.firstName && rawData.lastName && rawData.email) { + const result = await updateUserById({ + userId: user.user.userId, + fullName: `${rawData.firstName} ${rawData.lastName}`.trim(), + email: rawData.email, + }) + if (result.error) return { error: result.error } + await fetchUser() + } + } + + setIsKycModalOpen(true) + return {} + } - if (!offrampDetails) { - throw new Error('Offramp details not available') + // scenario 2: logged-in user is claiming + if ( + bankClaimType === BankClaimType.UserBankClaim || + (bankClaimType === BankClaimType.ReceiverKycNeeded && justCompletedKyc) + ) { + if (isProcessingKycSuccess) return {} + setIsProcessingKycSuccess(true) + if (justCompletedKyc) { + setJustCompletedKyc(false) + } + try { + const addBankAccountResponse = await addBankAccount(payload) + if (addBankAccountResponse.error) { + setError(addBankAccountResponse.error) + return { error: addBankAccountResponse.error } + } + if (addBankAccountResponse.data?.id) { + const bankDetails: IBankAccountDetails & { id?: string; bridgeAccountId?: string } = { + name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', + iban: + addBankAccountResponse.data.type === 'iban' + ? addBankAccountResponse.data.identifier + : undefined, + clabe: + addBankAccountResponse.data.type === 'clabe' ? addBankAccountResponse.data.identifier : '', + accountNumber: + addBankAccountResponse.data.type === 'us' ? addBankAccountResponse.data.identifier : '', + country: addBankAccountResponse.data.details.countryCode, + id: addBankAccountResponse.data.id, + bridgeAccountId: addBankAccountResponse.data.bridgeAccountId, + bic: addBankAccountResponse.data.bic ?? '', + routingNumber: addBankAccountResponse.data.routingNumber ?? '', + firstName: addBankAccountResponse.data.firstName || rawData.firstName, + lastName: addBankAccountResponse.data.lastName || rawData.lastName, + email: user?.user.email ?? '', + street: '', + city: '', + state: '', + postalCode: '', + } + setLocalBankDetails(bankDetails) + setBankDetails(bankDetails) + setReceiverFullName(`${bankDetails.firstName} ${bankDetails.lastName}`) + setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) + } + } finally { + setIsProcessingKycSuccess(false) + } + return {} + } + // scenario 3: guest user is claiming (using sender's KYC) + else if (bankClaimType === BankClaimType.GuestBankClaim) { + if (!selectedCountry) { + const err = 'Country not selected' + setError(err) + return { error: err } } - const claimTx = await claimLink({ - address: offrampDetails.depositInstructions.toAddress, - link: claimLinkData.link, - }) + try { + setLoadingState('Executing transaction') + setError(null) + const senderInfo = await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) + if (!senderInfo || ('error' in senderInfo && senderInfo.error)) { + throw new Error( + (senderInfo && typeof senderInfo.error === 'string' && senderInfo.error) || + 'Failed to get sender info' + ) + } + if (!senderInfo.bridgeCustomerId) throw new Error('Sender bridge customer ID not found') - setTransactionHash(claimTx) - await confirmOfframp(offrampDetails.transferId, claimTx) - if (setClaimType) setClaimType('claim-bank') - onCustom('SUCCESS') - } catch (e: any) { - const errorString = ErrorHandler(e) - setError(errorString) - Sentry.captureException(e) - } finally { - setLoadingState('Idle') + const payloadWithCountry = { + ...payload, + country: selectedCountry.id, + } + + const externalAccountResponse = await createBridgeExternalAccountForGuest( + senderInfo.bridgeCustomerId, + payloadWithCountry + ) + if ('error' in externalAccountResponse && externalAccountResponse.error) { + throw new Error(String(externalAccountResponse.error)) + } + if (!('id' in externalAccountResponse)) { + throw new Error('Failed to create external account') + } + + const finalBankDetails = { ...rawData, ...(externalAccountResponse as object) } + setLocalBankDetails(finalBankDetails) + setBankDetails(finalBankDetails) + setReceiverFullName(payload.accountOwnerName.firstName + ' ' + payload.accountOwnerName.lastName) + setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) + return {} + } catch (e: any) { + const errorString = ErrorHandler(e) + setError(errorString) + Sentry.captureException(e) + return { error: errorString } + } finally { + setLoadingState('Idle') + } } - }, [ - offrampDetails, - claimLink, - claimLinkData.link, - setTransactionHash, - confirmOfframp, - setClaimType, - onCustom, - setLoadingState, - setError, - ]) - - if (guestFlowStep === 'bank-confirm-claim' && offrampDetails && localBankDetails) { - return ( - { - setGuestFlowStep('bank-details-form') - setError(null) - }} - isProcessing={isLoading} - error={error} - bankDetails={localBankDetails} - fullName={receiverFullName} - /> - ) + return {} } - if (guestFlowStep === 'bank-country-list' || !selectedCountry) { - return + /** + * @name handleKycSuccess + * @description callback for when the KYC process is successfully completed. + */ + const handleKycSuccess = () => { + setIsKycModalOpen(false) + setJustCompletedKyc(true) + setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) } - return ( -
-
- setGuestFlowStep('bank-country-list')} /> -
- -
- ) + // main render logic based on the current flow step + switch (claimBankFlowStep) { + case ClaimBankFlowStep.SavedAccountsList: + return ( + setClaimBankFlowStep(null)} + savedAccounts={savedAccounts} + onAccountClick={async (account) => { + const [firstName, ...lastNameParts] = ( + account.details.accountOwnerName || + user?.user.fullName || + '' + ).split(' ') + const lastName = lastNameParts.join(' ') + + const bankDetails: IBankAccountDetails & { id?: string; bridgeAccountId?: string } = { + name: account.details.accountOwnerName || user?.user.fullName || '', + iban: account.type === 'iban' ? account.identifier : undefined, + clabe: account.type === 'clabe' ? account.identifier : '', + accountNumber: account.type === 'us' ? account.identifier : '', + country: account.details.countryCode, + id: account.id, + bridgeAccountId: account.bridgeAccountId, + bic: account.bic ?? '', + routingNumber: account.routingNumber ?? '', + firstName: firstName, + lastName: lastName, + email: user?.user.email ?? '', + street: '', + city: '', + state: '', + postalCode: '', + } + + setLocalBankDetails(bankDetails) + setBankDetails(bankDetails) + + const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim + const userForOfframp = isGuestFlow + ? await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) + : user?.user + if (userForOfframp && !('error' in userForOfframp)) { + setReceiverFullName(userForOfframp.fullName ?? '') + } + + setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) + }} + onSelectNewMethodClick={() => { + setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList) + }} + /> + ) + case ClaimBankFlowStep.BankCountryList: + return ( + + ) + case ClaimBankFlowStep.BankDetailsForm: + return ( +
+
+ + savedAccounts.length > 0 + ? setClaimBankFlowStep(ClaimBankFlowStep.SavedAccountsList) + : setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList) + } + /> +
+ + setIsKycModalOpen(false)} + onKycSuccess={handleKycSuccess} + /> +
+ ) + case ClaimBankFlowStep.BankConfirmClaim: + if (localBankDetails) { + return ( + handleCreateOfframpAndClaim(localBankDetails)} + onBack={() => { + setClaimBankFlowStep( + savedAccounts.length > 0 + ? ClaimBankFlowStep.SavedAccountsList + : ClaimBankFlowStep.BankDetailsForm + ) + setError(null) + }} + isProcessing={isLoading} + error={error} + bankDetails={localBankDetails} + fullName={receiverFullName} + /> + ) + } + return null + default: + return null + } } diff --git a/src/components/Claim/Link/views/ClaimCountryList.view.tsx b/src/components/Claim/Link/views/ClaimCountryList.view.tsx deleted file mode 100644 index 493a02a0b..000000000 --- a/src/components/Claim/Link/views/ClaimCountryList.view.tsx +++ /dev/null @@ -1,103 +0,0 @@ -'use client' -import { countryCodeMap, countryData } from '@/components/AddMoney/consts' -import EmptyState from '@/components/Global/EmptyStates/EmptyState' -import NavHeader from '@/components/Global/NavHeader' -import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' -import { SearchInput } from '@/components/SearchUsers/SearchInput' -import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' -import { useGuestFlow } from '@/context/GuestFlowContext' -import Image from 'next/image' -import { useMemo, useState } from 'react' -import { IClaimScreenProps } from '../../Claim.consts' -import { formatUnits } from 'viem' -import { formatTokenAmount, printableAddress } from '@/utils/general.utils' - -export const ClaimCountryListView = ({ claimLinkData }: Pick) => { - const { setGuestFlowStep, setSelectedCountry } = useGuestFlow() - const [searchTerm, setSearchTerm] = useState('') - - const supportedCountries = useMemo(() => { - const sepaCountries = Object.keys(countryCodeMap) - const supported = new Set([...sepaCountries, 'US', 'MX']) - - return countryData.filter((country) => supported.has(country.id)) - }, []) - - const filteredCountries = useMemo(() => { - if (!searchTerm) return supportedCountries - - return supportedCountries.filter( - (country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) || - country.currency?.toLowerCase().includes(searchTerm.toLowerCase()) - ) - }, [searchTerm, supportedCountries]) - - const handleCountrySelect = (country: (typeof countryData)[0]) => { - setSelectedCountry(country) - setGuestFlowStep('bank-details-form') - } - - return ( -
- setGuestFlowStep(null)} /> -
- - -
-
Which country do you want to receive to?
- setSearchTerm(e.target.value)} - onClear={() => setSearchTerm('')} - placeholder="Search country or currency" - /> -
- {searchTerm && filteredCountries.length === 0 ? ( - - ) : ( -
- {filteredCountries.map((country, index) => { - const twoLetterCountryCode = - countryCodeMap[country.id.toUpperCase()] ?? country.id.toLowerCase() - return ( - handleCountrySelect(country)} - position={'single'} - leftIcon={ -
- {`${country.title} { - e.currentTarget.style.display = 'none' - }} - /> -
- } - /> - ) - })} -
- )} -
-
- ) -} diff --git a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx index fe85f4a2c..703ed302f 100644 --- a/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx +++ b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx @@ -49,7 +49,7 @@ export function ConfirmBankClaimView({ }, [bankDetails]) const countryCodeForFlag = useMemo(() => { - return countryCodeMap[bankDetails.country.toUpperCase()] ?? bankDetails.country.toUpperCase() + return countryCodeMap[bankDetails?.country?.toUpperCase()] ?? bankDetails.country.toUpperCase() }, [bankDetails.country]) return ( @@ -71,8 +71,8 @@ export function ConfirmBankClaimView({ {/* todo: take full name from user, this name rn is of senders */} - {bankDetails.iban && } - {bankDetails.bic && } + {bankDetails.iban && } + {bankDetails.bic && } diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx new file mode 100644 index 000000000..d20b7310f --- /dev/null +++ b/src/components/Common/ActionList.tsx @@ -0,0 +1,165 @@ +'use client' + +import { SearchResultCard } from '../SearchUsers/SearchResultCard' +import StatusBadge from '../Global/Badges/StatusBadge' +import IconStack from '../Global/IconStack' +import mercadoPagoIcon from '@/assets/payment-apps/mercado-pago.svg' +import binanceIcon from '@/assets/exchanges/binance.svg' +import { METAMASK_LOGO, TRUST_WALLET_SMALL_LOGO } from '@/assets/wallets' +import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' +import { ClaimLinkData } from '@/services/sendLinks' +import { formatUnits } from 'viem' +import { useState } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import Divider from '../0_Bruddle/Divider' +import { Button } from '../0_Bruddle' +import { PEANUT_LOGO_BLACK } from '@/assets/illustrations' +import Image from 'next/image' +import { saveRedirectUrl } from '@/utils' +import { useRouter } from 'next/navigation' +import { PEANUTMAN_LOGO } from '@/assets/peanut' +import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' +import useSavedAccounts from '@/hooks/useSavedAccounts' + +interface Method { + id: string + title: string + description: string + icons: any[] + soon: boolean +} + +const ACTION_METHODS: Method[] = [ + { + id: 'bank', + title: 'Bank', + description: 'EUR, USD, ARS (more coming soon)', + icons: [ + 'https://flagcdn.com/w160/ar.png', + 'https://flagcdn.com/w160/de.png', + 'https://flagcdn.com/w160/us.png', + ], + soon: false, + }, + { + id: 'mercadopago', + title: 'Mercado Pago', + description: 'Instant transfers', + icons: [mercadoPagoIcon], + soon: true, + }, + { + id: 'exchange-or-wallet', + title: 'Exchange or Wallet', + description: 'Binance, Coinbase, Metamask and more', + icons: [binanceIcon, TRUST_WALLET_SMALL_LOGO, METAMASK_LOGO], + soon: false, + }, +] + +interface IActionListProps { + claimLinkData: ClaimLinkData + isLoggedIn: boolean +} + +/** + * Shows a list of available payment methods to choose from for claiming a send link or fullfilling a request link + * + * @param {object} props + * @param {ClaimLinkData} props.claimLinkData The claim link data + * @param {boolean} props.isLoggedIn Whether the user is logged in, used to show cta for continue with peanut if not logged in + * @returns {JSX.Element} + */ +export default function ActionList({ claimLinkData, isLoggedIn }: IActionListProps) { + const router = useRouter() + const { setClaimToExternalWallet, setFlowStep: setClaimBankFlowStep, setShowVerificationModal } = useClaimBankFlow() + const [showMinAmountError, setShowMinAmountError] = useState(false) + const { claimType } = useDetermineBankClaimType(claimLinkData.sender?.userId ?? '') + const savedAccounts = useSavedAccounts() + + const handleMethodClick = async (method: Method) => { + const amountInUsd = parseFloat(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) + if (method.id === 'bank' && amountInUsd < 1) { + setShowMinAmountError(true) + return + } + switch (method.id) { + case 'bank': + { + if (claimType === BankClaimType.GuestKycNeeded) { + setShowVerificationModal(true) + } else { + if (savedAccounts.length) { + setClaimBankFlowStep(ClaimBankFlowStep.SavedAccountsList) + } else { + setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList) + } + } + } + break + case 'mercadopago': + break // soon tag, so no action needed + case 'crypto': + case 'exchange-or-wallet': + setClaimToExternalWallet(true) + break + } + } + + return ( +
+ {!isLoggedIn && ( + + )} + +
+ {ACTION_METHODS.map((method) => ( + handleMethodClick(method)} key={method.id} method={method} /> + ))} +
+ setShowMinAmountError(false)} + title="Minimum Amount " + description="The minimum amount to claim a link to a bank account is $1. Please try claiming with a different method." + icon="alert" + ctas={[{ text: 'Close', shadowSize: '4', onClick: () => setShowMinAmountError(false) }]} + iconContainerClassName="bg-yellow-400" + preventClose={false} + modalPanelClassName="max-w-md mx-8" + /> +
+ ) +} + +const MethodCard = ({ method, onClick }: { method: Method; onClick: () => void }) => { + return ( + + {method.title} + {method.soon && } +
+ } + onClick={onClick} + isDisabled={method.soon} + rightContent={} + /> + ) +} diff --git a/src/components/Common/CountryList.tsx b/src/components/Common/CountryList.tsx new file mode 100644 index 000000000..e958a055a --- /dev/null +++ b/src/components/Common/CountryList.tsx @@ -0,0 +1,150 @@ +'use client' +import { countryCodeMap, CountryData, countryData } from '@/components/AddMoney/consts' +import EmptyState from '@/components/Global/EmptyStates/EmptyState' +import { SearchInput } from '@/components/SearchUsers/SearchInput' +import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' +import Image from 'next/image' +import { useMemo, useState } from 'react' +import { getCardPosition } from '../Global/Card' +import { useGeoLocaion } from '@/hooks/useGeoLocaion' +import { CountryListSkeleton } from './CountryListSkeleton' +import AvatarWithBadge from '../Profile/AvatarWithBadge' + +interface CountryListViewProps { + inputTitle: string + viewMode: 'claim-request' | 'add-withdraw' + onCountryClick: (country: CountryData) => void + onCryptoClick?: (flow: 'add' | 'withdraw') => void + flow?: 'add' | 'withdraw' +} + +/** + * Displays list of countries with search functionality! + * + * @param {object} props + * @param {string} props.inputTitle The title for the input + * @param {string} props.viewMode The view mode of the list, either 'claim-request' or 'add-withdraw' + * @param {function} props.onCountryClick The function to call when a country is clicked + * @param {function} props.onCryptoClick The function to call when the crypto button is clicked + * @param {string} props.flow The flow of the list, either 'add' or 'withdraw', only required for 'add-withdraw' view mode + * @returns {JSX.Element} + */ +export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClick, flow }: CountryListViewProps) => { + const [searchTerm, setSearchTerm] = useState('') + const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocaion() + + const supportedCountries = useMemo(() => { + // if it's a claim or request flow, we only show SEPA countries and US/MX + if (viewMode === 'claim-request') { + const sepaCountries = Object.keys(countryCodeMap) + const supported = new Set([...sepaCountries, 'US', 'MX']) + + return countryData.filter((country) => country.type === 'country' && supported.has(country.id)) + } + // if it's an add or withdraw flow, we show all countries + return countryData.filter((country) => country.type === 'country') + }, [viewMode]) + + // sort countries based on user's geo location, fallback to alphabetical order + const sortedCountries = useMemo(() => { + if (isGeoLoading) return [] + + return [...supportedCountries].sort((a, b) => { + if (userGeoLocationCountryCode) { + const aIsUserCountry = + countryCodeMap[a.id] === userGeoLocationCountryCode || a.id === userGeoLocationCountryCode + const bIsUserCountry = + countryCodeMap[b.id] === userGeoLocationCountryCode || b.id === userGeoLocationCountryCode + + if (aIsUserCountry && !bIsUserCountry) return -1 + if (!aIsUserCountry && bIsUserCountry) return 1 + } + return a.title.localeCompare(b.title) + }) + }, [supportedCountries, userGeoLocationCountryCode, isGeoLoading]) + + // filter countries based on search term + const filteredCountries = useMemo(() => { + if (!searchTerm) return sortedCountries + + return sortedCountries.filter( + (country) => + country.title.toLowerCase().includes(searchTerm.toLowerCase()) || + country.currency?.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [searchTerm, sortedCountries]) + + return ( +
+
+
{inputTitle}
+ setSearchTerm(e.target.value)} + onClear={() => setSearchTerm('')} + placeholder="Search country" + /> +
+ {isGeoLoading ? ( + + ) : ( +
+ {!searchTerm && viewMode === 'add-withdraw' && onCryptoClick && ( +
+ onCryptoClick(flow!)} + position={'single'} + leftIcon={ + + } + /> +
+ )} + {filteredCountries.length > 0 ? ( + filteredCountries.map((country, index) => { + const twoLetterCountryCode = + countryCodeMap[country.id.toUpperCase()] ?? country.id.toLowerCase() + const position = getCardPosition(index, filteredCountries.length) + return ( + onCountryClick(country)} + position={position} + leftIcon={ +
+ {`${country.title} { + e.currentTarget.style.display = 'none' + }} + /> +
+ } + /> + ) + }) + ) : ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/Common/CountryListRouter.tsx b/src/components/Common/CountryListRouter.tsx new file mode 100644 index 000000000..42a434404 --- /dev/null +++ b/src/components/Common/CountryListRouter.tsx @@ -0,0 +1,60 @@ +'use client' +import NavHeader from '@/components/Global/NavHeader' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' +import { formatUnits } from 'viem' +import { formatTokenAmount, printableAddress } from '@/utils/general.utils' +import { CountryList } from '@/components/Common/CountryList' +import { ClaimLinkData } from '@/services/sendLinks' +import { CountryData } from '@/components/AddMoney/consts' +import useSavedAccounts from '@/hooks/useSavedAccounts' + +interface ICountryListRouterViewProps { + claimLinkData: ClaimLinkData + inputTitle: string +} + +/** + * Used to display countries list for claim link and request flow with @PeanutActionDetailsCard component as header + * + * @param {object} props + * @param {ClaimLinkData} props.claimLinkData The claim link data + * @param {string} props.inputTitle The input title to be passed to @CountryList component + * @returns {JSX.Element} + */ +export const CountryListRouter = ({ claimLinkData, inputTitle }: ICountryListRouterViewProps) => { + const { setFlowStep: setClaimBankFlowStep, setSelectedCountry } = useClaimBankFlow() + const savedAccounts = useSavedAccounts() + + const handleCountryClick = (country: CountryData) => { + setSelectedCountry(country) + setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) + } + + return ( +
+ { + if (savedAccounts.length > 0) { + setClaimBankFlowStep(ClaimBankFlowStep.SavedAccountsList) + } else { + setClaimBankFlowStep(null) + } + }} + /> +
+ + + +
+
+ ) +} diff --git a/src/components/Common/CountryListSkeleton.tsx b/src/components/Common/CountryListSkeleton.tsx new file mode 100644 index 000000000..6d2e70420 --- /dev/null +++ b/src/components/Common/CountryListSkeleton.tsx @@ -0,0 +1,25 @@ +'use client' +import { getCardPosition } from '../Global/Card' +import { SearchResultCard } from '../SearchUsers/SearchResultCard' + +/** + * Displays a country list skeleton! + */ +export const CountryListSkeleton = () => { + return ( +
+ {Array.from({ length: 10 }).map((_, index) => { + const position = getCardPosition(index, 5) + return ( + } + position={position} + onClick={() => {}} + leftIcon={
} + /> + ) + })} +
+ ) +} diff --git a/src/components/Common/SavedAccountsView.tsx b/src/components/Common/SavedAccountsView.tsx new file mode 100644 index 000000000..fca18a728 --- /dev/null +++ b/src/components/Common/SavedAccountsView.tsx @@ -0,0 +1,128 @@ +'use client' +import { countryData as ALL_METHODS_DATA, countryCodeMap } from '@/components/AddMoney/consts' +import { shortenAddressLong, formatIban } from '@/utils/general.utils' +import { AccountType, Account } from '@/interfaces' +import Image from 'next/image' +import { Icon } from '@/components/Global/Icons/Icon' +import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' + +import NavHeader from '../Global/NavHeader' +import Divider from '../0_Bruddle/Divider' +import { Button } from '../0_Bruddle' + +interface SavedAccountListProps { + pageTitle: string + onPrev: () => void + savedAccounts: Account[] + onAccountClick: (account: Account, path: string) => void + onSelectNewMethodClick: () => void +} + +/** + * Component to render saved bank accounts + * + * @param {object} props + * @param {string} props.pageTitle The title of the page + * @param {function} props.onPrev The function to call when the previous button is clicked + * @param {Account[]} props.savedAccounts The accounts to render + * @param {function} props.onAccountClick The function to call when an account is clicked + * @param {function} props.onSelectNewMethodClick The function to call when the select new method button is clicked + */ +export default function SavedAccountsView({ + pageTitle, + onPrev, + savedAccounts, + onAccountClick, + onSelectNewMethodClick, +}: SavedAccountListProps) { + return ( +
+ +
+
+

Saved accounts

+ +
+ + +
+
+ ) +} + +export function SavedAccountsMapping({ + accounts, + onItemClick, +}: { + accounts: Account[] + onItemClick: (account: Account, path: string) => void +}) { + return ( +
+ {accounts.map((account, index) => { + let details: { countryCode?: string; countryName?: string; country?: string } = {} + if (typeof account.details === 'string') { + try { + details = JSON.parse(account.details) + } catch (error) { + console.error('Failed to parse account_details:', error) + } + } else if (typeof account.details === 'object' && account.details !== null) { + details = account.details as { country?: string } + } + + const threeLetterCountryCode = (details.countryCode ?? '').toUpperCase() + const twoLetterCountryCode = countryCodeMap[threeLetterCountryCode] ?? threeLetterCountryCode + + const countryCodeForFlag = twoLetterCountryCode.toLowerCase() ?? '' + + let countryInfo + if (account.type === AccountType.US) { + countryInfo = ALL_METHODS_DATA.find((c) => c.id === 'US') + } else { + countryInfo = details.countryName + ? ALL_METHODS_DATA.find((c) => c.title.toLowerCase() === details.countryName?.toLowerCase()) + : ALL_METHODS_DATA.find((c) => c.id === threeLetterCountryCode) + } + + const path = countryInfo ? `/withdraw/${countryInfo.path}/bank` : '/withdraw' + const isSingle = accounts.length === 1 + const isFirst = index === 0 + const isLast = index === accounts.length - 1 + + let position: 'first' | 'last' | 'middle' | 'single' = 'middle' + if (isSingle) position = 'single' + else if (isFirst) position = 'first' + else if (isLast) position = 'last' + + return ( + onItemClick(account, path)} + className="p-4 py-2.5" + leftIcon={ +
+ {countryCodeForFlag && ( + {`${details.countryName + )} +
+ +
+
+ } + /> + ) + })} +
+ ) +} diff --git a/src/components/GuestActions/MethodList.tsx b/src/components/GuestActions/MethodList.tsx deleted file mode 100644 index 11bc60d74..000000000 --- a/src/components/GuestActions/MethodList.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { SearchResultCard } from '../SearchUsers/SearchResultCard' -import StatusBadge from '../Global/Badges/StatusBadge' -import IconStack from '../Global/IconStack' -import mercadoPagoIcon from '@/assets/payment-apps/mercado-pago.svg' -import lemonIcon from '@/assets/exchanges/lemon.svg' -import binanceIcon from '@/assets/exchanges/binance.svg' -import ripioIcon from '@/assets/exchanges/ripio.svg' -import { RAINBOW_LOGO, METAMASK_LOGO, TRUST_WALLET_SMALL_LOGO } from '@/assets/wallets' -import { Button } from '../0_Bruddle' -import { useGuestFlow } from '@/context/GuestFlowContext' -import { getUserById } from '@/app/actions/users' -import { ClaimLinkData } from '@/services/sendLinks' -import { formatUnits } from 'viem' -import { useState } from 'react' -import ActionModal from '@/components/Global/ActionModal' - -interface Method { - id: string - title: string - description: string - icons: any[] - soon: boolean -} - -const GUEST_ACTION_METHODS: Method[] = [ - { - id: 'bank', - title: 'Bank', - description: 'EUR, USD, ARS (more coming soon)', - icons: [ - 'https://flagcdn.com/w160/ar.png', - 'https://flagcdn.com/w160/de.png', - 'https://flagcdn.com/w160/us.png', - ], - soon: false, - }, - { - id: 'mercadopago', - title: 'Mercado Pago', - description: 'Instant transfers', - icons: [mercadoPagoIcon], - soon: true, - }, - { - id: 'exchange', - title: 'Exchange', - description: 'Lemon, Binance, Ripio and more', - icons: [lemonIcon, binanceIcon, ripioIcon], - soon: false, - }, - { - id: 'crypto', - title: 'Crypto Wallet', - description: 'Metamask, Trustwallet and more', - icons: [RAINBOW_LOGO, TRUST_WALLET_SMALL_LOGO, METAMASK_LOGO], - soon: false, - }, -] - -export default function GuestActionList({ claimLinkData }: { claimLinkData: ClaimLinkData }) { - const { - showGuestActionsList, - setShowGuestActionsList, - setClaimToExternalWallet, - setGuestFlowStep, - setSenderDetails, - setShowVerificationModal, - } = useGuestFlow() - const [showMinAmountError, setShowMinAmountError] = useState(false) - - const handleMethodClick = async (method: Method) => { - const amountInUsd = parseFloat(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) - if (method.id === 'bank' && amountInUsd < 1) { - setShowMinAmountError(true) - return - } - switch (method.id) { - case 'bank': - { - if (!claimLinkData.sender?.userId) { - setShowVerificationModal(true) - return - } - const senderDetails = await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) - if (senderDetails && senderDetails.kycStatus === 'approved') { - setSenderDetails(senderDetails) - setGuestFlowStep('bank-country-list') - } else { - setShowVerificationModal(true) - } - } - break - case 'mercadopago': - break // soon tag, so no action needed - case 'crypto': - case 'exchange': - setClaimToExternalWallet(true) - break - } - } - - if (showGuestActionsList) { - return ( -
-

Where would you like to receive this?

-
- {GUEST_ACTION_METHODS.map((method) => ( - handleMethodClick(method)} key={method.id} method={method} /> - ))} -
- setShowMinAmountError(false)} - title="Minimum Amount " - description="The minimum amount to claim a link to a bank account is $1. Please try claiming with a different method." - icon="alert" - ctas={[{ text: 'Close', shadowSize: '4', onClick: () => setShowMinAmountError(false) }]} - iconContainerClassName="bg-yellow-400" - preventClose={false} - modalPanelClassName="max-w-md mx-8" - /> -
- ) - } - - return ( - - ) -} - -const MethodCard = ({ method, onClick }: { method: Method; onClick: () => void }) => { - return ( - - {method.title} - {method.soon && } -
- } - onClick={onClick} - isDisabled={method.soon} - rightContent={} - /> - ) -} diff --git a/src/context/GuestFlowContext.tsx b/src/context/ClaimBankFlowContext.tsx similarity index 54% rename from src/context/GuestFlowContext.tsx rename to src/context/ClaimBankFlowContext.tsx index 3a5394e83..bf44c6924 100644 --- a/src/context/GuestFlowContext.tsx +++ b/src/context/ClaimBankFlowContext.tsx @@ -3,19 +3,25 @@ import React, { createContext, ReactNode, useContext, useMemo, useState, useCallback } from 'react' import { CountryData } from '../components/AddMoney/consts' import { TCreateOfframpResponse } from '@/services/services.types' -import { User } from '@/interfaces' +import { Account, User } from '@/interfaces' import { IBankAccountDetails } from '@/components/AddWithdraw/DynamicBankAccountForm' +import { KYCStatus } from '@/utils/bridge-accounts.utils' -interface GuestFlowContextType { - showGuestActionsList: boolean - setShowGuestActionsList: (showGuestActionsList: boolean) => void +export enum ClaimBankFlowStep { + SavedAccountsList = 'saved-accounts-list', + BankDetailsForm = 'bank-details-form', + BankConfirmClaim = 'bank-confirm-claim', + BankCountryList = 'bank-country-list', +} + +interface ClaimBankFlowContextType { claimToExternalWallet: boolean setClaimToExternalWallet: (claimToExternalWallet: boolean) => void - guestFlowStep: string | null - setGuestFlowStep: (step: string | null) => void + flowStep: ClaimBankFlowStep | null + setFlowStep: (step: ClaimBankFlowStep | null) => void selectedCountry: CountryData | null setSelectedCountry: (country: CountryData | null) => void - resetGuestFlow: () => void + resetFlow: () => void offrampDetails?: TCreateOfframpResponse | null setOfframpDetails: (details: TCreateOfframpResponse | null) => void claimError?: string | null @@ -28,14 +34,21 @@ interface GuestFlowContextType { setShowVerificationModal: (show: boolean) => void bankDetails: IBankAccountDetails | null setBankDetails: (details: IBankAccountDetails | null) => void + savedAccounts: Account[] + setSavedAccounts: (accounts: Account[]) => void + selectedBankAccount: Account | null + setSelectedBankAccount: (account: Account | null) => void + senderKycStatus?: KYCStatus + setSenderKycStatus: (status?: KYCStatus) => void + justCompletedKyc: boolean + setJustCompletedKyc: (status: boolean) => void } -const GuestFlowContext = createContext(undefined) +const ClaimBankFlowContext = createContext(undefined) -export const GuestFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [showGuestActionsList, setShowGuestActionsList] = useState(false) - const [claimToExternalWallet, setClaimToExternalWallet] = useState(false) // this is a combined state for exchange and crypto wallets - const [guestFlowStep, setGuestFlowStep] = useState(null) +export const ClaimBankFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [claimToExternalWallet, setClaimToExternalWallet] = useState(false) + const [flowStep, setFlowStep] = useState(null) const [selectedCountry, setSelectedCountry] = useState(null) const [offrampDetails, setOfframpDetails] = useState(null) const [claimError, setClaimError] = useState(null) @@ -43,11 +56,14 @@ export const GuestFlowContextProvider: React.FC<{ children: ReactNode }> = ({ ch const [senderDetails, setSenderDetails] = useState(null) const [showVerificationModal, setShowVerificationModal] = useState(false) const [bankDetails, setBankDetails] = useState(null) + const [savedAccounts, setSavedAccounts] = useState([]) + const [selectedBankAccount, setSelectedBankAccount] = useState(null) + const [senderKycStatus, setSenderKycStatus] = useState() + const [justCompletedKyc, setJustCompletedKyc] = useState(false) - const resetGuestFlow = useCallback(() => { + const resetFlow = useCallback(() => { setClaimToExternalWallet(false) - setShowGuestActionsList(false) - setGuestFlowStep(null) + setFlowStep(null) setSelectedCountry(null) setOfframpDetails(null) setClaimError(null) @@ -55,19 +71,21 @@ export const GuestFlowContextProvider: React.FC<{ children: ReactNode }> = ({ ch setSenderDetails(null) setShowVerificationModal(false) setBankDetails(null) + setSavedAccounts([]) + setSelectedBankAccount(null) + setSenderKycStatus(undefined) + setJustCompletedKyc(false) }, []) const value = useMemo( () => ({ - showGuestActionsList, - setShowGuestActionsList, claimToExternalWallet, setClaimToExternalWallet, - guestFlowStep, - setGuestFlowStep, + flowStep, + setFlowStep, selectedCountry, setSelectedCountry, - resetGuestFlow, + resetFlow, offrampDetails, setOfframpDetails, claimError, @@ -80,30 +98,44 @@ export const GuestFlowContextProvider: React.FC<{ children: ReactNode }> = ({ ch setShowVerificationModal, bankDetails, setBankDetails, + savedAccounts, + setSavedAccounts, + selectedBankAccount, + setSelectedBankAccount, + senderKycStatus, + setSenderKycStatus, + justCompletedKyc, + setJustCompletedKyc, }), [ - showGuestActionsList, claimToExternalWallet, - guestFlowStep, + flowStep, selectedCountry, - resetGuestFlow, + resetFlow, offrampDetails, claimError, claimType, - setClaimType, senderDetails, showVerificationModal, bankDetails, + savedAccounts, + selectedBankAccount, + senderKycStatus, + justCompletedKyc, ] ) - return {children} + return ( + + {children} + + ) } -export const useGuestFlow = (): GuestFlowContextType => { - const context = useContext(GuestFlowContext) +export const useClaimBankFlow = (): ClaimBankFlowContextType => { + const context = useContext(ClaimBankFlowContext) if (context === undefined) { - throw new Error('useGuestFlow must be used within a GuestFlowContextProvider') + throw new Error('useClaimBankFlow must be used within a ClaimBankFlowContextProvider') } return context } diff --git a/src/context/contextProvider.tsx b/src/context/contextProvider.tsx index 37efb18ec..ea84d2fab 100644 --- a/src/context/contextProvider.tsx +++ b/src/context/contextProvider.tsx @@ -6,7 +6,7 @@ import { LoadingStateContextProvider } from './loadingStates.context' import { PushProvider } from './pushProvider' import { TokenContextProvider } from './tokenSelector.context' import { WithdrawFlowContextProvider } from './WithdrawFlowContext' -import { GuestFlowContextProvider } from './GuestFlowContext' +import { ClaimBankFlowContextProvider } from './ClaimBankFlowContext' export const ContextProvider = ({ children }: { children: React.ReactNode }) => { return ( @@ -16,11 +16,11 @@ export const ContextProvider = ({ children }: { children: React.ReactNode }) => - + {children} - + diff --git a/src/hooks/useDetermineBankClaimType.ts b/src/hooks/useDetermineBankClaimType.ts new file mode 100644 index 000000000..6f05f8f59 --- /dev/null +++ b/src/hooks/useDetermineBankClaimType.ts @@ -0,0 +1,76 @@ +import { getUserById } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' +import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' +import { useEffect, useState } from 'react' + +export enum BankClaimType { + GuestBankClaim = 'guest-bank-claim', + UserBankClaim = 'user-bank-claim', + ReceiverKycNeeded = 'receiver-kyc-needed', + GuestKycNeeded = 'guest-kyc-needed', +} + +/** + * Used to determine the bank claim type based on the sender and receiver kyc status + * @param {string} senderUserId The user id of the sender + * @returns {object} An object containing the bank claim type and a function to set the claim type + */ +export function useDetermineBankClaimType(senderUserId: string): { + claimType: BankClaimType + setClaimType: (claimType: BankClaimType) => void +} { + const { user } = useAuth() + const [claimType, setClaimType] = useState(BankClaimType.ReceiverKycNeeded) + const { setSenderDetails } = useClaimBankFlow() + + useEffect(() => { + const determineBankClaimType = async () => { + // check if receiver (logged in user) exists and is KYC approved + const receiverKycApproved = user?.user?.kycStatus === 'approved' + + if (receiverKycApproved) { + // condition 1: Receiver is KYC approved → UserBankClaim + setClaimType(BankClaimType.UserBankClaim) + return + } + + // condition 2: Receiver is not KYC approved, check sender status + if (!senderUserId) { + if (user?.user.userId) { + setClaimType(BankClaimType.ReceiverKycNeeded) + } else { + setClaimType(BankClaimType.GuestKycNeeded) + } + return + } + + try { + const senderDetails = await getUserById(senderUserId) + const senderKycApproved = senderDetails?.kycStatus === 'approved' + + if (senderKycApproved) { + // condition 3: Receiver not KYC approved BUT sender is → GuestBankClaim + setSenderDetails(senderDetails) + setClaimType(BankClaimType.GuestBankClaim) + } else { + // condition 4: Neither receiver nor sender are KYC approved → KycNeeded + if (user?.user.userId) { + setClaimType(BankClaimType.ReceiverKycNeeded) + } else { + setClaimType(BankClaimType.GuestKycNeeded) + } + } + } catch (error) { + if (user?.user.userId) { + setClaimType(BankClaimType.ReceiverKycNeeded) + } else { + setClaimType(BankClaimType.GuestKycNeeded) + } + } + } + + determineBankClaimType() + }, [user, senderUserId, setSenderDetails]) + + return { claimType, setClaimType } +} diff --git a/src/hooks/useGeoLocaion.ts b/src/hooks/useGeoLocaion.ts new file mode 100644 index 000000000..816f4032a --- /dev/null +++ b/src/hooks/useGeoLocaion.ts @@ -0,0 +1,34 @@ +'use client' +import { useEffect, useState } from 'react' + +/** + * Used to get the user's country code from ipapi.co + * @returns {object} An object containing the country code, whether the request is loading, and any error that occurred + */ +export const useGeoLocaion = () => { + const [countryCode, setCountryCode] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + // use ipapi.co to get the user's country code + const fetchCountry = async () => { + try { + const response = await fetch('https://ipapi.co/country') + if (!response.ok) { + throw new Error('Failed to fetch country') + } + const countryCode = await response.text() + setCountryCode(countryCode) + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchCountry() + }, []) + + return { countryCode, isLoading, error } +} diff --git a/src/hooks/useKycFlow.ts b/src/hooks/useKycFlow.ts index 885a0b767..4114c7d5c 100644 --- a/src/hooks/useKycFlow.ts +++ b/src/hooks/useKycFlow.ts @@ -126,26 +126,37 @@ export const useKycFlow = ({ onKycSuccess, flow }: UseKycFlowOptions = {}) => { (source: 'completed' | 'manual' | 'tos_accepted' = 'manual') => { const wasShowingTos = iframeOptions.src === apiResponse?.tosLink - // if we just closed the tos link after it was accepted, open the kyc link next. - if (wasShowingTos && source === 'tos_accepted' && apiResponse?.kycLink) { - const kycUrl = convertPersonaUrl(apiResponse.kycLink) - - setIframeOptions({ - src: kycUrl, - visible: true, - closeConfirmMessage: 'Are you sure? Your KYC progress will be lost.', - }) - } else if (source === 'completed') { - // if we just closed the kyc link after completion, open the "in progress" modal. + // handle tos acceptance: only act if the tos iframe is currently shown. + if (source === 'tos_accepted') { + if (wasShowingTos && apiResponse?.kycLink) { + const kycUrl = convertPersonaUrl(apiResponse.kycLink) + setIframeOptions({ + src: kycUrl, + visible: true, + closeConfirmMessage: 'Are you sure? Your KYC progress will be lost.', + }) + } + // ignore late ToS events when KYC is already open + return + } + + // When KYC signals completion, close iframe and show progress modal + if (source === 'completed') { setIframeOptions((prev) => ({ ...prev, visible: false })) setIsVerificationProgressModalOpen(true) - } else if (source === 'manual' && flow === 'add') { - router.push('/add-money') + return } - // if user is in withdraw flow, and they close on the ToS acceptance page - else { + + // manual abort: close modal; optionally redirect in add flow + if (source === 'manual') { setIframeOptions((prev) => ({ ...prev, visible: false })) + if (flow === 'add') { + router.push('/add-money') + } + return } + + // for any other sources, do nothing }, [iframeOptions.src, apiResponse, flow, router] ) diff --git a/src/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx new file mode 100644 index 000000000..04907c66a --- /dev/null +++ b/src/hooks/useSavedAccounts.tsx @@ -0,0 +1,23 @@ +import { useAuth } from '@/context/authContext' +import { AccountType } from '@/interfaces' +import { useMemo } from 'react' + +/** + * Used to get the user's saved accounts, for now limited to bank accounts with (IBAN, US, and CLABE) + * NOTE: This hook can be extended to support more account types in the future based on requirements + * @returns {array} An array of the user's saved bank accounts + */ +export default function useSavedAccounts() { + const { user } = useAuth() + + // filter out accounts that are not IBAN, US, or CLABE + const savedAccounts = useMemo(() => { + return ( + user?.accounts.filter( + (acc) => acc.type === AccountType.IBAN || acc.type === AccountType.US || acc.type === AccountType.CLABE + ) ?? [] + ) + }, [user]) + + return savedAccounts +} From 35a7a151f85b03e6f580543471e1cc804e682031 Mon Sep 17 00:00:00 2001 From: Mohd Zishan <72738005+Zishan-7@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:46:29 +0530 Subject: [PATCH 02/17] remove duplicate debounce code and use `useDebounce` hook instead (#1079) --- src/components/Global/ValidatedInput/index.tsx | 13 ++++--------- src/hooks/useUserSearch.ts | 12 +++--------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/components/Global/ValidatedInput/index.tsx b/src/components/Global/ValidatedInput/index.tsx index 790c1bea9..2cae05c10 100644 --- a/src/components/Global/ValidatedInput/index.tsx +++ b/src/components/Global/ValidatedInput/index.tsx @@ -1,5 +1,6 @@ import BaseInput from '@/components/0_Bruddle/BaseInput' import MoreInfo from '@/components/Global/MoreInfo' +import { useDebounce } from '@/hooks/useDebounce' import * as Sentry from '@sentry/nextjs' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { twMerge } from 'tailwind-merge' @@ -43,7 +44,7 @@ const ValidatedInput = ({ }: ValidatedInputProps) => { const [isValid, setIsValid] = useState(false) const [isValidating, setIsValidating] = useState(false) - const [debouncedValue, setDebouncedValue] = useState(value) + const debouncedValue = useDebounce(value, debounceTime) const previousValueRef = useRef(value) const currentValueRef = useRef(value) const listId = useRef(`datalist-${Math.random().toString(36).substr(2, 9)}`) @@ -128,16 +129,10 @@ const ValidatedInput = ({ } }, [debouncedValue]) + // Update currentValueRef when value changes useEffect(() => { currentValueRef.current = value - const handler = setTimeout(() => { - setDebouncedValue(value) - }, debounceTime) - - return () => { - clearTimeout(handler) - } - }, [value, debounceTime]) + }, [value]) const handleChange = (e: ChangeEvent) => { const newValue = e.target.value diff --git a/src/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index 1bc5464d1..aa0ab2592 100644 --- a/src/hooks/useUserSearch.ts +++ b/src/hooks/useUserSearch.ts @@ -1,26 +1,20 @@ import { useUserStore } from '@/redux/hooks' import { ApiUser, usersApi } from '@/services/users' import { useEffect, useRef, useState } from 'react' +import { useDebounce } from './useDebounce' export const useUserSearch = () => { const { user: authenticatedUser } = useUserStore() const [searchTerm, setSearchTerm] = useState('') - const [debouncedValue, setDebouncedValue] = useState(searchTerm) + const debouncedValue = useDebounce(searchTerm, 300) const [searchResults, setSearchResults] = useState([]) const [isSearching, setIsSearching] = useState(false) const [error, setError] = useState('') const currentValueRef = useRef(searchTerm) - // handle debounced search + // Update currentValueRef when searchTerm changes useEffect(() => { currentValueRef.current = searchTerm - const handler = setTimeout(() => { - setDebouncedValue(searchTerm) - }, 300) - - return () => { - clearTimeout(handler) - } }, [searchTerm]) // handle API call when debounced value changes From ee6e74974ab339a81c8082d5bcf917c4f0099d06 Mon Sep 17 00:00:00 2001 From: Mohd Zishan <72738005+Zishan-7@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:25:19 +0530 Subject: [PATCH 03/17] Landing page v2.1 (#1089) * lpv2.1 part 1 * Add exchange widget * add and integrate exchange API * add yourMoney component bg * update landing countries svg * integrate frankfurter API * fixes and improvements * decrease hero section height * allow max 2 decimal places * Add `/exchange` route * fix: overlay * make destination amount editable and bugg fixes * some fixes & currency improvements * crucial commit * fix checkmark, font size and weight --------- Co-authored-by: Hugo Montenegro --- scripts/compare-rates.mjs | 196 ++++ src/app/api/exchange-rate/route.ts | 160 +++ src/app/exchange/page.tsx | 13 + src/app/page.tsx | 17 +- src/assets/icons/bbva-logo.svg | 6 + src/assets/icons/brubank-logo.svg | 28 + src/assets/icons/github-white.png | Bin 0 -> 3547 bytes src/assets/icons/index.ts | 12 +- src/assets/icons/mercado-pago-logo.svg | 15 + src/assets/icons/n26-logo.svg | 3 + src/assets/icons/pix-logo.svg | 31 + src/assets/icons/revolut-logo.svg | 3 + src/assets/icons/santander-logo.svg | 3 + src/assets/icons/stripe-logo.svg | 9 + src/assets/icons/wise-logo.svg | 3 + .../illustrations/hand-middle-finger.svg | 4 + src/assets/illustrations/index.ts | 1 + .../illustrations/landing-countries.svg | 934 ++++++++++++++++++ .../illustrations/mobile-send-in-seconds.svg | 14 +- src/assets/illustrations/no-hidden-fees.svg | 192 ++-- src/assets/illustrations/pay-zero-fees.svg | 34 +- .../iphone-ss/iphone-drop-a-link-mobile.png | Bin 0 -> 298719 bytes src/assets/iphone-ss/iphone-drop-a-link.png | Bin 0 -> 198572 bytes src/components/0_Bruddle/Button.tsx | 1 + src/components/Global/Icons/Icon.tsx | 3 + src/components/Global/Icons/chevron-down.tsx | 10 + src/components/LandingPage/CurrencySelect.tsx | 196 ++++ src/components/LandingPage/Footer.tsx | 112 +++ src/components/LandingPage/RegulatedRails.tsx | 148 +++ .../LandingPage/businessIntegrate.tsx | 67 -- src/components/LandingPage/dropLink.tsx | 70 ++ src/components/LandingPage/hero.tsx | 12 +- src/components/LandingPage/imageAssets.tsx | 11 +- src/components/LandingPage/index.ts | 2 +- src/components/LandingPage/noFees.tsx | 286 ++++-- .../LandingPage/securityBuiltIn.tsx | 18 +- src/components/LandingPage/yourMoney.tsx | 59 +- src/constants/countryCurrencyMapping.ts | 37 + src/hooks/useExchangeRate.ts | 147 +++ 39 files changed, 2553 insertions(+), 304 deletions(-) create mode 100644 scripts/compare-rates.mjs create mode 100644 src/app/api/exchange-rate/route.ts create mode 100644 src/app/exchange/page.tsx create mode 100644 src/assets/icons/bbva-logo.svg create mode 100644 src/assets/icons/brubank-logo.svg create mode 100644 src/assets/icons/github-white.png create mode 100644 src/assets/icons/mercado-pago-logo.svg create mode 100644 src/assets/icons/n26-logo.svg create mode 100644 src/assets/icons/pix-logo.svg create mode 100644 src/assets/icons/revolut-logo.svg create mode 100644 src/assets/icons/santander-logo.svg create mode 100644 src/assets/icons/stripe-logo.svg create mode 100644 src/assets/icons/wise-logo.svg create mode 100644 src/assets/illustrations/hand-middle-finger.svg create mode 100644 src/assets/illustrations/landing-countries.svg create mode 100644 src/assets/iphone-ss/iphone-drop-a-link-mobile.png create mode 100644 src/assets/iphone-ss/iphone-drop-a-link.png create mode 100644 src/components/Global/Icons/chevron-down.tsx create mode 100644 src/components/LandingPage/CurrencySelect.tsx create mode 100644 src/components/LandingPage/Footer.tsx create mode 100644 src/components/LandingPage/RegulatedRails.tsx delete mode 100644 src/components/LandingPage/businessIntegrate.tsx create mode 100644 src/components/LandingPage/dropLink.tsx create mode 100644 src/constants/countryCurrencyMapping.ts create mode 100644 src/hooks/useExchangeRate.ts diff --git a/scripts/compare-rates.mjs b/scripts/compare-rates.mjs new file mode 100644 index 000000000..660568ce4 --- /dev/null +++ b/scripts/compare-rates.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +// Compare Bridge vs Frankfurter (and optionally local /api/exchange-rate) rates +// Usage examples: +// node scripts/compare-rates.mjs +// node scripts/compare-rates.mjs --pairs USD:EUR,USD:MXN --api http://localhost:3000/api/exchange-rate +// BRIDGE_API_KEY=xxx node scripts/compare-rates.mjs + +import { readFileSync } from 'fs' +import { resolve } from 'path' + +// Load .env files +function loadEnv() { + const envFiles = ['.env.local', '.env'] + for (const file of envFiles) { + try { + const envPath = resolve(file) + const envContent = readFileSync(envPath, 'utf8') + envContent.split('\n').forEach((line) => { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('=') + if (key && valueParts.length > 0) { + const value = valueParts.join('=').replace(/^["']|["']$/g, '') + if (!process.env[key]) { + process.env[key] = value + } + } + } + }) + console.log(`Loaded environment from ${file}`) + break + } catch (e) { + // File doesn't exist, continue to next + } + } +} + +loadEnv() + +const DEFAULT_PAIRS = [ + ['USD', 'EUR'], + ['USD', 'MXN'], + ['USD', 'BRL'], + ['EUR', 'USD'], + ['EUR', 'GBP'], +] + +const params = process.argv.slice(2) +const pairsArg = getArg('--pairs') +const apiArg = getArg('--api') + +const PAIRS = pairsArg ? pairsArg.split(',').map((p) => p.split(':').map((s) => s.trim().toUpperCase())) : DEFAULT_PAIRS + +const BRIDGE_API_KEY = process.env.BRIDGE_API_KEY + +function getArg(name) { + const i = params.indexOf(name) + if (i === -1) return null + return params[i + 1] || null +} + +async function fetchBridge(from, to) { + if (!BRIDGE_API_KEY) { + return { error: 'Missing BRIDGE_API_KEY' } + } + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const res = await fetch(url, { + method: 'GET', + headers: { 'Api-Key': BRIDGE_API_KEY }, + }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const { midmarket_rate, buy_rate, sell_rate } = data || {} + return { midmarket_rate, buy_rate, sell_rate } +} + +async function fetchFrankfurter(from, to) { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + const rate = data?.rates?.[to] + return { rate, rate_995: typeof rate === 'number' ? rate * 0.995 : undefined } +} + +async function fetchLocalApi(from, to) { + if (!apiArg) return {} + try { + const url = `${apiArg}?from=${from}&to=${to}` + const res = await fetch(url, { method: 'GET' }) + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` } + } + const data = await res.json() + return { rate: data?.rate } + } catch (error) { + return { error: `Connection failed: ${error.message}` } + } +} + +function fmt(n, digits = 6) { + return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : '-' +} + +function bps(a, b) { + if (typeof a !== 'number' || typeof b !== 'number' || !Number.isFinite(a) || !Number.isFinite(b) || b === 0) + return '-' + const rel = (a / b - 1) * 10000 + return `${rel.toFixed(1)} bps` +} + +async function run() { + console.log('Comparing rates...') + if (!BRIDGE_API_KEY) { + console.warn('Warning: BRIDGE_API_KEY not set. Bridge calls will be skipped or return errors.') + } + if (apiArg) { + console.log(`Also querying local API: ${apiArg}`) + } + + for (const [from, to] of PAIRS) { + const [bridge, frankData, local] = await Promise.all([ + fetchBridge(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchFrankfurter(from, to).catch((e) => ({ error: e?.message || String(e) })), + fetchLocalApi(from, to).catch((e) => ({ error: e?.message || String(e) })), + ]) + + const bridgeBuy = bridge?.buy_rate ? Number(bridge.buy_rate) : undefined + const bridgeMid = bridge?.midmarket_rate ? Number(bridge.midmarket_rate) : undefined + const bridgeSell = bridge?.sell_rate ? Number(bridge.sell_rate) : undefined + const frank = typeof frankData?.rate === 'number' ? frankData.rate : undefined + const frank995 = typeof frankData?.rate_995 === 'number' ? frankData.rate_995 : undefined + const localRate = typeof local?.rate === 'number' ? local.rate : undefined + + console.log(`\nPair: ${from} -> ${to}`) + console.table([ + { + source: 'Bridge', + buy: fmt(bridgeBuy), + mid: fmt(bridgeMid), + sell: fmt(bridgeSell), + note: bridge?.error || '', + }, + { + source: 'Frankfurter', + rate: fmt(frank), + rate_995: fmt(frank995), + note: frankData?.error || '', + }, + { + source: 'Local API', + rate: fmt(localRate), + note: local?.error || '', + }, + ]) + + // Delta analysis table + console.log(`\nDelta Analysis for ${from} -> ${to}:`) + console.table([ + { + comparison: 'Mid vs Frankfurt', + delta: bps(bridgeMid, frank), + }, + { + comparison: 'Mid vs Frankfurt×0.995', + delta: bps(bridgeMid, frank995), + }, + { + comparison: 'Sell vs Frankfurt×0.995', + delta: bps(bridgeSell, frank995), + }, + { + comparison: 'Sell vs Mid', + delta: bps(bridgeSell, bridgeMid), + }, + { + comparison: 'Buy vs Mid', + delta: bps(bridgeBuy, bridgeMid), + }, + { + comparison: 'Local vs Frankfurt×0.995', + delta: bps(localRate, frank995), + }, + ]) + } +} + +run().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts new file mode 100644 index 000000000..459f5b803 --- /dev/null +++ b/src/app/api/exchange-rate/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server' + +interface ExchangeRateResponse { + rate: number +} + +interface BridgeExchangeRateResponse { + midmarket_rate: string + buy_rate: string + sell_rate: string +} + +// Currency pairs that should use Bridge API (USD to these currencies only) +const BRIDGE_PAIRS = new Set(['USD-EUR', 'USD-MXN', 'USD-BRL']) + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const from = searchParams.get('from') + const to = searchParams.get('to') + + // Validate required parameters + if (!from || !to) { + return NextResponse.json({ error: 'Missing required parameters: from and to' }, { status: 400 }) + } + + const fromUc = from.toUpperCase() + const toUc = to.toUpperCase() + + // Same-currency pair: return 1:1 immediately + if (fromUc === toUc) { + return NextResponse.json( + { rate: 1 }, + { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + } + ) + } + + const pairKey = `${fromUc}-${toUc}` + const reversePairKey = `${toUc}-${fromUc}` + + // Check if we should use Bridge for this pair or its reverse + const shouldUseBridge = BRIDGE_PAIRS.has(pairKey) + const shouldUseBridgeReverse = BRIDGE_PAIRS.has(reversePairKey) + + if (shouldUseBridge || shouldUseBridgeReverse) { + // For Bridge pairs, we need to determine which rate to use + let bridgeResult + if (shouldUseBridge) { + // Direct pair (e.g., USD→EUR): use sell_rate + bridgeResult = await fetchFromBridge(fromUc, toUc, 'sell_rate', false) + } else { + // Reverse pair (e.g., EUR→USD): fetch USD→EUR and use buy_rate, then invert + bridgeResult = await fetchFromBridge(toUc, fromUc, 'buy_rate', true) + } + + if (bridgeResult) { + return bridgeResult + } + // Fall back to Frankfurter if Bridge fails + console.warn(`Bridge failed for ${pairKey}, falling back to Frankfurter`) + } + + // Use Frankfurter for all other pairs or as fallback + return await fetchFromFrankfurter(fromUc, toUc) + } catch (error) { + console.error('Exchange rate API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function fetchFromBridge( + from: string, + to: string, + rateType: 'buy_rate' | 'sell_rate', + shouldInvert: boolean +): Promise { + const bridgeAPIKey = process.env.BRIDGE_API_KEY + + if (!bridgeAPIKey) { + console.warn('Bridge API key not set') + return null + } + + try { + const url = `https://api.bridge.xyz/v0/exchange_rates?from=${from.toLowerCase()}&to=${to.toLowerCase()}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + // Bridge expects header name 'Api-Key' + headers: { 'Api-Key': bridgeAPIKey }, + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Bridge API error: ${response.status} ${response.statusText}`) + return null + } + + const bridgeData: BridgeExchangeRateResponse = await response.json() + + // Validate the response structure + if (!bridgeData[rateType]) { + console.error(`Invalid Bridge response: missing ${rateType}`) + return null + } + + let rate = parseFloat(bridgeData[rateType]) + + // If we fetched the reverse pair (e.g., fetched USD→EUR for EUR→USD request), + // we need to invert the rate + if (shouldInvert) { + rate = 1 / rate + } + + const exchangeRate: ExchangeRateResponse = { + rate, + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) + } catch (error) { + console.error('Bridge API exception:', error) + return null + } +} + +async function fetchFromFrankfurter(from: string, to: string): Promise { + const url = `https://api.frankfurter.app/latest?from=${from}&to=${to}` + const options: RequestInit & { next?: { revalidate?: number } } = { + method: 'GET', + next: { revalidate: 300 }, // Cache for 5 minutes + } + + const response = await fetch(url, options) + + if (!response.ok) { + console.error(`Frankfurter API error: ${response.status} ${response.statusText}`) + return NextResponse.json({ error: 'Failed to fetch exchange rates from API' }, { status: response.status }) + } + + const data = await response.json() + + const exchangeRate: ExchangeRateResponse = { + rate: data.rates[to] * 0.995, // Subtract 50bps + } + + return NextResponse.json(exchangeRate, { + headers: { + 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + }, + }) +} diff --git a/src/app/exchange/page.tsx b/src/app/exchange/page.tsx new file mode 100644 index 000000000..49143ff6f --- /dev/null +++ b/src/app/exchange/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import Layout from '@/components/Global/Layout' +import { NoFees } from '@/components/LandingPage' +import Footer from '@/components/LandingPage/Footer' +export default function ExchangePage() { + return ( + + +