diff --git a/src/app/(mobile-ui)/add-money/[country]/[regional-method]/page.tsx b/src/app/(mobile-ui)/add-money/[country]/[regional-method]/page.tsx index 204533ae2..61bb917b5 100644 --- a/src/app/(mobile-ui)/add-money/[country]/[regional-method]/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/[regional-method]/page.tsx @@ -1,5 +1,7 @@ 'use client' import MercadoPago from '@/components/AddMoney/components/RegionalMethods/MercadoPago' +import { CountryData, countryData } from '@/components/AddMoney/consts' +import { MantecaSupportedExchanges } from '@/components/AddMoney/consts' import { useParams } from 'next/navigation' export default function AddMoneyRegionalMethodPage() { @@ -7,7 +9,12 @@ export default function AddMoneyRegionalMethodPage() { const country = params.country as string const method = params['regional-method'] as string - if (country === 'argentina' && method === 'mercadopago') { + const countryDetails: CountryData | undefined = countryData.find((c) => c.path === country) + + if ( + MantecaSupportedExchanges[countryDetails?.id as keyof typeof MantecaSupportedExchanges] && + method === 'mercadopago' + ) { return } 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 5f4363288..f102d881b 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -9,7 +9,6 @@ import { useOnrampFlow } from '@/context/OnrampFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { formatAmount } from '@/utils' import { countryData } from '@/components/AddMoney/consts' -import { InitiateKYCModal } from '@/components/Kyc' import { BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { useWebSocket } from '@/hooks/useWebSocket' import { useAuth } from '@/context/authContext' @@ -26,6 +25,7 @@ import AddMoneyBankDetails from '@/components/AddMoney/components/AddMoneyBankDe import { getCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/bridge.utils' import { OnrampConfirmationModal } from '@/components/AddMoney/components/OnrampConfirmationModal' import MercadoPago from '@/components/AddMoney/components/RegionalMethods/MercadoPago' +import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' type AddStep = 'inputAmount' | 'kyc' | 'loading' | 'collectUserDetails' | 'showDetails' @@ -304,7 +304,7 @@ export default function OnrampBankPage() { if (step === 'kyc') { return (
- { - const pathname = usePathname() const loaderRef = useRef(null) const { user } = useUserStore() @@ -63,17 +61,27 @@ const HistoryPage = () => { const allEntries = useMemo(() => historyData?.pages.flatMap((page) => page.entries) ?? [], [historyData]) const combinedAndSortedEntries = useMemo(() => { + if (isLoading) { + return [] + } const entries: Array = [...allEntries] - if ( - user?.user?.bridgeKycStatus && - user.user.bridgeKycStatus !== 'not_started' && - user.user.bridgeKycStartedAt - ) { - entries.push({ - isKyc: true, - timestamp: user.user.bridgeKycStartedAt, - uuid: 'kyc-status-item', + if (user) { + if (user.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { + entries.push({ + isKyc: true, + timestamp: user.user.bridgeKycStartedAt ?? new Date(0).toISOString(), + uuid: 'bridge-kyc-status-item', + bridgeKycStatus: user.user.bridgeKycStatus, + }) + } + user.user.kycVerifications?.forEach((verification) => { + entries.push({ + isKyc: true, + timestamp: verification.approvedAt ?? new Date(0).toISOString(), + uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, + verification, + }) }) } @@ -84,7 +92,7 @@ const HistoryPage = () => { }) return entries - }, [allEntries, user]) + }, [allEntries, user, isLoading]) if (isLoading && combinedAndSortedEntries.length === 0) { return @@ -101,7 +109,7 @@ const HistoryPage = () => { ) } - if (combinedAndSortedEntries.length === 0) { + if (!isLoading && combinedAndSortedEntries.length === 0) { return (
@@ -151,7 +159,14 @@ const HistoryPage = () => {
)} {isKycStatusItem(item) ? ( - + ) : ( (() => { const { transactionDetails, transactionCardType } = diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9586757f1..b10777015 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -41,6 +41,7 @@ import { PostSignupActionManager } from '@/components/Global/PostSignupActionMan import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType' +import useKycStatus from '@/hooks/useKycStatus' 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 @@ -61,6 +62,7 @@ export default function Home() { const { isFetchingUser, addAccount } = useAuth() const { user } = useUserStore() + const { isUserKycApproved } = useKycStatus() const username = user?.user.username const [showIOSPWAInstallModal, setShowIOSPWAInstallModal] = useState(false) @@ -214,11 +216,7 @@ export default function Home() {
- +
diff --git a/src/app/kyc/success/page.tsx b/src/app/kyc/success/page.tsx new file mode 100644 index 000000000..7e050b722 --- /dev/null +++ b/src/app/kyc/success/page.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' +import Image from 'next/image' +import { HandThumbsUp } from '@/assets' + +/* +This page is just to let users know that their KYC was successful. Incase there's some issue with webosckets closing the modal, ideally this should not happen but added this as fallback guide +*/ +export default function KycSuccessPage() { + useEffect(() => { + if (window.parent) { + window.parent.postMessage({ source: 'peanut-kyc-success' }, '*') + } + }, []) + + return ( +
+ Peanut HandThumbsUp +
+

Verification successful!

+

You can now close this window.

+
+
+ ) +} diff --git a/src/components/AddMoney/components/InputAmountStep.tsx b/src/components/AddMoney/components/InputAmountStep.tsx index aae6e9ad8..b2ffe835c 100644 --- a/src/components/AddMoney/components/InputAmountStep.tsx +++ b/src/components/AddMoney/components/InputAmountStep.tsx @@ -5,34 +5,33 @@ import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import TokenAmountInput from '@/components/Global/TokenAmountInput' import { useRouter } from 'next/navigation' -import { CountryData } from '../consts' import ErrorAlert from '@/components/Global/ErrorAlert' import { useCurrency } from '@/hooks/useCurrency' import PeanutLoading from '@/components/Global/PeanutLoading' +type ICurrency = ReturnType interface InputAmountStepProps { onSubmit: () => void - selectedCountry: CountryData isLoading: boolean tokenAmount: string setTokenAmount: React.Dispatch> setTokenUSDAmount: React.Dispatch> error: string | null + currencyData?: ICurrency } const InputAmountStep = ({ tokenAmount, setTokenAmount, onSubmit, - selectedCountry, isLoading, error, setTokenUSDAmount, + currencyData, }: InputAmountStepProps) => { const router = useRouter() - const currencyData = useCurrency(selectedCountry.currency ?? 'ARS') - if (currencyData.isLoading) { + if (currencyData?.isLoading) { return } diff --git a/src/components/AddMoney/components/RegionalMethods/MercadoPago/index.tsx b/src/components/AddMoney/components/RegionalMethods/MercadoPago/index.tsx index d5e2e0493..42e44eb6d 100644 --- a/src/components/AddMoney/components/RegionalMethods/MercadoPago/index.tsx +++ b/src/components/AddMoney/components/RegionalMethods/MercadoPago/index.tsx @@ -1,9 +1,15 @@ -import React, { FC, useMemo, useState } from 'react' +'use client' +import { FC, useEffect, useMemo, useState } from 'react' import MercadoPagoDepositDetails from './MercadoPagoDepositDetails' import InputAmountStep from '../../InputAmountStep' -import { useParams } from 'next/navigation' -import { countryData } from '@/components/AddMoney/consts' +import { useParams, useRouter } from 'next/navigation' +import { CountryData, countryData } from '@/components/AddMoney/consts' import { MantecaDepositDetails } from '@/types/manteca.types' +import { InitiateMantecaKYCModal } from '@/components/Kyc/InitiateMantecaKYCModal' +import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' +import { useCurrency } from '@/hooks/useCurrency' +import { useAuth } from '@/context/authContext' +import { useWebSocket } from '@/hooks/useWebSocket' import { mantecaApi } from '@/services/manteca' interface MercadoPagoProps { @@ -14,22 +20,57 @@ type stepType = 'inputAmount' | 'depositDetails' const MercadoPago: FC = ({ source }) => { const params = useParams() + const router = useRouter() const [step, setStep] = useState('inputAmount') const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [tokenAmount, setTokenAmount] = useState('') const [tokenUSDAmount, setTokenUSDAmount] = useState('') const [error, setError] = useState(null) const [depositDetails, setDepositDetails] = useState() + const [isKycModalOpen, setIsKycModalOpen] = useState(false) const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) + const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry as CountryData }) + const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') + const { user, fetchUser } = useAuth() + + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: !!user?.user.username, + onMantecaKycStatusUpdate: (newStatus) => { + // listen for manteca kyc status updates, either when the user is approved or when the widget is finished to continue with the flow + if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') { + fetchUser() + setIsKycModalOpen(false) + } + }, + }) + + const handleKycCancel = () => { + setIsKycModalOpen(false) + if (selectedCountry?.path) { + router.push(`/add-money/${selectedCountry.path}`) + } + } const handleAmountSubmit = async () => { if (!selectedCountry?.currency) return if (isCreatingDeposit) return + // check if we still need to determine KYC status + if (isMantecaKycRequired === null) { + // still loading/determining KYC status, don't proceed yet + return + } + + if (isMantecaKycRequired === true) { + setIsKycModalOpen(true) + return + } + try { setError(null) setIsCreatingDeposit(true) @@ -51,19 +92,41 @@ const MercadoPago: FC = ({ source }) => { } } + // handle verification modal opening + useEffect(() => { + if (isMantecaKycRequired) { + setIsKycModalOpen(true) + } + }, [isMantecaKycRequired, countryData]) + if (!selectedCountry) return null if (step === 'inputAmount') { return ( - + <> + + {isKycModalOpen && ( + { + // close the modal and let the user continue with amount input + setIsKycModalOpen(false) + fetchUser() + }} + country={selectedCountry} + /> + )} + ) } diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index ac27ca5c9..365c99736 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -4,6 +4,20 @@ import { METAMASK_LOGO, RAINBOW_LOGO, TRUST_WALLET_LOGO } from '@/assets/wallets import { IconName } from '@/components/Global/Icons/Icon' import { StaticImageData } from 'next/image' +// ref: https://docs.manteca.dev/cripto/key-concepts/exchanges-multi-country#Available-Exchanges +export const MantecaSupportedExchanges = { + AR: 'ARGENTINA', + CL: 'CHILE', + BR: 'BRAZIL', + CO: 'COLOMBIA', + PA: 'PANAMA', + CR: 'COSTA_RICA', + GT: 'GUATEMALA', + // MX: 'MEXICO', // manteca supports MEXICO, but mercado pago doesnt support qr payments for mexico + PH: 'PHILIPPINES', + BO: 'BOLIVIA', +} + export interface CryptoSource { id: string name: string @@ -2579,7 +2593,7 @@ countryData.forEach((country) => { // 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') { - return LATAM_COUNTRY_CODES.includes(countryCode) + return !!MantecaSupportedExchanges[countryCode as keyof typeof MantecaSupportedExchanges] } return true }).map((m) => { @@ -2590,10 +2604,13 @@ countryData.forEach((country) => { } else if (newMethod.id === 'crypto-add') { newMethod.path = `/add-money/crypto` newMethod.isSoon = false - } else if (newMethod.id === 'mercado-pago-add' && countryCode === 'AR') { + } else if ( + newMethod.id === 'mercado-pago-add' && + MantecaSupportedExchanges[countryCode as keyof typeof MantecaSupportedExchanges] + ) { newMethod.isSoon = false newMethod.path = `/add-money/${country.path}/mercadopago` - } else { + } else if (newMethod.id === 'mercado-pago-add') { newMethod.isSoon = true } return newMethod diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 9614a5394..e67b73bc2 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -12,7 +12,6 @@ import { useParams, useRouter } from 'next/navigation' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' import { useEffect, useMemo, useRef, useState } from 'react' -import { InitiateKYCModal } from '@/components/Kyc' import { DynamicBankAccountForm, IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount, updateUserById } from '@/app/actions/users' import { BridgeKycStatus } from '@/utils/bridge-accounts.utils' @@ -26,6 +25,7 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import CryptoMethodDrawer from '../AddMoney/components/CryptoMethodDrawer' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' +import { InitiateBridgeKYCModal } from '../Kyc/InitiateBridgeKYCModal' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -244,7 +244,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> - setIsKycModalOpen(false)} onKycSuccess={handleKycSuccess} @@ -348,7 +348,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { closeDrawer={() => setIsDrawerOpen(false)} /> )} - setIsKycModalOpen(false)} onKycSuccess={handleKycSuccess} diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 471a9cb46..1e8990937 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -51,6 +51,7 @@ import { Button } from '@/components/0_Bruddle' import Image from 'next/image' import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' import { GuestVerificationModal } from '@/components/Global/GuestVerificationModal' +import useKycStatus from '@/hooks/useKycStatus' import MantecaFlowManager from './MantecaFlowManager' export const InitialClaimLinkView = (props: IClaimScreenProps) => { @@ -113,6 +114,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { const searchParams = useSearchParams() const prevRecipientType = useRef(null) const prevUser = useRef(user) + const { isUserBridgeKycApproved } = useKycStatus() useEffect(() => { if (!prevUser.current && user) { @@ -334,7 +336,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { recipient: recipient.name ?? recipient.address, password: '', }) - if (user?.user.bridgeKycStatus === 'approved') { + if (isUserBridgeKycApproved) { const account = user.accounts.find( (account) => account.identifier.replaceAll(/\s/g, '').toLowerCase() === diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index a7adde123..403c4ac1d 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -24,13 +24,13 @@ 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 { BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' +import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' type BankAccountWithId = IBankAccountDetails & ( @@ -484,7 +484,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { initialData={{}} error={error} /> - setIsKycModalOpen(false)} onKycSuccess={handleKycSuccess} diff --git a/src/components/Common/CountryList.tsx b/src/components/Common/CountryList.tsx index 4d467357d..22ab13a5e 100644 --- a/src/components/Common/CountryList.tsx +++ b/src/components/Common/CountryList.tsx @@ -1,10 +1,10 @@ 'use client' -import { countryCodeMap, CountryData, countryData } from '@/components/AddMoney/consts' +import { countryCodeMap, CountryData, countryData, MantecaSupportedExchanges } 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 { useMemo, useState, type ReactNode } from 'react' import { getCardPosition } from '../Global/Card' import { useGeoLocaion } from '@/hooks/useGeoLocaion' import { CountryListSkeleton } from './CountryListSkeleton' @@ -13,10 +13,11 @@ import StatusBadge from '../Global/Badges/StatusBadge' interface CountryListViewProps { inputTitle: string - viewMode: 'claim-request' | 'add-withdraw' + viewMode: 'claim-request' | 'add-withdraw' | 'general-verification' onCountryClick: (country: CountryData) => void onCryptoClick?: (flow: 'add' | 'withdraw') => void flow?: 'add' | 'withdraw' + getRightContent?: (country: CountryData, isSupported: boolean) => ReactNode } /** @@ -24,19 +25,26 @@ interface CountryListViewProps { * * @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 {string} props.viewMode The view mode of the list, either 'claim-request' or 'add-withdraw' or 'general-verification' * @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) => { +export const CountryList = ({ + inputTitle, + viewMode, + onCountryClick, + onCryptoClick, + flow, + getRightContent, +}: CountryListViewProps) => { const [searchTerm, setSearchTerm] = useState('') const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocaion() const supportedCountries = useMemo(() => { return countryData.filter((country) => country.type === 'country') - }, [viewMode]) + }, []) // sort countries based on user's geo location, fallback to alphabetical order const sortedCountries = useMemo(() => { @@ -105,17 +113,49 @@ export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClic const twoLetterCountryCode = countryCodeMap[country.id.toUpperCase()] ?? country.id.toLowerCase() const position = getCardPosition(index, filteredCountries.length) + + const isBridgeSupportedCountry = [ + 'US', + 'MX', + ...Object.keys(countryCodeMap), + ...Object.values(countryCodeMap), + ].includes(country.id) + const isMantecaSupportedCountry = Object.keys(MantecaSupportedExchanges).includes( + country.id + ) + + // determine if country is supported based on view mode + let isSupported = false + + if (viewMode === 'add-withdraw') { + // all countries supported for claim-request + isSupported = true + } else if (viewMode === 'general-verification') { + // support all bridge and manteca supported countries + isSupported = isBridgeSupportedCountry || isMantecaSupportedCountry + } else if (viewMode === 'claim-request') { + // support all countries + isSupported = isBridgeSupportedCountry + } else { + // support all countries + isSupported = true + } + // flag used to show soon badge based on the view mode, check country code map keys and values for supported countries - const isSupported = - viewMode === 'add-withdraw' || - ['US', 'MX', ...Object.keys(countryCodeMap), ...Object.values(countryCodeMap)].includes( - country.id - ) + // const isSupported = + // viewMode === 'add-withdraw' || + // viewMode === 'general-verification' || + // ['US', 'MX', ...Object.keys(countryCodeMap), ...Object.values(countryCodeMap)].includes( + // country.id + // ) + + const customRight = getRightContent ? getRightContent(country, isSupported) : undefined + return ( } + rightContent={customRight ?? (!isSupported && )} description={country.currency} onClick={() => onCountryClick(country)} position={position} diff --git a/src/components/Global/Footer/index.tsx b/src/components/Global/Footer/index.tsx deleted file mode 100644 index c03717120..000000000 --- a/src/components/Global/Footer/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import Link from 'next/link' - -import * as _consts from './consts' - -const Footer = () => { - return ( -
-
-
- {_consts.SOCIALS.map((social) => { - return ( - - {social.name} - - ) - })} -
- {/* note: temporarily removed links from fotter */} - {/*
- {_consts.LINKS.map((link) => { - return ( - - {link.name} - - ) - })} -
*/} -
-
- ) -} - -export default Footer diff --git a/src/components/Global/IconStack.tsx b/src/components/Global/IconStack.tsx index d08e06de4..5f3e46999 100644 --- a/src/components/Global/IconStack.tsx +++ b/src/components/Global/IconStack.tsx @@ -5,9 +5,10 @@ interface IconStackProps { icons: string[] iconSize?: number iconClassName?: string + imageClassName?: string } -const IconStack: React.FC = ({ icons, iconSize = 24, iconClassName = '' }) => { +const IconStack: React.FC = ({ icons, iconSize = 24, iconClassName = '', imageClassName }) => { return (
{icons.map((icon, index) => ( @@ -24,7 +25,7 @@ const IconStack: React.FC = ({ icons, iconSize = 24, iconClassNa alt={`icon-${index}`} width={iconSize} height={iconSize} - className="min-h-6 min-w-6 rounded-full" + className={twMerge('min-h-6 min-w-6 rounded-full', imageClassName)} />
))} diff --git a/src/components/Global/Layout/index.tsx b/src/components/Global/Layout/index.tsx index 46e0d5521..c12f4aa59 100644 --- a/src/components/Global/Layout/index.tsx +++ b/src/components/Global/Layout/index.tsx @@ -1,7 +1,6 @@ 'use client' import { Banner } from '@/components/Global/Banner' -import Footer from '@/components/Global/Footer' import { ThemeProvider } from '@/config' import { useFooterVisibility } from '@/context/footerVisibility' import { Widget } from '@typeform/embed-react' diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index f694a4713..6be08b5ab 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -14,8 +14,7 @@ import { twMerge } from 'tailwind-merge' import Card, { CardPosition, getCardPosition } from '../Global/Card' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem } from '../Kyc/KycStatusItem' -import { isKycStatusItem, KycHistoryEntry } from '@/hooks/useKycFlow' -import { BridgeKycStatus } from '@/utils' +import { isKycStatusItem, KycHistoryEntry } from '@/hooks/useBridgeKycFlow' import { useWallet } from '@/hooks/wallet/useWallet' import { useUserInteractions } from '@/hooks/useUserInteractions' @@ -36,7 +35,6 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern isError, error, } = useTransactionHistory({ mode, limit, username, filterMutualTxs, enabled: isLoggedIn }) - const bridgeKycStatus: BridgeKycStatus = user?.user?.bridgeKycStatus || 'not_started' // check if the username is the same as the current user const isSameUser = username === user?.user.username const { fetchBalance, getRewardWalletBalance } = useWallet() @@ -83,7 +81,7 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern const { interactions } = useUserInteractions(userIds) useEffect(() => { - if (historyData?.entries) { + if (!isLoading && historyData?.entries) { // Start with the fetched entries const entries: Array = [...historyData.entries] @@ -118,17 +116,22 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern // Add KYC status item if applicable and not on a public page // and the user is viewing their own history - if ( - isSameUser && - user?.user?.bridgeKycStatus && - user.user.bridgeKycStatus !== 'not_started' && - user.user.bridgeKycStartedAt && - !isPublic - ) { - entries.push({ - isKyc: true, - timestamp: user.user.bridgeKycStartedAt, - uuid: 'kyc-status-item', + if (isSameUser && !isPublic) { + if (user?.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { + entries.push({ + isKyc: true, + timestamp: user.user.bridgeKycStartedAt ?? new Date(0).toISOString(), + uuid: 'bridge-kyc-status-item', + bridgeKycStatus: user.user.bridgeKycStatus, + }) + } + user?.user.kycVerifications?.forEach((verification) => { + entries.push({ + isKyc: true, + timestamp: verification.approvedAt ?? new Date(0).toISOString(), + uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, + verification, + }) }) } @@ -142,7 +145,7 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern // Limit to the most recent entries setCombinedEntries(entries.slice(0, isPublic ? 20 : 5)) } - }, [historyData, wsHistoryEntries, isPublic, user]) + }, [historyData, wsHistoryEntries, isPublic, user, isLoading]) const pendingRequests = useMemo(() => { if (!combinedEntries.length) return [] @@ -182,22 +185,45 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern } // show empty state if no transactions exist - if (!combinedEntries.length) { + if (!isLoading && !combinedEntries.length) { return (
- {isSameUser && bridgeKycStatus !== 'not_started' && ( -
-

Activity

- -
- )} + {isSameUser && + (user?.user.bridgeKycStatus !== 'not_started' || + (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( +
+

Activity

+ {user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started' && ( + + )} + {user?.user.kycVerifications?.map((verification) => ( + + ))} +
+ )} -

Recent Transactions

- + {!user?.user.bridgeKycStatus && + (!user?.user.kycVerifications || user?.user.kycVerifications.length === 0) && ( + <> +

Recent Transactions

+ + + )}
) } @@ -257,7 +283,17 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern // Render KYC status item if it's its turn in the sorted list if (isKycStatusItem(item)) { - return + return ( + + ) } // map the raw history entry to the format needed by the ui components diff --git a/src/components/Kyc/Country-Flag-And-Name.tsx b/src/components/Kyc/Country-Flag-And-Name.tsx new file mode 100644 index 000000000..dbc72d5de --- /dev/null +++ b/src/components/Kyc/Country-Flag-And-Name.tsx @@ -0,0 +1,37 @@ +import Image from 'next/image' +import { countryData } from '../AddMoney/consts' +import IconStack from '../Global/IconStack' + +interface CountryFlagAndNameProps { + countryCode?: string + isBridgeRegion?: boolean +} + +export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagAndNameProps) => { + const countryName = countryData.find((c) => c.id === countryCode?.toUpperCase())?.title + return ( +
+ {isBridgeRegion ? ( + + ) : ( + {`${countryName} + )} + {isBridgeRegion ? 'US/EU/MX' : countryName} +
+ ) +} diff --git a/src/components/Kyc/CountryRegionRow.tsx b/src/components/Kyc/CountryRegionRow.tsx new file mode 100644 index 000000000..64cdb13ea --- /dev/null +++ b/src/components/Kyc/CountryRegionRow.tsx @@ -0,0 +1,20 @@ +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { CountryFlagAndName } from './Country-Flag-And-Name' + +interface CountryRegionRowProps { + countryCode?: string | null + isBridge?: boolean +} + +export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProps) => { + if (!isBridge && !countryCode) { + return null + } + + return ( + } + /> + ) +} diff --git a/src/components/Kyc/index.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx similarity index 89% rename from src/components/Kyc/index.tsx rename to src/components/Kyc/InitiateBridgeKYCModal.tsx index bef0cb809..ec822e8e2 100644 --- a/src/components/Kyc/index.tsx +++ b/src/components/Kyc/InitiateBridgeKYCModal.tsx @@ -1,12 +1,12 @@ import ActionModal from '@/components/Global/ActionModal' -import { useKycFlow } from '@/hooks/useKycFlow' +import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' import IframeWrapper from '@/components/Global/IframeWrapper' import { KycVerificationInProgressModal } from './KycVerificationInProgressModal' import { IconName } from '@/components/Global/Icons/Icon' import { saveRedirectUrl } from '@/utils' import useClaimLink from '../Claim/useClaimLink' -interface KycModalFlowProps { +interface BridgeKycModalFlowProps { isOpen: boolean onClose: () => void onKycSuccess?: () => void @@ -14,7 +14,13 @@ interface KycModalFlowProps { flow?: 'add' | 'withdraw' | 'request_fulfillment' } -export const InitiateKYCModal = ({ isOpen, onClose, onKycSuccess, onManualClose, flow }: KycModalFlowProps) => { +export const InitiateBridgeKYCModal = ({ + isOpen, + onClose, + onKycSuccess, + onManualClose, + flow, +}: BridgeKycModalFlowProps) => { const { isLoading, error, @@ -23,7 +29,7 @@ export const InitiateKYCModal = ({ isOpen, onClose, onKycSuccess, onManualClose, handleInitiateKyc, handleIframeClose, closeVerificationProgressModal, - } = useKycFlow({ onKycSuccess, flow, onManualClose }) + } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) const { addParamStep } = useClaimLink() const handleVerifyClick = async () => { diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx new file mode 100644 index 000000000..19e22a426 --- /dev/null +++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx @@ -0,0 +1,123 @@ +'use client' + +import ActionModal from '@/components/Global/ActionModal' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { IconName } from '@/components/Global/Icons/Icon' +import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' +import { CountryData } from '@/components/AddMoney/consts' +import { Button } from '../0_Bruddle' +import { PeanutDoesntStoreAnyPersonalInformation } from './KycVerificationInProgressModal' + +interface Props { + isOpen: boolean + onClose: () => void + onKycSuccess?: () => void + onManualClose?: () => void + country: CountryData + title?: string | React.ReactNode + description?: string | React.ReactNode + ctaText?: string + footer?: React.ReactNode +} + +export const InitiateMantecaKYCModal = ({ + isOpen, + onClose, + onKycSuccess, + onManualClose, + country, + title, + description, + ctaText, + footer, +}: Props) => { + const { isLoading, iframeOptions, openMantecaKyc, handleIframeClose } = useMantecaKycFlow({ + onClose: onManualClose, // any non-success close from iframe is a manual close in case of Manteca KYC + onSuccess: onKycSuccess, + onManualClose, + country, + }) + + return ( + <> + openMantecaKyc(country), + variant: 'purple', + disabled: isLoading, + shadowSize: '4', + icon: 'check-circle', + className: 'h-11', + }, + ]} + footer={footer} + /> + + + ) +} + +export const MantecaGeoSpecificKycModal = ({ + isUserBridgeKycApproved, + selectedCountry, + setIsMantecaModalOpen, + isMantecaModalOpen, +}: { + isUserBridgeKycApproved: boolean + selectedCountry: { id: string; title: string } + setIsMantecaModalOpen: (isOpen: boolean) => void + isMantecaModalOpen: boolean +}) => { + return ( + + You're already verified in Europe, USA, and Mexico, but to use features in{' '} + {selectedCountry.title} you need to complete a separate verification.
Since{' '} + we don't keep personal data, your previous KYC can't be reused. +

+ ) : ( +

+ Verify your identity to start using features like Mercado Pago payments in{' '} + {selectedCountry.title}.{' '} +

+ ) + } + footer={ + isUserBridgeKycApproved ? ( + + ) : ( + + ) + } + ctaText="Start Verification" + isOpen={isMantecaModalOpen} + onClose={() => setIsMantecaModalOpen(false)} + onKycSuccess={() => { + setIsMantecaModalOpen(false) + }} + onManualClose={() => setIsMantecaModalOpen(false)} + country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }} + /> + ) +} diff --git a/src/components/Kyc/KycFlow.tsx b/src/components/Kyc/KycFlow.tsx index 2a6504aa8..383c699ea 100644 --- a/src/components/Kyc/KycFlow.tsx +++ b/src/components/Kyc/KycFlow.tsx @@ -1,12 +1,12 @@ import { Button, ButtonProps } from '@/components/0_Bruddle/Button' import IframeWrapper from '@/components/Global/IframeWrapper' -import { useKycFlow } from '@/hooks/useKycFlow' +import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' // this component is the main entry point for the kyc flow // it renders a button that, when clicked, initiates the process of fetching // tos/kyc links, showing them in an iframe, and then displaying a status modal export const KycFlow = (props: ButtonProps) => { - const { isLoading, error, iframeOptions, handleInitiateKyc, handleIframeClose } = useKycFlow() + const { isLoading, error, iframeOptions, handleInitiateKyc, handleIframeClose } = useBridgeKycFlow() return ( <> diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index cd43a6caf..bf8834563 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -6,57 +6,79 @@ import PeanutLoading from '@/components/Global/PeanutLoading' import { Drawer, DrawerContent } from '../Global/Drawer' import { BridgeKycStatus } from '@/utils' import { getKycDetails } from '@/app/actions/users' -import { useKycFlow } from '@/hooks/useKycFlow' -import IFrameWrapper from '../Global/IframeWrapper' +import { IUserKycVerification, MantecaKycStatus } from '@/interfaces' +import { useUserStore } from '@/redux/hooks' +import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' +import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' +import { CountryData, countryData } from '@/components/AddMoney/consts' +import IFrameWrapper from '@/components/Global/IframeWrapper' // a helper to categorize the kyc status from the user object -const getKycStatusCategory = (bridgeKycStatus: BridgeKycStatus): 'processing' | 'completed' | 'failed' => { - let category: 'processing' | 'completed' | 'failed' - switch (bridgeKycStatus) { - // note: not_started status is handled explicitly in KycStatusItem component +const getKycStatusCategory = (status: BridgeKycStatus | MantecaKycStatus): 'processing' | 'completed' | 'failed' => { + switch (status) { case 'approved': - category = 'completed' - break + case MantecaKycStatus.ACTIVE: + return 'completed' case 'rejected': - category = 'failed' - break + case MantecaKycStatus.INACTIVE: + return 'failed' case 'under_review': case 'incomplete': + case MantecaKycStatus.ONBOARDING: default: - category = 'processing' - break + return 'processing' } - return category } interface KycStatusDrawerProps { isOpen: boolean onClose: () => void - bridgeKycStatus: BridgeKycStatus - bridgeKycStartedAt?: string - bridgeKycApprovedAt?: string - bridgeKycRejectedAt?: string + verification?: IUserKycVerification + bridgeKycStatus?: BridgeKycStatus } // this component determines which kyc state to show inside the drawer and fetches rejection reasons if the kyc has failed. -export const KycStatusDrawer = ({ - isOpen, - onClose, - bridgeKycStatus, - bridgeKycStartedAt, - bridgeKycApprovedAt, - bridgeKycRejectedAt, -}: KycStatusDrawerProps) => { +export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus }: KycStatusDrawerProps) => { const [rejectionReason, setRejectionReason] = useState(null) const [isLoading, setIsLoading] = useState(false) + const { user } = useUserStore() + + const status = verification ? verification.status : bridgeKycStatus + const statusCategory = status ? getKycStatusCategory(status) : undefined + const countryCode = verification ? verification.mantecaGeo || verification.bridgeGeo : null + const isBridgeKyc = !verification && !!bridgeKycStatus + const provider = verification ? verification.provider : 'BRIDGE' + const { - handleInitiateKyc, - iframeOptions, - handleIframeClose, - isLoading: isKycFlowLoading, - } = useKycFlow({ onKycSuccess: onClose }) + handleInitiateKyc: initiateBridgeKyc, + iframeOptions: bridgeIframeOptions, + handleIframeClose: handleBridgeIframeClose, + isLoading: isBridgeLoading, + } = useBridgeKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + + const country = countryCode ? countryData.find((c) => c.id.toUpperCase() === countryCode.toUpperCase()) : undefined + + const { + openMantecaKyc, + iframeOptions: mantecaIframeOptions, + handleIframeClose: handleMantecaIframeClose, + isLoading: isMantecaLoading, + } = useMantecaKycFlow({ + onSuccess: onClose, + onClose: onClose, + onManualClose: onClose, + country: country as CountryData, + }) + + const onRetry = async () => { + if (provider === 'MANTECA') { + await openMantecaKyc(country as CountryData) + } else { + await initiateBridgeKyc() + } + } - const statusCategory = getKycStatusCategory(bridgeKycStatus) + const isLoadingKyc = isBridgeLoading || isMantecaLoading useEffect(() => { // if the drawer is open and the kyc has failed, fetch the reason @@ -96,15 +118,30 @@ export const KycStatusDrawer = ({ switch (statusCategory) { case 'processing': - return + return ( + + ) case 'completed': - return + return ( + + ) case 'failed': return ( ) default: @@ -113,7 +150,7 @@ export const KycStatusDrawer = ({ } // don't render the drawer if the kyc status is unknown or not started - if (bridgeKycStatus === 'not_started') { + if (status === 'not_started' || !status) { return null } @@ -122,12 +159,8 @@ export const KycStatusDrawer = ({ {renderContent()} - + + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index bb0443751..ac4780fd8 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -8,14 +8,21 @@ import { useWebSocket } from '@/hooks/useWebSocket' import { BridgeKycStatus, formatDate } from '@/utils' import { HTMLAttributes } from 'react' import { twMerge } from 'tailwind-merge' +import { IUserKycVerification } from '@/interfaces' // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ position = 'first', className, + verification, + bridgeKycStatus, + bridgeKycStartedAt, }: { position?: CardPosition className?: HTMLAttributes['className'] + verification?: IUserKycVerification + bridgeKycStatus?: BridgeKycStatus + bridgeKycStartedAt?: string }) => { const { user } = useUserStore() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -34,22 +41,25 @@ export const KycStatusItem = ({ }, }) - const birdgeKycStatus = wsBridgeKycStatus || user?.user?.bridgeKycStatus + const finalBridgeKycStatus = wsBridgeKycStatus || bridgeKycStatus || user?.user?.bridgeKycStatus + const kycStatus = verification ? verification.status : finalBridgeKycStatus const subtitle = useMemo(() => { - const bridgeKycStartedAt = user?.user?.bridgeKycStartedAt - if (!bridgeKycStartedAt) { + const date = verification + ? (verification.approvedAt ?? verification.updatedAt ?? verification.createdAt) + : bridgeKycStartedAt + if (!date) { return 'Verification in progress' } try { - return `Submitted on ${formatDate(new Date(bridgeKycStartedAt)).split(' - ')[0]}` + return `Submitted on ${formatDate(new Date(date)).split(' - ')[0]}` } catch (error) { - console.error('Failed to parse bridgeKycStartedAt date:', error) + console.error('Failed to parse date:', error) return 'Verification in progress' } - }, [user?.user?.bridgeKycStartedAt]) + }, [bridgeKycStartedAt, verification]) - if (!birdgeKycStatus || birdgeKycStatus === 'not_started') { + if (!kycStatus || kycStatus === 'not_started') { return null } @@ -74,10 +84,8 @@ export const KycStatusItem = ({ ) diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 9c9d57b28..526798964 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -1,6 +1,7 @@ import { useRouter } from 'next/navigation' import ActionModal from '@/components/Global/ActionModal' import { Icon, IconName } from '@/components/Global/Icons/Icon' +import { twMerge } from 'tailwind-merge' interface KycVerificationInProgressModalProps { isOpen: boolean @@ -43,12 +44,16 @@ export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificat ]} preventClose hideModalCloseButton - footer={ -
- - Peanut doesn't store any personal information -
- } + footer={} /> ) } + +export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { + return ( +
+ + Peanut doesn't store any personal information +
+ ) +} diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 4daf99c7c..8ffd1f413 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -3,9 +3,18 @@ import { KYCStatusDrawerItem } from '../KycStatusItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useMemo } from 'react' import { formatDate } from '@/utils' +import { CountryRegionRow } from '../CountryRegionRow' // this component shows the kyc status when it's completed/approved. -export const KycCompleted = ({ bridgeKycApprovedAt }: { bridgeKycApprovedAt?: string }) => { +export const KycCompleted = ({ + bridgeKycApprovedAt, + countryCode, + isBridge, +}: { + bridgeKycApprovedAt?: string + countryCode?: string | null + isBridge?: boolean +}) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' try { @@ -21,11 +30,8 @@ export const KycCompleted = ({ bridgeKycApprovedAt }: { bridgeKycApprovedAt?: st - + +
) diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 855680e28..459746149 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -4,17 +4,24 @@ import { KYCStatusDrawerItem } from '../KycStatusItem' import Card from '@/components/Global/Card' import { useMemo } from 'react' import { formatDate } from '@/utils' +import { CountryRegionRow } from '../CountryRegionRow' // this component shows the kyc status when it's failed/rejected. // it displays the reason for the failure and provides a retry button. export const KycFailed = ({ reason, bridgeKycRejectedAt, + countryCode, + isBridge, onRetry, + isLoading, }: { reason: string | null bridgeKycRejectedAt?: string + countryCode?: string | null + isBridge?: boolean onRetry: () => void + isLoading?: boolean }) => { const rejectedOn = useMemo(() => { if (!bridgeKycRejectedAt) return 'N/A' @@ -32,11 +39,23 @@ export const KycFailed = ({ - + + + - {/* as requested, this button is currently for ui purposes and will be implemented later. */} -
) diff --git a/src/components/Kyc/states/KycProcessing.tsx b/src/components/Kyc/states/KycProcessing.tsx index 74a2ad682..5fa1cfe4d 100644 --- a/src/components/Kyc/states/KycProcessing.tsx +++ b/src/components/Kyc/states/KycProcessing.tsx @@ -3,9 +3,18 @@ import { KYCStatusDrawerItem } from '../KycStatusItem' import Card from '@/components/Global/Card' import { useMemo } from 'react' import { formatDate } from '@/utils' +import { CountryRegionRow } from '../CountryRegionRow' // this component shows the kyc status while it's being processed. -export const KycProcessing = ({ bridgeKycStartedAt }: { bridgeKycStartedAt?: string }) => { +export const KycProcessing = ({ + bridgeKycStartedAt, + countryCode, + isBridge, +}: { + bridgeKycStartedAt?: string + countryCode?: string | null + isBridge?: boolean +}) => { const submittedOn = useMemo(() => { if (!bridgeKycStartedAt) return 'N/A' try { @@ -21,7 +30,7 @@ export const KycProcessing = ({ bridgeKycStartedAt }: { bridgeKycStartedAt?: str - + = ({ haveSentMoneyToUser = false, }) => { const { user: authenticatedUser } = useAuth() - const isAuthenticatedUserVerified = authenticatedUser?.user.bridgeKycStatus === 'approved' + const { isUserKycApproved } = useKycStatus() + const isAuthenticatedUserVerified = isUserKycApproved && authenticatedUser?.user.username === username const [isDrawerOpen, setIsDrawerOpen] = useState(false) const profileUrl = `${BASE_URL}/${username}` - console.log('isVerified', isVerified) - return ( <>
diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 81bfa1181..7b540dfba 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -17,6 +17,7 @@ import Card from '@/components/Global/Card' import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif' import { checkIfInternalNavigation } from '@/utils' import { useAuth } from '@/context/authContext' +import { MantecaKycStatus } from '@/interfaces' interface PublicProfileProps { username: string @@ -45,7 +46,14 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa useEffect(() => { usersApi.getByUsername(username).then((user) => { if (user?.fullName) setFullName(user.fullName) - if (user?.bridgeKycStatus === 'approved') setIsKycVerified(true) + if ( + user?.bridgeKycStatus === 'approved' || + user?.kycVerifications?.some((v) => v.status === MantecaKycStatus.ACTIVE) + ) { + setIsKycVerified(true) + } else { + setIsKycVerified(false) + } // to check if the logged in user has sent money to the profile user, // we check the amount that the profile user has received from the logged in user. if (user?.totalUsdReceivedFromCurrentUser) { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index c924876f0..65f9e884c 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -8,13 +8,12 @@ import ProfileHeader from './components/ProfileHeader' import ProfileMenuItem from './components/ProfileMenuItem' import { useRouter } from 'next/navigation' import { checkIfInternalNavigation } from '@/utils' -import ActionModal from '../Global/ActionModal' -import { useState } from 'react' +import useKycStatus from '@/hooks/useKycStatus' export const Profile = () => { const { logoutUser, isLoggingOut, user } = useAuth() - const [isKycApprovedModalOpen, setIsKycApprovedModalOpen] = useState(false) const router = useRouter() + const { isUserKycApproved } = useKycStatus() const logout = async () => { await logoutUser() @@ -23,8 +22,6 @@ export const Profile = () => { const fullName = user?.user.fullName || user?.user?.username || 'Anonymous User' const username = user?.user.username || 'anonymous' - const isKycApproved = user?.user.bridgeKycStatus === 'approved' - return (
{ }} />
- +
{/* Menu Item - Invite Entry */} {/* Enable with Invites project. */} @@ -60,15 +57,9 @@ export const Profile = () => { label="Identity Verification" href="/profile/identity-verification" onClick={() => { - if (isKycApproved) { - setIsKycApprovedModalOpen(true) - } else { - router.push('/profile/identity-verification') - } + router.push('/profile/identity-verification') }} position="middle" - endIcon={isKycApproved ? 'check' : undefined} - endIconClassName={isKycApproved ? 'text-success-3 size-4' : undefined} /> {/* Enable with Account Management project. */} {/* {
- - 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 index 370908758..5f74d4bb7 100644 --- a/src/components/Profile/views/IdentityVerification.view.tsx +++ b/src/components/Profile/views/IdentityVerification.view.tsx @@ -1,15 +1,25 @@ 'use client' import { updateUserById } from '@/app/actions/users' import { Button } from '@/components/0_Bruddle' +import { countryCodeMap, MantecaSupportedExchanges } from '@/components/AddMoney/consts' import { UserDetailsForm, UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' +import { CountryList } from '@/components/Common/CountryList' 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 ActionModal from '@/components/Global/ActionModal' +import { + KycVerificationInProgressModal, + PeanutDoesntStoreAnyPersonalInformation, +} from '@/components/Kyc/KycVerificationInProgressModal' +import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' +import { Icon } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' -import { useKycFlow } from '@/hooks/useKycFlow' +import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' +import { MantecaKycStatus } from '@/interfaces' import { useRouter } from 'next/navigation' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' +import useKycStatus from '@/hooks/useKycStatus' const IdentityVerificationView = () => { const { user, fetchUser } = useAuth() @@ -18,6 +28,12 @@ const IdentityVerificationView = () => { const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) const [isUpdatingUser, setIsUpdatingUser] = useState(false) const [userUpdateError, setUserUpdateError] = useState(null) + const [showUserDetailsForm, setShowUserDetailsForm] = useState(false) + const [isAlreadyVerifiedModalOpen, setIsAlreadyVerifiedModalOpen] = useState(false) + const [isMantecaModalOpen, setIsMantecaModalOpen] = useState(false) + const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null) + const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null) + const { iframeOptions, handleInitiateKyc, @@ -26,7 +42,9 @@ const IdentityVerificationView = () => { closeVerificationProgressModal, error: kycError, isLoading: isKycLoading, - } = useKycFlow() + } = useBridgeKycFlow() + + const { isUserBridgeKycApproved } = useKycStatus() const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') const lastName = lastNameParts.join(' ') @@ -53,7 +71,6 @@ const IdentityVerificationView = () => { throw new Error(result.error) } await fetchUser() - // setStep('kyc') await handleInitiateKyc() } catch (error: any) { setUserUpdateError(error.message) @@ -64,47 +81,159 @@ const IdentityVerificationView = () => { return {} } - // if kyc is already approved, redirect to profile - useEffect(() => { - if (user?.user.bridgeKycStatus === 'approved') { + const handleBack = useCallback(() => { + if (showUserDetailsForm) { + setShowUserDetailsForm(false) + } else { router.replace('/profile') } - }, [user]) + }, [router, showUserDetailsForm]) + + // country validation helpers + const isBridgeSupportedCountry = useCallback((code: string) => { + const upper = code.toUpperCase() + return ( + upper === 'US' || + upper === 'MX' || + Object.keys(countryCodeMap).includes(upper) || + Object.values(countryCodeMap).includes(upper) + ) + }, []) + + const isMantecaSupportedCountry = useCallback((code: string) => { + const upper = code.toUpperCase() + return Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, upper) + }, []) + + const isVerifiedForCountry = useCallback( + (code: string) => { + const upper = code.toUpperCase() + // bridge approval covers us/mx/sepa generally + if (isBridgeSupportedCountry(upper) && isUserBridgeKycApproved) return true + // manteca per-geo check + const mantecaActive = user?.user.kycVerifications?.some( + (v) => + v.provider === 'MANTECA' && + (v.mantecaGeo || '').toUpperCase() === upper && + v.status === MantecaKycStatus.ACTIVE + ) + return Boolean(mantecaActive) + }, + [isBridgeSupportedCountry, user?.user.bridgeKycStatus, user?.user.kycVerifications] + ) + + // view components + const CountrySelectionView = () => ( +
+ + isVerifiedForCountry(country.id) ? ( + + ) : undefined + } + onCountryClick={(country) => { + const { id, title } = country + setUserClickedCountry({ id, title }) + + if (isVerifiedForCountry(id)) { + setIsAlreadyVerifiedModalOpen(true) + return + } + + if (isBridgeSupportedCountry(id)) { + setShowUserDetailsForm(true) + return + } + + if (isMantecaSupportedCountry(id)) { + setSelectedCountry({ id, title }) + setIsMantecaModalOpen(true) + } + }} + onCryptoClick={() => console.log('crypto')} + /> +
+ ) + + const UserDetailsFormView = () => ( +
+

Provide information to begin verification

+ + + + + + + + {(userUpdateError || kycError) && } + + + + +
+ ) + + // determine which view to show based on current state + const getCurrentView = () => { + return showUserDetailsForm ? : + } return ( -
- router.replace('/profile')} /> -
-

Provide information to begin verification

- +
+ + + {getCurrentView()} + + setIsAlreadyVerifiedModalOpen(false)} + title="You're already verified" + description={ +

+ Your identity has already been successfully verified for {userClickedCountry?.title}. You can + continue to use features available in this region. No further action is needed. +

+ } + icon="shield" + ctas={[ + { + text: 'Close', + shadowSize: '4', + className: 'md:py-2', + onClick: () => setIsAlreadyVerifiedModalOpen(false), + }, + ]} + /> - - - {(userUpdateError || kycError) && } - - - - -
+ )}
) } diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 0fd7a3c52..0993a7670 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -9,10 +9,13 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import ProfileEditField from '../components/ProfileEditField' import ProfileHeader from '../components/ProfileHeader' +import useKycStatus from '@/hooks/useKycStatus' export const ProfileEditView = () => { const router = useRouter() const { user, fetchUser } = useAuth() + const { isUserBridgeKycApproved } = useKycStatus() + const [isLoading, setIsLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') @@ -112,7 +115,7 @@ export const ProfileEditView = () => {
router.push('/profile')} /> - +
{ setIsKycModalOpen(false) diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index 9a58d0120..6116fc7e9 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -8,6 +8,7 @@ import { Tooltip } from '../Tooltip' import { useMemo } from 'react' import { isAddress } from 'viem' import { printableAddress } from '@/utils' +import useKycStatus from '@/hooks/useKycStatus' interface UserHeaderProps { username: string @@ -16,6 +17,8 @@ interface UserHeaderProps { } export const UserHeader = ({ username, fullName, isVerified }: UserHeaderProps) => { + const { isUserKycApproved: isViewerVerified } = useKycStatus() + return (
@@ -24,7 +27,11 @@ export const UserHeader = ({ username, fullName, isVerified }: UserHeaderProps) className="h-7 w-7 text-[11px] md:h-8 md:w-8 md:text-[13px]" name={fullName || username} /> - +
diff --git a/src/hooks/useKycFlow.ts b/src/hooks/useBridgeKycFlow.ts similarity index 95% rename from src/hooks/useKycFlow.ts rename to src/hooks/useBridgeKycFlow.ts index e652a3145..68466814d 100644 --- a/src/hooks/useKycFlow.ts +++ b/src/hooks/useBridgeKycFlow.ts @@ -6,17 +6,7 @@ import { useUserStore } from '@/redux/hooks' import { BridgeKycStatus, convertPersonaUrl } from '@/utils' import { InitiateKycResponse } from '@/app/actions/types/users.types' import { getKycDetails } from '@/app/actions/users' - -// persona event detail types -interface PersonaEventDetail { - inquiryId: string - status: string - sessionToken?: string -} - -interface PersonaEvent extends Event { - detail: PersonaEventDetail -} +import { IUserKycVerification } from '@/interfaces' interface UseKycFlowOptions { onKycSuccess?: () => void @@ -24,10 +14,12 @@ interface UseKycFlowOptions { onManualClose?: () => void } -export type KycHistoryEntry = { +export interface KycHistoryEntry { isKyc: true uuid: string timestamp: string + verification?: IUserKycVerification + bridgeKycStatus?: BridgeKycStatus } // type guard to check if an entry is a KYC status item in history section @@ -35,7 +27,7 @@ export const isKycStatusItem = (entry: object): entry is KycHistoryEntry => { return 'isKyc' in entry && entry.isKyc === true } -export const useKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFlowOptions = {}) => { +export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFlowOptions = {}) => { const { user } = useUserStore() const router = useRouter() const isMounted = useRef(false) diff --git a/src/hooks/useDetermineBankClaimType.ts b/src/hooks/useDetermineBankClaimType.ts index 5ebce26da..0d73234cb 100644 --- a/src/hooks/useDetermineBankClaimType.ts +++ b/src/hooks/useDetermineBankClaimType.ts @@ -2,6 +2,7 @@ import { getUserById } from '@/app/actions/users' import { useAuth } from '@/context/authContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useEffect, useState } from 'react' +import useKycStatus from './useKycStatus' export enum BankClaimType { GuestBankClaim = 'guest-bank-claim', @@ -22,11 +23,12 @@ export function useDetermineBankClaimType(senderUserId: string): { const { user } = useAuth() const [claimType, setClaimType] = useState(BankClaimType.ReceiverKycNeeded) const { setSenderDetails } = useClaimBankFlow() + const { isUserBridgeKycApproved } = useKycStatus() useEffect(() => { const determineBankClaimType = async () => { // check if receiver (logged in user) exists and is KYC approved - const receiverKycApproved = user?.user?.bridgeKycStatus === 'approved' + const receiverKycApproved = isUserBridgeKycApproved if (receiverKycApproved) { // condition 1: Receiver is KYC approved → UserBankClaim diff --git a/src/hooks/useDetermineBankRequestType.ts b/src/hooks/useDetermineBankRequestType.ts index 4b19e277a..5f6583fee 100644 --- a/src/hooks/useDetermineBankRequestType.ts +++ b/src/hooks/useDetermineBankRequestType.ts @@ -2,6 +2,7 @@ import { getUserById } from '@/app/actions/users' import { useAuth } from '@/context/authContext' import { useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { useEffect, useState } from 'react' +import useKycStatus from './useKycStatus' export enum BankRequestType { GuestBankRequest = 'guest-bank-request', @@ -22,10 +23,11 @@ export function useDetermineBankRequestType(requesterUserId: string): { const { user } = useAuth() const [requestType, setRequestType] = useState(BankRequestType.PayerKycNeeded) const { setRequesterDetails } = useRequestFulfillmentFlow() + const { isUserBridgeKycApproved } = useKycStatus() useEffect(() => { const determineBankRequestType = async () => { - const payerKycApproved = user?.user?.bridgeKycStatus === 'approved' + const payerKycApproved = isUserBridgeKycApproved if (payerKycApproved) { setRequestType(BankRequestType.UserBankRequest) diff --git a/src/hooks/useKycStatus.tsx b/src/hooks/useKycStatus.tsx new file mode 100644 index 000000000..136e1970b --- /dev/null +++ b/src/hooks/useKycStatus.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useAuth } from '@/context/authContext' +import { MantecaKycStatus } from '@/interfaces' +import { useMemo } from 'react' + +/** + * Used to get the user's KYC status for all providers - currently only bridge and manteca + * NOTE: This hook can be extended to support more providers in the future based on requirements + * @returns {object} An object with the user's KYC status for all providers and a combined status for all providers, if user is verified for any provider, return true + */ +export default function useKycStatus() { + const { user } = useAuth() + + const isUserBridgeKycApproved = user?.user.bridgeKycStatus === 'approved' + + const isUserMantecaKycApproved = useMemo( + () => + user?.user.kycVerifications?.some((verification) => verification.status === MantecaKycStatus.ACTIVE) ?? + false, + [user?.user.kycVerifications] + ) + const isUserKycApproved = isUserBridgeKycApproved || isUserMantecaKycApproved + + return { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserKycApproved } +} diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts new file mode 100644 index 000000000..25d6f697d --- /dev/null +++ b/src/hooks/useMantecaKycFlow.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react' +import type { IFrameWrapperProps } from '@/components/Global/IframeWrapper' +import { mantecaApi } from '@/services/manteca' +import { useAuth } from '@/context/authContext' +import { CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts' +import { BASE_URL } from '@/constants' +import { MantecaKycStatus } from '@/interfaces' + +type UseMantecaKycFlowOptions = { + onClose?: () => void + onSuccess?: () => void + onManualClose?: () => void + country?: CountryData +} + +export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }: UseMantecaKycFlowOptions) => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [iframeOptions, setIframeOptions] = useState>({ + src: '', + visible: false, + closeConfirmMessage: undefined, + }) + const { user } = useAuth() + const [isMantecaKycRequired, setNeedsMantecaKyc] = useState(false) + + const userKycVerifications = user?.user?.kycVerifications + + useEffect(() => { + // determine if manteca kyc is required based on geo data available in kycVerifications + const selectedGeo = country?.id + + if (selectedGeo && Array.isArray(userKycVerifications) && userKycVerifications.length > 0) { + const isuserActiveForSelectedGeo = userKycVerifications.some( + (v) => + v.provider === 'MANTECA' && + (v.mantecaGeo || '').toUpperCase() === selectedGeo.toUpperCase() && + v.status === MantecaKycStatus.ACTIVE + ) + setNeedsMantecaKyc(!isuserActiveForSelectedGeo) + return + } + + // if no verifications data available, keep as null (undetermined) + // only set to true if we have user data but no matching verification + if (user && userKycVerifications !== undefined) { + setNeedsMantecaKyc(true) + } + }, [userKycVerifications, country?.id, user]) + + const openMantecaKyc = useCallback(async (countryParam?: CountryData) => { + setIsLoading(true) + setError(null) + try { + const exchange = countryParam?.id + ? MantecaSupportedExchanges[countryParam.id as keyof typeof MantecaSupportedExchanges] + : MantecaSupportedExchanges.AR + const returnUrl = BASE_URL + '/kyc/success' + const { url } = await mantecaApi.initiateOnboarding({ returnUrl, exchange }) + setIframeOptions({ + src: url, + visible: true, + }) + return { success: true as const } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to initiate onboarding' + setError(message) + return { success: false as const, error: message } + } finally { + setIsLoading(false) + } + }, []) + + const handleIframeClose = useCallback( + (source?: 'manual' | 'completed' | 'tos_accepted') => { + setIframeOptions((prev) => ({ ...prev, visible: false })) + if (source === 'completed') { + onSuccess?.() + return + } + if (source === 'manual') { + onManualClose?.() + return + } + onClose?.() + }, + [onClose, onSuccess, onManualClose] + ) + + return { + isLoading, + error, + iframeOptions, + openMantecaKyc, + handleIframeClose, + isMantecaKycRequired, + } +} diff --git a/src/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx index 04907c66a..8f3bba74c 100644 --- a/src/hooks/useSavedAccounts.tsx +++ b/src/hooks/useSavedAccounts.tsx @@ -1,3 +1,5 @@ +'use client' + import { useAuth } from '@/context/authContext' import { AccountType } from '@/interfaces' import { useMemo } from 'react' diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index dc6a26ddd..331283c84 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -9,6 +9,7 @@ interface UseWebSocketOptions { username?: string onHistoryEntry?: (entry: HistoryEntry) => void onKycStatusUpdate?: (status: string) => void + onMantecaKycStatusUpdate?: (status: string) => void onTosUpdate?: (data: { accepted: boolean }) => void onConnect?: () => void onDisconnect?: () => void @@ -21,6 +22,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { username, onHistoryEntry, onKycStatusUpdate, + onMantecaKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, @@ -31,12 +33,28 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { const [historyEntries, setHistoryEntries] = useState([]) const wsRef = useRef(null) - const callbacksRef = useRef({ onHistoryEntry, onKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError }) + const callbacksRef = useRef({ + onHistoryEntry, + onKycStatusUpdate, + onMantecaKycStatusUpdate, + onTosUpdate, + onConnect, + onDisconnect, + onError, + }) // Update callbacks ref when useEffect(() => { - callbacksRef.current = { onHistoryEntry, onKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError } - }, [onHistoryEntry, onKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError]) + callbacksRef.current = { + onHistoryEntry, + onKycStatusUpdate, + onMantecaKycStatusUpdate, + onTosUpdate, + onConnect, + onDisconnect, + onError, + } + }, [onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError]) // Connect to WebSocket const connect = useCallback(() => { @@ -115,6 +133,14 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { } } + const handleMantecaKycStatusUpdate = (data: { status: string }) => { + if (callbacksRef.current.onMantecaKycStatusUpdate) { + callbacksRef.current.onMantecaKycStatusUpdate(data.status) + } else { + console.log(`[WebSocket] No onMantecaKycStatusUpdate callback registered for user: ${username}`) + } + } + const handleTosUpdate = (data: { status: string }) => { if (callbacksRef.current.onTosUpdate) { callbacksRef.current.onTosUpdate({ accepted: data.status === 'approved' }) @@ -129,6 +155,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.on('error', handleError) ws.on('history_entry', handleHistoryEntry) ws.on('kyc_status_update', handleKycStatusUpdate) + ws.on('manteca_kyc_status_update', handleMantecaKycStatusUpdate) ws.on('persona_tos_status_update', handleTosUpdate) // Auto-connect if enabled @@ -143,6 +170,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.off('error', handleError) ws.off('history_entry', handleHistoryEntry) ws.off('kyc_status_update', handleKycStatusUpdate) + ws.off('manteca_kyc_status_update', handleMantecaKycStatusUpdate) ws.off('persona_tos_status_update', handleTosUpdate) } }, [autoConnect, connect, username]) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 8451b10bc..643866a11 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -221,6 +221,24 @@ interface ReferralConnection { account_identifier: string } +export enum MantecaKycStatus { + ONBOARDING = 'ONBOARDING', + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', +} + +export interface IUserKycVerification { + provider: 'MANTECA' | 'BRIDGE' + mantecaGeo?: string | null + bridgeGeo?: string | null + status: MantecaKycStatus + approvedAt?: string | null + providerUserId?: string | null + providerRawStatus?: string | null + createdAt: string + updatedAt: string +} + export interface User { userId: string email: string @@ -230,6 +248,7 @@ export interface User { bridgeKycStartedAt?: string bridgeKycApprovedAt?: string bridgeKycRejectedAt?: string + kycVerifications?: IUserKycVerification[] // currently only used for Manteca, can be extended to other providers in the future, bridge is not migrated as it might affect existing users tosStatus?: string tosAcceptedAt?: string bridgeCustomerId: string | null diff --git a/src/services/manteca.ts b/src/services/manteca.ts index 7a6204a4b..1b5cbd522 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -148,6 +148,27 @@ export const mantecaApi = { return response.json() }, + initiateOnboarding: async (params: { + returnUrl: string + failureUrl?: string + exchange?: string + }): Promise<{ url: string }> => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/initiate-onboarding`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-token')}`, + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || `Failed to get onboarding URL`) + } + + return response.json() + }, deposit: async (params: CreateMantecaOnrampParams): Promise<{ data?: MantecaDepositDetails; error?: string }> => { try { diff --git a/src/services/users.ts b/src/services/users.ts index 3fde5794e..6b1a89b99 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -5,7 +5,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL, } from '@/constants' -import { AccountType } from '@/interfaces' +import { AccountType, IUserKycVerification } from '@/interfaces' import { IAttachmentOptions } from '@/redux/types/send-flow.types' import { fetchWithSentry } from '@/utils' import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' @@ -28,6 +28,7 @@ export type ApiUser = { totalUsdSentToCurrentUser: string totalUsdReceivedFromCurrentUser: string bridgeKycStatus: string + kycVerifications?: IUserKycVerification[] } export type RecentUser = Pick diff --git a/src/services/websocket.ts b/src/services/websocket.ts index a796d0a0f..f01671a1a 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,7 +1,13 @@ import { HistoryEntry } from '@/hooks/useTransactionHistory' export type WebSocketMessage = { - type: 'ping' | 'pong' | 'history_entry' | 'kyc_status_update' | 'persona_tos_status_update' + type: + | 'ping' + | 'pong' + | 'history_entry' + | 'kyc_status_update' + | 'manteca_kyc_status_update' + | 'persona_tos_status_update' data?: HistoryEntry } @@ -110,6 +116,12 @@ export class PeanutWebSocket { } break + case 'manteca_kyc_status_update': + if (message.data && 'status' in (message.data as object)) { + this.emit('manteca_kyc_status_update', message.data) + } + break + case 'persona_tos_status_update': if (message.data && 'status' in (message.data as object)) { this.emit('persona_tos_status_update', message.data) @@ -204,8 +216,14 @@ let websocketInstance: PeanutWebSocket | null = null export const getWebSocketInstance = (username?: string): PeanutWebSocket => { if (!websocketInstance && typeof window !== 'undefined') { - const wsUrl = process.env.NEXT_PUBLIC_PEANUT_WS_URL || '' + let wsUrl = process.env.NEXT_PUBLIC_PEANUT_WS_URL || '' const path = username ? `/ws/charges/${username}` : '/ws/charges' + + // use ws:// for local development to avoid SSL issues + if (window.location.hostname === 'localhost' && wsUrl.startsWith('wss://')) { + wsUrl = wsUrl.replace('wss://', 'ws://') + } + websocketInstance = new PeanutWebSocket(wsUrl, path) }