diff --git a/src/app/(mobile-ui)/add-money/crypto/page.tsx b/src/app/(mobile-ui)/add-money/crypto/page.tsx index 98932fc43..c35732866 100644 --- a/src/app/(mobile-ui)/add-money/crypto/page.tsx +++ b/src/app/(mobile-ui)/add-money/crypto/page.tsx @@ -11,14 +11,13 @@ import { import { CryptoDepositQR } from '@/components/AddMoney/views/CryptoDepositQR.view' import NetworkSelectionView, { SelectedNetwork } from '@/components/AddMoney/views/NetworkSelection.view' import TokenSelectionView from '@/components/AddMoney/views/TokenSelection.view' -import ActionModal from '@/components/Global/ActionModal' import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' -import { Slider } from '@/components/Slider' import { PEANUT_WALLET_CHAIN } from '@/constants' import { useWallet } from '@/hooks/wallet/useWallet' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' +import TokenAndNetworkConfirmationModal from '@/components/Global/TokenAndNetworkConfirmationModal' type AddMoneyCryptoStep = 'sourceSelection' | 'tokenSelection' | 'networkSelection' | 'riskModal' | 'qrScreen' @@ -105,24 +104,11 @@ const AddMoneyCryptoPage = ({ headerTitle, onBack, depositAddress }: AddMoneyCry onBack={handleBackToTokenSelection} /> {currentStep === 'riskModal' && selectedToken && selectedNetwork && ( - - Sending funds via any other network will result in a permanent loss. - - } - footer={ -
- v && setIsRiskAccepted(true)} /> -
- } - ctas={[]} - modalPanelClassName="max-w-xs" + onAccept={() => setIsRiskAccepted(true)} /> )} diff --git a/src/app/(mobile-ui)/profile/identity-verification/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/page.tsx new file mode 100644 index 000000000..904794567 --- /dev/null +++ b/src/app/(mobile-ui)/profile/identity-verification/page.tsx @@ -0,0 +1,10 @@ +import PageContainer from '@/components/0_Bruddle/PageContainer' +import IdentityVerificationView from '@/components/Profile/views/IdentityVerification.view' + +export default function IdentityVerificationPage() { + return ( + + + + ) +} diff --git a/src/app/(mobile-ui)/recover-funds/layout.tsx b/src/app/(mobile-ui)/recover-funds/layout.tsx new file mode 100644 index 000000000..43278bb9e --- /dev/null +++ b/src/app/(mobile-ui)/recover-funds/layout.tsx @@ -0,0 +1,12 @@ +import { generateMetadata } from '@/app/metadata' +import PageContainer from '@/components/0_Bruddle/PageContainer' +import React from 'react' + +export const metadata = generateMetadata({ + title: 'Recover Funds', + description: 'Recover funds that were mistakenly sent to your address in other tokens', +}) + +export default function RecoverFundsLayout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/src/app/(mobile-ui)/recover-funds/page.tsx b/src/app/(mobile-ui)/recover-funds/page.tsx new file mode 100644 index 000000000..f5479ca0a --- /dev/null +++ b/src/app/(mobile-ui)/recover-funds/page.tsx @@ -0,0 +1,280 @@ +'use client' + +import NavHeader from '@/components/Global/NavHeader' +import ScrollableList from '@/components/Global/TokenSelector/Components/ScrollableList' +import TokenListItem from '@/components/Global/TokenSelector/Components/TokenListItem' +import { IUserBalance } from '@/interfaces' +import { useState, useEffect, useMemo, useCallback, useContext } from 'react' +import { useWallet } from '@/hooks/wallet/useWallet' +import { fetchWalletBalances } from '@/app/actions/tokens' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { areEvmAddressesEqual, isTxReverted, getExplorerUrl } from '@/utils' +import { RecipientState } from '@/context/WithdrawFlowContext' +import GeneralRecipientInput, { GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' +import { Button } from '@/components/0_Bruddle' +import ErrorAlert from '@/components/Global/ErrorAlert' +import Card from '@/components/Global/Card' +import Image from 'next/image' +import AddressLink from '@/components/Global/AddressLink' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { erc20Abi, parseUnits, encodeFunctionData } from 'viem' +import type { Address, Hash, TransactionReceipt } from 'viem' +import { useRouter } from 'next/navigation' +import { loadingStateContext } from '@/context' +import Icon from '@/components/Global/Icon' +import { captureException } from '@sentry/nextjs' + +export default function RecoverFundsPage() { + const [tokenBalances, setTokenBalances] = useState([]) + const [selectedTokenAddress, setSelectedTokenAddress] = useState('') + const [recipient, setRecipient] = useState({ address: '', name: '' }) + const [errorMessage, setErrorMessage] = useState('') + const [inputChanging, setInputChanging] = useState(false) + const [fetchingBalances, setFetchingBalances] = useState(true) + const [isSigning, setIsSigning] = useState(false) + const [txHash, setTxHash] = useState('') + const [status, setStatus] = useState<'init' | 'review' | 'final'>('init') + const { address: peanutAddress, sendTransactions } = useWallet() + const router = useRouter() + const { loadingState, isLoading } = useContext(loadingStateContext) + + useEffect(() => { + if (!peanutAddress) return + setFetchingBalances(true) + fetchWalletBalances(peanutAddress) + .then((balances) => { + const nonUsdcArbitrumBalances = balances.balances.filter( + (b) => + b.chainId === PEANUT_WALLET_CHAIN.id.toString() && + !areEvmAddressesEqual(PEANUT_WALLET_TOKEN, b.address) + ) + setTokenBalances(nonUsdcArbitrumBalances) + }) + .finally(() => { + setFetchingBalances(false) + }) + }, [peanutAddress]) + + const selectedBalance = useMemo(() => { + if (selectedTokenAddress === '') return undefined + return tokenBalances.find((b) => areEvmAddressesEqual(b.address, selectedTokenAddress)) + }, [tokenBalances, selectedTokenAddress]) + + const reset = useCallback(() => { + setErrorMessage('') + setInputChanging(false) + setIsSigning(false) + setTxHash('') + setStatus('init') + setRecipient({ address: '', name: '' }) + setSelectedTokenAddress('') + }, []) + + const recoverFunds = useCallback(async () => { + if (!selectedBalance || !recipient.address) return + setIsSigning(true) + setErrorMessage('') + const amountStr = selectedBalance.amount.toFixed(selectedBalance.decimals) + const amount = parseUnits(amountStr, selectedBalance.decimals) + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient.address as Address, amount], + }) + let receipt: TransactionReceipt | null + let userOpHash: Hash + try { + const result = await sendTransactions([{ to: selectedBalance.address, data }]) + receipt = result.receipt + userOpHash = result.userOpHash + } catch (error) { + setErrorMessage('Error sending transaction, please try again') + setIsSigning(false) + return + } + if (receipt !== null && isTxReverted(receipt)) { + setErrorMessage('Transaction reverted, please try again') + setIsSigning(false) + return + } + setTxHash(receipt?.transactionHash ?? userOpHash) + setStatus('final') + setIsSigning(false) + }, [selectedBalance, recipient.address, sendTransactions]) + + if (!peanutAddress) return null + + if (fetchingBalances) { + return + } + + if (status === 'review' && (!selectedBalance || !recipient.address)) { + captureException(new Error('Invalid state, review without selected balance or recipient address')) + reset() + return null + } else if (status === 'review') { + return ( +
+ +
+ +
+
+ {`${selectedBalance!.symbol} +
+
+ +
+

+ You will receive to +

+

+ {selectedBalance!.amount} {selectedBalance!.symbol} in Arbitrum +

+
+
+ +
+
+ ) + } + + if (status === 'final') { + return ( +
+
+ +
+
+ {`${selectedBalance!.symbol} +
+
+ +
+

+ Sent to +

+

+ {selectedBalance!.amount} {selectedBalance!.symbol} in Arbitrum +

+ + View on explorer + + +
+
+ + +
+
+ ) + } + + return ( +
+ +
+

Select a token to recover

+ + {tokenBalances.length > 0 ? ( + tokenBalances.map((balance) => ( + { + setSelectedTokenAddress(balance.address) + }} + /> + )) + ) : ( +
+
No tokens to recover
+
+ )} +
+ { + setRecipient(update.recipient) + setErrorMessage(update.errorMessage) + setInputChanging(update.isChanging) + }} + /> + + {!!errorMessage && } +
+
+ ) +} diff --git a/src/components/AddMoney/components/CryptoMethodDrawer.tsx b/src/components/AddMoney/components/CryptoMethodDrawer.tsx index a7c17d174..b6e8ab64f 100644 --- a/src/components/AddMoney/components/CryptoMethodDrawer.tsx +++ b/src/components/AddMoney/components/CryptoMethodDrawer.tsx @@ -2,12 +2,11 @@ import { ARBITRUM_ICON, OTHER_CHAINS_ICON } from '@/assets' import { Card } from '@/components/0_Bruddle' -import ActionModal from '@/components/Global/ActionModal' import { Drawer, DrawerContent } from '@/components/Global/Drawer' -import { Slider } from '@/components/Slider' import Image from 'next/image' import { useRouter } from 'next/navigation' import React, { Dispatch, SetStateAction, useState } from 'react' +import TokenAndNetworkConfirmationModal from '@/components/Global/TokenAndNetworkConfirmationModal' const CryptoMethodDrawer = ({ isDrawerOpen, @@ -83,28 +82,15 @@ const CryptoMethodDrawer = ({ - { setShowRiskModal(false) setisDrawerOpen(true) }} - icon={'alert'} - iconContainerClassName="bg-yellow-1" - modalClassName="z-[9999]" - title={`Only send USDC on Arbitrum`} - description={ - - Sending funds via any other network will result in a permanent loss. - - } - footer={ -
- v && router.push('/add-money/crypto')} /> -
- } - ctas={[]} - modalPanelClassName="max-w-xs" + onAccept={() => { + router.push('/add-money/crypto') + }} + isVisible={showRiskModal} /> ) diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index 705be90cb..1d240cc46 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -58,6 +58,7 @@ import { FailedIcon } from './failed' import { ChevronDownIcon } from './chevron-down' import { DoubleCheckIcon } from './double-check' import { QuestionMarkIcon } from './question-mark' +import { ShieldIcon } from './shield' // available icon names export type IconName = @@ -120,6 +121,7 @@ export type IconName = | 'failed' | 'chevron-down' | 'question-mark' + | 'shield' export interface IconProps extends SVGProps { name: IconName @@ -187,6 +189,7 @@ const iconComponents: Record>> = failed: FailedIcon, 'chevron-down': ChevronDownIcon, 'question-mark': QuestionMarkIcon, + shield: ShieldIcon, } export const Icon: FC = ({ name, size = 24, width, height, ...props }) => { diff --git a/src/components/Global/Icons/shield.tsx b/src/components/Global/Icons/shield.tsx new file mode 100644 index 000000000..ca699c70d --- /dev/null +++ b/src/components/Global/Icons/shield.tsx @@ -0,0 +1,16 @@ +import { FC, SVGProps } from 'react' + +export const ShieldIcon: FC> = (props) => { + return ( + + + + + ) +} diff --git a/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx b/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx new file mode 100644 index 000000000..1f50efee7 --- /dev/null +++ b/src/components/Global/TokenAndNetworkConfirmationModal/index.tsx @@ -0,0 +1,56 @@ +import ActionModal from '@/components/Global/ActionModal' +import { Slider } from '@/components/Slider' +import { ARBITRUM_ICON } from '@/assets' +import { NetworkConfig } from '@/components/Global/TokenSelector/TokenSelector.consts' +import { CryptoToken } from '@/components/AddMoney/consts' +import Image from 'next/image' + +export default function TokenAndNetworkConfirmationModal({ + token, + network, + onClose, + onAccept, + isVisible = true, +}: { + token?: Pick + network?: Pick + onClose: () => void + onAccept: () => void + isVisible?: boolean +}) { + token = token ?? { symbol: 'USDC', icon: 'https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png' } + network = network ?? { name: 'Arbitrum', iconUrl: ARBITRUM_ICON } + + return ( + +
+ {token.symbol} + {token.symbol} +
+
+ {network.name} + {network.name} +
+ + Sending funds via any other network will result in a permanent loss. + + + } + footer={ +
+ v && onAccept()} /> +
+ } + ctas={[]} + modalPanelClassName="max-w-xs" + /> + ) +} diff --git a/src/components/Profile/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx index 5ca2f154e..2254f5e48 100644 --- a/src/components/Profile/components/ProfileMenuItem.tsx +++ b/src/components/Profile/components/ProfileMenuItem.tsx @@ -3,6 +3,7 @@ import Card, { CardPosition } from '@/components/Global/Card' import { Icon, IconName } from '@/components/Global/Icons/Icon' import Link from 'next/link' import React from 'react' +import { twMerge } from 'tailwind-merge' interface ProfileMenuItemProps { icon: IconName @@ -12,6 +13,8 @@ interface ProfileMenuItemProps { position?: CardPosition comingSoon?: boolean isExternalLink?: boolean + endIcon?: IconName + endIconClassName?: string } const ProfileMenuItem: React.FC = ({ @@ -22,6 +25,8 @@ const ProfileMenuItem: React.FC = ({ position = 'middle', comingSoon = false, isExternalLink, + endIcon, + endIconClassName, }) => { const content = (
@@ -34,7 +39,12 @@ const ProfileMenuItem: React.FC = ({ {comingSoon ? ( ) : ( - + )}
@@ -48,6 +58,14 @@ const ProfileMenuItem: React.FC = ({ ) } + if (onClick) { + return ( + + {content} + + ) + } + return ( { const { logoutUser, isLoggingOut, user } = useAuth() + const [isKycApprovedModalOpen, setIsKycApprovedModalOpen] = useState(false) const router = useRouter() const logout = async () => { @@ -20,6 +23,8 @@ export const Profile = () => { const fullName = user?.user.fullName || user?.user?.username || 'Anonymous User' const username = user?.user.username || 'anonymous' + const isKycApproved = user?.user.kycStatus === 'approved' + return (
{ }} />
- +
{/* Menu Item - Invite Entry */} {
{/* Enable with Account Management project. */} + + { + if (isKycApproved) { + setIsKycApprovedModalOpen(true) + } else { + router.push('/profile/identity-verification') + } + }} + position="middle" + endIcon={isKycApproved ? 'check' : undefined} + endIconClassName={isKycApproved ? 'text-success-3 size-4' : undefined} + /> {/* {
+ + setIsKycApprovedModalOpen(false)} + title="You’re already verified" + description="Your identity has already been successfully verified. No further action is needed." + icon="shield" + ctas={[ + { + text: 'Go back', + shadowSize: '4', + className: 'md:py-2', + onClick: () => setIsKycApprovedModalOpen(false), + }, + ]} + />
) } diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx new file mode 100644 index 000000000..2849b1761 --- /dev/null +++ b/src/components/Profile/views/IdentityVerification.view.tsx @@ -0,0 +1,106 @@ +'use client' +import { updateUserById } from '@/app/actions/users' +import { Button } from '@/components/0_Bruddle' +import { UserDetailsForm, UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' +import ErrorAlert from '@/components/Global/ErrorAlert' +import IframeWrapper from '@/components/Global/IframeWrapper' +import NavHeader from '@/components/Global/NavHeader' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useAuth } from '@/context/authContext' +import { useKycFlow } from '@/hooks/useKycFlow' +import { useRouter } from 'next/navigation' +import React, { useMemo, useRef, useState } from 'react' + +const IdentityVerificationView = () => { + const { user, fetchUser } = useAuth() + const router = useRouter() + const formRef = useRef<{ handleSubmit: () => void }>(null) + const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) + const [isUpdatingUser, setIsUpdatingUser] = useState(false) + const [userUpdateError, setUserUpdateError] = useState(null) + const { + iframeOptions, + handleInitiateKyc, + handleIframeClose, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + error: kycError, + isLoading: isKycLoading, + } = useKycFlow() + + const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') + const lastName = lastNameParts.join(' ') + + const initialUserDetails: Partial = useMemo( + () => ({ + firstName: user?.user.fullName ? firstName : '', + lastName: user?.user.fullName ? lastName : '', + email: user?.user.email ?? '', + }), + [user?.user.fullName, user?.user.email, firstName, lastName] + ) + + const handleUserDetailsSubmit = async (data: UserDetailsFormData) => { + setIsUpdatingUser(true) + setUserUpdateError(null) + try { + if (!user?.user.userId) throw new Error('User not found') + const result = await updateUserById({ + userId: user.user.userId, + fullName: `${data.firstName} ${data.lastName}`, + email: data.email, + }) + if (result.error) { + throw new Error(result.error) + } + await fetchUser() + // setStep('kyc') + await handleInitiateKyc() + } catch (error: any) { + setUserUpdateError(error.message) + return { error: error.message } + } finally { + setIsUpdatingUser(false) + } + return {} + } + + return ( +
+ router.replace('/profile')} /> +
+

Provide information to begin verification

+ + + + + {(userUpdateError || kycError) && } + + + + +
+
+ ) +} + +export default IdentityVerificationView