+
+ Internal testing tools and components. Publicly accessible for multi-device testing.
+
-
+
{tools.map((tool) => (
-
+
-
-
{tool.icon}
+
+
+
+
-
{tool.name}
-
{tool.description}
- {tool.status === 'active' && (
-
- Active
-
- )}
+
{tool.name}
+
{tool.description}
-
+
))}
-
- ℹ️ Info
-
- • These tools are only available in development mode
- • Perfect for testing on multiple devices
- • Share the URL with team members for testing
+
+
+
+ Info
+
+
+ These tools are only available in development mode
+ Perfect for testing on multiple devices
+ Share the URL with team members for testing
-
+
)
diff --git a/src/app/(mobile-ui)/dev/perk-success-test/page.tsx b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx
new file mode 100644
index 000000000..0df4e22a9
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Card } from '@/components/0_Bruddle/Card'
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import GlobalCard from '@/components/Global/Card'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { SoundPlayer } from '@/components/Global/SoundPlayer'
+import { useHaptic } from 'use-haptic'
+import { shootDoubleStarConfetti } from '@/utils/confetti'
+import { extractInviteeName } from '@/utils/general.utils'
+
+type MockPerk = {
+ id: string
+ name: string
+ amountUsd: number
+ reason: string
+}
+
+const MOCK_PERKS: MockPerk[] = [
+ {
+ id: 'mock-1',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Alice became a Card Pioneer',
+ },
+ {
+ id: 'mock-2',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Bob became a Card Pioneer',
+ },
+ {
+ id: 'mock-3',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Charlie became a Card Pioneer',
+ },
+ {
+ id: 'mock-4',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 10,
+ reason: 'Diana became a Card Pioneer (bonus!)',
+ },
+ {
+ id: 'mock-5',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Eve became a Card Pioneer',
+ },
+]
+
+export default function PerkSuccessTestPage() {
+ const [currentPerkIndex, setCurrentPerkIndex] = useState(0)
+ const [showSuccess, setShowSuccess] = useState(false)
+ const [canDismiss, setCanDismiss] = useState(false)
+ const [isExiting, setIsExiting] = useState(false)
+ const [playSound, setPlaySound] = useState(false)
+ const { triggerHaptic } = useHaptic()
+
+ const currentPerk = MOCK_PERKS[currentPerkIndex]
+
+ const handleShowSuccess = () => {
+ setShowSuccess(true)
+ setCanDismiss(false)
+ setIsExiting(false)
+ setPlaySound(true)
+ triggerHaptic()
+ shootDoubleStarConfetti({ origin: { x: 0.5, y: 0.4 } })
+
+ // Enable dismiss after 2 seconds
+ setTimeout(() => setCanDismiss(true), 2000)
+ }
+
+ const handleDismiss = () => {
+ if (!canDismiss) return
+
+ setIsExiting(true)
+ setTimeout(() => {
+ setShowSuccess(false)
+ setPlaySound(false)
+ // Move to next perk
+ setCurrentPerkIndex((prev) => (prev + 1) % MOCK_PERKS.length)
+ }, 400)
+ }
+
+ const inviteeName = extractInviteeName(currentPerk.reason)
+
+ return (
+
+
+
+
+ {/* Instructions */}
+
+ Test the perk claim success screen
+
+ 1. Click "Trigger Success" to show the success screen
+ 2. Wait 2 seconds before you can dismiss (debounce)
+ 3. Tap to dismiss and load next mock perk
+
+
+
+ {/* Current Perk Info */}
+
+
+ Current Mock Perk ({currentPerkIndex + 1}/{MOCK_PERKS.length})
+
+ ID: {currentPerk.id}
+ Amount: ${currentPerk.amountUsd}
+ Reason: {currentPerk.reason}
+
+
+ {/* Trigger Button */}
+ {!showSuccess && (
+
+ Trigger Success
+
+ )}
+
+ {/* Success Screen Preview */}
+ {showSuccess && (
+
+
+ SUCCESS SCREEN PREVIEW (tap to dismiss when ready)
+
+
+
+ {playSound &&
}
+
+ {/* Success card - full width, matches PaymentSuccessView */}
+
+ {/* Check icon */}
+
+
+
+
+ {/* Text content */}
+
+
You received
+
+${currentPerk.amountUsd}
+
+
+ {inviteeName}
+ joined Pioneers
+
+
+
+
+ {/* Tap to continue - fades in when ready */}
+
+ Tap to continue
+
+
+
+ )}
+
+ {/* Quick Actions */}
+
+ setCurrentPerkIndex((prev) => (prev + 1) % MOCK_PERKS.length)}
+ className="flex-1"
+ >
+ Next Perk
+
+ {
+ setShowSuccess(false)
+ setPlaySound(false)
+ setCurrentPerkIndex(0)
+ }}
+ className="flex-1"
+ >
+ Reset
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx
index 2ace526be..2caf96b09 100644
--- a/src/app/(mobile-ui)/history/page.tsx
+++ b/src/app/(mobile-ui)/history/page.tsx
@@ -12,7 +12,8 @@ import { useTransactionHistory } from '@/hooks/useTransactionHistory'
import { useUserStore } from '@/redux/hooks'
import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils'
import * as Sentry from '@sentry/nextjs'
-import { isKycStatusItem } from '@/hooks/useBridgeKycFlow'
+import { isKycStatusItem } from '@/components/Kyc/KycStatusItem'
+import { groupKycByRegion } from '@/utils/kyc-grouping.utils'
import { useAuth } from '@/context/authContext'
import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem'
import { isBadgeHistoryItem } from '@/components/Badges/badge.types'
@@ -165,30 +166,10 @@ const HistoryPage = () => {
})
})
- if (user) {
- if (user.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') {
- // Use appropriate timestamp based on KYC status
- const bridgeKycTimestamp = (() => {
- const status = user.user.bridgeKycStatus
- if (status === 'approved') return user.user.bridgeKycApprovedAt
- if (status === 'rejected') return user.user.bridgeKycRejectedAt
- return user.user.bridgeKycStartedAt
- })()
- entries.push({
- isKyc: true,
- timestamp: bridgeKycTimestamp ?? user.user.createdAt ?? new Date().toISOString(),
- uuid: 'bridge-kyc-status-item',
- bridgeKycStatus: user.user.bridgeKycStatus,
- })
- }
- user.user.kycVerifications?.forEach((verification) => {
- entries.push({
- isKyc: true,
- timestamp: verification.approvedAt ?? verification.updatedAt ?? verification.createdAt,
- uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`,
- verification,
- })
- })
+ // add one kyc entry per region (STANDARD, LATAM)
+ if (user?.user) {
+ const regionEntries = groupKycByRegion(user.user)
+ entries.push(...regionEntries)
}
entries.sort((a, b) => {
@@ -272,6 +253,7 @@ const HistoryPage = () => {
bridgeKycStartedAt={
item.bridgeKycStatus ? user?.user.bridgeKycStartedAt : undefined
}
+ region={item.region}
/>
) : isBadgeHistoryItem(item) ? (
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index f3a542d54..51a3b1531 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -10,7 +10,7 @@ import { UserHeader } from '@/components/UserHeader'
import { useAuth } from '@/context/authContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useUserStore } from '@/redux/hooks'
-import { formatExtendedNumber, getUserPreferences, updateUserPreferences, getRedirectUrl } from '@/utils/general.utils'
+import { formatExtendedNumber, getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
import { printableUsdc } from '@/utils/balance.utils'
import { useDisconnect } from '@reown/appkit/react'
import Link from 'next/link'
@@ -24,15 +24,17 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useClaimBankFlow } from '@/context/ClaimBankFlowContext'
-import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
+import { useDeviceType } from '@/hooks/useGetDeviceType'
import { useNotifications } from '@/hooks/useNotifications'
import useKycStatus from '@/hooks/useKycStatus'
+import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo'
import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA'
import InvitesIcon from '@/components/Home/InvitesIcon'
import NavigationArrow from '@/components/Global/NavigationArrow'
import { updateUserById } from '@/app/actions/users'
import { useHaptic } from 'use-haptic'
import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
// Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size
// Components are only loaded when user triggers them
@@ -43,6 +45,7 @@ const NoMoreJailModal = lazy(() => import('@/components/Global/NoMoreJailModal')
const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal'))
const KycCompletedModal = lazy(() => import('@/components/Home/KycCompletedModal'))
const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal'))
+const CardPioneerModal = lazy(() => import('@/components/Card/CardPioneerModal'))
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
@@ -64,6 +67,7 @@ export default function Home() {
const { isFetchingUser, fetchUser } = useAuth()
const { isUserKycApproved } = useKycStatus()
+ const { hasPurchased: hasCardPioneerPurchased } = useCardPioneerInfo()
const username = user?.user.username
const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false)
@@ -71,6 +75,13 @@ export default function Home() {
const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false)
const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false)
+ // Track if this is a fresh signup session - captured once on mount so it persists
+ // even after NoMoreJailModal clears the sessionStorage key
+ const [isPostSignupSession] = useState(() => {
+ if (typeof window === 'undefined') return false
+ return sessionStorage.getItem('showNoMoreJailModal') === 'true'
+ })
+
// sync modal state with user data when it changes
useEffect(() => {
if (user?.user.showKycCompletedModal !== undefined) {
@@ -260,6 +271,23 @@ export default function Home() {
+ {/* Card Pioneer Modal - Show to all users who haven't purchased */}
+ {/* Eligibility check happens during the flow (geo screen), not here */}
+ {/* Only shows if no higher-priority modals are active */}
+ {!underMaintenanceConfig.disableCardPioneers &&
+ !showBalanceWarningModal &&
+ !showPermissionModal &&
+ !showKycModal &&
+ !isPostSignupActionModalVisible &&
+ !user?.showEarlyUserModal &&
+ !isPostSignupSession && (
+
+
+
+
+
+ )}
+
{/* Referral Campaign Modal - DISABLED FOR NOW */}
{/*
{
const pathName = usePathname()
// Allow access to public paths without authentication
- const isPublicPath = PUBLIC_ROUTES_REGEX.test(pathName)
+ // Dev test pages (gift-test, shake-test) are only public in dev mode
+ const isPublicPath = isPublicRoute(pathName, IS_DEV)
- const { isFetchingUser, user } = useAuth()
+ const { isFetchingUser, user, userFetchError } = useAuth()
const [isReady, setIsReady] = useState(false)
const isUserLoggedIn = !!user?.user.userId || false
const isHome = pathName === '/home'
const isHistory = pathName === '/history'
const isSupport = pathName === '/support'
+ const isDev = pathName?.startsWith('/dev') ?? false
const alignStart = isHome || isHistory || isSupport
const router = useRouter()
const { showIosPwaInstallScreen } = useSetupStore()
@@ -99,6 +103,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
return
}
+ // show backend error screen when user fetch fails after retries
+ // user can retry or force logout to clear stale state
+ if (userFetchError && !isFetchingUser && !isPublicPath) {
+ return
+ }
+
// For public paths, skip user loading and just show content when ready
if (isPublicPath) {
if (!isReady) {
@@ -135,22 +145,26 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Sidebar - Fixed on desktop */}
-
-
+ )}
{/* Main content area */}
{/* Banner component handles maintenance and feedback banners */}
-
+ {!isDev &&
}
{/* Fixed top navbar */}
-
-
-
+ {!isDev && (
+
+
+
+ )}
{/* Scrollable content area */}
{
'relative flex-1 overflow-y-auto bg-background p-6 pb-24 md:pb-6',
!!isSupport && 'p-0 pb-20 md:p-6',
!!isHome && 'p-0 md:p-6 md:pr-0',
- isUserLoggedIn ? 'pb-24' : 'pb-4'
+ isUserLoggedIn ? 'pb-24' : 'pb-4',
+ isDev && 'p-0 pb-0'
)
)}
>
@@ -170,7 +185,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
'flex w-full items-center justify-center md:ml-auto md:w-[calc(100%-160px)]',
alignStart && 'items-start',
isSupport && 'h-full',
- isUserLoggedIn ? 'min-h-[calc(100dvh-160px)]' : 'min-h-[calc(100dvh-64px)]'
+ isUserLoggedIn ? 'min-h-[calc(100dvh-160px)]' : 'min-h-[calc(100dvh-64px)]',
+ isDev && 'min-h-[100dvh] items-start justify-start md:ml-0 md:w-full'
)}
>
{children}
@@ -179,9 +195,11 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Mobile navigation */}
-
-
-
+ {!isDev && (
+
+
+
+ )}
diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx
index 949777c86..ee1a36597 100644
--- a/src/app/(mobile-ui)/points/invites/page.tsx
+++ b/src/app/(mobile-ui)/points/invites/page.tsx
@@ -16,6 +16,7 @@ import Image from 'next/image'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { getInitialsFromName } from '@/utils/general.utils'
import { type PointsInvite } from '@/services/services.types'
+import { TRANSITIVITY_MULTIPLIER } from '@/constants/points.consts'
const InvitesPage = () => {
const router = useRouter()
@@ -45,10 +46,10 @@ const InvitesPage = () => {
)
}
- // Calculate total points earned (20% of each invitee's points)
+ // Calculate total points earned (50% of each invitee's points)
const totalPointsEarned =
invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
- return sum + Math.floor(invite.totalPoints * 0.2)
+ return sum + Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
}, 0) || 0
return (
@@ -75,7 +76,7 @@ const InvitesPage = () => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = Math.floor(invite.totalPoints * 0.2)
+ const pointsEarned = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 6f6ec33ec..dd1c38605 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -3,17 +3,15 @@
import PageContainer from '@/components/0_Bruddle/PageContainer'
import Card from '@/components/Global/Card'
import { getCardPosition } from '@/components/Global/Card/card.utils'
-import CopyToClipboard from '@/components/Global/CopyToClipboard'
import { Icon } from '@/components/Global/Icons/Icon'
import NavHeader from '@/components/Global/NavHeader'
import NavigationArrow from '@/components/Global/NavigationArrow'
import PeanutLoading from '@/components/Global/PeanutLoading'
-import ShareButton from '@/components/Global/ShareButton'
import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionAvatarBadge'
import { VerifiedUserLabel } from '@/components/UserHeader'
import { useAuth } from '@/context/authContext'
import { invitesApi } from '@/services/invites'
-import { generateInviteCodeLink, generateInvitesShareText, getInitialsFromName } from '@/utils/general.utils'
+import { getInitialsFromName } from '@/utils/general.utils'
import { useQuery } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { STAR_STRAIGHT_ICON, TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE } from '@/assets'
@@ -21,13 +19,17 @@ import Image from 'next/image'
import { pointsApi } from '@/services/points'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { type PointsInvite } from '@/services/services.types'
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
import InvitesGraph from '@/components/Global/InvitesGraph'
-import { IS_DEV } from '@/constants/general.consts'
+import { CashCard } from '@/components/Points/CashCard'
+import { TRANSITIVITY_MULTIPLIER } from '@/constants/points.consts'
+import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
+import { Button } from '@/components/0_Bruddle/Button'
const PointsPage = () => {
const router = useRouter()
const { user, fetchUser } = useAuth()
+ const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const getTierBadge = (tier: number) => {
const badges = [TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE]
@@ -55,15 +57,21 @@ const PointsPage = () => {
enabled: !!user?.user.userId,
})
- // In dev mode, show graph for all users. In production, only for Seedling badge holders.
- const hasSeedlingBadge = user?.user?.badges?.some((badge) => badge.code === 'SEEDLING_DEVCONNECT_BA_2025')
+ // Referral graph is now available for all users
const { data: myGraphResult } = useQuery({
queryKey: ['myInviteGraph', user?.user.userId],
queryFn: () => pointsApi.getUserInvitesGraph(),
- enabled: !!user?.user.userId && (IS_DEV || hasSeedlingBadge),
+ enabled: !!user?.user.userId,
})
+
+ // Cash status (comprehensive earnings tracking)
+ const { data: cashStatus } = useQuery({
+ queryKey: ['cashStatus', user?.user.userId],
+ queryFn: () => pointsApi.getCashStatus(),
+ enabled: !!user?.user.userId,
+ })
+
const username = user?.user.username
- const { inviteCode, inviteLink } = generateInviteCodeLink(username ?? '')
useEffect(() => {
// Re-fetch user to get the latest invitees list for showing heart Icon
@@ -89,95 +97,76 @@ const PointsPage = () => {
router.back()} />
-
-
+ {/* consolidated points and cash card */}
+
+ {/* points section */}
+
{tierInfo.data.totalPoints} {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
- {/* Progressive progress bar */}
-
-
-
-
= 2
- ? 100
- : Math.pow(
- Math.min(
- 1,
- tierInfo.data.nextTierThreshold > 0
- ? tierInfo.data.totalPoints / tierInfo.data.nextTierThreshold
- : 0
- ),
- 0.6
- ) * 100
- }%`,
- }}
+ {/* de-emphasized tier progress - smaller and flatter */}
+
+
+
+
+
= 2
+ ? 100
+ : Math.pow(
+ Math.min(
+ 1,
+ tierInfo.data.nextTierThreshold > 0
+ ? tierInfo.data.totalPoints /
+ tierInfo.data.nextTierThreshold
+ : 0
+ ),
+ 0.6
+ ) * 100
+ }%`,
+ }}
+ />
+
+ {tierInfo?.data.currentTier < 2 && (
+
+ )}
{tierInfo?.data.currentTier < 2 && (
-
- )}
-
-
-
-
You're at tier {tierInfo?.data.currentTier}.
- {tierInfo?.data.currentTier < 2 ? (
-
+
{tierInfo.data.pointsToNextTier}{' '}
- {tierInfo.data.pointsToNextTier === 1 ? 'point' : 'points'} needed to level up
+ {tierInfo.data.pointsToNextTier === 1 ? 'point' : 'points'} to next tier
- ) : (
-
You've reached the max tier!
)}
-
- {user?.invitedBy ? (
-
- router.push(`/${user.invitedBy}`)}
- className="inline-flex cursor-pointer items-center gap-1 font-bold"
- >
- {user.invitedBy}
- {' '}
- invited you and earned points. Now it's your turn! Invite friends and get 20% of their points.
-
- ) : (
-
-
-
- Do stuff on Peanut and get points. Invite friends and pocket 20% of their points, too.
-
-
- )}
-
Invite friends with your code
-
-
- {`${inviteCode}`}
-
-
-
+ {/* cash section */}
+ {cashStatus?.success && cashStatus.data && (
+
+ )}
+
- {/* User Graph - shows user, their inviter, and points flow regardless of invites */}
+ {/* invite graph with consolidated explanation */}
{myGraphResult?.data && (
<>
-
+
{
showUsernames
/>
-
-
-
- {IS_DEV
- ? 'Experimental. Enabled for all users in dev mode.'
- : 'Experimental. Only available for Seedlings badge holders.'}
-
-
+
+ {user?.invitedBy && (
+ <>
+ router.push(`/${user.invitedBy}`)}
+ className="inline-flex cursor-pointer items-center gap-1 font-bold"
+ >
+ {user.invitedBy}
+ {' '}
+ invited you.{' '}
+ >
+ )}
+ You earn rewards whenever friends you invite use Peanut!
+
>
)}
- {invites && invites?.invitees && invites.invitees.length > 0 && (
+ {/* if user has invites: show button above people list */}
+ {invites && invites?.invitees && invites.invitees.length > 0 ? (
<>
- Promise.resolve(generateInvitesShareText(inviteLink))}
- title="Share your invite link"
+ setIsInviteModalOpen(true)}
+ className="!mt-8 w-full"
>
Share Invite link
-
+
+
+ {/* people you invited */}
router.push('/points/invites')}
>
People you invited
@@ -214,17 +214,17 @@ const PointsPage = () => {
- {invites.invitees?.map((invite: PointsInvite, i: number) => {
+ {invites.invitees?.slice(0, 5).map((invite: PointsInvite, i: number) => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = Math.floor(invite.totalPoints * 0.2)
+ const pointsEarned = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
router.push(`/${username}`)}
className="cursor-pointer"
>
@@ -255,26 +255,36 @@ const PointsPage = () => {
})}
>
- )}
-
- {invites?.invitees?.length === 0 && (
-
-
-
-
- No invites yet
+ ) : (
+ <>
+ {/* if user has no invites: show empty state with modal button */}
+
+
+
+
+ No invites yet
-
- Send your invite link to start earning more rewards
-
- Promise.resolve(generateInvitesShareText(inviteLink))}
- title="Share your invite link"
- >
- Share Invite link
-
-
+
+ Send your invite link to start earning more rewards
+
+ setIsInviteModalOpen(true)}
+ className="w-full"
+ >
+ Share Invite link
+
+
+ >
)}
+
+ {/* Invite Modal */}
+ setIsInviteModalOpen(false)}
+ username={username ?? ''}
+ />
)
diff --git a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
index 6f1b7a965..4e194b62a 100644
--- a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
+++ b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
@@ -7,11 +7,10 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { printableUsdc } from '@/utils/balance.utils'
import { getExchangeRateWidgetRedirectRoute } from '@/utils/exchangeRateWidget.utils'
import { useRouter } from 'next/navigation'
-import { useEffect } from 'react'
export default function ExchangeRatePage() {
const router = useRouter()
- const { fetchBalance, balance } = useWallet()
+ const { balance } = useWallet()
const handleCtaAction = (sourceCurrency: string, destinationCurrency: string) => {
const formattedBalance = parseFloat(printableUsdc(balance ?? 0n))
@@ -20,11 +19,6 @@ export default function ExchangeRatePage() {
router.push(redirectRoute)
}
- useEffect(() => {
- // Fetch latest balance
- fetchBalance()
- }, [])
-
return (
router.replace('/profile')} />
diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx
deleted file mode 100644
index 8ffed617b..000000000
--- a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-'use client'
-import IdentityVerificationView from '@/components/Profile/views/IdentityVerification.view'
-
-export default function IdentityVerificationCountryPage() {
- return
-}
diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
deleted file mode 100644
index d1843f861..000000000
--- a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-'use client'
-import RegionsPage from '@/components/Profile/views/RegionsPage.view'
-import { useParams } from 'next/navigation'
-
-export default function IdentityVerificationRegionPage() {
- const params = useParams()
- const region = params.region as string
-
- return
-}
diff --git a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
index 29884066e..5f6049aa8 100644
--- a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
+++ b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
@@ -1,59 +1,7 @@
'use client'
import PageContainer from '@/components/0_Bruddle/PageContainer'
-import ActionModal from '@/components/Global/ActionModal'
-import { useIdentityVerification } from '@/hooks/useIdentityVerification'
-import { useParams, useRouter } from 'next/navigation'
-import { useEffect, useState } from 'react'
export default function IdentityVerificationLayout({ children }: { children: React.ReactNode }) {
- const [isAlreadyVerifiedModalOpen, setIsAlreadyVerifiedModalOpen] = useState(false)
- const router = useRouter()
- const { isRegionAlreadyUnlocked, isVerifiedForCountry } = useIdentityVerification()
- const params = useParams()
- const regionParams = params.region as string
- const countryParams = params.country as string
-
- useEffect(() => {
- const isAlreadyVerified =
- (countryParams && isVerifiedForCountry(countryParams)) ||
- (regionParams && isRegionAlreadyUnlocked(regionParams))
-
- if (isAlreadyVerified) {
- setIsAlreadyVerifiedModalOpen(true)
- }
- }, [countryParams, regionParams, isVerifiedForCountry, isRegionAlreadyUnlocked])
-
- return (
-
- {children}
-
- {
- setIsAlreadyVerifiedModalOpen(false)
- router.push('/profile')
- }}
- title="You're already verified"
- description={
-
- Your identity has already been successfully verified for this region. 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)
- router.push('/profile')
- },
- },
- ]}
- />
-
- )
+ return {children}
}
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index d684d7da3..2826a8aeb 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -2,7 +2,7 @@
import { useSearchParams, useRouter } from 'next/navigation'
import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react'
-import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/KycVerificationInProgressModal'
+import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation'
import Card from '@/components/Global/Card'
import { Button } from '@/components/0_Bruddle/Button'
import { Icon } from '@/components/Global/Icons/Icon'
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 07c05ca46..166f45d07 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -100,6 +100,9 @@ export default function WithdrawBankPage() {
case AccountType.CLABE:
countryId = 'MX'
break
+ case AccountType.GB:
+ countryId = 'GB'
+ break
default:
return {
currency: '',
@@ -124,6 +127,8 @@ export default function WithdrawBankPage() {
return bankAccount.routingNumber?.toUpperCase() ?? 'N/A'
} else if (bankAccount && bankAccount.type === AccountType.CLABE) {
return bankAccount.identifier?.toUpperCase() ?? 'N/A'
+ } else if (bankAccount && bankAccount.type === AccountType.GB) {
+ return bankAccount.sortCode ?? 'N/A'
}
return 'N/A'
@@ -281,8 +286,8 @@ export default function WithdrawBankPage() {
tokenSymbol={PEANUT_WALLET_TOKEN_SYMBOL}
/>
- {/* Warning for non-EUR SEPA countries */}
- {isNonEuroSepa && (
+ {/* Warning for non-EUR SEPA countries (not UK — UK uses Faster Payments with GBP) */}
+ {isNonEuroSepa && bankAccount?.type !== AccountType.GB && (
>
+ ) : bankAccount?.type === AccountType.GB ? (
+ <>
+
+
+ >
) : (
<>
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
index 5af73db77..ad58fbc39 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -274,6 +274,7 @@ export default function WithdrawCryptoPage() {
txHash: finalTxHash,
tokenAddress: PEANUT_WALLET_TOKEN,
payerAddress: address as Address,
+ squidQuoteId: xChainRoute?.rawResponse?.route?.quoteId,
})
setTransactionHash(finalTxHash)
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 8371f74f8..712d8b0bc 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -23,16 +23,16 @@ import AmountInput from '@/components/Global/AmountInput'
import { formatUnits, parseUnits } from 'viem'
import type { TransactionReceipt, Hash } from 'viem'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
-import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow'
-import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
import { useAuth } from '@/context/authContext'
-import { useWebSocket } from '@/hooks/useWebSocket'
import { useModalsContext } from '@/context/ModalsContext'
import Select from '@/components/Global/Select'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
import { useQueryClient } from '@tanstack/react-query'
import { captureException } from '@sentry/nextjs'
import useKycStatus from '@/hooks/useKycStatus'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
import { PointsAction } from '@/services/services.types'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
@@ -68,7 +68,6 @@ export default function MantecaWithdrawFlow() {
const [selectedBank, setSelectedBank] = useState(null)
const [accountType, setAccountType] = useState(null)
const [errorMessage, setErrorMessage] = useState(null)
- const [isKycModalOpen, setIsKycModalOpen] = useState(false)
const [isDestinationAddressValid, setIsDestinationAddressValid] = useState(false)
const [isDestinationAddressChanging, setIsDestinationAddressChanging] = useState(false)
// price lock state - holds the locked price from /withdraw/init
@@ -78,11 +77,17 @@ export default function MantecaWithdrawFlow() {
const { sendMoney, balance } = useWallet()
const { signTransferUserOp } = useSignUserOp()
const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext)
- const { user, fetchUser } = useAuth()
+ const { user } = useAuth()
const { setIsSupportModalOpen } = useModalsContext()
const queryClient = useQueryClient()
- const { isUserBridgeKycApproved } = useKycStatus()
+ const { isUserMantecaKycApproved } = useKycStatus()
const { hasPendingTransactions } = usePendingTransactions()
+
+ // inline sumsub kyc flow for manteca users who need LATAM verification
+ // regionIntent is NOT passed here to avoid creating a backend record on mount.
+ // intent is passed at call time: handleInitiateKyc('LATAM')
+ const sumsubFlow = useMultiPhaseKycFlow({})
+ const [showKycModal, setShowKycModal] = useState(false)
// Get method and country from URL parameters
const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc.
const countryFromUrl = searchParams.get('country') // argentina, brazil, etc.
@@ -106,9 +111,6 @@ export default function MantecaWithdrawFlow() {
isLoading: isCurrencyLoading,
} = useCurrency(selectedCountry?.currency!)
- // Initialize KYC flow hook
- const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry })
-
// validates withdrawal against user's limits
// currency comes from country config - hook normalizes it internally
const limitsValidation = useLimitsValidation({
@@ -117,19 +119,6 @@ export default function MantecaWithdrawFlow() {
currency: selectedCountry?.currency,
})
- // WebSocket listener for KYC status updates
- useWebSocket({
- username: user?.user.username ?? undefined,
- autoConnect: !!user?.user.username,
- onMantecaKycStatusUpdate: (newStatus) => {
- if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') {
- fetchUser()
- setIsKycModalOpen(false)
- setStep('review') // Proceed to review after successful KYC
- }
- },
- })
-
// Get country flag code
const countryFlagCode = useMemo(() => {
return selectedCountry?.iso2?.toLowerCase()
@@ -200,14 +189,8 @@ export default function MantecaWithdrawFlow() {
}
setErrorMessage(null)
- // check if we still need to determine KYC status
- if (isMantecaKycRequired === null) {
- return
- }
-
- // check KYC status before proceeding to review
- if (isMantecaKycRequired === true) {
- setIsKycModalOpen(true)
+ if (!isUserMantecaKycApproved) {
+ setShowKycModal(true)
return
}
@@ -250,7 +233,7 @@ export default function MantecaWithdrawFlow() {
usdAmount,
currencyCode,
currencyAmount,
- isMantecaKycRequired,
+ isUserMantecaKycApproved,
isLockingPrice,
])
@@ -342,7 +325,6 @@ export default function MantecaWithdrawFlow() {
setSelectedBank(null)
setAccountType(null)
setErrorMessage(null)
- setIsKycModalOpen(false)
setIsDestinationAddressValid(false)
setIsDestinationAddressChanging(false)
setBalanceErrorMessage(null)
@@ -468,6 +450,16 @@ export default function MantecaWithdrawFlow() {
}
return (
+ setShowKycModal(false)}
+ onVerify={async () => {
+ setShowKycModal(false)
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
{
@@ -649,23 +641,6 @@ export default function MantecaWithdrawFlow() {
{errorMessage && }
-
- {/* KYC Modal */}
- {isKycModalOpen && selectedCountry && (
- setIsKycModalOpen(false)}
- onManualClose={() => setIsKycModalOpen(false)}
- onKycSuccess={() => {
- setIsKycModalOpen(false)
- fetchUser()
- setStep('review')
- }}
- selectedCountry={selectedCountry}
- />
- )}
)}
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index f808f8de6..2732fee92 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -10,7 +10,9 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { formatAmount } from '@/utils/general.utils'
-import { getCountryFromAccount } from '@/utils/bridge.utils'
+import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils'
+import useGetExchangeRate from '@/hooks/useGetExchangeRate'
+import { AccountType } from '@/interfaces'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react'
import { formatUnits } from 'viem'
@@ -82,6 +84,43 @@ export default function WithdrawPage() {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
}, [balance])
+ // derive country and account type for minimum amount validation
+ const { countryIso2, rateAccountType } = useMemo(() => {
+ if (selectedBankAccount) {
+ const country = getCountryFromAccount(selectedBankAccount)
+ return { countryIso2: country?.iso2 || '', rateAccountType: selectedBankAccount.type as AccountType }
+ }
+ if (selectedMethod?.countryPath) {
+ const country = getCountryFromPath(selectedMethod.countryPath)
+ const iso2 = country?.iso2 || ''
+ let accountType: AccountType = AccountType.IBAN
+ if (iso2 === 'US') accountType = AccountType.US
+ else if (iso2 === 'GB') accountType = AccountType.GB
+ else if (iso2 === 'MX') accountType = AccountType.CLABE
+ return { countryIso2: iso2, rateAccountType: accountType }
+ }
+ return { countryIso2: '', rateAccountType: AccountType.US }
+ }, [selectedBankAccount, selectedMethod])
+
+ // fetch exchange rate for non-USD countries to convert local minimum to USD
+ const { exchangeRate } = useGetExchangeRate({
+ accountType: rateAccountType,
+ enabled: rateAccountType !== AccountType.US && countryIso2 !== '',
+ })
+
+ // compute minimum withdrawal in USD using the exchange rate
+ const minUsdAmount = useMemo(() => {
+ const localMin = getMinimumAmount(countryIso2)
+ // for US or unknown, minimum is already in USD
+ if (!countryIso2 || countryIso2 === 'US') return localMin
+ // for EUR countries, €1 ≈ $1
+ if (localMin === 1) return 1
+ // convert local minimum to USD: sellRate = local currency per 1 USD
+ const rate = parseFloat(exchangeRate || '0')
+ if (rate <= 0) return 1 // fallback while rate is loading
+ return Math.ceil(localMin / rate)
+ }, [countryIso2, exchangeRate])
+
// validate against user's limits for bank withdrawals
// note: crypto withdrawals don't have fiat limits
const limitsValidation = useLimitsValidation({
@@ -136,19 +175,22 @@ export default function WithdrawPage() {
return false
}
- // convert the entered token amount to USD to enforce the $1 min rule
+ // convert the entered token amount to USD
const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below
const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD
- if (usdEquivalent >= 1 && amount <= maxDecimalAmount) {
+ if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) {
setError({ showError: false, errorMessage: '' })
return true
}
// determine message
let message = ''
- if (usdEquivalent < 1) {
- message = isFromSendFlow ? 'Minimum send amount is $1.' : 'Minimum withdrawal is $1.'
+ if (usdEquivalent < minUsdAmount) {
+ const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}`
+ message = isFromSendFlow
+ ? `Minimum send amount is ${minDisplay}.`
+ : `Minimum withdrawal is ${minDisplay}.`
} else if (amount > maxDecimalAmount) {
message = 'Amount exceeds your wallet balance.'
} else {
@@ -157,7 +199,7 @@ export default function WithdrawPage() {
setError({ showError: true, errorMessage: message })
return false
},
- [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow]
+ [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount]
)
const handleTokenAmountChange = useCallback(
@@ -252,10 +294,10 @@ export default function WithdrawPage() {
if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true
const usdEq = (selectedTokenData?.price ?? 1) * numericAmount
- if (usdEq < 1) return true // below $1 min
+ if (usdEq < minUsdAmount) return true // below country-specific minimum
return numericAmount > maxDecimalAmount || error.showError
- }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price])
+ }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount])
if (step === 'inputAmount') {
// only show limits card for bank/manteca withdrawals, not crypto
diff --git a/src/app/actions/card.ts b/src/app/actions/card.ts
new file mode 100644
index 000000000..a3bd088ed
--- /dev/null
+++ b/src/app/actions/card.ts
@@ -0,0 +1,102 @@
+'use server'
+
+import { PEANUT_API_URL } from '@/constants/general.consts'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { getJWTCookie } from '@/utils/cookie-migration.utils'
+
+const API_KEY = process.env.PEANUT_API_KEY!
+
+export interface CardInfoResponse {
+ hasPurchased: boolean
+ chargeStatus?: string
+ chargeUuid?: string
+ paymentUrl?: string
+ isEligible: boolean
+ eligibilityReason?: string
+ price: number
+ currentTier: number
+ slotsRemaining?: number
+ recentPurchases?: number
+}
+
+export interface CardPurchaseResponse {
+ chargeUuid: string
+ paymentUrl: string
+ price: number
+ // Semantic URL components for direct navigation (avoids extra API call)
+ recipientAddress: string
+ chainId: string
+ tokenAmount: string
+ tokenSymbol: string
+}
+
+export interface CardErrorResponse {
+ error: string
+ message: string
+ chargeUuid?: string
+}
+
+/**
+ * Get card pioneer info for the authenticated user
+ */
+export const getCardInfo = async (): Promise<{ data?: CardInfoResponse; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ if (!jwtToken) {
+ return { error: 'Authentication required' }
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/card`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ return { error: errorData.message || 'Failed to get card info' }
+ }
+
+ const data = await response.json()
+ return { data }
+ } catch (e: any) {
+ return { error: e.message || 'An unexpected error occurred' }
+ }
+}
+
+/**
+ * Initiate card pioneer purchase
+ */
+export const purchaseCard = async (): Promise<{ data?: CardPurchaseResponse; error?: string; errorCode?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ if (!jwtToken) {
+ return { error: 'Authentication required', errorCode: 'NOT_AUTHENTICATED' }
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/card/purchase`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ })
+
+ if (!response.ok) {
+ const errorData: CardErrorResponse = await response.json()
+ return {
+ error: errorData.message || 'Failed to initiate purchase',
+ errorCode: errorData.error,
+ }
+ }
+
+ const data = await response.json()
+ return { data }
+ } catch (e: any) {
+ return { error: e.message || 'An unexpected error occurred' }
+ }
+}
diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts
index 7be1c2a88..39a1d84be 100644
--- a/src/app/actions/currency.ts
+++ b/src/app/actions/currency.ts
@@ -12,12 +12,14 @@ export const getCurrencyPrice = async (currencyCode: string): Promise<{ buy: num
if (currencyCode === 'USD') {
buy = 1
sell = 1
- } else if (['EUR', 'MXN'].includes(currencyCode)) {
+ } else if (['EUR', 'MXN', 'GBP'].includes(currencyCode)) {
let accountType: AccountType
if (currencyCode === 'EUR') {
accountType = AccountType.IBAN
} else if (currencyCode === 'MXN') {
accountType = AccountType.CLABE
+ } else if (currencyCode === 'GBP') {
+ accountType = AccountType.GB
} else {
throw new Error('Invalid currency code')
}
diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts
new file mode 100644
index 000000000..7222004c6
--- /dev/null
+++ b/src/app/actions/sumsub.ts
@@ -0,0 +1,54 @@
+'use server'
+
+import { type InitiateSumsubKycResponse, type KYCRegionIntent } from './types/sumsub.types'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { PEANUT_API_URL } from '@/constants/general.consts'
+import { getJWTCookie } from '@/utils/cookie-migration.utils'
+
+const API_KEY = process.env.PEANUT_API_KEY!
+
+// initiate kyc flow (using sumsub) and get websdk access token
+export const initiateSumsubKyc = async (params?: {
+ regionIntent?: KYCRegionIntent
+ levelName?: string
+}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+
+ if (!jwtToken) {
+ return { error: 'Authentication required' }
+ }
+
+ const body: Record
= {
+ regionIntent: params?.regionIntent,
+ levelName: params?.levelName,
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ body: JSON.stringify(body),
+ })
+
+ const responseJson = await response.json()
+
+ if (!response.ok) {
+ return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' }
+ }
+
+ return {
+ data: {
+ token: responseJson.token,
+ applicantId: responseJson.applicantId,
+ status: responseJson.status,
+ },
+ }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ return { error: message }
+ }
+}
diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts
new file mode 100644
index 000000000..8565d2961
--- /dev/null
+++ b/src/app/actions/types/sumsub.types.ts
@@ -0,0 +1,9 @@
+export interface InitiateSumsubKycResponse {
+ token: string | null // null when user is already APPROVED
+ applicantId: string | null
+ status: SumsubKycStatus
+}
+
+export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED'
+
+export type KYCRegionIntent = 'STANDARD' | 'LATAM'
diff --git a/src/app/actions/types/users.types.ts b/src/app/actions/types/users.types.ts
index 79811164a..63ca3b0f8 100644
--- a/src/app/actions/types/users.types.ts
+++ b/src/app/actions/types/users.types.ts
@@ -3,6 +3,8 @@ export enum BridgeEndorsementType {
BASE = 'base',
SEPA = 'sepa',
SPEI = 'spei',
+ PIX = 'pix',
+ FASTER_PAYMENTS = 'faster_payments',
}
// this type represents the detailed response from our initiate-kyc endpoint
@@ -24,6 +26,7 @@ export enum BridgeAccountType {
IBAN = 'iban',
US = 'us',
CLABE = 'clabe',
+ GB = 'gb', // uk bank accounts (sort code + account number)
}
// matches the BridgeAccountOwnerType enum on the backend
@@ -53,4 +56,5 @@ export interface AddBankAccountPayload {
}
bic?: string
routingNumber?: string
+ sortCode?: string // uk bank accounts
}
diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts
index e530ebf17..5929fed25 100644
--- a/src/app/actions/users.ts
+++ b/src/app/actions/users.ts
@@ -160,3 +160,47 @@ export async function getContacts(params: {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}
+
+// fetch bridge ToS acceptance link for users with pending ToS
+export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ })
+ const responseJson = await response.json()
+ if (!response.ok) {
+ return { error: responseJson.error || 'Failed to fetch Bridge ToS link' }
+ }
+ return { data: responseJson }
+ } catch (e: unknown) {
+ return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
+ }
+}
+
+// confirm bridge ToS acceptance after user closes the ToS iframe
+export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ })
+ const responseJson = await response.json()
+ if (!response.ok) {
+ return { error: responseJson.error || 'Failed to confirm Bridge ToS' }
+ }
+ return { data: responseJson }
+ } catch (e: unknown) {
+ return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
+ }
+}
diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts
index 8131a2493..3c061ba43 100644
--- a/src/app/api/exchange-rate/route.ts
+++ b/src/app/api/exchange-rate/route.ts
@@ -42,8 +42,8 @@ export async function GET(request: NextRequest) {
if (
MANTECA_CURRENCIES.has(fromUc) ||
MANTECA_CURRENCIES.has(toUc) ||
- ['EUR', 'MXN'].includes(fromUc) ||
- ['EUR', 'MXN'].includes(toUc)
+ ['EUR', 'MXN', 'GBP'].includes(fromUc) ||
+ ['EUR', 'MXN', 'GBP'].includes(toUc)
) {
const currencyPriceRate = await fetchFromCurrencyPrice(fromUc, toUc)
if (currencyPriceRate !== null) {
@@ -105,8 +105,8 @@ async function getExchangeRate(from: string, to: string): Promise
if (
MANTECA_CURRENCIES.has(from) ||
MANTECA_CURRENCIES.has(to) ||
- ['EUR', 'MXN'].includes(from) ||
- ['EUR', 'MXN'].includes(to)
+ ['EUR', 'MXN', 'GBP'].includes(from) ||
+ ['EUR', 'MXN', 'GBP'].includes(to)
) {
return await fetchFromCurrencyPrice(from, to)
}
@@ -122,7 +122,7 @@ async function getExchangeRate(from: string, to: string): Promise
async function fetchFromCurrencyPrice(from: string, to: string): Promise {
console.log('Fetching from getCurrencyPrice')
try {
- if (from === 'USD' && (MANTECA_CURRENCIES.has(to) || ['EUR', 'MXN'].includes(to))) {
+ if (from === 'USD' && (MANTECA_CURRENCIES.has(to) || ['EUR', 'MXN', 'GBP'].includes(to))) {
// USD → other currency: use sell rate (selling USD to get other currency)
const { sell } = await getCurrencyPrice(to)
if (!isFinite(sell) || sell <= 0) {
@@ -130,7 +130,7 @@ async function fetchFromCurrencyPrice(from: string, to: string): Promise {
+ const { user } = useAuth()
+ const router = useRouter()
+ const [isMobile, setIsMobile] = useState(false)
+
+ // feature flag: redirect to landing if card pioneers is disabled
+ useEffect(() => {
+ if (underMaintenanceConfig.disableCardPioneers) {
+ router.replace('/')
+ }
+ }, [router])
+
+ useEffect(() => {
+ const checkMobile = () => setIsMobile(window.innerWidth < 768)
+ checkMobile()
+ window.addEventListener('resize', checkMobile)
+ return () => window.removeEventListener('resize', checkMobile)
+ }, [])
+
+ if (underMaintenanceConfig.disableCardPioneers) {
+ return null
+ }
+
+ const handleCTA = () => {
+ if (user) {
+ router.push('/card')
+ } else {
+ router.push('/setup?redirect_uri=/card')
+ }
+ }
+
+ // Marquee copy from CARD_coremessaging.md
+ const marqueeProps = {
+ visible: true,
+ message: ['EARLY = EARN', 'BUILD YOUR TREE', 'ONE LINK', 'LIFETIME UPSIDE', '$5 PER INVITE', 'EARN FOREVER'],
+ }
+
+ return (
+
+ {/* Hero Section - Yellow with card */}
+
+ {!isMobile && }
+
+
+
+
+ YOUR DOLLARS.
+
+ EVERYWHERE.
+
+
+
+ Pay with the peanut card. Earn with every purchase, yours or your friends.
+
+
+ Self-custodial. Best rates. No hidden fees.
+
+
+
+
+
+
+
+
+ JOIN PIONEERS
+
+
+ $10 starter balance = your spot secured
+
+
+
+
+
+
+
+
+ {/* How it works - Cream */}
+
+ {!isMobile && }
+
+
+
+ HOW IT WORKS
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Earn Forever - Cream Background */}
+
+ {!isMobile && }
+
+
+ {/* Visual - Simplified Invite Visual */}
+
+
+ {/*
+ LAYOUT - Calculated with Python trigonometry
+ Container: 340x380
+
+ L0 (YOU): center (170, 190), 80x80px
+ L1 nodes: 48x48px, 120px from YOU
+ - Top: center (170, 70) - outward angle -90°
+ - Bottom-left: center (70, 310) - outward angle 129.8°
+ - Bottom-right: center (270, 310) - outward angle 50.2°
+
+ L2 nodes: 32x32px, 55px from parent L1 center, fanning at -45°, 0°, +45° from outward direction
+ Top L1 (170,70): (131,31), (170,15), (209,31)
+ Bottom-left L1 (70,310): (75,365), (35,352), (15,315)
+ Bottom-right L1 (270,310): (325,315), (305,352), (265,365)
+ */}
+
+ {/* Connection lines */}
+
+ {/* L0 to L1 edges */}
+
+
+
+
+ {/* Top L1 (170,70) to L2 */}
+
+
+
+
+ {/* Bottom-left L1 (70,310) to L2 */}
+
+
+
+
+ {/* Bottom-right L1 (270,310) to L2 */}
+
+
+
+
+
+ {/* L0: YOU node - center (170,190), top-left (130,150) */}
+
+ YOU
+
+
+ {/* +$5 BADGES - at visual midpoint between node edges
+ Top edge: YOU bottom (y=150) to L1 top (y=94) -> visual mid = (150+94)/2 = 122, badge top = 114
+ Bottom edges: at midpoint of line between centers
+ */}
+
+ +$5
+
+
+ +$5
+
+
+ +$5
+
+
+ {/* L1: Top primary - center (170,70), top-left (146,46) */}
+
+
+
+
+ {/* L1: Bottom-left primary - center (70,310), top-left (46,286) */}
+
+
+
+
+ {/* L1: Bottom-right primary - center (270,310), top-left (246,286) */}
+
+
+
+
+ {/* L2 NODES - positioned directly at calculated centers
+ Each node is 32x32, so top-left = center - 16
+ Top L1 (170,70): L2 at (131,31), (170,15), (209,31)
+ Bottom-left L1 (70,310): L2 at (75,365), (35,352), (15,315)
+ Bottom-right L1 (270,310): L2 at (325,315), (305,352), (265,365)
+ */}
+
+ {/* Top L1's children - labels 2px gap from node edge */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+ {/* Bottom-left L1's children */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+ {/* Bottom-right L1's children */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+ {/* Copy */}
+
+
+ INVITE ONCE.
+
+ EARN FOREVER.
+
+
+
+
+
+
+
+
+
+ START EARNING
+
+
+
+
+
+
+
+
+ {/* Coverage - Yellow */}
+
+ {!isMobile && }
+
+
+
+ ROLLING OUT
+
+ GLOBALLY
+
+
+
+ Starting with US , Latin America , and Africa
+
+
+
+ {/* Individual country flags */}
+ {[
+ { name: 'United States', code: 'us' },
+ { name: 'Brazil', code: 'br' },
+ { name: 'Argentina', code: 'ar' },
+ { name: 'Mexico', code: 'mx' },
+ { name: 'Nigeria', code: 'ng' },
+ { name: 'Kenya', code: 'ke' },
+ { name: 'South Africa', code: 'za' },
+ ].map((country, i) => (
+
+
+ {country.name}
+
+ ))}
+
+ {/* Region pills without flags */}
+
+ Latin America
+
+
+ Africa
+
+
+ + more
+
+
+
+
+
+
+
+ {/* FAQ - Cream */}
+
+
+
+
+ {/* Final CTA - Secondary Yellow */}
+
+ {!isMobile && }
+
+
+
+
+ Early access is open
+
+
+
+ READY TO
+
+ JOIN?
+
+
+ $10 reserves your spot. And for every friend you invite, earn forever.
+
+
+
+ BECOME A PIONEER
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Floating stars component - matches Manteca.tsx pattern exactly
+const FloatingStars = () => {
+ // Match Manteca's star configuration pattern
+ const starConfigs = [
+ { className: 'absolute left-12 top-10', delay: 0.2 },
+ { className: 'absolute left-56 top-1/2', delay: 0.2 },
+ { className: 'absolute bottom-20 left-20', delay: 0.2 },
+ { className: 'absolute -top-16 right-20 md:top-10', delay: 0.6 },
+ { className: 'absolute bottom-20 right-44', delay: 0.6 },
+ ]
+
+ return (
+ <>
+ {starConfigs.map((config, index) => (
+
+ ))}
+ >
+ )
+}
+
+// Step card component
+const StepCard = ({
+ num,
+ title,
+ desc,
+ color,
+ textLight,
+ delay,
+}: {
+ num: string
+ title: string
+ desc: string
+ color: string
+ textLight?: boolean
+ delay: number
+}) => (
+
+
+ {num}
+
+ {title}
+ {desc}
+
+)
+
+// Reward item component
+const RewardItem = ({ amount, label }: { amount: string; label: string }) => (
+
+
+ {amount}
+
+ {label}
+
+)
+
+export default CardLandingPage
diff --git a/src/app/lp/card/page.tsx b/src/app/lp/card/page.tsx
new file mode 100644
index 000000000..4952bf794
--- /dev/null
+++ b/src/app/lp/card/page.tsx
@@ -0,0 +1,14 @@
+import { generateMetadata as generateMeta } from '@/app/metadata'
+import CardLandingPage from './CardLandingPage'
+
+export const metadata = generateMeta({
+ title: 'Card Pioneers | Get Early Access to Peanut Card',
+ description:
+ 'Join Card Pioneers for early access to the Peanut Card. Reserve your spot with $10, earn $5 for every friend who joins, and spend your dollars globally.',
+ keywords:
+ 'peanut card, card pioneers, crypto card, digital dollars, global spending, early access, referral rewards, international card',
+})
+
+export default function CardLPPage() {
+ return
+}
diff --git a/src/app/lp/page.tsx b/src/app/lp/page.tsx
new file mode 100644
index 000000000..e613c7406
--- /dev/null
+++ b/src/app/lp/page.tsx
@@ -0,0 +1,8 @@
+'use client'
+
+/**
+ * /lp route - Landing page that is ALWAYS accessible regardless of auth state.
+ * This allows logged-in users to view the marketing landing page.
+ * For SEO, the root "/" remains the canonical landing page URL.
+ */
+export { default } from '@/app/page'
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 7a66bb1ad..b8ba35d04 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,19 +1,20 @@
-import Link from 'next/link'
import PageContainer from '@/components/0_Bruddle/PageContainer'
import PEANUTMAN_CRY from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_05.gif'
import Image from 'next/image'
export default function NotFound() {
return (
-
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f36aa3352..e129e937c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -11,10 +11,12 @@ import {
SendInSeconds,
YourMoney,
RegulatedRails,
+ CardPioneers,
} from '@/components/LandingPage'
import Footer from '@/components/LandingPage/Footer'
import Manteca from '@/components/LandingPage/Manteca'
import TweetCarousel from '@/components/LandingPage/TweetCarousel'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
import { useFooterVisibility } from '@/context/footerVisibility'
import { useEffect, useState, useRef } from 'react'
@@ -196,6 +198,12 @@ export default function LandingPage() {
+ {!underMaintenanceConfig.disableCardPioneers && (
+ <>
+
+
+ >
+ )}
diff --git a/src/assets/badges/index.ts b/src/assets/badges/index.ts
index 3c9667660..f75d53628 100644
--- a/src/assets/badges/index.ts
+++ b/src/assets/badges/index.ts
@@ -1,3 +1,5 @@
+// TODO: consolidate these with public/badges - we have duplicate badge systems
+// These tier badges should probably move to public/badges and use CODE_TO_PATH in badge.utils.ts
export { default as TIER_0_BADGE } from './tier0.svg'
export { default as TIER_1_BADGE } from './tier1.svg'
export { default as TIER_2_BADGE } from './tier2.svg'
diff --git a/src/assets/cards/Cart Gradient 10.svg b/src/assets/cards/Cart Gradient 10.svg
new file mode 100644
index 000000000..c4c030cb6
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 10.svg
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/Cart Gradient 4.svg b/src/assets/cards/Cart Gradient 4.svg
new file mode 100644
index 000000000..605eb3a40
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 4.svg
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/Cart Gradient 5.png b/src/assets/cards/Cart Gradient 5.png
new file mode 100644
index 000000000..abd139106
Binary files /dev/null and b/src/assets/cards/Cart Gradient 5.png differ
diff --git a/src/assets/cards/Cart Gradient 9.svg b/src/assets/cards/Cart Gradient 9.svg
new file mode 100644
index 000000000..77a1bf628
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 9.svg
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/DEPRECATED_Cart Gradient 5.svg b/src/assets/cards/DEPRECATED_Cart Gradient 5.svg
new file mode 100644
index 000000000..9133d2123
--- /dev/null
+++ b/src/assets/cards/DEPRECATED_Cart Gradient 5.svg
@@ -0,0 +1,625 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/index.ts b/src/assets/cards/index.ts
new file mode 100644
index 000000000..1b79ed297
--- /dev/null
+++ b/src/assets/cards/index.ts
@@ -0,0 +1,4 @@
+export { default as CARD_GRADIENT_4 } from './Cart Gradient 4.svg'
+export { default as CARD_GRADIENT_5 } from './Cart Gradient 5.png'
+export { default as CARD_GRADIENT_9 } from './Cart Gradient 9.svg'
+export { default as CARD_GRADIENT_10 } from './Cart Gradient 10.svg'
diff --git a/src/assets/index.ts b/src/assets/index.ts
index 6cadbffb7..9fee1067e 100644
--- a/src/assets/index.ts
+++ b/src/assets/index.ts
@@ -1,5 +1,6 @@
export * from './badges'
export * from './bg'
+export * from './cards'
export * from './chains'
export * from './exchanges'
export * from './icons'
diff --git a/src/components/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx
deleted file mode 100644
index 01412bb3f..000000000
--- a/src/components/AddMoney/UserDetailsForm.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-'use client'
-import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
-import { useForm, Controller } from 'react-hook-form'
-import BaseInput from '@/components/0_Bruddle/BaseInput'
-import ErrorAlert from '@/components/Global/ErrorAlert'
-
-export type UserDetailsFormData = {
- fullName: string
- email: string
-}
-
-interface UserDetailsFormProps {
- onSubmit: (data: UserDetailsFormData) => Promise<{ error?: string }>
- isSubmitting: boolean
- onValidChange?: (isValid: boolean) => void
- initialData?: Partial
-}
-
-export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>(
- ({ onSubmit, onValidChange, initialData }, ref) => {
- const [submissionError, setSubmissionError] = useState(null)
-
- const {
- control,
- handleSubmit,
- formState: { errors, isValid },
- } = useForm({
- defaultValues: {
- fullName: initialData?.fullName ?? '',
- email: initialData?.email ?? '',
- },
- mode: 'all',
- })
-
- useEffect(() => {
- onValidChange?.(isValid)
- }, [isValid, onValidChange])
-
- useImperativeHandle(ref, () => ({
- handleSubmit: handleSubmit(async (data) => {
- setSubmissionError(null)
- const result = await onSubmit(data)
- if (result?.error) {
- setSubmissionError(result.error)
- }
- }),
- }))
-
- const renderInput = (
- name: keyof UserDetailsFormData,
- placeholder: string,
- rules: any,
- type: string = 'text'
- ) => {
- return (
-
-
- (
-
- )}
- />
-
-
- {errors[name] && }
-
-
- )
- }
- return (
-
- )
- }
-)
-
-UserDetailsForm.displayName = 'UserDetailsForm'
diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
index 58adcebd2..fe8631a4c 100644
--- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx
+++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
@@ -135,11 +135,12 @@ export default function AddMoneyBankDetails({ flow = 'add-money' }: IAddMoneyBan
return formatCurrencyAmount(amount, onrampCurrency)
}, [amount, onrampCurrency, flow])
+ const isUk = currentCountryDetails?.id === 'GB' || currentCountryDetails?.iso3 === 'GBR'
+
const generateBankDetails = async () => {
const formattedAmount = formattedCurrencyAmount
const isMexico = currentCountryDetails?.id === 'MX'
const isUs = currentCountryDetails?.id === 'US'
- const isEuro = !isUs && !isMexico
let bankDetails = `Bank Transfer Details:
Amount: ${formattedAmount}
@@ -152,7 +153,7 @@ Beneficiary Address: ${onrampData?.depositInstructions?.bankBeneficiaryAddress}
`
}
- if (isEuro || isMexico) {
+ if (!isUs && !isMexico && !isUk) {
bankDetails += `
Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
`
@@ -161,11 +162,19 @@ Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
// for mexico, include clabe
if (isMexico) {
bankDetails += `
+Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
CLABE: ${onrampData?.depositInstructions?.clabe || 'Loading...'}`
}
- // only include bank address and account details for non-mexico countries
- if (!isMexico) {
+ // uk faster payments
+ if (isUk) {
+ bankDetails += `
+Sort Code: ${onrampData?.depositInstructions?.sortCode || 'Loading...'}
+Account Number: ${onrampData?.depositInstructions?.accountNumber || 'Loading...'}`
+ }
+
+ // us and sepa countries
+ if (!isMexico && !isUk) {
bankDetails += `
Bank Address: ${onrampData?.depositInstructions?.bankAddress || 'Loading...'}`
@@ -297,45 +306,60 @@ Please use these details to complete your bank transfer.`
allowCopy={!!onrampData?.depositInstructions?.clabe}
hideBottomBorder
/>
+ ) : isUk ? (
+ <>
+
+
+ >
) : (
-
+
- )}
- {currentCountryDetails?.id !== 'MX' && (
-
+
+ onrampData?.depositInstructions?.bic ||
+ 'N/A'
+ }
+ allowCopy={
+ !!(
+ onrampData?.depositInstructions?.bankRoutingNumber ||
+ onrampData?.depositInstructions?.bic
+ )
+ }
+ hideBottomBorder
+ />
+ >
)}
{isNonUsdCurrency && (
{
const params = useParams()
- const router = useRouter()
const queryClient = useQueryClient()
// URL state - persisted in query params
@@ -57,16 +56,20 @@ const MantecaAddMoney: FC = () => {
const [isCreatingDeposit, setIsCreatingDeposit] = useState(false)
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 { isUserBridgeKycApproved } = useKycStatus()
+ const { isUserMantecaKycApproved } = useKycStatus()
const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS')
- const { user, fetchUser } = useAuth()
+ const { user } = useAuth()
+
+ // inline sumsub kyc flow for manteca users who need LATAM verification
+ // regionIntent is NOT passed here to avoid creating a backend record on mount.
+ // intent is passed at call time: handleInitiateKyc('LATAM')
+ const sumsubFlow = useMultiPhaseKycFlow({})
+ const [showKycModal, setShowKycModal] = useState(false)
// validates deposit amount against user's limits
// currency comes from country config - hook normalizes it internally
@@ -76,18 +79,6 @@ const MantecaAddMoney: FC = () => {
currency: selectedCountry?.currency,
})
- 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)
- }
- },
- })
-
// Validate USD amount (min check only - max is handled by limits validation)
useEffect(() => {
// if user hasn't entered any amount yet, don't show error
@@ -118,13 +109,6 @@ const MantecaAddMoney: FC = () => {
}
}, [step, queryClient])
- const handleKycCancel = () => {
- setIsKycModalOpen(false)
- if (selectedCountry?.path) {
- router.push(`/add-money/${selectedCountry.path}`)
- }
- }
-
// Handle displayed amount change - save to URL
// This is called by AmountInput with the currently DISPLAYED value
const handleDisplayedAmountChange = useCallback(
@@ -156,14 +140,8 @@ const MantecaAddMoney: FC = () => {
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)
+ if (!isUserMantecaKycApproved) {
+ setShowKycModal(true)
return
}
@@ -191,14 +169,14 @@ const MantecaAddMoney: FC = () => {
} finally {
setIsCreatingDeposit(false)
}
- }, [currentDenomination, selectedCountry, displayedAmount, isMantecaKycRequired, isCreatingDeposit, setUrlState])
-
- // handle verification modal opening
- useEffect(() => {
- if (isMantecaKycRequired) {
- setIsKycModalOpen(true)
- }
- }, [isMantecaKycRequired])
+ }, [
+ currentDenomination,
+ selectedCountry,
+ displayedAmount,
+ isUserMantecaKycApproved,
+ isCreatingDeposit,
+ setUrlState,
+ ])
// Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation)
useEffect(() => {
@@ -212,6 +190,16 @@ const MantecaAddMoney: FC = () => {
if (step === 'inputAmount') {
return (
<>
+ setShowKycModal(false)}
+ onVerify={async () => {
+ setShowKycModal(false)
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
{
limitsValidation={limitsValidation}
limitsCurrency={limitsValidation.currency}
/>
- {isKycModalOpen && (
- {
- // close the modal and let the user continue with amount input
- setIsKycModalOpen(false)
- fetchUser()
- }}
- selectedCountry={selectedCountry}
- />
- )}
>
)
}
diff --git a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx
index 6f1e2d4af..ab5323573 100644
--- a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx
+++ b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx
@@ -10,6 +10,7 @@ import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { Icon } from '@/components/Global/Icons/Icon'
import Image from 'next/image'
import { Card } from '@/components/0_Bruddle/Card'
+import InfoCard from '@/components/Global/InfoCard'
import {
MANTECA_ARG_DEPOSIT_CUIT,
MANTECA_ARG_DEPOSIT_NAME,
@@ -110,6 +111,12 @@ const MantecaDepositShareDetails = ({
+
Account details
{depositAddress && (
diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts
index 2858c4019..b368d057e 100644
--- a/src/components/AddMoney/consts/index.ts
+++ b/src/components/AddMoney/consts/index.ts
@@ -2843,7 +2843,9 @@ export const NON_EUR_SEPA_ALPHA2 = new Set(
!!c.iso3 &&
BRIDGE_ALPHA3_TO_ALPHA2[c.iso3] &&
// exclude usa explicitly; bridge map includes it but it's not sepa
- c.iso3 !== 'USA'
+ c.iso3 !== 'USA' &&
+ // exclude uk explicitly; uses faster payments, not sepa
+ c.iso3 !== 'GBR'
)
.map((c) => ({ alpha2: BRIDGE_ALPHA3_TO_ALPHA2[c.iso3!], currency: c.currency }))
.filter((x) => x.alpha2 && x.currency && x.currency !== 'EUR')
diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
index de668ef0d..639b02fd2 100644
--- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
+++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
@@ -12,7 +12,7 @@ import EmptyState from '../Global/EmptyStates/EmptyState'
import { useAuth } from '@/context/authContext'
import { useEffect, useMemo, useRef, useState } from 'react'
import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm'
-import { addBankAccount, updateUserById } from '@/app/actions/users'
+import { addBankAccount } from '@/app/actions/users'
import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils'
import { type AddBankAccountPayload } from '@/app/actions/types/users.types'
import { useWebSocket } from '@/hooks/useWebSocket'
@@ -22,11 +22,12 @@ import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useAppDispatch } from '@/redux/hooks'
import { bankFormActions } from '@/redux/slices/bank-form-slice'
-import { InitiateBridgeKYCModal } from '../Kyc/InitiateBridgeKYCModal'
import useKycStatus from '@/hooks/useKycStatus'
import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal'
import { ActionListCard } from '@/components/ActionListCard'
import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
interface AddWithdrawCountriesListProps {
flow: 'add' | 'withdraw'
@@ -48,6 +49,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
const { setSelectedBankAccount, amountToWithdraw, setSelectedMethod, setAmountToWithdraw } = useWithdrawFlow()
const dispatch = useAppDispatch()
+ // inline sumsub kyc flow for bridge bank users who need verification
+ // regionIntent is NOT passed here to avoid creating a backend record on mount.
+ // intent is passed at call time: handleInitiateKyc('STANDARD')
+ const sumsubFlow = useMultiPhaseKycFlow({
+ onKycSuccess: () => {
+ setIsKycModalOpen(false)
+ setView('form')
+ },
+ onManualClose: () => setIsKycModalOpen(false),
+ })
+
// component level states
const [view, setView] = useState<'list' | 'form'>(flow === 'withdraw' && amountToWithdraw ? 'form' : 'list')
const [isKycModalOpen, setIsKycModalOpen] = useState(false)
@@ -135,53 +147,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
}
// scenario (2): if the user hasn't completed kyc yet
+ // name and email are now collected by sumsub sdk — no need to save them beforehand
if (!isUserKycVerified) {
- // update user's name and email if they are not present
- const hasNameOnLoad = !!user?.user.fullName
- const hasEmailOnLoad = !!user?.user.email
-
- if (!hasNameOnLoad || !hasEmailOnLoad) {
- if (user?.user.userId) {
- // Build update payload to only update missing fields
- const updatePayload: Record = { userId: user.user.userId }
-
- if (!hasNameOnLoad && rawData.accountOwnerName) {
- updatePayload.fullName = rawData.accountOwnerName.trim()
- }
-
- if (!hasEmailOnLoad && rawData.email) {
- updatePayload.email = rawData.email.trim()
- }
-
- // Only call update if we have fields to update
- if (Object.keys(updatePayload).length > 1) {
- const result = await updateUserById(updatePayload)
- if (result.error) {
- return { error: result.error }
- }
- try {
- await fetchUser()
- } catch (err) {
- console.error('Failed to refresh user data after update:', err)
- }
- }
- }
- }
-
- setIsKycModalOpen(true)
+ await sumsubFlow.handleInitiateKyc('STANDARD')
}
return {}
}
- const handleKycSuccess = () => {
- // only transition to form if this component initiated the KYC modal
- if (isKycModalOpen) {
- setIsKycModalOpen(false)
- setView('form')
- }
- }
-
const handleWithdrawMethodClick = (method: SpecificPaymentMethod) => {
// preserve method param only if coming from bank send flow (not crypto)
const methodQueryParam = isBankFromSend ? `?method=${methodParam}` : ''
@@ -312,11 +285,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
initialData={{}}
error={null}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
}
@@ -431,11 +400,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
isKycApprovedModalOpen={showKycStatusModal}
onClose={() => setShowKycStatusModal(false)}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
}
diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
index 649ebba9b..375479a18 100644
--- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx
+++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
@@ -98,6 +98,7 @@ export const AddWithdrawRouterView: FC = ({
acc.type === AccountType.IBAN ||
acc.type === AccountType.US ||
acc.type === AccountType.CLABE ||
+ acc.type === AccountType.GB ||
acc.type === AccountType.MANTECA
) ?? []
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
index a2ffab55d..c94b48157 100644
--- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx
+++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
@@ -8,7 +8,13 @@ import BaseInput from '@/components/0_Bruddle/BaseInput'
import BaseSelect from '@/components/0_Bruddle/BaseSelect'
import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { useParams, useRouter } from 'next/navigation'
-import { validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils'
+import {
+ validateIban,
+ validateBic,
+ isValidRoutingNumber,
+ isValidSortCode,
+ isValidUKAccountNumber,
+} from '@/utils/bridge-accounts.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { getBicFromIban } from '@/app/actions/ibanToBic'
import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard'
@@ -35,6 +41,7 @@ export type IBankAccountDetails = {
accountNumber: string
bic: string
routingNumber: string
+ sortCode: string // uk bank accounts
clabe: string
street: string
city: string
@@ -71,7 +78,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
) => {
const isMx = country.toUpperCase() === 'MX'
const isUs = country.toUpperCase() === 'USA'
- const isIban = isUs || isMx ? false : isIBANCountry(country)
+ const isUk = country.toUpperCase() === 'GB' || country.toUpperCase() === 'GBR'
+ const isIban = isUs || isMx || isUk ? false : isIBANCountry(country)
const { user } = useAuth()
const dispatch = useAppDispatch()
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -107,6 +115,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
accountNumber: '',
bic: '',
routingNumber: '',
+ sortCode: '', // uk bank accounts
clabe: '',
street: '',
city: '',
@@ -162,12 +171,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
const isUs = country.toUpperCase() === 'USA'
const isMx = country.toUpperCase() === 'MX'
- const isIban = isUs || isMx ? false : isIBANCountry(country)
+ const isUk = country.toUpperCase() === 'GB' || country.toUpperCase() === 'GBR'
+ const isIban = isUs || isMx || isUk ? false : isIBANCountry(country)
let accountType: BridgeAccountType
if (isIban) accountType = BridgeAccountType.IBAN
else if (isUs) accountType = BridgeAccountType.US
else if (isMx) accountType = BridgeAccountType.CLABE
+ else if (isUk) accountType = BridgeAccountType.GB
else throw new Error('Unsupported country')
const accountNumber = isMx ? data.clabe : data.accountNumber
@@ -193,9 +204,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
let bic = data.bic || getValues('bic')
const iban = data.iban || getValues('iban')
+ // uk account numbers may be 6-7 digits, pad to 8 for bridge api
+ const cleanedAccountNumber = isUk
+ ? accountNumber.replace(/\s/g, '').padStart(8, '0')
+ : accountNumber.replace(/\s/g, '')
+
const payload: Partial = {
accountType,
- accountNumber: accountNumber.replace(/\s/g, ''),
+ accountNumber: cleanedAccountNumber,
countryCode: isUs ? 'USA' : country.toUpperCase(),
countryName: selectedCountry,
accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL,
@@ -217,11 +233,16 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
payload.routingNumber = data.routingNumber
}
+ if (isUk && data.sortCode) {
+ payload.sortCode = data.sortCode.replace(/[-\s]/g, '')
+ }
+
const result = await onSuccess(payload as AddBankAccountPayload, {
...data,
iban: isIban ? data.accountNumber || iban || '' : '',
accountNumber: isIban ? '' : data.accountNumber,
bic: bic,
+ sortCode: isUk ? data.sortCode : '',
country,
firstName: firstName.trim(),
lastName: lastName.trim(),
@@ -458,16 +479,27 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
}
}
)
- : renderInput(
- 'accountNumber',
- 'Account Number',
- {
- required: 'Account number is required',
- validate: async (value: string) =>
- validateUSBankAccount(value).isValid || 'Invalid account number',
- },
- 'text'
- )}
+ : isUk
+ ? renderInput(
+ 'accountNumber',
+ 'Account Number',
+ {
+ required: 'Account number is required',
+ validate: (value: string) =>
+ isValidUKAccountNumber(value) || 'Account number must be 6-8 digits',
+ },
+ 'text'
+ )
+ : renderInput(
+ 'accountNumber',
+ 'Account Number',
+ {
+ required: 'Account number is required',
+ validate: async (value: string) =>
+ validateUSBankAccount(value).isValid || 'Invalid account number',
+ },
+ 'text'
+ )}
{isIban &&
renderInput(
@@ -503,8 +535,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
validate: async (value: string) =>
(await isValidRoutingNumber(value)) || 'Invalid routing number',
})}
+ {isUk &&
+ renderInput('sortCode', 'Sort Code', {
+ required: 'Sort code is required',
+ validate: (value: string) => isValidSortCode(value) || 'Sort code must be 6 digits',
+ })}
- {!isIban && (
+ {!isIban && !isUk && (
<>
{renderInput(
'street',
diff --git a/src/components/Auth/auth.e2e.test.ts b/src/components/Auth/auth.e2e.test.ts
new file mode 100644
index 000000000..a80979f24
--- /dev/null
+++ b/src/components/Auth/auth.e2e.test.ts
@@ -0,0 +1,106 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Authentication Flow E2E Tests
+ *
+ * Tests basic auth UI flows without actual wallet connections.
+ * Does NOT test MetaMask/WalletConnect popups (external dependencies).
+ *
+ * Focus: UI rendering, navigation, error states
+ */
+
+test.describe('Auth UI Flow', () => {
+ test('should show auth options when not logged in', async ({ page }) => {
+ await page.goto('/')
+
+ // look for common auth UI elements
+ // adjust selectors based on actual implementation
+ const authElements = [
+ page.getByRole('button', { name: /connect|sign in|log in/i }),
+ page.locator('text=/wallet|authenticate/i'),
+ ]
+
+ // Check if any auth element is visible
+ let foundAuthElement = false
+ for (const el of authElements) {
+ const visible = await el.isVisible().catch(() => false)
+ if (visible) {
+ foundAuthElement = true
+ break
+ }
+ }
+
+ // If no auth UI visible, user might already be logged in or auth is elsewhere
+ // This is a soft assertion - real implementation varies
+ // We just log and don't fail since auth UI location varies by implementation
+ })
+
+ test('should open auth modal/drawer when connect clicked', async ({ page }) => {
+ await page.goto('/')
+
+ // find and click connect button
+ const connectButton = page.getByRole('button', { name: /connect|sign in|log in/i }).first()
+
+ if (await connectButton.isVisible()) {
+ await connectButton.click()
+
+ // should show modal or drawer with wallet options
+ // look for common wallet names
+ await expect(page.locator('text=/metamask|walletconnect|coinbase|rainbow/i').first()).toBeVisible({
+ timeout: 5000,
+ })
+ }
+ })
+
+ test('should close auth modal when close button clicked', async ({ page }) => {
+ await page.goto('/')
+
+ // open auth modal
+ const connectButton = page.getByRole('button', { name: /connect|sign in|log in/i }).first()
+
+ if (await connectButton.isVisible()) {
+ await connectButton.click()
+
+ // wait for modal to appear
+ await page.waitForSelector('text=/metamask|walletconnect/i', { timeout: 5000 })
+
+ // find and click close button
+ const closeButton = page.getByRole('button', { name: /close|cancel|×/i }).first()
+
+ if (await closeButton.isVisible()) {
+ await closeButton.click()
+
+ // modal should be closed
+ await expect(page.locator('text=/metamask|walletconnect/i').first()).not.toBeVisible()
+ }
+ }
+ })
+})
+
+test.describe('Auth State Persistence', () => {
+ test('should maintain auth state across page navigation', async ({ page }) => {
+ // this test requires actual auth - skip for now
+ // real auth requires wallet connection which is external dependency
+ test.skip()
+ })
+
+ test('should handle auth state on page refresh', async ({ page }) => {
+ // skip - requires actual wallet connection
+ test.skip()
+ })
+})
+
+test.describe('Protected Routes', () => {
+ test('should redirect unauthenticated users from protected routes', async ({ page }) => {
+ // try to access a protected route
+ // adjust route based on actual protected pages
+ await page.goto('/profile')
+
+ // wait for client-side redirect to occur (useEffect-based auth redirect)
+ await page.waitForURL(/\/setup|\/home|^\/$/, { timeout: 10000 })
+
+ // verify user is NOT on the protected route
+ const url = page.url()
+ expect(url).not.toContain('/profile')
+ })
+})
diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts
index 65c82761f..58461b552 100644
--- a/src/components/Badges/badge.utils.ts
+++ b/src/components/Badges/badge.utils.ts
@@ -13,6 +13,7 @@ const CODE_TO_PATH: Record = {
BIGGEST_REQUEST_POT: '/badges/biggest_request_pot.svg',
SEEDLING_DEVCONNECT_BA_2025: '/badges/seedlings_devconnect.svg',
ARBIVERSE_DEVCONNECT_BA_2025: '/badges/arbiverse_devconnect.svg',
+ CARD_PIONEER: '/badges/peanut-pioneer.png',
}
// public-facing descriptions for badges (third-person perspective)
@@ -28,6 +29,7 @@ const PUBLIC_DESCRIPTIONS: Record = {
BIGGEST_REQUEST_POT: 'High Roller or Master Beggar? They created the pot with the highest number of contributors.',
SEEDLING_DEVCONNECT_BA_2025: 'Peanut Ambassador. They spread the word and brought others into the ecosystem.',
ARBIVERSE_DEVCONNECT_BA_2025: 'Peanut 🤝 Arbiverse. They joined us at the amazing Arbiverse booth.',
+ CARD_PIONEER: 'A true Card Pioneer. Among the first to pay everywhere with Peanut.',
}
export function getBadgeIcon(code?: string) {
diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx
index b97b044eb..b63dfa4f7 100644
--- a/src/components/Badges/index.tsx
+++ b/src/components/Badges/index.tsx
@@ -9,18 +9,25 @@ import { getCardPosition } from '../Global/Card/card.utils'
import EmptyState from '../Global/EmptyStates/EmptyState'
import { Icon } from '../Global/Icons/Icon'
import ActionModal from '../Global/ActionModal'
-import { useMemo, useState } from 'react'
+import { useMemo, useState, useEffect } from 'react'
import { useUserStore } from '@/redux/hooks'
import { ActionListCard } from '../ActionListCard'
+import { useAuth } from '@/context/authContext'
type BadgeView = { title: string; description: string; logo: string | StaticImageData }
export const Badges = () => {
const router = useRouter()
const { user: authUser } = useUserStore()
+ const { fetchUser } = useAuth()
const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false)
const [selectedBadge, setSelectedBadge] = useState(null)
+ // TODO: fetchUser from context may not be memoized - could cause unnecessary re-renders
+ useEffect(() => {
+ fetchUser()
+ }, [fetchUser])
+
// map api badges to view badges
const badges: BadgeView[] = useMemo(() => {
// get badges from user object and map to card fields
diff --git a/src/components/Card/CardDetailsScreen.tsx b/src/components/Card/CardDetailsScreen.tsx
new file mode 100644
index 000000000..42c556c52
--- /dev/null
+++ b/src/components/Card/CardDetailsScreen.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import NavHeader from '@/components/Global/NavHeader'
+import Image from 'next/image'
+import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
+
+interface CardDetailsScreenProps {
+ price: number
+ currentTier: number
+ onContinue: () => void
+ onBack: () => void
+}
+
+const CardDetailsScreen = ({ price, currentTier, onContinue, onBack }: CardDetailsScreenProps) => {
+ const isDiscounted = currentTier >= 2
+ const originalPrice = 10
+
+ return (
+
+
+
+
+ {/* Peanut mascot background - matches PaymentSuccessView sizing */}
+
+
+ {/* Steps */}
+
+
+
+ 1. You deposit{' '}
+ {isDiscounted ? (
+ <>
+ ${originalPrice} {' '}
+ ${price}
+ >
+ ) : (
+ ${price}
+ )}{' '}
+ now to reserve your card
+ {isDiscounted && (
+ (because you're tier {currentTier})
+ )}
+
+
+
+
+ 2. You'll be first to get your card on April 14th
+
+
+
+
+ 3. Once you get your Peanut Card, the ${price} becomes
+ your starter balance!
+
+
+
+
+ 4. Invite people: you get rewarded for every person you
+ invite, now and forever.
+
+
+
+
+ {/* FAQ Link */}
+
+ For full conditions,{' '}
+
+ read the FAQ
+
+
+
+ {/* CTA Button */}
+
+ Continue
+
+
+
+ )
+}
+
+export default CardDetailsScreen
diff --git a/src/components/Card/CardGeoScreen.tsx b/src/components/Card/CardGeoScreen.tsx
new file mode 100644
index 000000000..2f1b9de66
--- /dev/null
+++ b/src/components/Card/CardGeoScreen.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import { Icon } from '@/components/Global/Icons/Icon'
+import Card from '@/components/Global/Card'
+import InfoCard from '@/components/Global/InfoCard'
+import { useRouter } from 'next/navigation'
+import { saveRedirectUrl } from '@/utils/general.utils'
+
+interface CardGeoScreenProps {
+ isEligible: boolean
+ eligibilityReason?: string
+ onContinue: () => void
+ onInitiatePurchase: () => void
+ onBack: () => void
+ purchaseError?: string | null
+}
+
+const CardGeoScreen = ({
+ isEligible,
+ eligibilityReason,
+ onContinue,
+ onInitiatePurchase,
+ onBack,
+ purchaseError,
+}: CardGeoScreenProps) => {
+ const router = useRouter()
+
+ // State 3: KYC approved but couldn't fetch country - show warning but allow proceeding
+ const hasKycButNoCountry = !isEligible && eligibilityReason === 'KYC_APPROVED_NO_COUNTRY'
+
+ // State 1 & 2: No KYC or KYC in progress - show verification prompt
+ // TODO: Replace string matching with structured eligibility codes from backend (e.g., NEEDS_KYC, KYC_IN_PROGRESS)
+ const needsKycVerification =
+ !isEligible &&
+ !hasKycButNoCountry &&
+ (eligibilityReason?.toLowerCase().includes('country information not available') ||
+ eligibilityReason?.toLowerCase().includes('please complete kyc'))
+
+ const handleStartVerification = () => {
+ saveRedirectUrl()
+ // TODO: Path says "europe" but Bridge covers all regions - consider renaming route or using generic path
+ router.push('/profile/identity-verification/europe/bridge')
+ }
+
+ return (
+
+
+
+
+ {isEligible ? (
+ <>
+ {/* Eligible State */}
+
+
+
+
+
+
You're Eligible!
+
+ Great news! Card Pioneers is available in your region. Continue to see how the
+ program works.
+
+
+
+ >
+ ) : hasKycButNoCountry ? (
+ <>
+ {/* State 3: KYC approved but couldn't fetch country - show warning but allow proceeding */}
+
+
+
+
+
+
Verification Complete
+
+ Your identity has been verified. You can proceed with your card reservation.
+
+
+
+
+ {/* Warning banner - country data not synced yet */}
+
+ >
+ ) : needsKycVerification ? (
+ <>
+ {/* Needs KYC Verification State */}
+
+
+
+
+
+
Verification Required
+
Card Purchare requires identity verification.
+
+
+
+ {/*
+
+
Verification helps us determine your region eligibility.
+
*/}
+ >
+ ) : (
+ <>
+ {/* Not Eligible State */}
+
+
+
+
+
+
Not Available Yet
+
+ Card Pioneers isn't available in your region yet. We're working hard to expand
+ coverage.
+
+
+
+
+
+
+
+ We'll notify you when we launch in your area. In the meantime, keep using Peanut to earn
+ points!
+
+
+ >
+ )}
+
+ {purchaseError &&
}
+
+ {/* CTA Buttons */}
+ {isEligible || hasKycButNoCountry ? (
+
+ Reserve my card
+
+ ) : needsKycVerification ? (
+
+ Start Verification
+
+ ) : (
+
+ Go Back
+
+ )}
+
+
+ )
+}
+
+export default CardGeoScreen
diff --git a/src/components/Card/CardInfoScreen.tsx b/src/components/Card/CardInfoScreen.tsx
new file mode 100644
index 000000000..07bfe5e93
--- /dev/null
+++ b/src/components/Card/CardInfoScreen.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState, useRef } from 'react'
+
+interface CardInfoScreenProps {
+ onContinue: () => void
+ hasPurchased: boolean
+ slotsRemaining?: number
+ recentPurchases?: number
+}
+
+// Rolling digit component - animates a single digit sliding down using CSS keyframes
+const RollingDigit = ({ digit, duration = 400 }: { digit: string; duration?: number }) => {
+ const [currentDigit, setCurrentDigit] = useState(digit)
+ const [prevDigit, setPrevDigit] = useState(null)
+ const [animationKey, setAnimationKey] = useState(0)
+ const prevDigitRef = useRef(digit)
+
+ useEffect(() => {
+ if (digit !== prevDigitRef.current) {
+ setPrevDigit(prevDigitRef.current)
+ setCurrentDigit(digit)
+ setAnimationKey((k) => k + 1)
+ prevDigitRef.current = digit
+
+ // Clear prevDigit after animation
+ const timer = setTimeout(() => {
+ setPrevDigit(null)
+ }, duration)
+
+ return () => clearTimeout(timer)
+ }
+ }, [digit, duration])
+
+ const animationStyle = `
+ @keyframes slideOut {
+ from { transform: translateY(0); opacity: 1; }
+ to { transform: translateY(-100%); opacity: 0; }
+ }
+ @keyframes slideIn {
+ from { transform: translateY(100%); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+ }
+ `
+
+ return (
+
+
+ {/* Previous digit - slides out */}
+ {prevDigit !== null && (
+
+ {prevDigit}
+
+ )}
+ {/* Current digit - slides in (or static if no animation) */}
+
+ {currentDigit}
+
+
+ )
+}
+
+// Rolling number display - splits number into digits and animates each
+const RollingNumber = ({ value, duration = 400 }: { value: number; duration?: number }) => {
+ const digits = String(value).split('')
+
+ return (
+
+ {digits.map((digit, index) => (
+
+ ))}
+
+ )
+}
+
+const CardInfoScreen = ({ onContinue, hasPurchased, slotsRemaining, recentPurchases }: CardInfoScreenProps) => {
+ const router = useRouter()
+ const [displayValue, setDisplayValue] = useState(null)
+ const timeoutRef = useRef(null)
+ const hasAnimated = useRef(false)
+
+ // Realistic slot decrement: first tick after 4-12s, then every 15-40s
+ useEffect(() => {
+ if (slotsRemaining === undefined) return
+
+ // Update display value on refetch without re-triggering the animation
+ if (hasAnimated.current) {
+ setDisplayValue(slotsRemaining)
+ return
+ }
+
+ hasAnimated.current = true
+ setDisplayValue(slotsRemaining)
+
+ const scheduleTick = (isFirst: boolean) => {
+ const delay = isFirst
+ ? 2000 + Math.random() * 3000 // 2-5 seconds for first tick
+ : 8000 + Math.random() * 12000 // 8-20 seconds for subsequent ticks
+ timeoutRef.current = setTimeout(() => {
+ setDisplayValue((prev) => {
+ if (prev === null || prev <= 1) return prev
+ return prev - 1
+ })
+ scheduleTick(false)
+ }, delay)
+ }
+
+ scheduleTick(true)
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [slotsRemaining])
+
+ return (
+
+
router.back()} />
+
+
+ {/* Description and FAQ link */}
+
+
+ Get access to the best card in the world. Spend globally at the best rates, and get rewarded for
+ every spend of you and your friends.
+
+
+ Have a question? Read the FAQ
+
+
+
+ {/* Card Hero with 3D effect */}
+
+
+ {/* Slots remaining counter */}
+ {displayValue !== null && (
+
+
+
+ slots left
+
+
+ {recentPurchases && recentPurchases > 0
+ ? `${recentPurchases} ${recentPurchases === 1 ? 'person' : 'people'} joined in the last 24h`
+ : 'Join the pioneers today'}
+
+
+ )}
+
+ {/* CTA Button */}
+ {hasPurchased ? (
+
+ Already a Pioneer
+
+ ) : (
+
+ Join Now
+
+ )}
+
+
+ )
+}
+
+export default CardInfoScreen
diff --git a/src/components/Card/CardPioneerModal.tsx b/src/components/Card/CardPioneerModal.tsx
new file mode 100644
index 000000000..9e76f1952
--- /dev/null
+++ b/src/components/Card/CardPioneerModal.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/0_Bruddle/Button'
+import BaseModal from '@/components/Global/Modal'
+import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
+
+const STORAGE_KEY = 'card-pioneer-modal-dismissed'
+const DISMISS_DURATION_DAYS = 3
+
+interface CardPioneerModalProps {
+ hasPurchased: boolean
+}
+
+/**
+ * Popup modal shown to eligible users who haven't purchased Card Pioneer yet.
+ * Shown on app open, can be dismissed by closing the modal (re-shows after X days).
+ */
+const CardPioneerModal = ({ hasPurchased }: CardPioneerModalProps) => {
+ const router = useRouter()
+ const [isVisible, setIsVisible] = useState(false)
+
+ // Check if modal should be shown
+ useEffect(() => {
+ // Don't show if already purchased
+ // Note: Eligibility check happens during the flow (geo screen), not here
+ if (hasPurchased) {
+ return
+ }
+
+ // Check localStorage for dismissal
+ const dismissedAt = localStorage.getItem(STORAGE_KEY)
+ if (dismissedAt) {
+ const dismissedDate = new Date(dismissedAt)
+ const now = new Date()
+ const daysSinceDismissed = (now.getTime() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)
+
+ if (daysSinceDismissed < DISMISS_DURATION_DAYS) {
+ return
+ }
+ }
+
+ // Show modal with a small delay for better UX
+ const timer = setTimeout(() => {
+ setIsVisible(true)
+ }, 1000)
+
+ return () => clearTimeout(timer)
+ }, [hasPurchased])
+
+ const handleDismiss = () => {
+ localStorage.setItem(STORAGE_KEY, new Date().toISOString())
+ setIsVisible(false)
+ }
+
+ const handleJoinNow = () => {
+ setIsVisible(false)
+ router.push('/card')
+ }
+
+ return (
+
+
+ {/* Title */}
+
Become a Pioneer
+
+ {/* Description */}
+
+ Join the Peanut Card Pioneers now to earn rewards for every purchase of you and your friends!
+
+
+ {/* Card Hero - scaled down for popup */}
+
+
+ {/* CTA */}
+
+
+ Get Early Access
+
+
+
+
+ )
+}
+
+export default CardPioneerModal
diff --git a/src/components/Card/CardPurchaseScreen.tsx b/src/components/Card/CardPurchaseScreen.tsx
new file mode 100644
index 000000000..4c1b7f7ca
--- /dev/null
+++ b/src/components/Card/CardPurchaseScreen.tsx
@@ -0,0 +1,216 @@
+'use client'
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import { Icon } from '@/components/Global/Icons/Icon'
+import Card from '@/components/Global/Card'
+import { cardApi, CardPurchaseError } from '@/services/card'
+import Loading from '@/components/Global/Loading'
+
+interface CardPurchaseScreenProps {
+ price: number
+ existingChargeUuid?: string | null
+ existingPaymentUrl?: string | null
+ onPurchaseInitiated: (chargeUuid: string, paymentUrl: string) => void
+ onPurchaseComplete: () => void
+ onBack: () => void
+}
+
+type PurchaseState = 'idle' | 'creating' | 'awaiting_payment' | 'error'
+
+const CardPurchaseScreen = ({
+ price,
+ existingChargeUuid,
+ existingPaymentUrl,
+ onPurchaseInitiated,
+ onPurchaseComplete,
+ onBack,
+}: CardPurchaseScreenProps) => {
+ const [purchaseState, setPurchaseState] = useState(existingChargeUuid ? 'awaiting_payment' : 'idle')
+ const [chargeUuid, setChargeUuid] = useState(existingChargeUuid || null)
+ const [paymentUrl, setPaymentUrl] = useState(existingPaymentUrl || null)
+ const [error, setError] = useState(null)
+
+ // Guard against double-submit race condition (React state updates are async,
+ // so rapid clicks could trigger multiple API calls before state updates)
+ const isInitiatingRef = useRef(false)
+
+ // Initialize purchase with debounce guard
+ const initiatePurchase = useCallback(async () => {
+ if (isInitiatingRef.current) return
+ isInitiatingRef.current = true
+
+ setPurchaseState('creating')
+ setError(null)
+
+ try {
+ const response = await cardApi.purchase()
+ setChargeUuid(response.chargeUuid)
+ setPaymentUrl(response.paymentUrl)
+ onPurchaseInitiated(response.chargeUuid, response.paymentUrl)
+ setPurchaseState('awaiting_payment')
+ } catch (err) {
+ if (err instanceof CardPurchaseError) {
+ if (err.code === 'ALREADY_PURCHASED') {
+ // User already purchased, redirect to success
+ onPurchaseComplete()
+ return
+ }
+ setError(err.message)
+ } else {
+ setError('Failed to initiate purchase. Please try again.')
+ }
+ setPurchaseState('error')
+ } finally {
+ isInitiatingRef.current = false
+ }
+ }, [onPurchaseInitiated, onPurchaseComplete])
+
+ // Open payment URL in new tab
+ const openPaymentUrl = useCallback(() => {
+ if (paymentUrl) {
+ window.open(paymentUrl, '_blank', 'noopener,noreferrer')
+ }
+ }, [paymentUrl])
+
+ // Poll for payment completion with timeout
+ useEffect(() => {
+ if (purchaseState !== 'awaiting_payment' || !chargeUuid) return
+
+ let attempts = 0
+ const maxAttempts = 40 // 40 attempts * 3s = 2 minutes max
+
+ const pollInterval = setInterval(async () => {
+ attempts++
+
+ // Check for timeout
+ if (attempts > maxAttempts) {
+ clearInterval(pollInterval)
+ setError('Payment verification timed out. Please check your transaction status.')
+ setPurchaseState('error')
+ return
+ }
+
+ try {
+ const info = await cardApi.getInfo()
+ if (info.hasPurchased) {
+ clearInterval(pollInterval)
+ onPurchaseComplete()
+ }
+ } catch {
+ // Ignore polling errors - will retry on next interval
+ }
+ }, 3000)
+
+ return () => clearInterval(pollInterval)
+ }, [purchaseState, chargeUuid, onPurchaseComplete])
+
+ return (
+
+
+
+
+ {purchaseState === 'idle' && (
+ <>
+
+
+
+
+
+
Confirm Purchase
+
+ You're about to reserve your Card Pioneer spot for ${price}. This amount will become
+ your starter balance when the card launches.
+
+
+
+
+ {/* Price Summary */}
+
+
+ Pioneer Reservation
+ ${price}
+
+
+ >
+ )}
+
+ {purchaseState === 'creating' && (
+
+
+
+
Creating Payment...
+
Setting up your purchase. Please wait.
+
+
+ )}
+
+ {purchaseState === 'awaiting_payment' && (
+ <>
+
+
+
+
+
+
Complete Payment
+
+ Click below to open the payment page and complete your Pioneer reservation.
+
+
+
+
+
+ Open Payment Page
+
+
+
+
+ Waiting for payment confirmation...
+
+ >
+ )}
+
+ {purchaseState === 'error' && (
+
+
+
+
+
+
Something Went Wrong
+
+ {error || 'An error occurred while processing your purchase.'}
+
+
+
+ )}
+
+ {/* CTA Buttons */}
+ {purchaseState === 'idle' && (
+
+ Pay ${price}
+
+ )}
+
+ {purchaseState === 'error' && (
+
+
+ Try Again
+
+
+ Go Back
+
+
+ )}
+
+
+ )
+}
+
+export default CardPurchaseScreen
diff --git a/src/components/Card/CardSuccessScreen.tsx b/src/components/Card/CardSuccessScreen.tsx
new file mode 100644
index 000000000..bf8c3bd3c
--- /dev/null
+++ b/src/components/Card/CardSuccessScreen.tsx
@@ -0,0 +1,144 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import { Icon } from '@/components/Global/Icons/Icon'
+import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
+import { SoundPlayer } from '@/components/Global/SoundPlayer'
+import { shootStarConfetti } from '@/utils/confetti'
+import { useAuth } from '@/context/authContext'
+import Image from 'next/image'
+import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
+
+interface CardSuccessScreenProps {
+ onViewBadges: () => void
+}
+
+const CardSuccessScreen = ({ onViewBadges }: CardSuccessScreenProps) => {
+ const [showConfetti, setShowConfetti] = useState(false)
+ const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
+ const { user } = useAuth()
+ const router = useRouter()
+
+ // Trigger star confetti on mount
+ useEffect(() => {
+ if (!showConfetti) {
+ setShowConfetti(true)
+ const duration = 2000
+ const end = Date.now() + duration
+ let cancelled = false
+
+ const frame = () => {
+ if (cancelled) return
+
+ shootStarConfetti({
+ particleCount: 20,
+ origin: { x: 0, y: 0.8 },
+ spread: 55,
+ startVelocity: 30,
+ ticks: 100,
+ })
+ shootStarConfetti({
+ particleCount: 20,
+ origin: { x: 1, y: 0.8 },
+ spread: 55,
+ startVelocity: 30,
+ ticks: 100,
+ })
+
+ if (Date.now() < end) {
+ requestAnimationFrame(frame)
+ }
+ }
+ frame()
+
+ return () => {
+ cancelled = true
+ }
+ }
+ }, [showConfetti])
+
+ return (
+ <>
+
+
+
+
+ {/* Peanut mascot background - matches PaymentSuccessView */}
+
+
+ {/* Success card */}
+
+
+
+
+
+
You're a Pioneer!
+ Card Reserved
+
+
+
+ {/* What you unlocked */}
+
+
+
+
+
+ Pioneer badge added to your profile
+
+
+
+
+
+ Priority access during launch
+
+
+
+
+
+ $5 for every friend who joins
+
+
+
+
+
+ Earn forever on every purchase
+
+
+
+ {/* CTAs */}
+
+ setIsInviteModalOpen(true)}
+ className="w-full"
+ >
+
+ Share Invite Link
+
+
+ View Your Badges
+
+
+
+
+
+ setIsInviteModalOpen(false)}
+ username={user?.user?.username ?? ''}
+ />
+ >
+ )
+}
+
+export default CardSuccessScreen
diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx
index b27e54a4d..e014c639b 100644
--- a/src/components/Claim/Link/MantecaFlowManager.tsx
+++ b/src/components/Claim/Link/MantecaFlowManager.tsx
@@ -12,9 +12,9 @@ import MantecaReviewStep from './views/MantecaReviewStep'
import { Button } from '@/components/0_Bruddle/Button'
import { useRouter } from 'next/navigation'
import useKycStatus from '@/hooks/useKycStatus'
-import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
-import { useAuth } from '@/context/authContext'
-import { type CountryData } from '@/components/AddMoney/consts'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
interface MantecaFlowManagerProps {
claimLinkData: ClaimLinkData
@@ -27,33 +27,23 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS)
const router = useRouter()
const [destinationAddress, setDestinationAddress] = useState('')
- const [isKYCModalOpen, setIsKYCModalOpen] = useState(false)
- const argentinaCountryData = {
- id: 'AR',
- type: 'country',
- title: 'Argentina',
- currency: 'ARS',
- path: 'argentina',
- iso2: 'AR',
- iso3: 'ARG',
- } as CountryData
+ const { isUserMantecaKycApproved } = useKycStatus()
- const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus()
- const { fetchUser } = useAuth()
+ // inline sumsub kyc flow for manteca users who need LATAM verification
+ // regionIntent is NOT passed here to avoid creating a backend record on mount.
+ // intent is passed at call time: handleInitiateKyc('LATAM')
+ const sumsubFlow = useMultiPhaseKycFlow({})
+ const [showKycModal, setShowKycModal] = useState(false)
const isSuccess = currentStep === MercadoPagoStep.SUCCESS
const selectedCurrency = selectedCountry?.currency || 'ARS'
const regionalMethodLogo = regionalMethodType === 'mercadopago' ? MERCADO_PAGO : PIX
const logo = selectedCountry?.id ? undefined : regionalMethodLogo
- const handleKycCancel = () => {
- setIsKYCModalOpen(false)
- onPrev()
- }
-
+ // show confirmation modal if user hasn't completed manteca verification
useEffect(() => {
if (!isUserMantecaKycApproved) {
- setIsKYCModalOpen(true)
+ setShowKycModal(true)
}
}, [isUserMantecaKycApproved])
@@ -125,23 +115,17 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
/>
{renderStepDetails()}
-
- {isKYCModalOpen && (
- {
- // close the modal and let the user continue with amount input
- setIsKYCModalOpen(false)
- fetchUser()
- }}
- selectedCountry={selectedCountry || argentinaCountryData}
- />
- )}
+ setShowKycModal(false)}
+ onVerify={async () => {
+ setShowKycModal(false)
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
)
}
diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx
index f19e1ccd5..6d601ac8e 100644
--- a/src/components/Claim/Link/views/BankFlowManager.view.tsx
+++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx
@@ -18,7 +18,7 @@ import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/servi
import { getOfframpCurrencyConfig } from '@/utils/bridge.utils'
import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils'
import peanut from '@squirrel-labs/peanut-sdk'
-import { addBankAccount, getUserById, updateUserById } from '@/app/actions/users'
+import { addBankAccount, getUserById } from '@/app/actions/users'
import SavedAccountsView from '../../../Common/SavedAccountsView'
import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType'
import useSavedAccounts from '@/hooks/useSavedAccounts'
@@ -31,8 +31,9 @@ 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'
import { useSearchParams } from 'next/navigation'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
type BankAccountWithId = IBankAccountDetails &
(
@@ -76,6 +77,20 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
const { claimLink } = useClaimLink()
const dispatch = useAppDispatch()
+ // inline sumsub kyc flow for users who need verification
+ // regionIntent is NOT passed here to avoid creating a backend record on mount.
+ // intent is passed at call time: handleInitiateKyc('STANDARD')
+ const sumsubFlow = useMultiPhaseKycFlow({
+ onKycSuccess: async () => {
+ if (justCompletedKyc) return
+ setIsKycModalOpen(false)
+ await fetchUser()
+ setJustCompletedKyc(true)
+ setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm)
+ },
+ onManualClose: () => setIsKycModalOpen(false),
+ })
+
// local states for this component
const [localBankDetails, setLocalBankDetails] = useState