diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index f7d191152..084d8daed 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -25,6 +25,7 @@ import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney import { updateUserById } from '@/app/actions/users' import AddMoneyBankDetails from '@/components/AddMoney/components/AddMoneyBankDetails' import { getOnrampCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/bridge.utils' +import { Slider } from '@/components/Slider' type AddStep = 'inputAmount' | 'kyc' | 'loading' | 'collectUserDetails' | 'showDetails' @@ -97,14 +98,9 @@ export default function OnrampBankPage() { if (step === 'loading') { const currentKycStatus = liveKycStatus || user?.user.kycStatus const isUserKycVerified = currentKycStatus === 'approved' - const hasUserDetails = user?.user.fullName && user?.user.email if (!isUserKycVerified) { - if (hasUserDetails) { - setStep('kyc') - } else { - setStep('collectUserDetails') - } + setStep('collectUserDetails') } else { setStep('inputAmount') if (amountFromContext && !rawTokenAmount) { @@ -388,28 +384,20 @@ export default function OnrampBankPage() { title="IMPORTANT!" description={ <> - In the following step you'll see a "Deposit Message" item, copy and paste - it exactly as it is on the description field of your transfer. + In the following step you'll see a
"Deposit Message" item
{' '} + copy and paste it exactly as it is on
the description field of your transfer.

- Without it, we won't be able to credit your money. + + Without it your deposit will be returned and might take 2-10 working days to process. + } - checkbox={{ - text: 'I understand and accept the risk.', - checked: isRiskAccepted, - onChange: setIsRiskAccepted, - }} - ctas={[ - { - text: 'Continue', - variant: isRiskAccepted ? 'purple' : 'dark', - shadowSize: '4', - onClick: handleWarningConfirm, - disabled: !isRiskAccepted, - className: 'w-full', - }, - ]} + footer={ +
+ v && handleWarningConfirm()} /> +
+ } preventClose={false} modalPanelClassName="max-w-md mx-8" /> diff --git a/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx b/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx index ccbafaa78..581e69861 100644 --- a/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx +++ b/src/app/(mobile-ui)/add-money/crypto/direct/page.tsx @@ -2,7 +2,8 @@ import PaymentPage from '@/app/[...recipient]/client' import PeanutLoading from '@/components/Global/PeanutLoading' -import { useUserStore } from '@/redux/hooks' +import { useAppDispatch, useUserStore } from '@/redux/hooks' +import { paymentActions } from '@/redux/slices/payment-slice' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -12,6 +13,11 @@ export default function AddMoneyCryptoDirectPage() { const { user } = useUserStore() const [recipientUsername, setRecipientUsername] = useState(null) const [isLoading, setIsLoading] = useState(true) + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(paymentActions.resetPaymentState()) + }, [dispatch]) useEffect(() => { if (user?.user.username) { @@ -21,7 +27,7 @@ export default function AddMoneyCryptoDirectPage() { return } setIsLoading(false) - }, [searchParams, router]) + }, [searchParams, router, user]) if (isLoading) { return diff --git a/src/app/(mobile-ui)/add-money/crypto/page.tsx b/src/app/(mobile-ui)/add-money/crypto/page.tsx index 5194b9343..a5ebe3520 100644 --- a/src/app/(mobile-ui)/add-money/crypto/page.tsx +++ b/src/app/(mobile-ui)/add-money/crypto/page.tsx @@ -6,10 +6,10 @@ import NetworkSelectionView, { SelectedNetwork } from '@/components/AddMoney/vie import TokenSelectionView from '@/components/AddMoney/views/TokenSelection.view' import ActionModal from '@/components/Global/ActionModal' import NavHeader from '@/components/Global/NavHeader' +import { Slider } from '@/components/Slider' import { useWallet } from '@/hooks/wallet/useWallet' import { useRouter } from 'next/navigation' -import { useState } from 'react' -import { twMerge } from 'tailwind-merge' +import { useEffect, useState } from 'react' type AddMoneyCryptoStep = 'sourceSelection' | 'tokenSelection' | 'networkSelection' | 'riskModal' | 'qrScreen' @@ -22,6 +22,12 @@ const AddMoneyCryptoPage = () => { const [selectedNetwork, setSelectedNetwork] = useState(null) const [isRiskAccepted, setIsRiskAccepted] = useState(false) + useEffect(() => { + if (isRiskAccepted) { + setCurrentStep('qrScreen') + } + }, [isRiskAccepted]) + const handleCryptoSourceSelected = (source: CryptoSource) => { setSelectedSource(source) setCurrentStep('tokenSelection') @@ -38,12 +44,6 @@ const AddMoneyCryptoPage = () => { setCurrentStep('riskModal') } - const handleRiskContinue = () => { - if (isRiskAccepted) { - setCurrentStep('qrScreen') - } - } - const resetSelections = () => { setSelectedToken(null) setSelectedNetwork(null) @@ -88,26 +88,19 @@ const AddMoneyCryptoPage = () => { visible={true} onClose={handleBackToNetworkSelectionFromRisk} icon={'alert'} + iconContainerClassName="bg-yellow-1" title={`Only send ${selectedToken.symbol} on ${selectedNetwork.name}`} - description="Sending funds via any other network will result in a permanent loss of funds. Peanut is not responsible for any loss of funds due to incorrect network selection." - checkbox={{ - text: 'I understand and accept the risk.', - checked: isRiskAccepted, - onChange: setIsRiskAccepted, - }} - ctas={[ - { - text: 'Continue', - onClick: handleRiskContinue, - disabled: !isRiskAccepted, - variant: isRiskAccepted ? 'purple' : 'stroke', - shadowSize: '4', - className: twMerge( - !isRiskAccepted ? 'border-grey-2 text-grey-2' : '', - 'text-black border border-black h-11 hover:text-black' - ), - }, - ]} + description={ + + Sending funds via any other network will result in a permanent loss. + + } + footer={ +
+ v && setIsRiskAccepted(true)} /> +
+ } + ctas={[]} modalPanelClassName="max-w-xs" /> )} diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index b035556d6..4adc55ac8 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -37,6 +37,8 @@ import FloatingReferralButton from '@/components/Home/FloatingReferralButton' 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' 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 @@ -45,6 +47,7 @@ export default function Home() { const { balance, address, isFetchingBalance, isFetchingRewardBalance } = useWallet() const { rewardWalletBalance } = useWalletStore() const [isRewardsModalOpen, setIsRewardsModalOpen] = useState(false) + const { resetGuestFlow } = useGuestFlow() const [isBalanceHidden, setIsBalanceHidden] = useState(() => { const prefs = getUserPreferences() return prefs?.balanceHidden ?? false @@ -60,6 +63,7 @@ export default function Home() { const [showAddMoneyPromptModal, setShowAddMoneyPromptModal] = useState(false) const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [showReferralCampaignModal, setShowReferralCampaignModal] = useState(false) + const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) const userFullName = useMemo(() => { if (!user) return @@ -77,6 +81,10 @@ export default function Home() { const isLoading = isFetchingUser && !username + useEffect(() => { + resetGuestFlow() + }, [resetGuestFlow]) + useEffect(() => { // We have some users that didn't have the peanut wallet created // correctly, so we need to create it @@ -104,15 +112,45 @@ export default function Home() { (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) const isStandalone = window.matchMedia('(display-mode: standalone)').matches const hasSeenModalThisSession = sessionStorage.getItem('hasSeenIOSPWAPromptThisSession') + const redirectUrl = getFromLocalStorage('redirect') - if (isIOS && !isStandalone && !hasSeenModalThisSession && !user?.hasPwaInstalled) { + if ( + isIOS && + !isStandalone && + !hasSeenModalThisSession && + !user?.hasPwaInstalled && + !isPostSignupActionModalVisible && + !redirectUrl + ) { setShowIOSPWAInstallModal(true) sessionStorage.setItem('hasSeenIOSPWAPromptThisSession', 'true') } else { setShowIOSPWAInstallModal(false) } } - }, [user?.hasPwaInstalled]) + }, [user?.hasPwaInstalled, isPostSignupActionModalVisible]) + + // effect for showing balance warning modal + useEffect(() => { + if (typeof window !== 'undefined' && !isFetchingBalance) { + const hasSeenBalanceWarning = getFromLocalStorage('hasSeenBalanceWarning') + const balanceInUsd = Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) + + // show if: + // 1. balance is above the threshold + // 2. user hasn't seen this warning in the current session + // 3. no other modals are currently active + if ( + balanceInUsd > BALANCE_WARNING_THRESHOLD && + !hasSeenBalanceWarning && + !showIOSPWAInstallModal && + !showAddMoneyPromptModal && + !isPostSignupActionModalVisible + ) { + setShowBalanceWarningModal(true) + } + } + }, [balance, isFetchingBalance, showIOSPWAInstallModal, showAddMoneyPromptModal]) // effect for showing balance warning modal useEffect(() => { @@ -151,7 +189,8 @@ export default function Home() { balance === 0n && !hasSeenAddMoneyPromptThisSession && !showIOSPWAInstallModal && - !showBalanceWarningModal + !showBalanceWarningModal && + !isPostSignupActionModalVisible ) { setShowAddMoneyPromptModal(true) sessionStorage.setItem('hasSeenAddMoneyPromptThisSession', 'true') @@ -236,6 +275,9 @@ export default function Home() { {/* Floating Referral Button */} setShowReferralCampaignModal(true)} /> + + {/* Post Signup Action Modal */} + ) } diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index ccf0fc7ba..29a5238c1 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -1,6 +1,9 @@ 'use client' +import { MarqueeWrapper } from '@/components/Global/MarqueeWrapper' import { useRouter } from 'next/navigation' +import { HandThumbsUp } from '@/assets' +import Image from 'next/image' import GuestLoginModal from '@/components/Global/GuestLoginModal' import PeanutLoading from '@/components/Global/PeanutLoading' import TopNavbar from '@/components/Global/TopNavbar' @@ -17,9 +20,6 @@ import PullToRefresh from 'pulltorefreshjs' import { useEffect, useMemo, useState } from 'react' import { twMerge } from 'tailwind-merge' import '../../styles/globals.css' -import { MarqueeWrapper } from '@/components/Global/MarqueeWrapper' -import Image from 'next/image' -import { HandThumbsUp } from '@/assets' const publicPathRegex = /^\/(request\/pay|claim|pay\/.+$)/ @@ -114,7 +114,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => { )} - {/* Fixed top navbar */} {showFullPeanutWallet && (
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index de4c224c5..3ed982878 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -27,6 +27,7 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { captureMessage } from '@sentry/nextjs' import type { Address } from 'viem' +import { Slider } from '@/components/Slider' export default function WithdrawCryptoPage() { const router = useRouter() @@ -77,6 +78,10 @@ export default function WithdrawCryptoPage() { setError(null) }, [setError]) + useEffect(() => { + dispatch(paymentActions.resetPaymentState()) + }, [dispatch]) + useEffect(() => { if (!amountToWithdraw) { console.error('Amount not available in WithdrawFlowContext for withdrawal, redirecting.') @@ -264,13 +269,6 @@ export default function WithdrawCryptoPage() { const fromChainId = isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : withdrawData.chain.chainId const toChainId = activeChargeDetailsFromStore.chainId - console.log('Cross-chain check:', { - fromChainId, - toChainId, - isPeanutWallet, - isCrossChain: fromChainId !== toChainId, - }) - return fromChainId !== toChainId }, [withdrawData, activeChargeDetailsFromStore, isPeanutWallet]) @@ -345,7 +343,7 @@ export default function WithdrawCryptoPage() { headerTitle="Withdraw" recipientType="ADDRESS" type="SEND" - currencyAmount={`$ ${amountToWithdraw}`} + amount={amountToWithdraw} isWithdrawFlow={true} redirectTo="/withdraw" message={ @@ -368,16 +366,16 @@ export default function WithdrawCryptoPage() { title="Is this address compatible?" description="Only send to address that support the selected network and token. Incorrect transfers may be lost." icon="alert" - ctas={[ - { - text: 'Proceed', - onClick: handleCompatibilityProceed, - variant: 'purple', - shadowSize: '4', - className: 'h-10 text-sm', - icon: 'check-circle', - }, - ]} + footer={ +
+ { + if (!v) return + handleCompatibilityProceed() + }} + /> +
+ } />
) diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 361a456b1..a03aed12c 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -389,6 +389,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props)
{currentView === 'INITIAL' && ( ) : ( { + try { + const response = await fetchWithSentry(`${API_URL}/bridge/customers/${customerId}/external-accounts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': API_KEY, + }, + body: JSON.stringify({ ...accountDetails, reuseOnError: true }), // note: reuseOnError is used to avoid showing errors for duplicate accounts on guest flow + }) + + const data = await response.json() + + if (!response.ok) { + return { error: data.error || 'Failed to create external account.' } + } + + return data + } catch (error) { + console.error(`Error creating external account for ${customerId}:`, error) + if (error instanceof Error) { + return { error: error.message } + } + return { error: 'An unexpected error occurred.' } + } +} diff --git a/src/app/actions/offramp.ts b/src/app/actions/offramp.ts index 297c50d29..e34958716 100644 --- a/src/app/actions/offramp.ts +++ b/src/app/actions/offramp.ts @@ -70,6 +70,45 @@ export async function createOfframp( } } +export async function createOfframpForGuest( + params: TCreateOfframpRequest +): Promise<{ data?: CreateOfframpSuccessResponse; error?: string }> { + const apiUrl = process.env.PEANUT_API_URL + + if (!apiUrl || !API_KEY) { + console.error('API URL or API Key is not configured.') + return { error: 'Server configuration error.' } + } + + try { + const response = await fetchWithSentry(`${apiUrl}/bridge/offramp/create-for-guest`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': API_KEY, + }, + body: JSON.stringify({ + ...params, + provider: 'bridge', + }), + }) + + const data = await response.json() + + if (!response.ok) { + return { error: data.error || 'Failed to create off-ramp transfer for guest.' } + } + + return { data } + } catch (error) { + console.error('Error calling create off-ramp for guest API:', error) + if (error instanceof Error) { + return { error: error.message } + } + return { error: 'An unexpected error occurred.' } + } +} + /** * Server Action to confirm an off-ramp transfer after the user has sent funds. * diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index bfd66aa3d..b5001dc04 100644 --- a/src/app/actions/tokens.ts +++ b/src/app/actions/tokens.ts @@ -106,7 +106,6 @@ const MOBULA_API_KEY = process.env.MOBULA_API_KEY! export const fetchTokenPrice = unstable_cache( async (tokenAddress: string, chainId: string): Promise => { try { - tokenAddress = isAddressZero(tokenAddress) ? '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' : tokenAddress const mobulaResponse = await fetchWithSentry( diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index 84a9cfb9c..81d036b4c 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -5,6 +5,7 @@ import { ApiUser } from '@/services/users' import { fetchWithSentry } from '@/utils' import { cookies } from 'next/headers' import { AddBankAccountPayload, BridgeEndorsementType, InitiateKycResponse } from './types/users.types' +import { User } from '@/interfaces' const API_KEY = process.env.PEANUT_API_KEY! @@ -94,3 +95,27 @@ export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ return { error: e.message || 'An unexpected error occurred' } } } + +export async function getUserById(userId: string): Promise { + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'api-key': API_KEY, + }, + }) + + if (!response.ok) { + const errorData = await response.json() + console.error(`Failed to fetch user ${userId}:`, errorData) + return null + } + const responseJson = await response.json() + + return responseJson + } catch (error) { + console.error(`Error fetching user ${userId}:`, error) + return null + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a45c9cc5c..434de7e6e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -40,6 +40,11 @@ const knerdFilled = localFont({ variable: '--font-knerd-filled', }) +const robotoFlexBold = localFont({ + src: '../assets/fonts/roboto-flex-bold.ttf', + variable: '--font-roboto-flex-bold', +}) + export const metadata = generateMetadata({ title: 'Peanut - Instant Global P2P Payments in Digital Dollars', description: @@ -60,7 +65,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index cbecf3a1c..9cb4b2cbb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -34,7 +34,7 @@ export default function LandingPage() { message: ['No fees', 'Instant', '24/7', 'USD', 'EUR', 'CRYPTO', 'GLOBAL', 'SELF-CUSTODIAL'], }, primaryCta: { - label: 'TRY NOW', + label: 'TRY NOW!', href: '/setup', }, } @@ -50,7 +50,22 @@ export default function LandingPage() { { id: '1', question: 'What is Peanut?', - answer: 'Peanut is the simplest way to send and receive crypto or fiat. Peanut lets you request, send and cashout digital dollars using links and QR codes.', + answer: 'Peanut is the easiest way to send digital dollars to anyone anywhere. Peanut’s tech is powered by cutting-edge cryptography and the security of biometric user authentication as well as a network of modern and fully licensed banking providers.', + }, + { + id: '2', + question: 'Do I have to KYC?', + answer: 'No! You can use core functionalities (like sending and receiving money) without KYC. Bank connections, however, trigger a one‑time check handled by Persona, a SOC2 Type 2 certified and GDPR compliant ISO 27001–certified provider used by brands like Square and Robinhood. Your documents remain locked away with Persona, not Peanut, and Peanut only gets a yes/no response, keeping your privacy intact.', + }, + { + id: '3', + question: 'Could a thief drain my wallet if they stole my phone?', + answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\nThis means that neither Peanut or even regulators could freeze, us or you to hand over your account, because we can’t hand over what we don’t have. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.', + }, + { + id: '4', + question: 'What happens to my funds if Peanut’s servers were breached?', + answer: "Nothing. Your funds sit in your self‑custodied smart account (not on Peanut servers). Every transfer still needs a signature from your biometric passkey, so a server‑side attacker has no way to move a cent without the private key sealed in your device's Secure Enclave. Even if Peanut were offline, you could point any ERC‑4337‑compatible wallet at your smart account and recover access independently.", }, ], marquee: { @@ -161,7 +176,7 @@ export default function LandingPage() { const marqueeProps = { visible: hero.marquee.visible, message: hero.marquee.message } return ( - + - - - - - - + + + + + + + diff --git a/src/assets/scribble.svg b/src/assets/scribble.svg index 7586df560..6cce7db64 100644 --- a/src/assets/scribble.svg +++ b/src/assets/scribble.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/wallets/index.ts b/src/assets/wallets/index.ts index 146834c74..f31d91c8e 100644 --- a/src/assets/wallets/index.ts +++ b/src/assets/wallets/index.ts @@ -2,4 +2,4 @@ export { default as COINBASE_LOGO } from './coinbase.svg' export { default as METAMASK_LOGO } from './metamask.svg' export { default as RAINBOW_LOGO } from './rainbow.svg' export { default as TRUST_WALLET_LOGO } from './trust_wallet.svg' - +export { default as TRUST_WALLET_SMALL_LOGO } from './trust_wallet_2.svg' diff --git a/src/assets/wallets/trust_wallet_2.svg b/src/assets/wallets/trust_wallet_2.svg new file mode 100644 index 000000000..1334573e4 --- /dev/null +++ b/src/assets/wallets/trust_wallet_2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx index 033524bdc..a1ace78f3 100644 --- a/src/components/AddMoney/UserDetailsForm.tsx +++ b/src/components/AddMoney/UserDetailsForm.tsx @@ -54,7 +54,6 @@ export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDeta rules: any, type: string = 'text' ) => { - const isDisabled = name === 'email' ? !!initialData?.email : !!initialData?.firstName // also check for lastname implicitly with firstname return (
@@ -68,7 +67,6 @@ export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDeta type={type} placeholder={placeholder} className="h-12 w-full rounded-sm border border-n-1 bg-white px-4 text-sm" - disabled={isDisabled} /> )} /> diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 4399f2a2d..c177487ba 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -42,7 +42,7 @@ export default function AddMoneyBankDetails() { const countryCodeForFlag = useMemo(() => { const countryId = currentCountryDetails?.id || 'USA' - const countryCode = countryCodeMap[countryId] + const countryCode = countryCodeMap[countryId] || countryId // if countryId is not in countryCodeMap, use countryId because for some countries countryId is of 2 digit and countryCodeMap is a mapping of 3 digit to 2 digit country codes return countryCode?.toLowerCase() || 'us' }, [currentCountryDetails]) diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index f0cc48767..2401ef3b9 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -157,6 +157,13 @@ export const UPDATED_DEFAULT_ADD_MONEY_METHODS: SpecificPaymentMethod[] = [ description: 'Usually in minutes - KYC required', isSoon: false, }, + { + id: 'crypto-add', + icon: 'wallet-outline' as IconName, + title: 'From Crypto', + description: 'Usually arrives instantly', + isSoon: false, + }, { id: 'mercado-pago-add', icon: MERCADO_PAGO, @@ -188,6 +195,24 @@ export const DEFAULT_BANK_WITHDRAW_METHOD: SpecificPaymentMethod = { isSoon: false, } +export const DEFAULT_WITHDRAW_METHODS: SpecificPaymentMethod[] = [ + { + id: 'crypto-withdraw', + icon: 'wallet-outline' as IconName, + title: 'Crypto', + description: 'Withdraw to a wallet address', + isSoon: false, + path: '/withdraw/crypto', + }, + { + id: 'default-bank-withdraw', + icon: 'bank' as IconName, + title: 'To Bank', + description: 'Standard bank withdrawal', + isSoon: false, + }, +] + const countrySpecificWithdrawMethods: Record< string, Array<{ title: string; description: string; icon?: IconName | string }> @@ -2061,6 +2086,14 @@ countryData.forEach((country) => { }) } + const cryptoWithdrawMethod = DEFAULT_WITHDRAW_METHODS.find((m) => m.id === 'crypto-withdraw') + if (cryptoWithdrawMethod) { + const cryptoExists = withdrawList.some((m) => m.id === 'crypto-withdraw') + if (!cryptoExists) { + withdrawList.unshift(cryptoWithdrawMethod) + } + } + // filter add methods: include Mercado Pago only for LATAM countries const currentAddMethods = UPDATED_DEFAULT_ADD_MONEY_METHODS.filter((method) => { if (method.id === 'mercado-pago-add') { @@ -2072,6 +2105,9 @@ countryData.forEach((country) => { if (newMethod.id === 'bank-transfer-add') { newMethod.path = `/add-money/${country.path}/bank` newMethod.isSoon = !isCountryEnabledForBankTransfer(countryCode) || countryCode === 'MX' + } else if (newMethod.id === 'crypto-add') { + newMethod.path = `/add-money/crypto` + newMethod.isSoon = false } else { newMethod.isSoon = true } diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index b6b7479dc..27ff70324 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -13,7 +13,7 @@ import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' import { useEffect, useRef, useState } from 'react' import { InitiateKYCModal } from '@/components/Kyc' -import { DynamicBankAccountForm, FormData } from './DynamicBankAccountForm' +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' @@ -36,7 +36,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const { setFromBankSelected } = useOnrampFlow() const [view, setView] = useState<'list' | 'form'>('list') const [isKycModalOpen, setIsKycModalOpen] = useState(false) - const [cachedBankDetails, setCachedBankDetails] = useState | null>(null) + const [cachedBankDetails, setCachedBankDetails] = useState | null>(null) const formRef = useRef<{ handleSubmit: () => void }>(null) const [liveKycStatus, setLiveKycStatus] = useState(user?.user?.kycStatus as KYCStatus) @@ -78,7 +78,10 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } }, [user, liveKycStatus, cachedBankDetails]) - const handleFormSubmit = async (payload: AddBankAccountPayload, rawData: FormData): Promise<{ error?: string }> => { + const handleFormSubmit = async ( + payload: AddBankAccountPayload, + rawData: IBankAccountDetails + ): Promise<{ error?: string }> => { const currentKycStatus = liveKycStatus || user?.user.kycStatus const isUserKycVerified = currentKycStatus === 'approved' @@ -248,7 +251,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { backgroundColor: method.icon === ('bank' as IconName) ? '#FFC900' - : getColorForUsername(method.title).lightShade, + : method.id === 'crypto-add' || method.id === 'crypto-withdraw' + ? '#FFC900' + : getColorForUsername(method.title).lightShade, color: method.icon === ('bank' as IconName) ? 'black' : 'black', }} /> diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index e2b8ed69a..02531d777 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -260,7 +260,7 @@ export const AddWithdrawRouterView: FC = ({ })) return ( -
+

Recent methods

@@ -270,7 +270,13 @@ export const AddWithdrawRouterView: FC = ({ isAllMethodsView={false} />
-
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 5f39171d1..65a79dca9 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -10,7 +10,7 @@ import { useParams } from 'next/navigation' import { validateBankAccount, validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils' import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' -import PeanutActionDetailsCard from '../Global/PeanutActionDetailsCard' +import PeanutActionDetailsCard, { PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' @@ -18,7 +18,8 @@ const isIBANCountry = (country: string) => { return countryCodeMap[country.toUpperCase()] !== undefined } -export type FormData = { +export type IBankAccountDetails = { + name?: string firstName: string lastName: string email: string @@ -30,19 +31,24 @@ export type FormData = { city: string state: string postalCode: string + iban?: string + country: string } interface DynamicBankAccountFormProps { country: string - onSuccess: (payload: AddBankAccountPayload, rawData: FormData) => Promise<{ error?: string }> - initialData?: Partial + onSuccess: (payload: AddBankAccountPayload, rawData: IBankAccountDetails) => Promise<{ error?: string }> + initialData?: Partial + flow?: 'claim' | 'withdraw' + actionDetailsProps?: Partial } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( - ({ country, onSuccess, initialData }, ref) => { + ({ country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { const { user } = useAuth() const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) + const [showBicField, setShowBicField] = useState(false) const { country: countryName } = useParams() const { amountToWithdraw } = useWithdrawFlow() const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') @@ -53,7 +59,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D handleSubmit, setValue, formState: { errors, isValid, isValidating, touchedFields }, - } = useForm({ + } = useForm({ defaultValues: { firstName: firstName ?? '', lastName: lastName ?? '', @@ -75,7 +81,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D handleSubmit: handleSubmit(onSubmit), })) - const onSubmit = async (data: FormData) => { + const onSubmit = async (data: IBankAccountDetails) => { setIsSubmitting(true) setSubmissionError(null) try { @@ -119,8 +125,11 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const result = await onSuccess(payload as AddBankAccountPayload, { ...data, + iban: isIban ? data.accountNumber : undefined, + country, firstName: data.firstName.trim(), lastName: data.lastName.trim(), + name: data.name, }) if (result.error) { setSubmissionError(result.error) @@ -137,7 +146,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const isMx = country.toUpperCase() === 'MX' const renderInput = ( - name: keyof FormData, + name: keyof IBankAccountDetails, placeholder: string, rules: any, type: string = 'text', @@ -186,6 +195,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D recipientName={country} amount={amountToWithdraw} tokenSymbol={PEANUT_WALLET_TOKEN_SYMBOL} + {...actionDetailsProps} />
@@ -197,13 +207,18 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D }} className="space-y-4" > - {!user?.user?.fullName && ( + {flow === 'claim' && + renderInput('name', 'Full Name', { + required: 'Full name is required', + })} + {flow !== 'claim' && !user?.user?.fullName && (
{renderInput('firstName', 'First Name', { required: 'First name is required' })} {renderInput('lastName', 'Last Name', { required: 'Last name is required' })}
)} - {!user?.user?.email && + {flow !== 'claim' && + !user?.user?.email && renderInput('email', 'E-mail', { required: 'Email is required', })} @@ -228,12 +243,17 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D if (!field.value || field.value.trim().length === 0) return try { + setShowBicField(false) + setValue('bic', '', { shouldValidate: false }) const bic = await getBicFromIban(field.value.trim()) if (bic) { setValue('bic', bic, { shouldValidate: true }) + } else { + setShowBicField(true) } } catch (error) { console.warn('Failed to fetch BIC:', error) + setShowBicField(true) } } ) @@ -249,6 +269,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D )} {isIban && + showBicField && renderInput('bic', 'BIC', { required: 'BIC is required', validate: async (value: string) => (await validateBic(value)) || 'Invalid BIC code', diff --git a/src/components/Claim/Claim.consts.ts b/src/components/Claim/Claim.consts.ts index f5deca324..0dde26017 100644 --- a/src/components/Claim/Claim.consts.ts +++ b/src/components/Claim/Claim.consts.ts @@ -56,8 +56,6 @@ export interface IClaimScreenProps { setUserId: (id: string | undefined) => void initialKYCStep: number setInitialKYCStep: (step: number) => void - claimToExternalWallet: boolean - setClaimToExternalWallet: (claimToExternalWallet: boolean) => void } export enum claimLinkStateType { diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 05ee7de3f..0ed1cae89 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -24,6 +24,7 @@ import PeanutLoading from '../Global/PeanutLoading' import * as _consts from './Claim.consts' import * as genericViews from './Generic' import FlowManager from './Link/FlowManager' +import { useGuestFlow } from '@/context/GuestFlowContext' export const Claim = ({}) => { const [step, setStep] = useState<_consts.IClaimScreenState>(_consts.INIT_VIEW_STATE) @@ -51,7 +52,6 @@ export const Claim = ({}) => { password: '', recipient: '', }) - const [claimToExternalWallet, setClaimToExternalWallet] = useState(false) const { setSelectedChainID, setSelectedTokenAddress } = useContext(tokenSelectorContext) const { selectedTransaction, openTransactionDetails } = useTransactionDetailsDrawer() @@ -272,8 +272,6 @@ export const Claim = ({}) => { setUserId, initialKYCStep, setInitialKYCStep, - claimToExternalWallet, - setClaimToExternalWallet, } as unknown as _consts.IClaimScreenProps } /> diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 90aff2f48..cc7862cdf 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -12,13 +12,13 @@ import { usdcAddressOptimism, } from '@/components/Offramp/Offramp.consts' import { ActionType, estimatePoints } from '@/components/utils/utils' -import * as consts from '@/constants' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PINTA_WALLET_CHAIN, PINTA_WALLET_TOKEN, ROUTE_NOT_FOUND_ERROR, + SQUID_API_URL, } from '@/constants' import { TRANSACTIONS } from '@/constants/query.consts' import { loadingStateContext, tokenSelectorContext } from '@/context' @@ -42,32 +42,38 @@ import { useQueryClient } from '@tanstack/react-query' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { formatUnits } from 'viem' -import * as _consts from '../Claim.consts' +import { IClaimScreenProps } from '../Claim.consts' import useClaimLink from '../useClaimLink' - -export const InitialClaimLinkView = ({ - onNext, - claimLinkData, - setRecipient, - recipient, - tokenPrice, - setClaimType, - setEstimatedPoints, - attachment, - setTransactionHash, - onCustom, - selectedRoute, - setSelectedRoute, - hasFetchedRoute, - setHasFetchedRoute, - recipientType, - setRecipientType, - setOfframpForm, - setUserType, - setInitialKYCStep, - setClaimToExternalWallet, - claimToExternalWallet, -}: _consts.IClaimScreenProps) => { +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' + +export const InitialClaimLinkView = (props: IClaimScreenProps) => { + const { + onNext, + claimLinkData, + setRecipient, + recipient, + tokenPrice, + setClaimType, + setEstimatedPoints, + attachment, + setTransactionHash, + onCustom, + selectedRoute, + setSelectedRoute, + hasFetchedRoute, + setHasFetchedRoute, + recipientType, + setRecipientType, + setOfframpForm, + setUserType, + setInitialKYCStep, + } = props const [isValidRecipient, setIsValidRecipient] = useState(false) const [errorState, setErrorState] = useState<{ showError: boolean @@ -76,7 +82,17 @@ export const InitialClaimLinkView = ({ const [isXchainLoading, setIsXchainLoading] = useState(false) const [routes, setRoutes] = useState([]) const [inputChanging, setInputChanging] = useState(false) + const [showConfirmationModal, setShowConfirmationModal] = useState(false) + const { + claimToExternalWallet, + resetGuestFlow, + showGuestActionsList, + guestFlowStep, + showVerificationModal, + setShowVerificationModal, + setClaimToExternalWallet, + } = useGuestFlow() const { setLoadingState, isLoading } = useContext(loadingStateContext) const { selectedChainID, @@ -129,79 +145,88 @@ export const InitialClaimLinkView = ({ } }, [recipientType, claimLinkData.chainId, isPeanutChain, claimLinkData.tokenAddress]) - const handleClaimLink = useCallback(async () => { - setLoadingState('Loading') - setErrorState({ - showError: false, - errorMessage: '', - }) + const handleClaimLink = useCallback( + async (bypassModal = false) => { + if (!isPeanutWallet && !bypassModal) { + setShowConfirmationModal(true) + return + } + setShowConfirmationModal(false) + + setLoadingState('Loading') + setErrorState({ + showError: false, + errorMessage: '', + }) + + if (recipient.address === '') return - if (recipient.address === '') return + try { + setLoadingState('Executing transaction') + if (isPeanutWallet) { + await sendLinksApi.claim(user?.user.username ?? address, claimLinkData.link) - try { - setLoadingState('Executing transaction') - if (isPeanutWallet) { - await sendLinksApi.claim(user?.user.username ?? address, claimLinkData.link) - - setClaimType('claim') - onCustom('SUCCESS') - fetchBalance() - queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) - } else { - // Check if cross-chain claiming is needed - const needsXChain = - selectedChainID !== claimLinkData.chainId || - !areEvmAddressesEqual(selectedTokenAddress, claimLinkData.tokenAddress) - - let claimTxHash: string - if (needsXChain) { - claimTxHash = await claimLinkXchain({ - address: recipient.address, - link: claimLinkData.link, - destinationChainId: selectedChainID, - destinationToken: selectedTokenAddress, - }) - setClaimType('claimxchain') - } else { - claimTxHash = await claimLink({ - address: recipient.address, - link: claimLinkData.link, - }) setClaimType('claim') - } + onCustom('SUCCESS') + fetchBalance() + queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + } else { + // Check if cross-chain claiming is needed + const needsXChain = + selectedChainID !== claimLinkData.chainId || + !areEvmAddressesEqual(selectedTokenAddress, claimLinkData.tokenAddress) + + let claimTxHash: string + if (needsXChain) { + claimTxHash = await claimLinkXchain({ + address: recipient.address, + link: claimLinkData.link, + destinationChainId: selectedChainID, + destinationToken: selectedTokenAddress, + }) + setClaimType('claimxchain') + } else { + claimTxHash = await claimLink({ + address: recipient.address, + link: claimLinkData.link, + }) + setClaimType('claim') + } - setTransactionHash(claimTxHash) - onCustom('SUCCESS') - queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + setTransactionHash(claimTxHash) + onCustom('SUCCESS') + queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + } + } catch (error) { + const errorString = ErrorHandler(error) + setErrorState({ + showError: true, + errorMessage: errorString, + }) + Sentry.captureException(error) + } finally { + setLoadingState('Idle') } - } catch (error) { - const errorString = ErrorHandler(error) - setErrorState({ - showError: true, - errorMessage: errorString, - }) - Sentry.captureException(error) - } finally { - setLoadingState('Idle') - } - }, [ - claimLinkData.link, - claimLinkData.chainId, - claimLinkData.tokenAddress, - isPeanutWallet, - fetchBalance, - recipient.address, - user, - claimLink, - claimLinkXchain, - selectedChainID, - selectedTokenAddress, - onCustom, - setLoadingState, - setClaimType, - setTransactionHash, - queryClient, - ]) + }, + [ + claimLinkData.link, + claimLinkData.chainId, + claimLinkData.tokenAddress, + isPeanutWallet, + fetchBalance, + recipient.address, + user, + claimLink, + claimLinkXchain, + selectedChainID, + selectedTokenAddress, + onCustom, + setLoadingState, + setClaimType, + setTransactionHash, + queryClient, + ] + ) useEffect(() => { if (isPeanutWallet) resetSelectedToken() @@ -285,8 +310,8 @@ export const InitialClaimLinkView = ({ }) if (user?.user.kycStatus === 'approved') { const account = user.accounts.find( - (account: any) => - account.account_identifier.replaceAll(/\s/g, '').toLowerCase() === + (account) => + account.identifier.replaceAll(/\s/g, '').toLowerCase() === recipient.address.replaceAll(/\s/g, '').toLowerCase() ) @@ -355,7 +380,7 @@ export const InitialClaimLinkView = ({ const isReward = useMemo(() => { if (!claimLinkData.tokenAddress) return false - return areEvmAddressesEqual(claimLinkData.tokenAddress, consts.PINTA_WALLET_TOKEN) + return areEvmAddressesEqual(claimLinkData.tokenAddress, PINTA_WALLET_TOKEN) }, [claimLinkData.tokenAddress]) const fetchRoute = useCallback( @@ -385,7 +410,7 @@ export const InitialClaimLinkView = ({ : claimLinkData.tokenAddress.toLowerCase() const route = await getSquidRouteRaw({ - squidRouterUrl: `${consts.SQUID_API_URL}/route`, + squidRouterUrl: `${SQUID_API_URL}/v2/route`, fromChain: claimLinkData.chainId.toString(), fromToken: fromToken, fromAmount: tokenAmount.toString(), @@ -433,6 +458,12 @@ export const InitialClaimLinkView = ({ ] ) + useEffect(() => { + if (guestFlowStep?.startsWith('bank-')) { + resetSelectedToken() + } + }, [guestFlowStep, resetSelectedToken]) + useEffect(() => { let isMounted = true if (isReward || !claimLinkData.tokenAddress) { @@ -517,57 +548,81 @@ export const InitialClaimLinkView = ({ ]) const getButtonText = () => { - if (isPeanutWallet && !isPeanutChain) { - return 'Review' + if (isPeanutWallet) { + return ( +
+
Receive on
+
+ Peanut Logo + Peanut Logo +
+
+ ) } - if ((selectedRoute || (isXChain && hasFetchedRoute)) && !isPeanutChain) { + if (selectedRoute || (isXChain && hasFetchedRoute)) { return 'Review' } - if (isLoading) { - return 'Claiming' + if (isLoading && !inputChanging) { + return 'Receiving' } - return 'Claim' + return 'Receive now' + } + + const handleClaimAction = () => { + if (isPeanutWallet && !isPeanutChain) { + setRefetchXchainRoute(true) + onNext() + } else if (recipientType === 'iban' || recipientType === 'us') { + handleIbanRecipient() + } else if ((selectedRoute || (isXChain && hasFetchedRoute)) && !isPeanutChain) { + onNext() + } else { + handleClaimLink() + } } const guestAction = () => { if (!!user?.user.userId || claimToExternalWallet) return null return (
- - {!isPeanutClaimOnlyMode && ( + {!showGuestActionsList && ( )} + {!isPeanutClaimOnlyMode && }
) } + if (guestFlowStep?.startsWith('bank-')) { + return + } + return (
- {!!user?.user.userId || claimToExternalWallet ? ( + {!!user?.user.userId || showGuestActionsList ? (
{ if (claimToExternalWallet) { setClaimToExternalWallet(false) + } else if (showGuestActionsList) { + resetGuestFlow() } else { router.push('/home') } @@ -576,7 +631,7 @@ export const InitialClaimLinkView = ({
) : (
-
Claim
+
Receive
)}
@@ -604,6 +659,7 @@ export const InitialClaimLinkView = ({ recipientType !== 'iban' && recipientType !== 'us' && !isPeanutClaimOnlyMode && + guestFlowStep !== 'bank-country-selection' && !!claimToExternalWallet && ( )} @@ -651,18 +707,7 @@ export const InitialClaimLinkView = ({
+ setShowConfirmationModal(false)} + title="Is this address compatible?" + description={ +
+

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

+

Incorrect transfers may be lost.

+
+ } + icon="alert" + iconContainerClassName="bg-yellow-400" + footer={ +
+ { + if (!v) return + // for cross-chain claims, advance to the confirm screen first + if (isXChain) { + setShowConfirmationModal(false) + onNext() + } else { + // direct on-chain claim – initiate immediately + handleClaimLink(true) + } + }} + /> +
+ } + preventClose={false} + modalPanelClassName="max-w-md mx-8" + /> + setShowVerificationModal(false)} + title="This method requires verification" + description="To receive funds on your bank account, you’ll create a free Peanut Wallet and complete a quick identity check (KYC)." + icon="alert" + iconContainerClassName="bg-yellow-400" + ctaClassName="md:flex-col gap-4" + ctas={[ + { + text: 'Start verification', + shadowSize: '4', + className: 'md:py-2.5', + onClick: () => { + saveRedirectUrl() + router.push('/setup') + }, + }, + { + text: 'Claim with other method', + variant: 'transparent', + className: 'w-full h-auto underline underline-offset-2', + onClick: () => { + setShowVerificationModal(false) + }, + }, + ]} + preventClose={false} + modalPanelClassName="max-w-md mx-8" + />
) } diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 175326387..9c3d338d3 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -1,6 +1,5 @@ 'use client' import { Button } from '@/components/0_Bruddle' -import AddressLink from '@/components/Global/AddressLink' import Card from '@/components/Global/Card' import DisplayIcon from '@/components/Global/DisplayIcon' import ErrorAlert from '@/components/Global/ErrorAlert' @@ -51,12 +50,29 @@ export const ConfirmClaimLinkView = ({ return areEvmAddressesEqual(claimLinkData.tokenAddress, PINTA_WALLET_TOKEN) }, [claimLinkData.tokenAddress]) + // Determine which chain/token details to show – prefer the selectedRoute details if present, + // otherwise fall back to what the user picked in the token selector. const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ - chainId: selectedRoute?.route.params.toChain, - tokenAddress: selectedRoute?.route.estimate.toToken.address, - tokenSymbol: selectedRoute?.route.estimate.toToken.symbol, + chainId: selectedRoute?.route.params.toChain ?? selectedChainID, + tokenAddress: selectedRoute?.route.estimate.toToken.address ?? selectedTokenAddress, + tokenSymbol: selectedRoute?.route.estimate.toToken.symbol ?? claimLinkData.tokenSymbol, }) + // calculate minimum amount the user will receive after slippage + const minReceived = useMemo(() => { + let amountNumber: number + + // manual 1% slippage calculation based on the deposited token amount + amountNumber = Number(formatUnits(BigInt(claimLinkData.amount), claimLinkData.tokenDecimals)) * 0.99 // subtract 1% + + const formattedAmount = formatTokenAmount(amountNumber) + + return `$ ${formattedAmount}` + }, [selectedRoute, resolvedTokenSymbol, claimLinkData]) + + // Network fee display – always sponsored in this flow + const networkFeeDisplay: string = 'Sponsored by Peanut!' + const handleOnClaim = async () => { if (!recipient) { return @@ -138,20 +154,21 @@ export const ConfirmClaimLinkView = ({ /> {!isReward && ( - - } - /> - - {selectedRoute && ( + {/* Min received row */} + {minReceived && ( + + )} + + {/* Token & network row */} + { +
)}
- )} - - {resolvedTokenSymbol || selectedRoute?.route.estimate.toToken.symbol} on{' '} - - {resolvedChainName || selectedRoute?.route.params.toChain} + + {resolvedTokenSymbol || claimLinkData.tokenSymbol} on{' '} + {resolvedChainName || selectedChainID} - -
- } - hideBottomBorder={!selectedRoute} - /> - {selectedRoute && ( - <> - - - - )} +
+ } + /> + } + + {/* Max network fee row */} + + + {/* Peanut fee row */} + )} @@ -202,7 +212,7 @@ export const ConfirmClaimLinkView = ({ disabled={isLoading} loading={isLoading} > - {isLoading ? 'Claiming' : 'Claim'} + Receive now {errorState.showError && } diff --git a/src/components/Claim/Link/Onchain/Success.view.tsx b/src/components/Claim/Link/Onchain/Success.view.tsx index 4fbd6232b..32a128f20 100644 --- a/src/components/Claim/Link/Onchain/Success.view.tsx +++ b/src/components/Claim/Link/Onchain/Success.view.tsx @@ -3,9 +3,10 @@ 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 { useUserStore } from '@/redux/hooks' import { ESendLinkStatus, sendLinksApi } from '@/services/sendLinks' -import { getTokenDetails, printableAddress } from '@/utils' +import { formatTokenAmount, getTokenDetails, printableAddress, shortenAddressLong } from '@/utils' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useEffect, useMemo } from 'react' @@ -18,11 +19,13 @@ export const SuccessClaimLinkView = ({ setTransactionHash, claimLinkData, type, + tokenPrice, }: _consts.IClaimScreenProps) => { const { user: authUser } = useUserStore() const { fetchUser } = useAuth() const router = useRouter() const queryClient = useQueryClient() + const { offrampDetails, claimType, bankDetails } = useGuestFlow() useEffect(() => { queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) @@ -72,43 +75,80 @@ export const SuccessClaimLinkView = ({ return tokenDetails }, [claimLinkData]) + const maskedAccountNumber = useMemo(() => { + if (bankDetails?.iban) { + return `to ${shortenAddressLong(bankDetails.iban)}` + } + if (bankDetails?.clabe) { + return `to ${shortenAddressLong(bankDetails.clabe)}` + } + if (bankDetails?.accountNumber) { + return `to ${shortenAddressLong(bankDetails.accountNumber)}` + } + return 'to your bank account' + }, [bankDetails]) + + const isBankClaim = claimType === 'claim-bank' + + const navHeaderTitle = 'Receive' + + const cardProps = { + viewType: 'SUCCESS' as const, + transactionType: (isBankClaim ? 'CLAIM_LINK_BANK_ACCOUNT' : 'CLAIM_LINK') as + | 'CLAIM_LINK_BANK_ACCOUNT' + | 'CLAIM_LINK', + recipientType: isBankClaim ? ('BANK_ACCOUNT' as const) : ('USERNAME' as const), + recipientName: isBankClaim + ? maskedAccountNumber + : (claimLinkData.sender?.username ?? printableAddress(claimLinkData.senderAddress)), + amount: isBankClaim + ? (formatTokenAmount( + Number(formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)) * (tokenPrice ?? 0) + ) ?? '') + : formatUnits(claimLinkData.amount, tokenDetails?.decimals ?? 6), + tokenSymbol: isBankClaim ? (offrampDetails?.quote.destination_currency ?? '') : claimLinkData.tokenSymbol, + message: isBankClaim + ? maskedAccountNumber + : `from ${claimLinkData.sender?.username || printableAddress(claimLinkData.senderAddress)}`, + title: isBankClaim ? 'You will receive' : 'You claimed', + } + + const renderButtons = () => { + if (authUser?.user.userId) { + return ( + + ) + } + return ( + + ) + } + return (
{ router.push('/home') }} />
- - {!!authUser?.user.userId ? ( - - ) : ( - - )} + + {renderButtons()}
) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx new file mode 100644 index 000000000..85c22cff7 --- /dev/null +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -0,0 +1,229 @@ +'use client' + +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 { loadingStateContext } from '@/context' +import { createBridgeExternalAccountForGuest } from '@/app/actions/external-accounts' +import { confirmOfframp, 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 { 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 NavHeader from '@/components/Global/NavHeader' + +export const BankFlowManager = (props: IClaimScreenProps) => { + const { onCustom, claimLinkData, setTransactionHash } = props + const { guestFlowStep, setGuestFlowStep, selectedCountry, setClaimType, setBankDetails } = useGuestFlow() + const { isLoading, setLoadingState } = useContext(loadingStateContext) + const { claimLink } = useClaimLink() + const [offrampDetails, setOfframpDetails] = useState(null) + const [localBankDetails, setLocalBankDetails] = useState(null) + const [receiverFullName, setReceiverFullName] = useState('') + const [error, setError] = useState(null) + + const handleSuccess = async (payload: AddBankAccountPayload, rawData: IBankAccountDetails) => { + if (!selectedCountry) { + const err = 'Country not selected' + setError(err) + return { error: err } + } + + 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' } + } + + setReceiverFullName(rawData.name ?? '') + sessionStorage.setItem('receiverFullName', rawData.name ?? '') + + const [firstName, ...lastNameParts] = rawData.name?.split(' ') ?? ['', ''] + const lastName = lastNameParts.join(' ') + + const paymentRail = getBridgeChainName(claimLinkData.chainId) + const currency = getBridgeTokenName(claimLinkData.chainId, claimLinkData.tokenAddress) + + 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 + 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 + + const offrampRequestParams: TCreateOfframpRequest = { + amount: formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals), + userId: userResponse.userId, + sendLinkPubKey: pubKey, + source: { + paymentRail: paymentRail, + currency: currency, + fromAddress: peanutContractAddress, + }, + destination: { + ...getOfframpCurrencyConfig(selectedCountry.id), + externalAccountId: externalAccountResponse.id, + }, + features: { + allowAnyFromAddress: true, + }, + } + + const offrampResponse = await createOfframpForGuest(offrampRequestParams) + + if (offrampResponse.error || !offrampResponse.data) { + setError(offrampResponse.error || 'Failed to create offramp') + return { error: offrampResponse.error || 'Failed to create offramp' } + } + + setOfframpDetails(offrampResponse.data as TCreateOfframpResponse) + + setLocalBankDetails(rawData) + setBankDetails(rawData) + setGuestFlowStep('bank-confirm-claim') + return {} + } 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) + + if (!offrampDetails) { + throw new Error('Offramp details not available') + } + + const claimTx = await claimLink({ + address: offrampDetails.depositInstructions.toAddress, + link: claimLinkData.link, + }) + + 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') + } + }, [ + 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} + /> + ) + } + + if (guestFlowStep === 'bank-country-list' || !selectedCountry) { + return + } + + return ( +
+
+ setGuestFlowStep('bank-country-list')} /> +
+ +
+ ) +} diff --git a/src/components/Claim/Link/views/ClaimCountryList.view.tsx b/src/components/Claim/Link/views/ClaimCountryList.view.tsx new file mode 100644 index 000000000..734ff32bd --- /dev/null +++ b/src/components/Claim/Link/views/ClaimCountryList.view.tsx @@ -0,0 +1,103 @@ +'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, resetGuestFlow } = 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 ( +
+ resetGuestFlow()} /> +
+ + +
+
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 new file mode 100644 index 000000000..7658d2a2e --- /dev/null +++ b/src/components/Claim/Link/views/Confirm.bank-claim.view.tsx @@ -0,0 +1,112 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import { countryCodeMap } from '@/components/AddMoney/consts' +import Card from '@/components/Global/Card' +import ErrorAlert from '@/components/Global/ErrorAlert' +import NavHeader from '@/components/Global/NavHeader' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { IBankAccountDetails } from '@/components/AddWithdraw/DynamicBankAccountForm' +import { useMemo } from 'react' +import { ClaimLinkData } from '@/services/sendLinks' +import { formatUnits } from 'viem' +import ExchangeRate from '@/components/ExchangeRate' +import { AccountType } from '@/interfaces' + +interface ConfirmBankClaimViewProps { + onConfirm: () => void + onBack: () => void + isProcessing?: boolean + error?: string | null + bankDetails: IBankAccountDetails + fullName: string + claimLinkData: ClaimLinkData +} + +export function ConfirmBankClaimView({ + onConfirm, + onBack, + isProcessing, + error, + bankDetails, + fullName, + claimLinkData, +}: ConfirmBankClaimViewProps) { + const displayedFullName = useMemo(() => { + if (fullName) return fullName + if (typeof window !== 'undefined') { + return sessionStorage.getItem('receiverFullName') ?? '' + } + return '' + }, [fullName]) + + const accountType = useMemo(() => { + if (bankDetails.iban) return AccountType.IBAN + if (bankDetails.clabe) return AccountType.CLABE + if (bankDetails.accountNumber && bankDetails.routingNumber) return AccountType.US + return AccountType.IBAN // Default or handle error + }, [bankDetails]) + + const countryCodeForFlag = useMemo(() => { + return countryCodeMap[bankDetails.country.toUpperCase()] ?? bankDetails.country.toUpperCase() + }, [bankDetails.country]) + + return ( +
+
+ +
+
+ + + + {/* todo: take full name from user, this name rn is of senders */} + + {bankDetails.iban && } + {bankDetails.bic && } + + + + +
+ {error ? ( + + ) : ( + + )} + + {error && } +
+
+
+ ) +} diff --git a/src/components/Create/useCreateLink.tsx b/src/components/Create/useCreateLink.tsx index 0fde487e6..e1b6d266e 100644 --- a/src/components/Create/useCreateLink.tsx +++ b/src/components/Create/useCreateLink.tsx @@ -238,48 +238,7 @@ export const useCreateLink = () => { throw error } } - const submitDirectTransfer = async ({ - txHash, - chainId, - senderAddress, - amountUsd, - transaction, - }: { - txHash: string - chainId: string - senderAddress: string - amountUsd: number - transaction?: peanutInterfaces.IPeanutUnsignedTransaction - }) => { - try { - const response = await fetchWithSentry('/api/peanut/submit-direct-transfer', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - txHash, - chainId, - senderAddress: senderAddress, - amountUsd, - transaction: { - ...transaction, - value: - transaction?.value && transaction.value !== BigInt(0) - ? transaction.value.toString() - : undefined, - }, - }), - }) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - } catch (error) { - console.error('Failed to publish file (complete):', error) - return '' - } - } const prepareDirectSendTx = ({ recipient, tokenValue, @@ -463,7 +422,6 @@ export const useCreateLink = () => { submitClaimLinkInit, submitClaimLinkConfirm, prepareDirectSendTx, - submitDirectTransfer, createLink, } } diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index a7c21cbf2..74464f29c 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -16,7 +16,6 @@ const ExchangeRate = ({ accountType }: ExchangeRateProps) => { setIsFetchingRate(true) try { const { data, error: rateError } = await getExchangeRate(accountType) - console.log('data', data) if (rateError) { console.error('Failed to fetch exchange rate:', rateError) @@ -41,18 +40,16 @@ const ExchangeRate = ({ accountType }: ExchangeRateProps) => { return } - if (exchangeRate) { - return ( - - ) - } + const displayValue = exchangeRate ? `1 USD = ${parseFloat(exchangeRate).toFixed(4)} ${toCurrency}` : '-' - return null + return ( + + ) } export default ExchangeRate diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx index 3be551e37..20954f919 100644 --- a/src/components/Global/FAQs/index.tsx +++ b/src/components/Global/FAQs/index.tsx @@ -81,7 +81,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) { animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }} - className="mt-1 overflow-hidden leading-6 text-n-1" + className="mt-1 overflow-hidden whitespace-pre-line leading-6 text-n-1" > {faq.answer} {faq.calModal && ( diff --git a/src/components/Global/IconStack.tsx b/src/components/Global/IconStack.tsx new file mode 100644 index 000000000..d08e06de4 --- /dev/null +++ b/src/components/Global/IconStack.tsx @@ -0,0 +1,35 @@ +import Image from 'next/image' +import { twMerge } from 'tailwind-merge' + +interface IconStackProps { + icons: string[] + iconSize?: number + iconClassName?: string +} + +const IconStack: React.FC = ({ icons, iconSize = 24, iconClassName = '' }) => { + return ( +
+ {icons.map((icon, index) => ( +
+ {`icon-${index}`} +
+ ))} +
+ ) +} + +export default IconStack diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index defb7eb56..e00619e56 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -49,6 +49,7 @@ import { WalletOutlineIcon } from './wallet-outline' import { BadgeIcon } from './badge' import { UserIdIcon } from './user-id' import { ClockIcon } from './clock' +import { DollarIcon } from './dollar' // available icon names export type IconName = @@ -102,6 +103,7 @@ export type IconName = | 'badge' | 'user-id' | 'clock' + | 'dollar' export interface IconProps extends SVGProps { name: IconName @@ -120,6 +122,7 @@ const iconComponents: Record>> = check: CheckIcon, 'chevron-up': ChevronUpIcon, download: DownloadIcon, + dollar: DollarIcon, eye: EyeIcon, 'eye-slash': EyeSlashIcon, exchange: ExchangeIcon, diff --git a/src/components/Global/Icons/dollar.tsx b/src/components/Global/Icons/dollar.tsx new file mode 100644 index 000000000..2400b9fd8 --- /dev/null +++ b/src/components/Global/Icons/dollar.tsx @@ -0,0 +1,10 @@ +import { FC, SVGProps } from 'react' + +export const DollarIcon: FC> = (props) => ( + + + +) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index 728c3da9a..4ce8c236d 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -95,6 +95,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra