diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index c64d7400d..a471b20eb 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -9,7 +9,7 @@ import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import Link from 'next/link' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' @@ -172,7 +172,7 @@ export default function NotificationsPage() { onClick={() => handleNotificationClick(notif.id)} > icon ({ // The page imports PeanutThinking from @/assets/mascot and STAR_STRAIGHT_ICON // from @/assets/icons directly — mock those paths, not the @/assets barrel, and -// keep sibling exports (e.g. PEANUTMAN_LOGO, ETHEREUM_ICON used by QRScanner) intact. +// keep sibling exports (e.g. PEANUTMAN, ETHEREUM_ICON used by QRScanner) intact. jest.mock('@/assets/mascot', () => ({ ...jest.requireActual('@/assets/mascot'), PeanutThinking: '/peanut-guy.gif', diff --git a/src/app/dev/loading-words/page.tsx b/src/app/dev/loading-words/page.tsx index 3f0ae1b91..89a649728 100644 --- a/src/app/dev/loading-words/page.tsx +++ b/src/app/dev/loading-words/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { PAYMENT_LOADING_WORDS } from '@/components/Global/PeanutLoading/words' import { useEffect, useState } from 'react' @@ -41,7 +41,7 @@ export default function LoadingWordsPreview() {
- Peanut + Peanut
diff --git a/src/assets/illustrations/index.ts b/src/assets/illustrations/index.ts index 28f5a8537..9e711b84f 100644 --- a/src/assets/illustrations/index.ts +++ b/src/assets/illustrations/index.ts @@ -8,7 +8,6 @@ export { default as HandThumbsUpV2 } from './hand-thumbs-up-v2.svg' export { default as HandToken } from './new-hand-token.svg' export { default as AboutPeanut } from './new-hero-description.svg' //export { default as PeanutArmHoldingBeer } from './peanut-arm-holding-beer.svg' -export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg' export { default as PeanutsBG } from './peanuts-bg.svg' export { default as Sparkle } from './sparkle.svg' export { default as Star } from './star.svg' diff --git a/src/assets/logos/index.ts b/src/assets/logos/index.ts index 878328846..4cb16fce1 100644 --- a/src/assets/logos/index.ts +++ b/src/assets/logos/index.ts @@ -1,4 +1,5 @@ export { default as PEANUT_LOGO } from './peanut-logo.svg'; +export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg'; export { default as MEPA_ARGENTINA_LOGO } from './mepa-arg.svg'; export { default as PIX_BRZ_LOGO } from './pix-brz.svg'; export { default as DEVCONNECT_LOGO } from './devconnect.svg'; \ No newline at end of file diff --git a/src/assets/illustrations/peanut-logo-dark.svg b/src/assets/logos/peanut-logo-dark.svg similarity index 100% rename from src/assets/illustrations/peanut-logo-dark.svg rename to src/assets/logos/peanut-logo-dark.svg diff --git a/src/assets/mascot/index.ts b/src/assets/mascot/index.ts index 6092e03b1..e11d769ba 100644 --- a/src/assets/mascot/index.ts +++ b/src/assets/mascot/index.ts @@ -15,7 +15,7 @@ export { default as PeanutWalking } from './peanut-walking.webp' // mid-stride, export { default as PeanutPointingDown } from './peanut-pointing-down.webp' // both hands pointing down — marketing CTA // Stills -export { default as PEANUTMAN_LOGO } from './peanut-logo.svg' +export { default as PEANUTMAN } from './peanutman.svg' export { default as PEANUTMAN_PFP } from './peanut-pfp.svg' export { default as PEANUTMAN_HOLDING_BEER } from './peanut-beer.svg' export { default as PEANUTMAN_MOBILE } from './peanut-club.webp' diff --git a/src/assets/mascot/peanut-logo.svg b/src/assets/mascot/peanutman.svg similarity index 100% rename from src/assets/mascot/peanut-logo.svg rename to src/assets/mascot/peanutman.svg diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c00fc73de..a63caaf6f 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -34,6 +34,7 @@ import { railJurisdictionForBank } from '@/utils/bridge.utils' import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' import { useModalsContext } from '@/context/ModalsContext' import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config' @@ -126,6 +127,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const bankCountry = useMemo(() => railJurisdictionForBank(currentCountry?.id), [currentCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() + const [showProvideEmail, setShowProvideEmail] = useState(false) const { setIsSupportModalOpen } = useModalsContext() // stores the callback to replay after tos acceptance in the list view @@ -149,6 +151,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (gate.kind === 'accept-tos') { pendingAfterTosRef.current = onAfterTos ?? null guardWithTos() + } else if (gate.kind === 'provide-email') { + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } @@ -178,6 +182,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } if (gate.kind === 'accept-tos') { guardWithTos() + } else if (gate.kind === 'provide-email') { + // A rail that flipped to email-blocked between form-open and submit + // is self-serve — open the email sheet, NOT the contact-support KYC + // modal (mirrors checkBridgeGate; the whole point of provide-email). + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } @@ -379,6 +388,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { onSkip={hideTos} reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> + setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index 0a1737561..f49360439 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -160,6 +160,10 @@ jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBan jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null })) jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null })) +jest.mock('@/components/Kyc/ProvideEmailStep', () => ({ + __esModule: true, + default: (props: any) => (props.visible ?
: null), +})) jest.mock('@/components/Kyc/InitiateKycModal', () => ({ InitiateKycModal: (props: any) => (props.visible ?
: null), })) @@ -235,6 +239,22 @@ describe('AddWithdrawCountriesList — bank gate', () => { expect(mockPush).not.toHaveBeenCalled() expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() }) + + // provide-email is a self-serve gate (one email unblocks the rail) — it must + // open the email sheet, NEVER the contact-support KYC modal. Both the click + // gate (checkBridgeGate) and the form-submit gate (handleFormSubmit) must + // route it there; a missing branch on the submit path turned self-serve + // recovery into a support ticket (2026-07 review finding). + it('an email-blocked gate opens the provide-email sheet, not the contact-support KYC modal', () => { + setCapabilities('provide-email', [{ status: 'blocked', channel: 'bank', country: 'US' }]) + + render() + fireEvent.click(screen.getByTestId('method-bank')) + + expect(screen.getByTestId('provide-email-sheet')).toBeInTheDocument() + expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() + expect(mockPush).not.toHaveBeenCalled() + }) }) /** diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index d8163b4d2..3d5905471 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -9,7 +9,7 @@ // Codes here must match the backend registry (peanut-api-ts // src/acknowledgments/seed-definitions.ts). How the whole system works + how to // add a badge: peanut-api-ts/docs/BADGES.md. -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export type BadgeMeta = { path: string @@ -245,7 +245,7 @@ export const BADGE_CODES: readonly string[] = Object.keys(BADGES) export function getBadgeIcon(code?: string): string { // .src: the svg import is StaticImageData (typed `any` by the module shim, so the // annotation alone can't enforce this) — raw consumers need a string URL. - return (code && BADGES[code]?.path) || PEANUTMAN_LOGO.src + return (code && BADGES[code]?.path) || PEANUTMAN.src } // returns the public-facing description for a badge code (third-person perspective) diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 14bff2756..8577681ac 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { twMerge } from 'tailwind-merge' import { Icon } from '@/components/Global/Icons/Icon' import { PEANUT_CARD_HAND, VISA_BRAND_MARK } from '@/assets/cards' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export interface RevealedCardDetails { pan: string @@ -86,7 +86,7 @@ const CardFace: FC = ({
{/* Top row: peanut icon (no wordmark) + Visa */}
- + Visa
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index f8f0216d5..c8af37514 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -23,11 +23,11 @@ import { type FC, type CSSProperties } from 'react' import { CARD_W, CARD_H } from './shareAssetLayout' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import PEANUT_CARD_HAND_PIXEL_ASSET from '@/assets/cards/peanut-card-hand-pixel.png' import VISA_BRAND_MARK_ASSET from '@/assets/cards/visa-brand-mark.png' -const ASSET_PEANUTMAN_LOGO = PEANUTMAN_LOGO.src +const ASSET_PEANUTMAN = PEANUTMAN.src const ASSET_VISA_BRAND = VISA_BRAND_MARK_ASSET.src const ASSET_CARD_HAND_PIXEL = PEANUT_CARD_HAND_PIXEL_ASSET.src @@ -93,9 +93,9 @@ export const PixelatedCardFace: FC = ({ style={{ top: 24, left: 28, right: 28, zIndex: 2 }} > {blurAll ? ( - + ) : ( - + )} {!hideVisa && (blurAll ? ( diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 970a57d1e..2e0e699a3 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -42,7 +42,7 @@ import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol } from '@/utils/general.utils' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { GuestVerificationModal } from '@/components/Global/GuestVerificationModal' import { useCapabilities } from '@/hooks/useCapabilities' import MantecaFlowManager from './MantecaFlowManager' @@ -822,7 +822,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
Receive on
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 1cf1bae36..3fa6d8754 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -25,10 +25,10 @@ import { useContext, useMemo, useState } from 'react' import ActionModal from '@/components/Global/ActionModal' import Divider from '../../0_Bruddle/Divider' import { Button } from '@/components/0_Bruddle/Button' -import { PEANUT_LOGO_BLACK } from '@/assets/illustrations' +import { PEANUT_LOGO_BLACK } from '@/assets/logos' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' import { tokenSelectorContext } from '@/context' @@ -243,7 +243,7 @@ export default function SendLinkActionList({ > {showDevconnectMethod ?
Claim on
:
Continue with
}
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Global/ConfirmInviteModal/index.tsx b/src/components/Global/ConfirmInviteModal/index.tsx index a9bc915fb..8a811a8ae 100644 --- a/src/components/Global/ConfirmInviteModal/index.tsx +++ b/src/components/Global/ConfirmInviteModal/index.tsx @@ -1,7 +1,7 @@ 'use client' import { type FC } from 'react' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import Modal from '../Modal' import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' @@ -52,7 +52,7 @@ const ConfirmInviteModal: FC = ({ diff --git a/src/components/Global/CreateAccountButton/index.tsx b/src/components/Global/CreateAccountButton/index.tsx index 7cd3e0c48..fb3e2d4dd 100644 --- a/src/components/Global/CreateAccountButton/index.tsx +++ b/src/components/Global/CreateAccountButton/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' @@ -13,7 +13,7 @@ const CreateAccountButton = ({ onClick }: CreateAccountButtonProps) => { diff --git a/src/components/Global/PeanutLoading/CyclingLoading.tsx b/src/components/Global/PeanutLoading/CyclingLoading.tsx index f5e63cc25..3595a741a 100644 --- a/src/components/Global/PeanutLoading/CyclingLoading.tsx +++ b/src/components/Global/PeanutLoading/CyclingLoading.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { useEffect, useState } from 'react' import { PAYMENT_LOADING_WORDS } from './words' @@ -26,7 +26,7 @@ export default function CyclingLoading() {
- logo + logo {word}
diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx index 6d4a2d02f..a2d566fd5 100644 --- a/src/components/Global/PeanutLoading/index.tsx +++ b/src/components/Global/PeanutLoading/index.tsx @@ -1,4 +1,4 @@ -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import { twMerge } from 'tailwind-merge' export default function PeanutLoading({ @@ -18,7 +18,7 @@ export default function PeanutLoading({ )} >
- logo + Peanut mascot {message ?? 'Loading...'}
diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index cfb9ee688..816b9fd46 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom' import { Button } from '@/components/0_Bruddle/Button' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { ETHEREUM_ICON } from '@/assets/icons' import Image from 'next/image' import { Icon } from '../Icons/Icon' @@ -14,7 +14,7 @@ import CameraPermissionModal from './CameraPermissionModal' // ============================================================================ const PAYMENT_METHODS = [ - { src: PEANUTMAN_LOGO, alt: 'Peanut', name: 'Peanut' }, + { src: PEANUTMAN, alt: 'Peanut', name: 'Peanut' }, { src: MERCADO_PAGO, alt: 'Mercado Pago', name: 'Mercado Pago' }, { src: PIX, alt: 'PIX', name: 'PIX' }, { src: ETHEREUM_ICON, alt: 'Ethereum and EVMs', name: 'ETH & EVMs' }, diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 6e6f7db8c..d34f2dd0e 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -7,13 +7,14 @@ import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useAuth } from '@/context/authContext' import { buildContactSupportMessage } from '@/utils/contact-support.utils' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' interface ActivationCTAsProps { activationStep: ActivationStep @@ -76,7 +77,7 @@ const STEPS: Record, StepConfig> = { export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() - const { rails, channelOf } = useCapabilities() + const { rails, channelOf, nextActionsForRail } = useCapabilities() const { user } = useAuth() // Suppress the "Unlock payments" verify CTA while identity is mid-flight // (Sumsub processing / action_required). The user already took the verify @@ -88,20 +89,32 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // qr-only channels — never through card. Top-level status (not per-op // refinement): Manteca's pool tier reads `enabled` at the rail level even when // deposit/withdraw individually need an upgrade — that's not a rejection. - const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail } = useMemo(() => { - const rejectableRails = rails.filter((rail) => { - const channel = channelOf(rail) - return channel === 'bank' || channel === 'qr-only' - }) - const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') - const blocked = rejectableRails.find((rail) => rail.status === 'blocked') - return { - hasFixableRejection: !!fixableRail, - hasBlockedRejection: !!blocked, - primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, - blockedRail: blocked, - } - }, [rails, channelOf]) + const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail, isEmailBlocked } = + useMemo(() => { + const rejectableRails = rails.filter((rail) => { + const channel = channelOf(rail) + return channel === 'bank' || channel === 'qr-only' + }) + const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') + // Email-blocked rails carry a self-serve provide-email action (same + // contract the capability gate reads) — prefer one over an earlier + // blocked rail with a terminal reason, since one email fixes them all. + const emailBlocked = rejectableRails.find( + (rail) => + rail.status === 'blocked' && nextActionsForRail(rail.id).some((a) => a.kind === 'provide-email') + ) + const blocked = emailBlocked ?? rejectableRails.find((rail) => rail.status === 'blocked') + return { + hasFixableRejection: !!fixableRail, + hasBlockedRejection: !!blocked, + // Same precedence the copy/onClick use: email-blocked → fixable → terminal. + primaryRejectionMessage: (emailBlocked ?? fixableRail ?? blocked)?.reason?.userMessage ?? null, + blockedRail: blocked, + isEmailBlocked: !!emailBlocked, + } + }, [rails, channelOf, nextActionsForRail]) + + const [showProvideEmail, setShowProvideEmail] = useState(false) const lastTrackedStep = useRef(null) useEffect(() => { @@ -127,6 +140,22 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (activationStep === 'verify' && isIdentityProcessing && !isIdentityActionRequired) return null if (hasProviderRejection) { + // Email-blocked (status=blocked) outranks a fixable RFI (status=requires-info) + // — the canonical `deriveGate` order, and the order this card's onClick + // already follows (isEmailBlocked first). Ranking fixable above email here + // made the copy say "Upload document" while the button opened the email + // sheet, and hid the document-upload path entirely when both coexisted. + if (isEmailBlocked) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Add your email', + description: + primaryRejectionMessage || 'We need an email address to finish setting up your account.', + ctaLabel: 'Add email', + href: '', // handled in onClick + } + } if (hasFixableRejection) { return { icon: 'globe-lock', @@ -153,6 +182,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa activationStep, hasProviderRejection, hasFixableRejection, + isEmailBlocked, primaryRejectionMessage, isIdentityProcessing, isIdentityActionRequired, @@ -193,7 +223,9 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa shadowSize="4" className="mt-2 w-full" onClick={() => { - if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { + if (isEmailBlocked) { + setShowProvideEmail(true) + } else if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { // REQUIRES_SUPPORT class (or any blocked rail) — pre-fill Crisp // with the failure context so support can dispatch without // re-investigating the user's state. @@ -219,6 +251,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa )}
+ setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) } diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx new file mode 100644 index 000000000..9668e5b8f --- /dev/null +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import ProfileEditField from '@/components/Profile/components/ProfileEditField' +import { updateUserById } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' +import { isValidEmail } from '@/utils/format.utils' +import type { IconName } from '@/components/Global/Icons/Icon' + +interface ProvideEmailStepProps { + visible: boolean + onComplete: () => void + onSkip: () => void +} + +/** + * Self-serve recovery for the no-email KYC dead-end: the BE emits a + * `provide-email` NextAction when provider submission failed because no email + * was ever captured. Saving an email here flips the blocked rails back to + * PENDING server-side and re-runs submission automatically — no support + * ticket needed. (Shell mirrors BridgeTosStep.) + */ +export default function ProvideEmailStep({ visible, onComplete, onSkip }: ProvideEmailStepProps) { + const { user, fetchUser } = useAuth() + const [email, setEmail] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (visible) { + setEmail('') + setError(null) + } + }, [visible]) + + const handleSave = useCallback(async () => { + const trimmed = email.trim() + if (!isValidEmail(trimmed)) { + setError('Please enter a valid email address.') + return + } + const userId = user?.user?.userId + if (!userId) { + setError('Still loading your account — please try again in a moment.') + return + } + setIsSaving(true) + setError(null) + try { + const response = await updateUserById({ userId, email: trimmed }) + if (response.error) { + setError(response.error) + return + } + // The BE resubmits the blocked rails on email set; refetch so the + // provide-email gate clears (rails go PENDING → the UI shows the + // normal in-progress state instead of this modal). + await fetchUser() + onComplete() + } catch { + setError('Something went wrong saving your email. Please try again.') + } finally { + setIsSaving(false) + } + }, [email, user?.user?.userId, fetchUser, onComplete]) + + return ( + + + {error &&

{error}

} +
+ } + /> + ) +} diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 9578ebeba..72b33dfdb 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -1,6 +1,6 @@ 'use client' -import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' @@ -76,7 +76,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa
{!isLoggedIn ? (
- Peanut Logo + Peanut mascot Peanut Text
) : ( diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index c2a14631a..03881c27f 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -26,7 +26,7 @@ import { VerifiedUserLabel } from '../UserHeader' import { PerkIcon } from './PerkIcon' import { useHaptic } from 'use-haptic' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import InvitesIcon from '../Home/InvitesIcon' // Lazy load transaction details drawer (~40KB) to reduce initial bundle size @@ -167,7 +167,7 @@ const TransactionCard: React.FC = ({ {isTestTransaction ? (
Peanut Logo
- Peanut Logo + Peanut Logo

Enjoy Peanut!

diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx index cac78d970..7fb188424 100644 --- a/src/features/payments/shared/components/SendWithPeanutCta.tsx +++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx @@ -8,7 +8,7 @@ * - logged in: "send with peanut" + executes payment */ -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import type { IconName } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' @@ -100,7 +100,7 @@ export default function SendWithPeanutCta({ const peanutLogo = useMemo((): React.ReactNode => { return (
- Peanut Logo + Peanut Logo Peanut Logo
) diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index 6d660a5ad..f97c444a7 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -230,3 +230,166 @@ describe('useSumsubKycFlow — terminal-error exits clear the user-initiated gua await waitFor(() => expect(onKycSuccess).toHaveBeenCalledTimes(1)) }) }) + +// Incident 2026-07-02: while the verification-progress modal was open, this hook +// fired initiateSumsubKyc — a MUTATING endpoint — on a fixed 5s setInterval for +// the entire modal-open, re-running provider submissions for approved-LATAM +// self-recovery users (86 in 20 min for one user). The poll now backs off on a +// time-escalating schedule (5s for the first minute, then 10s → 20s → 60s) and +// then holds a steady 60s cadence for as long as the modal is open — it never +// hard-stops, so a late/missed websocket event is always eventually recovered +// (the earlier 15-min cap stranded users on "Almost there"). Backoff is +// time-based, not error-based: the poll returns HTTP 200 even when the backend +// reprocess fails. +describe('useSumsubKycFlow — verification-progress poll backoff', () => { + beforeEach(() => { + jest.useFakeTimers() + mockInitiate.mockReset() + // PENDING is a keep-open status (the transition effect only closes on a + // terminal non-APPROVED state), so the modal stays open across polls and + // every recorded call is attributable to the poll timer. + mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'PENDING' } }) + mockWs.handler = undefined + }) + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + // Open the modal via handleSdkComplete (the SDK-submitted path): it flips + // isVerificationProgressModalOpen without itself calling initiateSumsubKyc. + // regionIntent is left undefined so the mount-time status fetch short-circuits. + const openModal = () => { + const view = renderHook(() => useSumsubKycFlow({})) + act(() => { + view.result.current.handleSdkComplete() + }) + return view + } + + it('escalates the poll cadence 5s → 10s → 20s as the modal stays open', async () => { + openModal() + + // setInterval-parity: nothing fires immediately, the first poll is one delay out. + expect(mockInitiate).toHaveBeenCalledTimes(0) + + // first ~1 min: 5s cadence → 12 polls by t=60s. + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(12) + + // the next poll is now 10s out, not 5s: +5s yields nothing, +5s more yields one. + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(12) + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(13) + + // 60–120s band holds the 10s cadence → 18 polls by t=120s. + await act(async () => { + await jest.advanceTimersByTimeAsync(50_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(18) + + // 120–180s band escalates to 20s: +19s nothing, +1s one. + await act(async () => { + await jest.advanceTimersByTimeAsync(19_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(18) + await act(async () => { + await jest.advanceTimersByTimeAsync(1_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(19) + }) + + it('keeps polling past 15 min (no strand) but stays bounded at the 60s steady cadence', async () => { + openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(15 * 60_000) + }) + const countAt15m = mockInitiate.mock.calls.length + // escalation kept the first 15 min far below a fixed-5s cadence (~180). + expect(countAt15m).toBeGreaterThan(0) + expect(countAt15m).toBeLessThan(40) + + // 10 more minutes at the 60s floor → ~10 further polls, NOT zero (the old + // cap stranded the user here) and NOT a 5s-cadence flood. + await act(async () => { + await jest.advanceTimersByTimeAsync(10 * 60_000) + }) + const delta = mockInitiate.mock.calls.length - countAt15m + expect(delta).toBeGreaterThanOrEqual(8) + expect(delta).toBeLessThanOrEqual(12) + }) + + it('a status that goes APPROVED after a long wait still surfaces (no strand)', async () => { + const onKycSuccess = jest.fn() + const view = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + act(() => { + view.result.current.handleSdkComplete() + }) + + // 20 minutes of "still pending" — well past the old 15-min cap. + await act(async () => { + await jest.advanceTimersByTimeAsync(20 * 60_000) + }) + // Backend finally approves; the next poll picks it up. + mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'APPROVED' } }) + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(onKycSuccess).toHaveBeenCalled() + }) + + it('cancels the pending timer when the modal closes', async () => { + const { result } = openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + + act(() => { + result.current.closeVerificationProgressModal() + }) + + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending timer on unmount', async () => { + const { unmount } = openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + + unmount() + + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + }) + + it('polls initiateSumsubKyc with the same region/level/country args as before', async () => { + openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledWith({ + regionIntent: undefined, + levelName: undefined, + targetCountry: undefined, + }) + }) +}) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 1c77a60c5..d16af6deb 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -18,6 +18,36 @@ interface UseSumsubKycFlowOptions { regionIntent?: KYCRegionIntent } +// Time-escalating schedule for the verification-progress-modal status poll. +// initiateSumsubKyc is a MUTATING endpoint — for approved-LATAM users in the +// self-recovery state each call re-runs a full provider submission. A fixed 5s +// interval hammered it for the entire modal-open (incident 2026-07-02: 86 +// re-submissions in 20 min for one user). We keep the fast 5s cadence only for +// the first minute (the common quick transition), then back off. The backoff is +// purely time-based, NOT error-based: the poll returns HTTP 200 even when the +// backend reprocess fails, so an error count would never escalate. +const KYC_POLL_SCHEDULE: ReadonlyArray<{ untilMs: number; delayMs: number }> = [ + { untilMs: 60_000, delayMs: 5_000 }, // first ~1 min: fast path for the common quick transition + { untilMs: 120_000, delayMs: 10_000 }, + { untilMs: 180_000, delayMs: 20_000 }, +] +// After the escalation schedule the poll settles at this steady cadence for as +// long as the modal stays open. It does NOT stop: a missed websocket event +// (laptop sleep, mobile background, network switch) can land at any time during +// a long manual review, and a hard stop would strand the user on "Almost there" +// forever with onKycSuccess never firing. The 60s floor plus the backend's own +// self-recovery cooldown (which short-circuits repeat submissions server-side) +// keeps the steady poll cheap — nothing like the fixed-5s battering ram this +// schedule replaced. +const KYC_POLL_MAX_DELAY_MS = 60_000 + +const getKycPollDelayMs = (elapsedMs: number): number => { + for (const { untilMs, delayMs } of KYC_POLL_SCHEDULE) { + if (elapsedMs < untilMs) return delayMs + } + return KYC_POLL_MAX_DELAY_MS +} + export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseSumsubKycFlowOptions = {}) => { const { user } = useUserStore() const router = useRouter() @@ -118,12 +148,21 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: fetchCurrentStatus() }, [regionIntent]) - // polling fallback for missed websocket events. - // when the verification progress modal is open, poll status every 5s - // so the flow can transition even if the websocket event never arrives. + // polling fallback for missed websocket events. while the verification + // progress modal is open, re-check status on a time-escalating schedule + // (KYC_POLL_SCHEDULE) so the flow can transition even if the websocket event + // never arrives — without hammering the mutating initiate endpoint. A + // self-rescheduling setTimeout chain (rather than a fixed setInterval) lets + // the delay grow as the modal stays open, settling at a steady 60s cadence — + // it keeps polling for the whole modal-open lifetime so a late/missed + // websocket event is always eventually recovered. useEffect(() => { if (!isVerificationProgressModalOpen) return + const startedAt = Date.now() + let timeoutId: ReturnType + let cancelled = false + const pollStatus = async () => { try { const response = await initiateSumsubKyc({ @@ -139,8 +178,22 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } } - const interval = setInterval(pollStatus, 5000) - return () => clearInterval(interval) + const scheduleNext = () => { + const elapsed = Date.now() - startedAt + timeoutId = setTimeout(async () => { + await pollStatus() + // the modal may have closed (cleanup ran) while the poll was in + // flight — don't re-arm a timer after teardown. + if (cancelled) return + scheduleNext() + }, getKycPollDelayMs(elapsed)) + } + + scheduleNext() + return () => { + cancelled = true + clearTimeout(timeoutId) + } }, [isVerificationProgressModalOpen]) const handleInitiateKyc = useCallback( diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 5471d7a30..e16693627 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -100,7 +100,7 @@ export interface RailCapability { * document (used for the country-not-supported CTA * on Manteca-only rails; user has a self-fix path). */ -export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' +export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' | 'provide-email' export interface NextAction { key: string // stable id, referenced by RailCapability.blockingActions diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 72f86ac69..d0a01f369 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -578,3 +578,44 @@ describe('deriveGate — advisory pre-empt (future-dated requirement on a ready expect(gate.kind).toBe('fixable-rejection') }) }) + +describe('deriveGate — provide-email self-serve for email-blocked rails', () => { + const provideEmailAction: NextAction = { + key: 'provide-email', + kind: 'provide-email', + purpose: 'submission-failed-no-email', + } + + test('blocked rail carrying provide-email action → provide-email gate (self-fix path)', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['provide-email'], + reason: { + code: 'email_required', + userMessage: 'We need an email address to finish setting up this account.', + }, + }) + + const gate = deriveGate(state([rail], [provideEmailAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('provide-email') + if (gate.kind === 'provide-email') { + expect(gate.userMessage).toMatch(/email address/) + expect(gate.reason?.code).toBe('email_required') + } + expect(getGateUserMessage(gate)).toMatch(/email address/) + }) + + test('blocked rail without provide-email action still dead-ends on blocked-rejection', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['contact-support'], + reason: { code: 'submission_failed', userMessage: 'We hit a snag.' }, + }) + const supportAction: NextAction = { key: 'contact-support', kind: 'contact-support', purpose: 'kyc-support' } + + const gate = deriveGate(state([rail], [supportAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('blocked-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index dbc183fe2..9237d702b 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -68,6 +68,12 @@ export type GateState = * CTA opens a fresh Sumsub WebSDK after POST /users/identity/restart. */ | { kind: 'restart-identity'; userMessage: string | null; reason?: CapabilityReason } + /** + * Blocked rail whose pipeline failure is self-serviceable: no email was + * ever captured, so provider submission couldn't run. CTA opens an email + * form; on save the BE flips the rails back to PENDING and resubmits. + */ + | { kind: 'provide-email'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'needs-identity' } | { kind: 'needs-enrollment' } @@ -200,6 +206,21 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. + // provide-email is a USER-level fix (one email unblocks every email-blocked + // rail), so any blocked rail carrying it wins over an earlier blocked rail + // with a terminal reason — .find() order must not shadow the self-serve path. + const emailBlocked = candidates.find( + (rail) => + rail.status === 'blocked' && + railActions(rail, actionByKey).some((action) => action.kind === 'provide-email') + ) + if (emailBlocked) { + return { + kind: 'provide-email', + userMessage: emailBlocked.reason?.userMessage ?? null, + reason: emailBlocked.reason, + } + } const blocked = candidates.find((rail) => rail.status === 'blocked') if (blocked) { const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') @@ -287,6 +308,10 @@ export function getKycModalVariant( kind: GateState['kind'] ): 'blocked' | 'provider_rejection' | 'cross_region' | 'restart_identity' | 'default' { if (kind === 'blocked-rejection') return 'blocked' + // Floor for consumers not yet wired to render the email sheet: show the + // contact-support variant, never the 'default' re-verify CTA (the user's + // identity is already verified — bouncing them into Sumsub is wrong). + if (kind === 'provide-email') return 'blocked' if (kind === 'restart-identity') return 'restart_identity' if (kind === 'fixable-rejection') return 'provider_rejection' if (kind === 'needs-enrollment') return 'cross_region' @@ -303,6 +328,7 @@ export function getGateUserMessage(gate: GateState): string | undefined { gate.kind === 'fixable-rejection' || gate.kind === 'blocked-rejection' || gate.kind === 'restart-identity' || + gate.kind === 'provide-email' || gate.kind === 'accept-tos' || gate.kind === 'waiting-on-provider' ) {