('burst')
const [heroText, setHeroText] = useState("I'M IN!")
@@ -298,7 +321,11 @@ export default function ShareBuilderPage() {
variant="purple"
shadowSize="4"
className="flex-1"
- onClick={() => setSeedNonce((n) => n + 1)}
+ onClick={() => {
+ // Remounts ShareAssetD3 (key) → card face repaints; re-gate Save.
+ setAssetReady(false)
+ setSeedNonce((n) => n + 1)
+ }}
>
Reroll seed
@@ -306,7 +333,10 @@ export default function ShareBuilderPage() {
variant="stroke"
shadowSize="4"
className="flex-1"
- onClick={() => setAnimate((a) => !a)}
+ onClick={() => {
+ setAssetReady(false)
+ setAnimate((a) => !a)
+ }}
>
{animate ? '✓ Animate' : 'Animate off'}
@@ -343,6 +373,7 @@ export default function ShareBuilderPage() {
}}
>
setAssetReady(true)}
/>
@@ -383,7 +415,14 @@ export default function ShareBuilderPage() {
Share
-
+
Save image
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index ec7045921c..060ddb02cf 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -32,6 +32,7 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
+import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
@@ -89,9 +90,26 @@ export default function WithdrawBankPage() {
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country])
const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
- const sumsubFlow = useMultiPhaseKycFlow({})
- // A ready bank rail can still carry a future-dated requirement (the gate's
- // `advisory`). Offer it as a skippable pre-empt before the withdrawal.
+ // EEA-uplift funnel events (PostHog): started on launch, completed on KYC
+ // success. trackCompleted no-ops unless an uplift was started this session.
+ const {
+ trackStarted: trackUpliftStarted,
+ trackCompleted: trackUpliftCompleted,
+ reset: resetUpliftFunnel,
+ } = useEeaUpliftFunnel('withdraw')
+
+ const sumsubFlow = useMultiPhaseKycFlow({
+ // Fire completed at Sumsub approval (verification submitted), not at
+ // end-of-flow — so it isn't lost if the user drops during the
+ // post-approval ToS / preparing steps.
+ onKycApproved: () => trackUpliftCompleted(),
+ // Abandoned attempt: clear the pending start so a later unrelated KYC
+ // success on this page can't mis-fire eea_uplift_completed.
+ onManualClose: resetUpliftFunnel,
+ })
+ // A ready bank rail can still carry a pending Bridge requirement (the gate's
+ // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the
+ // withdrawal — the offramp cannot proceed until it's completed.
const advisory = gate.kind === 'ready' ? gate.advisory : undefined
const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({
advisory,
@@ -99,8 +117,11 @@ export default function WithdrawBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
- onCompleteNow: () =>
- advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(),
+ onCompleteNow: () => {
+ if (!advisory) return Promise.resolve()
+ trackUpliftStarted(advisory)
+ return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
+ },
})
const [showKycModal, setShowKycModal] = useState(false)
const { setIsSupportModalOpen } = useModalsContext()
@@ -322,8 +343,8 @@ export default function WithdrawBankPage() {
}
}
- // Offer the skippable advisory pre-empt once, then run the offramp. When the
- // gate isn't `ready` (or nothing is future-dated) this is a no-op and
+ // Enforce the mandatory verification pre-empt, then run the offramp. When the
+ // gate isn't `ready` (or there's no pending requirement) this is a no-op and
// proceedWithOfframp runs straight away (it handles the not-ready cases).
const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp())
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 4dd2b4abf7..b79d827e00 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -71,6 +71,8 @@ import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper'
import { useLimits } from '@/hooks/useLimits'
import { isVerifiedForCountry } from '@/utils/regions.utils'
import PixKeySendView from '@/components/Withdraw/views/PixKeySend.view'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
+import { MantecaTransfersMaintenanceView } from '@/components/Global/Banner/MantecaTransfersMaintenanceView'
type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'
@@ -86,6 +88,13 @@ export default function MantecaWithdrawFlow() {
if (searchParams.get('country') === 'brazil' && searchParams.get('method') === 'pix') {
return
}
+ // Manteca provider outage — block the offramp only for currencies still
+ // down. Placed AFTER the Brazil-PIX delegation so PIX-over-QR sends (which
+ // ride the QR-payment endpoint, not Manteca offramp) stay open.
+ const withdrawCurrency = countryData.find((c) => c.path === searchParams.get('country'))?.currency?.toUpperCase()
+ if (withdrawCurrency && (underMaintenanceConfig.disabledMantecaCurrencies as string[]).includes(withdrawCurrency)) {
+ return
+ }
return
}
diff --git a/src/app/actions/__tests__/currency.test.ts b/src/app/actions/__tests__/currency.test.ts
index 307bcf1837..3e88107b3c 100644
--- a/src/app/actions/__tests__/currency.test.ts
+++ b/src/app/actions/__tests__/currency.test.ts
@@ -61,6 +61,28 @@ describe('getCurrencyPrice (uncached, commit-path)', () => {
await expect(getCurrencyPrice('ARS')).resolves.toEqual({ buy: 1200, sell: 1180 })
})
+ it('reads the current Manteca shape where the effective rate is nested under effectivePrice', async () => {
+ // Manteca moved effectiveBuy/effectiveSell under effectivePrice on 2026-07-01;
+ // reading the old top-level fields yielded NaN → "Invalid buy rate from provider".
+ mantecaGetPrices.mockResolvedValue({
+ buy: '1596.90',
+ sell: '1541.96',
+ effectivePrice: { buy: '1596.900', sell: '1541.960' },
+ })
+ const { getCurrencyPrice } = loadModule()
+ await expect(getCurrencyPrice('ARS')).resolves.toEqual({ buy: 1596.9, sell: 1541.96 })
+ })
+
+ it('prefers effectivePrice over the legacy top-level fields when both are present', async () => {
+ mantecaGetPrices.mockResolvedValue({
+ effectiveBuy: '1',
+ effectiveSell: '1',
+ effectivePrice: { buy: '1200', sell: '1180' },
+ })
+ const { getCurrencyPrice } = loadModule()
+ await expect(getCurrencyPrice('ARS')).resolves.toEqual({ buy: 1200, sell: 1180 })
+ })
+
it('throws on unknown currency code', async () => {
const { getCurrencyPrice } = loadModule()
await expect(getCurrencyPrice('XYZ')).rejects.toThrow('Invalid currency code')
diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts
index 5fec4cdae5..c694b7a4ee 100644
--- a/src/app/actions/currency.ts
+++ b/src/app/actions/currency.ts
@@ -33,9 +33,14 @@ const fetchCurrencyPrice = async (currencyCode: string): Promise<{ buy: number;
if (MANTECA_CURRENCIES.includes(currencyCode)) {
const response = await mantecaApi.getPrices({ asset: 'USDC', against: currencyCode })
+ // Manteca moved the effective rate under `effectivePrice.{buy,sell}` on 2026-07-01
+ // (previously top-level `effectiveBuy`/`effectiveSell`). Read the new shape first and
+ // fall back to the legacy fields so a provider rollback can't re-break pricing.
+ const effectiveBuy = response.effectivePrice?.buy ?? response.effectiveBuy
+ const effectiveSell = response.effectivePrice?.sell ?? response.effectiveSell
return {
- buy: assertPositiveRate('buy', Number(response.effectiveBuy)),
- sell: assertPositiveRate('sell', Number(response.effectiveSell)),
+ buy: assertPositiveRate('buy', Number(effectiveBuy)),
+ sell: assertPositiveRate('sell', Number(effectiveSell)),
}
}
diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx
index fcc2951936..84f7db8878 100644
--- a/src/app/shhhhh/ShhhhhLandingPage.tsx
+++ b/src/app/shhhhh/ShhhhhLandingPage.tsx
@@ -9,6 +9,7 @@ import { Button } from '@/components/0_Bruddle/Button'
import { Marquee } from '@/components/LandingPage'
import { useAuth } from '@/context/authContext'
import { PixelatedCardFace } from '@/components/Card/share-asset/PixelatedCardFace'
+import { inflateWaitlistPosition } from '@/components/Card/doorTally.utils'
import { Sparkle, Star } from '@/assets/illustrations'
import { cardApi } from '@/services/card'
import { invitesApi } from '@/services/invites'
@@ -42,7 +43,8 @@ function WaitlistJoined({
dark ? 'bg-secondary-1 text-n-1' : 'bg-white text-n-1'
}`}
>
- ✓ You're on the waitlist{typeof position === 'number' ? ` · #${position}` : ''}
+ ✓ You're on the waitlist
+ {typeof position === 'number' ? ` · #${inflateWaitlistPosition(position)}` : ''}
We'll holler when your turn comes up.
diff --git a/src/assets/cards/peanut-card-hand-pixel.png b/src/assets/cards/peanut-card-hand-pixel.png
new file mode 100644
index 0000000000..76cfbc07f9
Binary files /dev/null and b/src/assets/cards/peanut-card-hand-pixel.png differ
diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts
index a8e442e023..8a48047adb 100644
--- a/src/components/Badges/badge.utils.ts
+++ b/src/components/Badges/badge.utils.ts
@@ -221,6 +221,11 @@ export const BADGES: Record = {
description: 'Ilha da Magia, baby. Coconuts and consensus.',
displayName: 'Ethereum Hub Floripa',
},
+ IRL_NOMADS: {
+ path: '/badges/irl_nomads.svg',
+ description: 'No fixed address, just good coffee and better wifi. Certified Nomad.',
+ displayName: 'Nomad Mode',
+ },
// Skip Pass — friends-of-Peanut who bypassed the waitlist via /invite?campaign=skip.
// Awarded by the backend /badge/award endpoint, which also flips hasAppAccess.
WAITLIST_SKIP: {
diff --git a/src/components/Card/ApplicationStatusScreen.tsx b/src/components/Card/ApplicationStatusScreen.tsx
index a10240148e..ced7d5061e 100644
--- a/src/components/Card/ApplicationStatusScreen.tsx
+++ b/src/components/Card/ApplicationStatusScreen.tsx
@@ -35,8 +35,11 @@ const COPY: Record = {
body: "We hit a snag while processing your card application. Our team needs to take a look — message support and we'll get you sorted.",
},
rejected: {
+ // The specific reason (when known) renders above via `reasonMessage`;
+ // this body stays reassuring — a declined card doesn't touch the rest
+ // of the account, so point the user back to what still works.
title: "We couldn't issue you a card",
- body: "Your card application wasn't approved. This might be because of duplicate attempt, incomplete documents, information mismatch, or regional restrictions.",
+ body: 'You can still use Peanut freely to deposit, withdraw, and pay with crypto.',
},
}
diff --git a/src/components/Card/BadgeSkipCelebration.tsx b/src/components/Card/BadgeSkipCelebration.tsx
index 037787ea3d..1156feb2e7 100644
--- a/src/components/Card/BadgeSkipCelebration.tsx
+++ b/src/components/Card/BadgeSkipCelebration.tsx
@@ -61,6 +61,9 @@ type Phase = 'looking-up' | 'shaking' | 'revealed'
const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, tier, pointsBalance, onContinue }) => {
const [phase, setPhase] = useState('looking-up')
const [hideUsername, setHideUsername] = useState(false)
+ // Gate the Share/Save buttons until the card face's async hand
+ // mounts — otherwise an early capture snapshots a blank card.
+ const [assetReady, setAssetReady] = useState(false)
const { triggerHaptic } = useHaptic()
const captureRef = useRef(null)
const hasBadge = !!badgeCode
@@ -165,6 +168,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t
cardLast4="0420"
hideUsername={hideUsername}
animate={phase === 'revealed'}
+ onReady={() => setAssetReady(true)}
/>
)}
@@ -184,7 +188,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t
value={hideUsername}
onChange={(e) => setHideUsername(e.target.checked)}
/>
-
+
Continue to your card
diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx
index 488a6c3d14..c2525f60c5 100644
--- a/src/components/Card/CardRejectionScreen.tsx
+++ b/src/components/Card/CardRejectionScreen.tsx
@@ -8,8 +8,12 @@
* a shareable door rejection: a dark "not tonight, " asset with a
* smug peanut bouncer, the scarcity tally as screen copy, and a primary
* "Tweet to appeal" CTA that shares the asset (random caption tagging
- * @joinpeanut). The friendly waitlist-joined screen is the cooldown AFTER
- * they share, so they don't rage-quit.
+ * @joinpeanut).
+ *
+ * This is the TERMINAL waitlist screen (no separate cooldown): once the user
+ * has joined (`alreadyJoined`) the secondary "Join anyway" button becomes an
+ * "on the list" confirmation, but the shareable asset + "Tweet to appeal"
+ * stay so they can keep appealing / re-grab the asset.
*/
import { type FC, useEffect, useRef, useState } from 'react'
@@ -17,43 +21,61 @@ import * as Sentry from '@sentry/nextjs'
import { Button } from '@/components/0_Bruddle/Button'
import NavHeader from '@/components/Global/NavHeader'
import ErrorAlert from '@/components/Global/ErrorAlert'
+import { Icon } from '@/components/Global/Icons/Icon'
import { ScaledRejectionAsset } from '@/components/Card/share-asset/ScaledRejectionAsset'
import { captureShareAsset, canShareImageFiles } from '@/components/Card/share-asset/captureShareAsset'
import { pickRejectionCaption } from '@/components/Card/share-asset/rejectionCaptions'
import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types'
+import { computeDoorTally } from '@/components/Card/doorTally.utils'
import { cardApi } from '@/services/card'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
interface Props {
username?: string
- /** Door tally — scarcity flex, rendered as screen copy (not on the asset). */
- applicants?: number
- admitted?: number
+ /** Real waitlist size (total who joined). The screen inflates this for the
+ * FOMO "tried" tally — mirrors the /shhhhh ScarcityCounter flex. Undefined
+ * while /card is still loading → a sane floor renders. */
+ waitlistTotal?: number
+ /** Real number admitted (released/granted). Shown verbatim as "got in". */
+ admittedTotal?: number
/** Which smug mascot the asset shows. */
mascot?: RejectionMascot
+ /** True once the user is already on the waitlist — swaps the "Join anyway"
+ * button for an "on the list" confirmation while keeping the asset + appeal. */
+ alreadyJoined?: boolean
onPrev?: () => void
- /** Called after the user joins the waitlist. Parent should refetch /card,
- * which flips the state machine to (cooldown). */
+ /** Called after the user joins the waitlist. Parent refetches /card; the
+ * user stays on this screen, now in its `alreadyJoined` state. */
onJoined?: () => void
}
const CardRejectionScreen: FC = ({
username,
- applicants = 213,
- admitted = 7,
+ waitlistTotal,
+ admittedTotal,
mascot = 'cool',
+ alreadyJoined = false,
onPrev,
onJoined,
}) => {
+ // "tried" = real waitlist size, inflated for FOMO; "got in" = real admitted.
+ // Deterministic (pure fn of the counts) so it never jitters between renders.
+ const { applicants, admitted } = computeDoorTally(waitlistTotal, admittedTotal)
const captureRef = useRef(null)
const [sharing, setSharing] = useState(false)
const [joining, setJoining] = useState(false)
const [joinError, setJoinError] = useState(null)
+ // Local "just joined" override so the CTA swaps to the on-the-list state
+ // immediately on a confirmed join, without waiting for the parent's /card
+ // refetch (CodeRabbit). OR'd with the `alreadyJoined` prop.
+ const [locallyJoined, setLocallyJoined] = useState(false)
+ const showJoined = alreadyJoined || locallyJoined
const safeUsername = (username || '').trim() || 'anon'
useEffect(() => {
- posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false })
+ posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: alreadyJoined })
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once on mount with the entry state
}, [])
const handleJoin = async (): Promise => {
@@ -62,6 +84,7 @@ const CardRejectionScreen: FC = ({
try {
const res = await cardApi.joinWaitlist()
posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position })
+ setLocallyJoined(true)
onJoined?.()
} catch (e) {
// Keep the user-facing message generic; raw BE text can leak
@@ -77,6 +100,9 @@ const CardRejectionScreen: FC = ({
}
const handleAppeal = async (): Promise => {
+ // Clear any stale "failed to join" error from an earlier handleJoin so
+ // the success state can't render alongside it (CodeRabbit).
+ setJoinError(null)
const caption = pickRejectionCaption()
// Appeal = tweet AND join the waitlist. Joining is NOT access — release
@@ -91,6 +117,7 @@ const CardRejectionScreen: FC = ({
.joinWaitlist()
.then((res) => {
posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position, source: 'appeal' })
+ setLocallyJoined(true)
})
.catch((e) => {
// Non-fatal: the tweet still goes out, and the CARD_SHARE_ASSET_SHARED
@@ -184,15 +211,22 @@ const CardRejectionScreen: FC = ({
>
Tweet to appeal
-
- Join the waitlist anyway
-
+ {showJoined ? (
+
+
+ You're on the list — we'll holler when it's your turn
+
+ ) : (
+
+ Join the waitlist anyway
+
+ )}
)
diff --git a/src/components/Card/CardUnlockDrawer.tsx b/src/components/Card/CardUnlockDrawer.tsx
index 1d106bdd01..871d309bb3 100644
--- a/src/components/Card/CardUnlockDrawer.tsx
+++ b/src/components/Card/CardUnlockDrawer.tsx
@@ -33,6 +33,9 @@ interface Props {
export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, badges }) => {
const captureRef = useRef(null)
const [hideUsername, setHideUsername] = useState(false)
+ // Gate the Share/Save buttons until the card face's async hand
+ // mounts — otherwise an early capture snapshots a blank card.
+ const [assetReady, setAssetReady] = useState(false)
useEffect(() => {
if (!isOpen) return
@@ -68,6 +71,7 @@ export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username,
cardLast4="0420"
hideUsername={hideUsername}
animate={false}
+ onReady={() => setAssetReady(true)}
/>
@@ -78,7 +82,7 @@ export const CardUnlockDrawer: FC
= ({ isOpen, onClose, entry, username,
value={hideUsername}
onChange={(e) => setHideUsername(e.target.checked)}
/>
-
+
diff --git a/src/components/Card/CardWaitlistJoinedScreen.tsx b/src/components/Card/CardWaitlistJoinedScreen.tsx
deleted file mode 100644
index 8f8f2cb478..0000000000
--- a/src/components/Card/CardWaitlistJoinedScreen.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-'use client'
-
-/**
- * — confirmation after the user has joined
- * the waitlist. Mirrors the canonical success layout used by
- * PaymentSuccessView / Send / Withdraw: chill-peanut gif floating above,
- * success Card with check-icon + headline, descriptive paragraph, CTA.
- *
- * No queue-position display. We intentionally don't surface the position
- * because the BE counter is best-effort (re-orders on admin grants /
- * churn), so a specific number invites speculation we can't honor.
- */
-
-import { type FC, useEffect } from 'react'
-import Image from 'next/image'
-import { Button } from '@/components/0_Bruddle/Button'
-import Card from '@/components/Global/Card'
-import NavHeader from '@/components/Global/NavHeader'
-import { Icon } from '@/components/Global/Icons/Icon'
-import { PeanutWhistling } from '@/assets/mascot'
-import { useRouter } from 'next/navigation'
-import posthog from 'posthog-js'
-import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
-
-interface Props {
- onPrev?: () => void
-}
-
-const CardWaitlistJoinedScreen: FC = ({ onPrev }) => {
- const router = useRouter()
-
- useEffect(() => {
- posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: true })
- }, [])
-
- return (
-
-
-
-
-
-
-
-
-
-
-
You're on the waitlist
-
- We'll let you know when it's your turn
-
-
-
-
-
-
router.push('/home')} variant="purple" shadowSize="4" className="w-full">
- Back to home
-
-
- )
-}
-
-export default CardWaitlistJoinedScreen
diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx
index 9d6771f62b..8154976a6b 100644
--- a/src/components/Card/PhysicalCardScreen.tsx
+++ b/src/components/Card/PhysicalCardScreen.tsx
@@ -76,8 +76,7 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {
You are on the list!
- You are #{data.position} on the list. We'll let you know when cards are ready to be
- shipped.
+ {`You are #${data.position} on the list. We'll let you know when cards are ready to be shipped.`}
) : (
diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx
new file mode 100644
index 0000000000..fd3f414ce2
--- /dev/null
+++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * ApplicationStatusScreen — rejected-card copy contract.
+ *
+ * The rejected variant must (1) surface the specific, vetted reason from the
+ * capability read-model when present (e.g. "…available in your state yet."),
+ * (2) always reassure the user that the rest of Peanut still works, and
+ * (3) keep the Contact-support path. The reason line is optional — older
+ * rejections without a persisted reason fall back to the body alone.
+ */
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen'
+
+// NavHeader reads useAuth; stub it so the presentational screen renders alone.
+jest.mock('@/context/authContext', () => ({
+ useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }),
+}))
+// next/image → plain img so jsdom doesn't choke on the optimizer.
+jest.mock('next/image', () => ({
+ __esModule: true,
+ // eslint-disable-next-line @next/next/no-img-element -- test stub, not real markup
+ default: (props: Record) => ,
+}))
+
+const REASSURANCE = 'You can still use Peanut freely to deposit, withdraw, and pay with crypto.'
+
+describe('ApplicationStatusScreen — rejected', () => {
+ it('renders the specific reason above the reassurance body', () => {
+ render(
+
+ )
+ expect(screen.getByText("We couldn't issue you a card")).toBeInTheDocument()
+ const reason = screen.getByText("Peanut cards aren't available in your state yet.")
+ const body = screen.getByText(REASSURANCE)
+ // Contract: the specific reason renders ABOVE the reassurance body.
+ expect(reason.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ // Both paragraphs live in the same copy block (reason + body).
+ expect(body.parentElement?.querySelectorAll('p')).toHaveLength(2)
+ })
+
+ it('falls back to the reassurance body alone when no reason is provided', () => {
+ render( )
+ const body = screen.getByText(REASSURANCE)
+ expect(body).toBeInTheDocument()
+ // No phantom reason paragraph — the copy block holds only the body .
+ expect(body.parentElement?.querySelectorAll('p')).toHaveLength(1)
+ })
+
+ it('keeps the Contact support action', () => {
+ const onContactSupport = jest.fn()
+ render( )
+ fireEvent.click(screen.getByText('Contact support'))
+ expect(onContactSupport).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/components/Card/__tests__/doorTally.utils.test.ts b/src/components/Card/__tests__/doorTally.utils.test.ts
new file mode 100644
index 0000000000..28cc842caa
--- /dev/null
+++ b/src/components/Card/__tests__/doorTally.utils.test.ts
@@ -0,0 +1,77 @@
+import {
+ inflateApplicants,
+ computeDoorTally,
+ inflateWaitlistPosition,
+ DOOR_TALLY_APPLICANTS_FLOOR,
+ DOOR_TALLY_ADMITTED_FALLBACK,
+ DOOR_TALLY_FOMO_MULTIPLIER,
+} from '../doorTally.utils'
+
+describe('inflateApplicants', () => {
+ test('multiplies the real waitlist size by the FOMO factor', () => {
+ // 1000 * 5 = 5000, comfortably above the floor.
+ expect(inflateApplicants(1000)).toBe(1000 * DOOR_TALLY_FOMO_MULTIPLIER)
+ })
+
+ test('clamps to the floor when the inflated value is small', () => {
+ // 10 * 5 = 50 < floor → floor wins.
+ expect(inflateApplicants(10)).toBe(DOOR_TALLY_APPLICANTS_FLOOR)
+ })
+
+ test('clears the 213 floor at the current prod waitlist size (~55)', () => {
+ // The whole point of ×5: 55 * 5 = 275 > 213, so the real (inflated)
+ // number renders instead of the masked floor.
+ expect(inflateApplicants(55)).toBe(275)
+ })
+
+ test('returns a whole number above the floor', () => {
+ expect(Number.isInteger(inflateApplicants(101))).toBe(true)
+ // 101 * 5 = 505.
+ expect(inflateApplicants(101)).toBe(505)
+ })
+
+ test.each([undefined, NaN, 0, -5, Infinity])('falls back to the floor for %p', (input) => {
+ expect(inflateApplicants(input as number)).toBe(DOOR_TALLY_APPLICANTS_FLOOR)
+ })
+
+ test('is deterministic — same input, same output (no jitter)', () => {
+ expect(inflateApplicants(742)).toBe(inflateApplicants(742))
+ })
+})
+
+describe('computeDoorTally', () => {
+ test('inflates "tried" but shows "got in" verbatim', () => {
+ // 500 * 5 = 2500 tried; 42 admitted shown as-is.
+ expect(computeDoorTally(500, 42)).toEqual({ applicants: 2500, admitted: 42 })
+ })
+
+ test('uses loading fallbacks when counts are missing', () => {
+ expect(computeDoorTally(undefined, undefined)).toEqual({
+ applicants: DOOR_TALLY_APPLICANTS_FLOOR,
+ admitted: DOOR_TALLY_ADMITTED_FALLBACK,
+ })
+ })
+
+ test('admitted=0 is real and shown as 0 (not the fallback)', () => {
+ expect(computeDoorTally(1000, 0).admitted).toBe(0)
+ })
+})
+
+describe('inflateWaitlistPosition', () => {
+ test('scales the real position by the same FOMO factor as the tally', () => {
+ // #56 on /shhhhh → #280, consistent with the in-app "{tried} tried".
+ expect(inflateWaitlistPosition(56)).toBe(56 * DOOR_TALLY_FOMO_MULTIPLIER)
+ })
+
+ test('no floor — a low (good) position scales straight, not clamped to 213', () => {
+ expect(inflateWaitlistPosition(3)).toBe(15)
+ })
+
+ test('returns a whole number', () => {
+ expect(Number.isInteger(inflateWaitlistPosition(7))).toBe(true)
+ })
+
+ test.each([0, -5, NaN, Infinity])('returns non-positive / invalid input unchanged (%p)', (input) => {
+ expect(inflateWaitlistPosition(input as number)).toBe(input)
+ })
+})
diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts
new file mode 100644
index 0000000000..98dab41484
--- /dev/null
+++ b/src/components/Card/doorTally.utils.ts
@@ -0,0 +1,75 @@
+/**
+ * Door-tally math for the Berghain rejection screen ("N tried · M got in").
+ *
+ * The backend (/card → waitlistTotal, admittedTotal) reports the REAL counts.
+ * "tried" gets inflated for FOMO — the same fake-scarcity flex the /shhhhh
+ * landing already does with its "only 20 a week" ScarcityCounter — while
+ * "got in" is shown verbatim (the real number admitted).
+ *
+ * The inflation is a pure function of the real count (a constant multiplier
+ * with a floor), so it's deterministic per render and never jitters between
+ * frames. No randomness on purpose.
+ */
+
+/**
+ * FOMO inflation factor applied to the real waitlist size for "tried". ×5 (not
+ * ×3) so the real number clears the 213 floor at the current prod waitlist size
+ * (~55 → 55×5 = 275 > 213) — otherwise the floor masks the real count and the
+ * tally looks frozen at 213.
+ */
+export const DOOR_TALLY_FOMO_MULTIPLIER = 5
+
+/**
+ * Minimum "tried" — keeps the door looking busy when the real waitlist is
+ * still small, and doubles as the loading fallback. 213 mirrors the original
+ * hardcoded tally so the copy reads identically before counts land.
+ */
+export const DOOR_TALLY_APPLICANTS_FLOOR = 213
+
+/** Loading fallback for "got in" (real admitted count, before it lands). */
+export const DOOR_TALLY_ADMITTED_FALLBACK = 7
+
+export type DoorTally = {
+ /** Inflated "tried" count (FOMO). */
+ applicants: number
+ /** Real "got in" count, verbatim. */
+ admitted: number
+}
+
+/**
+ * Inflate the real waitlist size into the FOMO "tried" number. Falls back to
+ * the floor for missing/zero/invalid input (e.g. counts still loading).
+ */
+export function inflateApplicants(waitlistTotal?: number): number {
+ if (typeof waitlistTotal !== 'number' || !Number.isFinite(waitlistTotal) || waitlistTotal <= 0) {
+ return DOOR_TALLY_APPLICANTS_FLOOR
+ }
+ return Math.max(DOOR_TALLY_APPLICANTS_FLOOR, Math.round(waitlistTotal * DOOR_TALLY_FOMO_MULTIPLIER))
+}
+
+/**
+ * Build the door tally from the real backend counts: inflated "tried" + real
+ * "got in" (with sane fallbacks while the counts are loading).
+ */
+export function computeDoorTally(waitlistTotal?: number, admittedTotal?: number): DoorTally {
+ const admitted =
+ typeof admittedTotal === 'number' && Number.isFinite(admittedTotal) && admittedTotal >= 0
+ ? Math.round(admittedTotal)
+ : DOOR_TALLY_ADMITTED_FALLBACK
+ return { applicants: inflateApplicants(waitlistTotal), admitted }
+}
+
+/**
+ * Inflate a user's REAL waitlist position onto the same FOMO scale as the door
+ * tally's "tried" count (×DOOR_TALLY_FOMO_MULTIPLIER), so /shhhhh's "you're #N"
+ * matches the in-app "{tried} tried · {got in} got in" instead of leaking the
+ * real (small) queue size — the two surfaces otherwise disagree (#56 on /shhhhh
+ * vs "275 tried" in-app). No floor: a low position is a GOOD signal (near the
+ * front), so we only scale it; non-positive / invalid input is returned as-is.
+ */
+export function inflateWaitlistPosition(position: number): number {
+ if (typeof position !== 'number' || !Number.isFinite(position) || position <= 0) {
+ return position
+ }
+ return Math.round(position * DOOR_TALLY_FOMO_MULTIPLIER)
+}
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx
index d699f842eb..f8f0216d53 100644
--- a/src/components/Card/share-asset/PixelatedCardFace.tsx
+++ b/src/components/Card/share-asset/PixelatedCardFace.tsx
@@ -1,6 +1,6 @@
/**
- * — the pink peanut card with a runtime-pixelated
- * `peanut-card-hand.svg` overlay.
+ * — the pink peanut card with a pre-pixelated
+ * peanut-card-hand overlay.
*
* Renders at native 620×391 (CARD_W/CARD_H). Wrap in `transform: scale(...)`
* if you need a smaller preview — the inner layout uses absolute pixel
@@ -8,10 +8,10 @@
* .
*
* When `blurAll` is true (the closed-beta tease), the visa logo, peanut
- * logo, card number, and Virtual pill all get rasterised through the
- * SAME canvas → image-rendering:pixelated pipeline as the hand, with a
- * shared CELL_PX cell size — so every detail on the card is hidden by
- * consistent chunky pixels instead of a mix of pixelation + CSS blur.
+ * logo, card number, and Virtual pill all get rasterised through a
+ * canvas → image-rendering:pixelated pipeline, with a shared CELL_PX cell
+ * size — so every detail on the card is hidden by consistent chunky pixels
+ * instead of a mix of pixelation + CSS blur.
*
* Shared between , the eligibility-check screen, and the
* `/shhhhh` LP hero. Keep this file the single source of truth for the
@@ -24,12 +24,12 @@
import { type FC, type CSSProperties } from 'react'
import { CARD_W, CARD_H } from './shareAssetLayout'
import { PEANUTMAN_LOGO } from '@/assets/mascot'
-import PEANUT_CARD_HAND_ASSET from '@/assets/cards/peanut-card-hand.svg'
+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_VISA_BRAND = VISA_BRAND_MARK_ASSET.src
-const ASSET_CARD_HAND = PEANUT_CARD_HAND_ASSET.src
+const ASSET_CARD_HAND_PIXEL = PEANUT_CARD_HAND_PIXEL_ASSET.src
// Shared pixel-cell target across every element on the card. Each
// element rasterises to (displayPx / CELL_PX) pixels then stretches back
@@ -37,18 +37,15 @@ const ASSET_CARD_HAND = PEANUT_CARD_HAND_ASSET.src
// uniform regardless of element size.
const CELL_PX = 14
-// The hand is much bigger and benefits from a finer raster — keep it at
-// its original 36px so the silhouette stays recognisable.
-const HAND_RASTER_PX = 36
-
export interface PixelatedCardFaceProps {
/** Kept in the type for in-app surfaces that render the real card
* number — the share-asset rendering deliberately ignores this and
* always shows "????" so a screenshot can't leak the PAN. */
last4?: string
- /** Extra classes for the outer card div (the pink rounded box). */
+ /** Extra classes for the pink rounded card face. Sizing is fixed at
+ * CARD_W×CARD_H on the wrapper — scale via . */
className?: string
- /** Extra inline styles merged on top of the defaults (width/height/shadow). */
+ /** Extra inline styles merged onto the pink card face (e.g. background). */
style?: CSSProperties
/** When true, every element on the card (logos, number, pill) is run
* through the same canvas-rasterisation pipeline as the hand so the
@@ -58,6 +55,10 @@ export interface PixelatedCardFaceProps {
* display the Visa wordmark for compliance reasons (it renders crisp
* there since the share asset is the one surface that doesn't `blurAll`). */
hideVisa?: boolean
+ /** Fires once the pixelated hand has loaded — i.e. the card face is
+ * fully painted. Capture surfaces gate the Share/Save buttons on this so a
+ * snapshot can never fire before the hand is ready. */
+ onReady?: () => void
}
export const PixelatedCardFace: FC = ({
@@ -65,39 +66,50 @@ export const PixelatedCardFace: FC = ({
style,
blurAll = false,
hideVisa = false,
+ onReady,
}) => (
-
-
+ // The drop-shadow is an offset sibling, NOT a CSS box-shadow: html-to-image
+ // renders box-shadow on a rounded element as a SQUARE block, so the captured
+ // PNG showed a square shadow behind the rounded card. A real black rounded
+ // element shifted by the shadow distance captures faithfully.
+
+
+
+
- {/* Top row: peanut logo (left) + visa logo (right) */}
-
- {blurAll ? (
-
- ) : (
-
- )}
- {!hideVisa &&
- (blurAll ? (
-
+ {/* Top row: peanut logo (left) + visa logo (right) */}
+
+ {blurAll ? (
+
) : (
-
- ))}
-
+
+ )}
+ {!hideVisa &&
+ (blurAll ? (
+
+ ) : (
+
+ ))}
+
- {/* Bottom: card number — same `•••• ????` pattern in both modes.
+ {/* Bottom: card number — same `•••• ????` pattern in both modes.
The share asset deliberately obscures the real PAN so a
screenshot can't leak it; the eligibility-check tease uses
the same string for visual continuity. (The `last4` prop is
@@ -105,28 +117,29 @@ export const PixelatedCardFace: FC
= ({
card face — share-asset + tease callers just don't pass it.)
Lowered toward the card edge now that the Virtual pill is
gone — gives the layout room to breathe. */}
-
- {blurAll ? (
-
- ) : (
-
- ????
-
- )}
+
+ {blurAll ? (
+
+ ) : (
+
+ ????
+
+ )}
+
)
@@ -264,23 +277,27 @@ const PixelatedText: FC
= ({ text, displayW, displayH, font,
}
// ---------------------------------------------------------------------------
-// The hand — same algorithm, just keeps its larger 36px raster so the
-// silhouette stays recognisable.
+// The hand — a pre-pixelated PNG rendered as a plain , NOT a runtime
+// . html-to-image silently drops a live it can't serialise
+// (canvas.toDataURL() returns empty on iOS Safari for an SVG-sourced canvas →
+// blank card, no error: the launch-day "blank share asset" bug), but inlines
+// reliably — same path the badge stickers take, which never blank. The
+// PNG is authored at the 36px raster and upscaled by image-rendering:pixelated,
+// so it reads identically to the old canvas.
// ---------------------------------------------------------------------------
-const PixelatedHand: FC = () => (
- {
- if (!node || node.firstChild) return
- const handRatio = 560 / 471 // hand display w/h
- const rasterW = handRatio > 1 ? HAND_RASTER_PX : Math.max(1, Math.round(HAND_RASTER_PX * handRatio))
- const rasterH = handRatio > 1 ? Math.max(1, Math.round(HAND_RASTER_PX / handRatio)) : HAND_RASTER_PX
- rasterImg(ASSET_CARD_HAND, rasterW, rasterH, (canvas) => {
- canvas.style.width = '100%'
- canvas.style.height = '100%'
- canvas.style.imageRendering = 'pixelated'
- node.appendChild(canvas)
- })
+const PixelatedHand: FC<{ onReady?: () => void }> = ({ onReady }) => (
+
onReady?.()}
+ ref={(img) => {
+ // A cached image can already be `complete` before React attaches
+ // onLoad, so the load event never fires — that would leave the
+ // capture gate stuck disabled. Fire onReady directly in that case.
+ if (img?.complete) onReady?.()
}}
className="pointer-events-none absolute select-none"
style={{
@@ -290,6 +307,7 @@ const PixelatedHand: FC = () => (
height: 471,
transform: 'rotate(-15deg)',
transformOrigin: 'center',
+ imageRendering: 'pixelated',
}}
/>
)
diff --git a/src/components/Card/share-asset/ShareAssetActions.tsx b/src/components/Card/share-asset/ShareAssetActions.tsx
index f8ef100d0a..372c64e626 100644
--- a/src/components/Card/share-asset/ShareAssetActions.tsx
+++ b/src/components/Card/share-asset/ShareAssetActions.tsx
@@ -69,9 +69,14 @@ interface Props {
source: string
/** Optional filename for the downloaded PNG. */
filename?: string
+ /** Whether the share asset has finished painting its card face (the async
+ * hand
has mounted — see PixelatedCardFace.onReady). Until then,
+ * capturing would snapshot a blank card, so both buttons stay disabled.
+ * Defaults to true so callers that don't wire the signal aren't blocked. */
+ ready?: boolean
}
-export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png' }) => {
+export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png', ready = true }) => {
const [isSharing, setIsSharing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState(null)
@@ -163,7 +168,7 @@ export const ShareAssetActions: FC = ({ captureRef, source, filename = 'p
shadowSize="4"
className="w-full"
loading={isSharing}
- disabled={isSharing || isSaving}
+ disabled={isSharing || isSaving || !ready}
icon={ }
>
Share
@@ -173,7 +178,7 @@ export const ShareAssetActions: FC = ({ captureRef, source, filename = 'p
variant="stroke"
className="w-full"
loading={isSaving}
- disabled={isSharing || isSaving}
+ disabled={isSharing || isSaving || !ready}
icon={ }
>
Save image
diff --git a/src/components/Card/share-asset/ShareAssetD3.tsx b/src/components/Card/share-asset/ShareAssetD3.tsx
index e4d3ce0340..cd73d18fa2 100644
--- a/src/components/Card/share-asset/ShareAssetD3.tsx
+++ b/src/components/Card/share-asset/ShareAssetD3.tsx
@@ -100,6 +100,7 @@ const ShareAssetD3: FC = ({
usernameStyle,
hideUsername = false,
animate = true,
+ onReady,
}) => {
const safeUsername = (username || '').trim() || 'anon'
const safeLast4 = (cardLast4 || '').trim().padStart(4, '•').slice(-4) || '5695'
@@ -219,7 +220,7 @@ const ShareAssetD3: FC = ({
: 'none',
}}
>
-
+
{/* ─── Stickers (z-index 4) — raw badge art collaged ON TOP of the
@@ -256,26 +257,36 @@ const ShareAssetD3: FC = ({
animation: animate ? `fadeUp 600ms ease-out ${ANIM_ATTRIBUTION_DELAY}ms both` : 'none',
}}
>
-
- peanut.me/
-
- {safeUsername}
+ {/* The pill's drop-shadow is an offset sibling, NOT a CSS
+ box-shadow: html-to-image renders box-shadow on a `rounded-full`
+ element as a SQUARE block, so the captured PNG showed a square
+ shadow behind the rounded pill. The rotation lives on the
+ wrapper so the black rounded shadow tracks the pill's tilt. */}
+
+
+
+ peanut.me/
+
+ {safeUsername}
+
-
+
{/* ─── Hero "I got in" message sticker (top, z-index 5 — above the
diff --git a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts
index 87066615cf..a2f790887c 100644
--- a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts
+++ b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts
@@ -1,7 +1,7 @@
/**
* @jest-environment jsdom
*/
-import { ShareAssetCaptureError } from '../captureShareAsset'
+import { ShareAssetCaptureError, waitForAssetReady } from '../captureShareAsset'
describe('ShareAssetCaptureError', () => {
test('carries failedImages + originalReject + name=ShareAssetCaptureError', () => {
@@ -24,3 +24,84 @@ describe('ShareAssetCaptureError', () => {
expect(err instanceof Error).toBe(true)
})
})
+
+/**
+ * Regression guard for the launch-day "blank share asset" bug. The card-face
+ * hand used to be a runtime
, which html-to-image silently dropped when
+ * toDataURL() returned empty (iOS Safari, SVG-sourced canvas) → blank card, no
+ * error. The hand is now a plain , so every visible element of the asset
+ * is decode-gated the same way the badge stickers (which never blanked) are.
+ * waitForAssetReady must block the snapshot until fonts + every are ready.
+ */
+describe('waitForAssetReady — share-asset capture readiness gate', () => {
+ afterEach(() => {
+ // The fonts test stubs document.fonts; drop it so later runs see jsdom's
+ // default (undefined) and don't inherit a slow ready promise.
+ if (Object.getOwnPropertyDescriptor(document, 'fonts')) {
+ // @ts-expect-error — test-only cleanup of the stubbed accessor
+ delete document.fonts
+ }
+ })
+
+ test('does NOT resolve until every has decoded — the hand included', async () => {
+ const node = document.createElement('div')
+ let resolveHandDecode!: () => void
+ const hand = document.createElement('img')
+ ;(hand as unknown as { decode: () => Promise }).decode = () =>
+ new Promise((resolve) => {
+ resolveHandDecode = resolve
+ })
+ node.appendChild(hand)
+
+ let resolved = false
+ const pending = waitForAssetReady(node).then(() => {
+ resolved = true
+ })
+ await new Promise((r) => setTimeout(r, 20))
+ expect(resolved).toBe(false) // still gated — a snapshot here could be blank
+
+ resolveHandDecode() // the hand bitmap is ready
+ await pending
+ expect(resolved).toBe(true)
+ })
+
+ test('awaits image decode for every before snapshotting', async () => {
+ const node = document.createElement('div')
+ const badge = document.createElement('img')
+ const hand = document.createElement('img')
+ const badgeDecode = jest.fn().mockResolvedValue(undefined)
+ const handDecode = jest.fn().mockResolvedValue(undefined)
+ ;(badge as unknown as { decode: unknown }).decode = badgeDecode
+ ;(hand as unknown as { decode: unknown }).decode = handDecode
+ node.appendChild(badge)
+ node.appendChild(hand)
+
+ await waitForAssetReady(node)
+ expect(badgeDecode).toHaveBeenCalledTimes(1)
+ expect(handDecode).toHaveBeenCalledTimes(1)
+ })
+
+ test('resolves (never hangs) when images expose no decode() — jsdom / older browsers', async () => {
+ const node = document.createElement('div')
+ node.appendChild(document.createElement('img')) // no decode() in jsdom
+ await expect(waitForAssetReady(node)).resolves.toBeUndefined()
+ })
+
+ test('awaits document.fonts.ready before snapshotting', async () => {
+ let fontsReady = false
+ Object.defineProperty(document, 'fonts', {
+ configurable: true,
+ value: {
+ ready: new Promise((resolve) =>
+ setTimeout(() => {
+ fontsReady = true
+ resolve()
+ }, 20)
+ ),
+ },
+ })
+ const node = document.createElement('div')
+ await waitForAssetReady(node)
+ expect(fontsReady).toBe(true)
+ })
+})
diff --git a/src/components/Card/share-asset/captureShareAsset.ts b/src/components/Card/share-asset/captureShareAsset.ts
index 02ea692161..a4f50b5be0 100644
--- a/src/components/Card/share-asset/captureShareAsset.ts
+++ b/src/components/Card/share-asset/captureShareAsset.ts
@@ -40,8 +40,37 @@ export class ShareAssetCaptureError extends Error {
}
}
+/**
+ * Wait for the asset's content to be painted before we snapshot.
+ *
+ * Every visible element of the asset is a plain now — badge stickers, the
+ * card logos, AND the card's pixelated hand (see PixelatedCardFace). The hand
+ * used to be a runtime , which html-to-image silently dropped when it
+ * couldn't serialise it (canvas.toDataURL() returns empty on iOS Safari for an
+ * SVG-sourced canvas → blank card, no error: the launch-day "blank share asset"
+ * bug). With everything as , readiness is simply:
+ * - document.fonts.ready (the hero/username use a web font)
+ * - every decoded (badges, logos, the hand)
+ * so the snapshot only fires once the bitmaps are actually ready.
+ */
+export async function waitForAssetReady(node: HTMLElement): Promise {
+ if (typeof document !== 'undefined' && document.fonts?.ready) {
+ try {
+ await document.fonts.ready
+ } catch {
+ // fonts.ready can reject in odd states — capture anyway.
+ }
+ }
+ await Promise.all(
+ Array.from(node.querySelectorAll('img')).map((img) =>
+ typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve()
+ )
+ )
+}
+
export async function captureShareAsset(node: HTMLElement): Promise {
try {
+ await waitForAssetReady(node)
const blob = await toBlob(node, {
width: CANVAS_W,
height: CANVAS_H,
diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts
index 4a46eafe1b..79032881fe 100644
--- a/src/components/Card/share-asset/shareAsset.types.ts
+++ b/src/components/Card/share-asset/shareAsset.types.ts
@@ -105,4 +105,12 @@ export interface ShareAssetD3Props {
* snapshots / image export so the final frame renders deterministically.
*/
animate?: boolean
+
+ /**
+ * Fires once the card face's async hand has mounted (forwarded
+ * straight to ). Capture surfaces gate the
+ * Share/Save buttons on this so a snapshot can never fire before the card
+ * face paints — the deterministic fix for the blank-card capture bug.
+ */
+ onReady?: () => void
}
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx
index 7d87796dac..1cf1bae360 100644
--- a/src/components/Claim/Link/SendLinkActionList.tsx
+++ b/src/components/Claim/Link/SendLinkActionList.tsx
@@ -108,12 +108,18 @@ export default function SendLinkActionList({
return claimType === BankClaimType.GuestKycNeeded || claimType === BankClaimType.ReceiverKycNeeded
}, [claimType])
+ // Guest claim-to-bank (claimer unverified, but the sender can receive a bank
+ // off-ramp) is under maintenance: the BE 503s POST /bridge/offramp/create-for-guest.
+ // Render the bank option greyed + "Soon!" so guests can't enter a flow that would
+ // fail. The authenticated self off-ramp (UserBankClaim) is unaffected.
+ const isGuestBankClaim = claimType === BankClaimType.GuestBankClaim
+
// filter and sort payment methods based on geolocation
const { filteredMethods: sortedActionMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({
sortUnavailable: true,
isMethodUnavailable: (method) =>
method.soon ||
- (method.id === 'bank' && requiresVerification) ||
+ (method.id === 'bank' && (requiresVerification || isGuestBankClaim)) ||
(['mercadopago', 'pix'].includes(method.id) && !isMantecaPayEnabled),
methods: showDevconnectMethod
? DEVCONNECT_CLAIM_METHODS.filter((method) => method.id !== 'devconnect')
@@ -273,6 +279,7 @@ export default function SendLinkActionList({
key={method.id}
method={method}
requiresVerification={methodRequiresVerification}
+ soon={method.id === 'bank' && isGuestBankClaim}
/>
)
})}
@@ -323,12 +330,17 @@ const MethodCard = ({
onClick,
requiresVerification,
isDisabled,
+ soon,
}: {
method: PaymentMethod
onClick: () => void
requiresVerification?: boolean
isDisabled?: boolean
+ // forces the "Soon!" badge + greyed/non-interactive state even when the
+ // static method config has soon=false (e.g. guest claim-to-bank maintenance)
+ soon?: boolean
}) => {
+ const showSoon = method.soon || soon
return (
{method.title}
- {(method.soon || requiresVerification) && (
+ {(showSoon || requiresVerification) && (
}
onClick={onClick}
- isDisabled={method.soon || isDisabled}
+ isDisabled={showSoon || isDisabled}
rightContent={ }
/>
)
diff --git a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx
new file mode 100644
index 0000000000..88f6b1557f
--- /dev/null
+++ b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx
@@ -0,0 +1,159 @@
+/**
+ * SendLinkActionList — guest claim-to-bank maintenance gating
+ *
+ * The GUEST claim-to-bank off-ramp is under maintenance (BE 503s
+ * POST /bridge/offramp/create-for-guest). The bank method must render greyed +
+ * "Soon!" and be non-interactive when the claim resolves to GuestBankClaim,
+ * while the authenticated self off-ramp (UserBankClaim) stays fully clickable.
+ */
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+
+// ---------- module-level mocks (before importing the component) ----------
+
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
+}))
+
+jest.mock('next/image', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ const { priority, fill, ...rest } = props
+ return
+ },
+}))
+
+jest.mock('use-haptic', () => ({
+ useHaptic: () => ({ triggerHaptic: jest.fn() }),
+}))
+
+// Heavy leaf modals / CTAs that are not under test
+jest.mock('@/components/Global/ActionModal', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+jest.mock('../../../Global/ConfirmInviteModal', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+jest.mock('../../../Global/SupportCTA', () => ({
+ __esModule: true,
+ default: () => null,
+}))
+
+// Bank claim type — the value under test. `BankClaimType` mirrors the real enum.
+let mockClaimType = 'user-bank-claim'
+jest.mock('@/hooks/useDetermineBankClaimType', () => ({
+ BankClaimType: {
+ GuestBankClaim: 'guest-bank-claim',
+ UserBankClaim: 'user-bank-claim',
+ ReceiverKycNeeded: 'receiver-kyc-needed',
+ GuestKycNeeded: 'guest-kyc-needed',
+ },
+ useDetermineBankClaimType: () => ({ claimType: mockClaimType, setClaimType: jest.fn() }),
+}))
+
+const mockSetFlowStep = jest.fn()
+jest.mock('@/context/ClaimBankFlowContext', () => ({
+ ClaimBankFlowStep: {
+ SavedAccountsList: 'saved-accounts-list',
+ BankCountryList: 'bank-country-list',
+ BankDetailsForm: 'bank-details-form',
+ BankConfirmClaim: 'bank-confirm-claim',
+ },
+ useClaimBankFlow: () => ({
+ setClaimToExternalWallet: jest.fn(),
+ setFlowStep: mockSetFlowStep,
+ setShowVerificationModal: jest.fn(),
+ setClaimToMercadoPago: jest.fn(),
+ setRegionalMethodType: jest.fn(),
+ setHideTokenSelector: jest.fn(),
+ }),
+}))
+
+jest.mock('@/hooks/useSavedAccounts', () => ({
+ __esModule: true,
+ default: () => [],
+}))
+
+jest.mock('../../useClaimLink', () => ({
+ __esModule: true,
+ default: () => ({ addParamStep: jest.fn() }),
+}))
+
+jest.mock('@/hooks/useCapabilities', () => ({
+ useCapabilities: () => ({ canDo: () => false }),
+}))
+
+jest.mock('@/context/authContext', () => ({
+ useAuth: () => ({ user: { user: { userId: 'me', hasAppAccess: true } } }),
+}))
+
+jest.mock('@/redux/hooks', () => ({
+ useAppDispatch: () => jest.fn(),
+}))
+
+jest.mock('@/context', () => ({
+ tokenSelectorContext: React.createContext({
+ setSelectedTokenAddress: jest.fn(),
+ setSelectedChainID: jest.fn(),
+ devconnectChainId: '',
+ devconnectRecipientAddress: '',
+ devconnectTokenAddress: '',
+ }),
+}))
+
+// Return a fixed method set so the test is independent of geolocation.
+const bankMethod = { id: 'bank', title: 'Bank', description: 'EUR, USD, MXN, ARS & more', icons: [], soon: false }
+const walletMethod = {
+ id: 'exchange-or-wallet',
+ title: 'Exchange or Wallet',
+ description: 'Binance, Metamask and more',
+ icons: [],
+ soon: false,
+}
+jest.mock('@/hooks/useGeoFilteredPaymentOptions', () => ({
+ useGeoFilteredPaymentOptions: () => ({ filteredMethods: [bankMethod, walletMethod], isLoading: false }),
+}))
+
+// ---------- import component under test AFTER mocks ----------
+import SendLinkActionList from '../SendLinkActionList'
+
+const claimLinkData = {
+ amount: BigInt(10000000), // 10 USDC — above the $5 bank minimum
+ tokenDecimals: 6,
+ sender: { userId: 'sender-123', username: 'alice' },
+} as any
+
+function renderList() {
+ return render( )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe('SendLinkActionList — guest claim-to-bank maintenance', () => {
+ test('GuestBankClaim: bank option is greyed + "Soon!" and non-interactive', () => {
+ mockClaimType = 'guest-bank-claim'
+ renderList()
+
+ // SOON badge present on the bank option
+ expect(screen.getByText('Soon!')).toBeInTheDocument()
+
+ // clicking the disabled bank card does not start the bank flow
+ fireEvent.click(screen.getByText('Bank'))
+ expect(mockSetFlowStep).not.toHaveBeenCalled()
+ })
+
+ test('UserBankClaim: authenticated self off-ramp stays interactive (no "Soon!")', () => {
+ mockClaimType = 'user-bank-claim'
+ renderList()
+
+ expect(screen.queryByText('Soon!')).not.toBeInTheDocument()
+
+ // clicking the enabled bank card enters the bank flow
+ fireEvent.click(screen.getByText('Bank'))
+ expect(mockSetFlowStep).toHaveBeenCalledWith('bank-country-list')
+ })
+})
diff --git a/src/components/Global/Banner/MantecaTransfersMaintenanceView.tsx b/src/components/Global/Banner/MantecaTransfersMaintenanceView.tsx
new file mode 100644
index 0000000000..df08681107
--- /dev/null
+++ b/src/components/Global/Banner/MantecaTransfersMaintenanceView.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { useModalsContext } from '@/context/ModalsContext'
+import { useRouter } from 'next/navigation'
+
+/**
+ * Provider-outage screen for the Manteca add-money (onramp) and withdraw
+ * (offramp) flows when `disableMantecaTransfers` is on. Mirrors the QR-pay
+ * provider-outage screen (disabledPaymentProviders) — same layout and copy.
+ * User-facing copy is provider-agnostic on purpose: users never see "Manteca".
+ */
+export function MantecaTransfersMaintenanceView({ action }: { action: 'deposits' | 'withdrawals' }) {
+ const router = useRouter()
+ const { setIsSupportModalOpen } = useModalsContext()
+ return (
+
+
+
+
+
+ Service Temporarily Unavailable
+
+ We're experiencing issues with {action} due to an external provider outage. We're working to restore
+ service as soon as possible.
+
+
+
router.push('/home')} variant="purple" shadowSize="4">
+ Go Back
+
+
setIsSupportModalOpen(true)}
+ className="flex w-full items-center justify-center gap-2 text-sm font-medium text-grey-1 transition-colors hover:text-black"
+ >
+
+ Having trouble?
+
+
+ )
+}
diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx
index 4c56d0d0ce..fae5b136f8 100644
--- a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx
+++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx
@@ -17,9 +17,9 @@ interface CardLaunchCTABannerProps {
* Presentational only — gating + persistence live in the `CardLaunchCTA`
* container so this can be force-rendered in the /dev/home-ctas preview.
*
- * Tone matches the /shhhhh teaser: pink, hard black border + shadow, extra-black
- * uppercase headline, provocative "maybe it's for you" framing. The whole card
- * is a tap target (parity with CarouselCTA); the X stops propagation.
+ * In line with the other activation CTAs: white card, black border, no drop
+ * shadow, standard purple primary CTA. The whole card is a tap target (parity
+ * with CarouselCTA); the X stops propagation.
*/
export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunchCTABannerProps) {
const { triggerHaptic } = useHaptic()
@@ -39,7 +39,7 @@ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunch
role="button"
tabIndex={0}
onClick={handleTryDoor}
- className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border-2 border-n-1 bg-primary-1 p-5 shadow-[4px_4px_0_#000]"
+ className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-n-1 bg-white p-5"
>
-
shhh
+
shhhh
Tap to find out if you're in
= {
founderhaus: 'FOUNDER_HOUSE',
alumni: 'EVENT_ALUMNI',
touched_grass: 'TOUCHED_GRASS',
+ irl_nomads: 'IRL_NOMADS',
survivor: 'SUPPORT_SURVIVOR',
notsoshhh: 'NOT_SO_SHHHH',
festajunina: 'FESTA_JUNINA_2026',
@@ -32,6 +33,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = {
'touched-grass': 'TOUCHED_GRASS',
'festa-junina': 'FESTA_JUNINA_2026',
'card-alpha': 'CARD_ALPHA',
+ 'irl-nomads': 'IRL_NOMADS',
}
// Bare ?campaign= links (no invite code) that are claimable without an invite —
@@ -46,7 +48,12 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = {
// bare link, but `/invite` shows generic badge-claim copy (not "skip").
export const SKIP_CAMPAIGN = 'skip'
export const WAITLIST_SKIP_CAMPAIGNS: ReadonlySet = new Set([SKIP_CAMPAIGN, 'event_alumni'])
-export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set(['touched_grass', 'card_alpha', 'festa_junina_2026'])
+export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([
+ 'touched_grass',
+ 'card_alpha',
+ 'festa_junina_2026',
+ 'irl_nomads',
+])
export type CampaignClassification = {
/** Claimable from a bare link with no invite code (auto-claim + gate bypass). */
diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx
index bf0a62a11b..39c8f970b4 100644
--- a/src/components/Kyc/AdvisoryPreemptModal.tsx
+++ b/src/components/Kyc/AdvisoryPreemptModal.tsx
@@ -5,11 +5,8 @@ interface AdvisoryPreemptModalProps {
/** ISO date the requirement becomes blocking; drives the deadline copy. */
effectiveDate?: string
isLoading?: boolean
- /** Launch the verification flow early. */
+ /** Launch the verification flow. */
onCompleteNow: () => void
- /** Dismiss and continue with what the user was doing. */
- onSkip: () => void
- onClose: () => void
}
function formatEffectiveDate(iso?: string): string | null {
@@ -23,32 +20,33 @@ function formatEffectiveDate(iso?: string): string | null {
}
/**
- * Skippable pre-empt for a future-dated verification requirement on a rail that
- * still works today (the gate's `ready` + `advisory`). "Complete now" launches
- * the verification early; "Not now" lets the user carry on and resolve it later.
- * Once the effective date passes the backend reclassifies the requirement to
- * blocking and the non-skippable InitiateKycModal takes over — there is no FE
- * cutover logic here.
+ * Mandatory pre-empt for a pending Bridge verification requirement on the bank
+ * rails. Non-closable and non-skippable: the user must complete the verification
+ * before they can continue with a bank transfer. There is no "Not now" / X /
+ * backdrop dismiss — the only way forward is "Complete now".
*/
export default function AdvisoryPreemptModal({
visible,
effectiveDate,
isLoading = false,
onCompleteNow,
- onSkip,
- onClose,
}: AdvisoryPreemptModalProps) {
const formatted = formatEffectiveDate(effectiveDate)
return (
{}}
+ preventClose
+ hideModalCloseButton
icon="badge"
- title="One quick step coming up"
+ title="One quick step to continue"
description={
formatted
- ? `To keep using bank transfers, you'll need to complete a short verification by ${formatted}. Take care of it now so nothing pauses later.`
- : `To keep using bank transfers, you'll need to complete a short verification soon. Take care of it now so nothing pauses later.`
+ ? `To continue using bank transfers, please complete a short verification (required by ${formatted}). It only takes a couple of minutes.`
+ : `To continue using bank transfers, please complete a short verification. It only takes a couple of minutes.`
}
ctas={[
{
@@ -58,7 +56,6 @@ export default function AdvisoryPreemptModal({
shadowSize: '4',
disabled: isLoading,
},
- { text: 'Not now', onClick: onSkip, variant: 'stroke', disabled: isLoading },
]}
/>
)
diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx
index c4d7e496a4..1ca19f1672 100644
--- a/src/components/Kyc/states/KycActionRequired.tsx
+++ b/src/components/Kyc/states/KycActionRequired.tsx
@@ -5,8 +5,14 @@ import { Button } from '@/components/0_Bruddle/Button'
import type { IconName } from '@/components/Global/Icons/Icon'
// this component shows the identity-verification status when more action is needed
-// from the user. Displays a friendly actionMessage when present, otherwise the
-// normalized reject labels (e.g. bad photo quality, expired doc).
+// from the user. Prefers the per-label copy when reject labels are present (e.g.
+// DUPLICATE_EMAIL → "Email already in use, sign in to that account or contact
+// support") and only falls back to the backend's generic actionMessage when there
+// are none. The backend (identity.ts → actionMessageFor) sends a fixed, generic
+// "resubmit your documents" message for every action_required state — it is never
+// label-specific — so checking it first would mask the actionable per-label copy.
+// RejectLabelsList already renders its own generic fallback for empty labels, so
+// the no-labels-no-actionMessage case lands there safely.
export const KycActionRequired = ({
onResume,
isLoading,
@@ -22,7 +28,7 @@ export const KycActionRequired = ({
- {actionMessage ? (
+ {!rejectLabels?.length && actionMessage ? (
) : (
diff --git a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx
new file mode 100644
index 0000000000..0f60a64d98
--- /dev/null
+++ b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react'
+import { KycActionRequired } from '../KycActionRequired'
+
+// Integration-level companion to KycStates.test.tsx: that suite mocks
+// RejectLabelsList to assert branch selection. Here we render the REAL
+// RejectLabelsList + reject-label copy map so a regression in the
+// DUPLICATE_EMAIL → "Email already in use" mapping (the whole point of the
+// precedence fix) actually fails a test instead of shipping silently.
+
+jest.mock('use-haptic', () => ({
+ useHaptic: () => ({ triggerHaptic: jest.fn() }),
+}))
+
+jest.mock('@/hooks/useLongPress', () => ({
+ useLongPress: () => ({ isLongPressed: false, pressProgress: 0, handlers: {} }),
+}))
+
+jest.mock('../../KYCStatusDrawerItem', () => ({
+ KYCStatusDrawerItem: () =>
,
+}))
+
+// InfoCard is the leaf both branches render through; surface its text so we can
+// assert the actual copy. RejectLabelsList is intentionally NOT mocked.
+jest.mock('@/components/Global/InfoCard', () => ({
+ __esModule: true,
+ default: ({ title, description }: { title?: string; description?: string }) => (
+ <>
+ {title ?
{title}
: null}
+ {description ?
{description}
: null}
+ >
+ ),
+}))
+
+describe('KycActionRequired — real reject-label copy', () => {
+ it('renders the DUPLICATE_EMAIL guidance, not the generic resubmit message', () => {
+ render(
+
+ )
+
+ expect(screen.getByText(/already linked to another Peanut account/i)).toBeInTheDocument()
+ expect(screen.getByText(/sign in to that account/i)).toBeInTheDocument()
+ expect(screen.queryByText(/resubmit your documents/i)).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/Kyc/states/__tests__/KycStates.test.tsx b/src/components/Kyc/states/__tests__/KycStates.test.tsx
index 1242339d62..8e0d22fe66 100644
--- a/src/components/Kyc/states/__tests__/KycStates.test.tsx
+++ b/src/components/Kyc/states/__tests__/KycStates.test.tsx
@@ -48,6 +48,31 @@ describe('KYC state cards', () => {
expect(onResume).toHaveBeenCalledWith()
})
+ it('shows per-label reject copy (not the generic actionMessage) when reject labels are present', () => {
+ // DUPLICATE_EMAIL regression: the backend always sends a generic
+ // "resubmit your documents" actionMessage for action_required, which used
+ // to mask the specific "Email already in use" reject-label copy.
+ render(
+
+ )
+
+ expect(screen.getByTestId('reject-labels-list')).toBeInTheDocument()
+ expect(screen.queryByText(/resubmit your documents/i)).not.toBeInTheDocument()
+ })
+
+ it('falls back to the generic actionMessage when there are no reject labels', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Please resubmit your documents.')).toBeInTheDocument()
+ expect(screen.queryByTestId('reject-labels-list')).not.toBeInTheDocument()
+ })
+
it('does not pass the click event to failed retry', () => {
const onRetry = jest.fn()
render(
)
diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts
index f7a021dde1..eb0b096b53 100644
--- a/src/config/underMaintenance.config.ts
+++ b/src/config/underMaintenance.config.ts
@@ -45,6 +45,13 @@
* - the /card flow, /shhhhh page, and waitlist pill stay reachable regardless — this only mutes the proactive in-app nudge
* - currently false (CTA live, routes to /shhhhh); set true to dial down in-app load without touching the flow
*
+ * 9. disabledMantecaCurrencies: per-currency kill-switch for the Manteca add-money (onramp) and withdraw (offramp) flows
+ * - list the fiat currencies still down (e.g. ['BRL']) — those countries' /add-money/
/manteca and
+ * /withdraw/manteca show a "temporarily unavailable" screen; currencies NOT listed stay live
+ * - Manteca currencies are ARS (Argentina) and BRL (Brazil); empty array = all Manteca transfers enabled
+ * - use during a partial Manteca outage so recovered currencies (e.g. ARS) come back while others stay blocked
+ * - does NOT touch QR payments (Manteca QR / Brazil PIX-over-QR stay open) — that is disabledPaymentProviders
+ *
* note: if either mode is enabled, the maintenance banner will show everywhere
*
* I HOPE WE NEVER NEED TO USE THIS...
@@ -62,8 +69,13 @@ interface MaintenanceConfig {
disableCardPioneers: boolean
disableCardLaunchCTA: boolean
pixBrazilOnrampMaintenance: boolean
+ /** Manteca fiat currencies still down (e.g. ['BRL']); currencies not listed stay live. Empty = all enabled. */
+ disabledMantecaCurrencies: MantecaCurrency[]
}
+// Manteca first-party bank/kyc rails currently exist only in Argentina (ARS) and Brazil (BRL).
+export type MantecaCurrency = 'ARS' | 'BRL'
+
const underMaintenanceConfig: MaintenanceConfig = {
enableFullMaintenance: false, // set to true to redirect all pages to /maintenance
enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages
@@ -73,6 +85,7 @@ const underMaintenanceConfig: MaintenanceConfig = {
disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature
disableCardLaunchCTA: false, // kill-switch for the in-app "shhh" card CTA (funnel card step + activated home splash). Set true to mute it (dial down in-app load); /card flow + /shhhhh + waitlist stay reachable regardless.
pixBrazilOnrampMaintenance: true, // set to false when BRL-via-PIX deposits are stable again
+ disabledMantecaCurrencies: [], // Manteca restored (ARS + BRL live). Add a currency here to block it during a future outage.
}
// shared user-facing copy for cross-chain disabled paths — keep wording aligned with TokenSelector banner
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index b19ec1b3ef..b6e2bd74c0 100644
--- a/src/constants/analytics.consts.ts
+++ b/src/constants/analytics.consts.ts
@@ -33,6 +33,14 @@ export const ANALYTICS_EVENTS = {
KYC_REJECTED: 'kyc_rejected',
KYC_ABANDONED: 'kyc_abandoned',
+ // ── EEA uplift (Bridge endorsement re-verification) ──
+ // Dedicated funnel events for the mandatory EEA-uplift gate so the flow can
+ // be filtered directly in PostHog. `started` = user launched the
+ // verification from the gate; `completed` = the KYC flow succeeded for that
+ // same uplift attempt.
+ EEA_UPLIFT_STARTED: 'eea_uplift_started',
+ EEA_UPLIFT_COMPLETED: 'eea_uplift_completed',
+
// ── KYC (Manteca) ──
MANTECA_KYC_INITIATED: 'manteca_kyc_initiated',
MANTECA_KYC_COMPLETED: 'manteca_kyc_completed',
diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts
index d8991d5607..32ad761477 100644
--- a/src/hooks/useAdvisoryPreempt.test.ts
+++ b/src/hooks/useAdvisoryPreempt.test.ts
@@ -16,7 +16,7 @@ describe('useAdvisoryPreempt', () => {
expect(result.current.modalProps.visible).toBe(false)
})
- test('advisory present → intercept opens the modal and defers proceed', () => {
+ test('advisory present → intercept opens the modal and blocks proceed', () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))
@@ -28,54 +28,92 @@ describe('useAdvisoryPreempt', () => {
expect(result.current.modalProps.effectiveDate).toBe('2099-06-29')
})
- test('skip runs the deferred proceed; once dismissed, later intercepts pass straight through', () => {
- const proceed = jest.fn()
+ test('hard gate: while the advisory is pending, repeated intercepts never pass through', () => {
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))
- act(() => result.current.intercept(proceed))
- act(() => result.current.modalProps.onSkip())
-
- expect(proceed).toHaveBeenCalledTimes(1)
- expect(result.current.modalProps.visible).toBe(false)
-
- // Dismissed for the session — a second proceed runs immediately, no re-prompt.
+ const proceed1 = jest.fn()
+ act(() => result.current.intercept(proceed1))
const proceed2 = jest.fn()
act(() => result.current.intercept(proceed2))
- expect(proceed2).toHaveBeenCalledTimes(1)
- expect(result.current.modalProps.visible).toBe(false)
+
+ expect(proceed1).not.toHaveBeenCalled()
+ expect(proceed2).not.toHaveBeenCalled()
+ expect(result.current.modalProps.visible).toBe(true)
})
- test('onClose dismisses without running the deferred proceed (X must not trigger the money action)', () => {
+ test('completeNow launches the verification, hides the modal, and does NOT run the deferred proceed', async () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))
act(() => result.current.intercept(proceed))
- act(() => result.current.modalProps.onClose())
+ await act(async () => {
+ await result.current.modalProps.onCompleteNow()
+ })
+ expect(onCompleteNow).toHaveBeenCalledTimes(1)
expect(proceed).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(false)
+ })
- // ...but it dismisses for the session — the next click passes through, no re-prompt.
- const proceed2 = jest.fn()
- act(() => result.current.intercept(proceed2))
- expect(proceed2).toHaveBeenCalledTimes(1)
- expect(result.current.modalProps.visible).toBe(false)
+ test('completeNow ignores rapid double-clicks (single submit)', async () => {
+ let resolve: () => void = () => {}
+ const onCompleteNow = jest.fn(() => new Promise((r) => (resolve = r)))
+ const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))
+
+ await act(async () => {
+ result.current.modalProps.onCompleteNow()
+ result.current.modalProps.onCompleteNow()
+ resolve()
+ })
+
+ expect(onCompleteNow).toHaveBeenCalledTimes(1)
})
- test('completeNow launches the verification and does NOT run the deferred proceed', async () => {
+ test('once the requirement clears (advisory undefined), intercept passes through again', () => {
+ const onCompleteNow = jest.fn()
+ const { result, rerender } = renderHook(
+ ({ adv }: { adv: GateAdvisory | undefined }) => useAdvisoryPreempt({ advisory: adv, onCompleteNow }),
+ { initialProps: { adv: advisory as GateAdvisory | undefined } }
+ )
+
+ const blocked = jest.fn()
+ act(() => result.current.intercept(blocked))
+ expect(blocked).not.toHaveBeenCalled()
+
+ // Backend cleared the requirement → gate no longer carries an advisory.
+ rerender({ adv: undefined })
const proceed = jest.fn()
+ act(() => result.current.intercept(proceed))
+ expect(proceed).toHaveBeenCalledTimes(1)
+ })
+
+ test('auto-closes the modal when the advisory clears while it is open', () => {
const onCompleteNow = jest.fn()
+ const { result, rerender } = renderHook(
+ ({ adv }: { adv: GateAdvisory | undefined }) => useAdvisoryPreempt({ advisory: adv, onCompleteNow }),
+ { initialProps: { adv: advisory as GateAdvisory | undefined } }
+ )
+
+ act(() => result.current.intercept(jest.fn()))
+ expect(result.current.modalProps.visible).toBe(true)
+
+ // backend cleared the requirement while the modal was open
+ rerender({ adv: undefined })
+ expect(result.current.modalProps.visible).toBe(false)
+ })
+
+ test('re-shows the modal if the launch (onCompleteNow) fails', async () => {
+ const onCompleteNow = jest.fn().mockRejectedValue(new Error('launch failed'))
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))
- act(() => result.current.intercept(proceed))
+ act(() => result.current.intercept(jest.fn()))
await act(async () => {
- await result.current.modalProps.onCompleteNow()
+ await expect(result.current.modalProps.onCompleteNow()).rejects.toThrow('launch failed')
})
- expect(onCompleteNow).toHaveBeenCalledTimes(1)
- expect(proceed).not.toHaveBeenCalled()
- expect(result.current.modalProps.visible).toBe(false)
+ // hard gate must not silently vanish on a failed launch
+ expect(result.current.modalProps.visible).toBe(true)
})
})
diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts
index 78f29eebb2..9f6a7d3ec7 100644
--- a/src/hooks/useAdvisoryPreempt.ts
+++ b/src/hooks/useAdvisoryPreempt.ts
@@ -1,75 +1,69 @@
-import { useCallback, useRef, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import type { GateAdvisory } from '@/utils/capability-gate'
interface UseAdvisoryPreemptArgs {
/** The advisory from a `ready` gate (`gate.kind === 'ready' ? gate.advisory : undefined`). */
advisory: GateAdvisory | undefined
- /** Launch the verification flow early — e.g. `sumsubFlow.handleInitiateKyc(region, advisory.levelKey, …)`. */
+ /** Launch the verification flow — e.g. `sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)`. */
onCompleteNow: () => void | Promise
isLoading?: boolean
}
/**
- * Drives the skippable advisory pre-empt at the add/withdraw entry points. The
- * rail is usable now, so we don't block — we intercept the "proceed" step ONCE
- * per session with a skippable modal. "Complete now" launches the verification
- * early; "Not now" dismisses and runs the original proceed action. Either choice
- * marks it dismissed so the user isn't re-prompted mid-session.
+ * Drives the MANDATORY verification pre-empt at the add/withdraw entry points.
+ * If a pending Bridge requirement (`advisory`) exists, the user CANNOT proceed
+ * with the transfer until they complete it: `intercept` opens a non-closable,
+ * non-skippable modal and never runs the deferred action. "Complete now"
+ * launches the verification; once it clears, the gate drops the advisory and the
+ * next add/withdraw click passes straight through.
*
* Returns `intercept(proceed)` to call in the gate's `ready` branch, and
* `modalProps` to spread onto {@link AdvisoryPreemptModal}.
*/
export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false }: UseAdvisoryPreemptArgs) {
- const [dismissed, setDismissed] = useState(false)
const [visible, setVisible] = useState(false)
- const pendingProceed = useRef<(() => void) | null>(null)
- // Guards against double-submit: onCompleteNow now fires a real network call
+ // Guards against double-submit: onCompleteNow fires a real network call
// (self-heal resubmit), so rapid clicks before isLoading disables the CTA
// would otherwise launch duplicate requests.
const completingRef = useRef(false)
+ // Keep the modal in sync with the requirement: if the backend clears the
+ // advisory (requirement resolved) while the modal is open, auto-close it so
+ // the gate doesn't linger over an already-unblocked transfer.
+ useEffect(() => {
+ if (!advisory) setVisible(false)
+ }, [advisory])
+
const intercept = useCallback(
(proceed: () => void) => {
- if (advisory && !dismissed) {
- pendingProceed.current = proceed
+ // Hard gate: a pending requirement blocks the transfer outright. The
+ // deferred action only runs when there is no advisory — i.e. the user
+ // has completed verification and the backend cleared the requirement.
+ if (advisory) {
setVisible(true)
return
}
proceed()
},
- [advisory, dismissed]
+ [advisory]
)
const completeNow = useCallback(async () => {
if (completingRef.current) return
completingRef.current = true
- setDismissed(true)
setVisible(false)
- pendingProceed.current = null
try {
await onCompleteNow()
+ } catch (error) {
+ // Launch failed — re-show the gate so the user isn't left with a
+ // silently dismissed mandatory step and a still-pending requirement.
+ setVisible(true)
+ throw error
} finally {
completingRef.current = false
}
}, [onCompleteNow])
- const skip = useCallback(() => {
- setDismissed(true)
- setVisible(false)
- const proceed = pendingProceed.current
- pendingProceed.current = null
- proceed?.()
- }, [])
-
- // X / backdrop / Escape: dismiss for the session WITHOUT running the deferred
- // proceed — closing the dialog must not auto-trigger the add/withdraw action.
- // The user's next add/withdraw click then passes straight through (dismissed).
- const close = useCallback(() => {
- setDismissed(true)
- setVisible(false)
- pendingProceed.current = null
- }, [])
-
return {
intercept,
modalProps: {
@@ -77,8 +71,6 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false
effectiveDate: advisory?.effectiveDate,
isLoading,
onCompleteNow: completeNow,
- onSkip: skip,
- onClose: close,
},
}
}
diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts
new file mode 100644
index 0000000000..f6699601ef
--- /dev/null
+++ b/src/hooks/useEeaUpliftFunnel.test.ts
@@ -0,0 +1,73 @@
+import { act, renderHook } from '@testing-library/react'
+import posthog from 'posthog-js'
+import { useEeaUpliftFunnel } from './useEeaUpliftFunnel'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import type { GateAdvisory } from '@/utils/capability-gate'
+
+jest.mock('posthog-js', () => ({ capture: jest.fn() }))
+const capture = posthog.capture as jest.Mock
+
+const advisory: GateAdvisory = {
+ effectiveDate: '2026-06-29',
+ actionKey: 'sumsub:eea_uplift',
+ requirementKey: 'sof_individual_primary_purpose',
+}
+
+beforeEach(() => capture.mockClear())
+
+describe('useEeaUpliftFunnel', () => {
+ test('trackStarted fires eea_uplift_started with channel + advisory props', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(advisory))
+
+ expect(capture).toHaveBeenCalledTimes(1)
+ expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, {
+ channel: 'deposit',
+ requirement_key: 'sof_individual_primary_purpose',
+ action_key: 'sumsub:eea_uplift',
+ effective_date: '2026-06-29',
+ })
+ })
+
+ test('trackCompleted fires eea_uplift_completed only after a start', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('withdraw'))
+ act(() => result.current.trackStarted(advisory))
+ capture.mockClear()
+
+ act(() => result.current.trackCompleted())
+ expect(capture).toHaveBeenCalledTimes(1)
+ expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, {
+ channel: 'withdraw',
+ requirement_key: 'sof_individual_primary_purpose',
+ action_key: 'sumsub:eea_uplift',
+ effective_date: '2026-06-29',
+ })
+ })
+
+ test('trackCompleted is a no-op when no start was recorded (generic KYC success)', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackCompleted())
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ test('trackCompleted only fires once per start (ref cleared)', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(advisory))
+ capture.mockClear()
+
+ act(() => result.current.trackCompleted())
+ act(() => result.current.trackCompleted())
+ expect(capture).toHaveBeenCalledTimes(1)
+ })
+
+ test('reset clears a pending start so a later success cannot mis-fire completed', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(advisory))
+ capture.mockClear()
+
+ // user abandoned the flow → reset, then an unrelated KYC success fires
+ act(() => result.current.reset())
+ act(() => result.current.trackCompleted())
+ expect(capture).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts
new file mode 100644
index 0000000000..c6eb11fe72
--- /dev/null
+++ b/src/hooks/useEeaUpliftFunnel.ts
@@ -0,0 +1,59 @@
+import { useCallback, useRef } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import type { GateAdvisory } from '@/utils/capability-gate'
+
+type UpliftChannel = 'deposit' | 'withdraw'
+
+/**
+ * Fires the EEA-uplift funnel events for PostHog so the flow can be filtered
+ * directly: `eea_uplift_started` when the user launches the verification, and
+ * `eea_uplift_completed` on KYC success.
+ *
+ * `trackCompleted` only emits if a start was recorded in this session — the KYC
+ * success callback on the bank pages is shared with non-uplift KYC, so the
+ * `startedRef` guard stops a generic success from mis-firing the completed
+ * event. The advisory snapshot is captured at start time because the gate's
+ * `advisory` clears once the requirement resolves, so it's gone by completion.
+ *
+ * `reset` clears the pending start on abandonment (KYC modal closed without
+ * success). Without it the latch would survive an abandoned attempt, and a later
+ * unrelated KYC success on the same mounted page could mis-fire `completed`.
+ */
+export function useEeaUpliftFunnel(channel: UpliftChannel) {
+ const startedRef = useRef(null)
+
+ const trackStarted = useCallback(
+ // `advisory` is required: callers gate on it before launching, and the
+ // funnel contract needs requirement_key / action_key / effective_date
+ // always present on the event.
+ (advisory: GateAdvisory) => {
+ startedRef.current = advisory
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, {
+ channel,
+ requirement_key: advisory.requirementKey,
+ action_key: advisory.actionKey,
+ effective_date: advisory.effectiveDate,
+ })
+ },
+ [channel]
+ )
+
+ const trackCompleted = useCallback(() => {
+ const advisory = startedRef.current
+ if (!advisory) return
+ startedRef.current = null
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, {
+ channel,
+ requirement_key: advisory.requirementKey,
+ action_key: advisory.actionKey,
+ effective_date: advisory.effectiveDate,
+ })
+ }, [channel])
+
+ const reset = useCallback(() => {
+ startedRef.current = null
+ }, [])
+
+ return { trackStarted, trackCompleted, reset }
+}
diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts
index 2e4f836380..d6877ffba6 100644
--- a/src/hooks/useMultiPhaseKycFlow.ts
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -75,6 +75,14 @@ export async function confirmBridgeTosAndAwaitRails(fetchUser: () => Promise void
+ /**
+ * Fired the moment Sumsub reports approval (the verification was submitted),
+ * BEFORE the post-approval ToS / rail-preparing steps. Use this for
+ * "completed the verification" signals so they aren't lost when the user
+ * drops during those follow-up steps. `onKycSuccess` still fires later, once
+ * the whole flow settles.
+ */
+ onKycApproved?: () => void
onManualClose?: () => void
regionIntent?: KYCRegionIntent
}
@@ -87,7 +95,12 @@ interface UseMultiPhaseKycFlowOptions {
* use this hook anywhere kyc is initiated. pair with SumsubKycModals
* for the modal rendering.
*/
-export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseMultiPhaseKycFlowOptions) => {
+export const useMultiPhaseKycFlow = ({
+ onKycSuccess,
+ onKycApproved,
+ onManualClose,
+ regionIntent,
+}: UseMultiPhaseKycFlowOptions) => {
const { fetchUser, user } = useAuth()
const acquisitionSource = user?.invitedBy ? 'referred' : 'organic'
@@ -163,6 +176,11 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
// the next route mount / window focus. See useSubmissionWindow.
markSubmitted()
+ // Sumsub approved the submission — the verification flow is done from the
+ // user's side. Fire here (not in completeFlow) so a "completed" signal
+ // survives a drop during the post-approval ToS / preparing steps.
+ onKycApproved?.()
+
// for real-time flow, optimistically show "Identity verified!" while we check rails
if (isRealtimeFlowRef.current) {
setModalPhase('preparing')
@@ -192,7 +210,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
// all settled — done
completeFlow()
- }, [fetchUser, startTracking, clearPreparingTimer, completeFlow])
+ }, [fetchUser, startTracking, clearPreparingTimer, completeFlow, onKycApproved])
const {
isLoading,
diff --git a/src/services/card.ts b/src/services/card.ts
index 94b5b3c315..d1814f16eb 100644
--- a/src/services/card.ts
+++ b/src/services/card.ts
@@ -35,6 +35,16 @@ export interface CardInfoResponse {
waitlistReleasedAt: string | null
/** Skip-badge codes the user holds (subset of SKIP_BADGE_CODES on BE). */
skipBadges: string[]
+ /** Global door-tally counts (same for every user) — power the Berghain
+ * rejection screen. `waitlistTotal` = total who joined the waitlist (the FE
+ * inflates it for the FOMO "tried"); `admittedTotal` = total released/granted
+ * (shown verbatim as "got in").
+ *
+ * OPTIONAL on purpose: the BE that returns these (peanut-api-ts) deploys
+ * first, but during the rollout window — and for any older API — the FE
+ * must tolerate `undefined`. `computeDoorTally` falls back to 213 / 7. */
+ waitlistTotal?: number
+ admittedTotal?: number
}
export interface WaitlistStateResponse {
diff --git a/src/services/manteca.ts b/src/services/manteca.ts
index 5e11eaf86b..4e7db4fd59 100644
--- a/src/services/manteca.ts
+++ b/src/services/manteca.ts
@@ -96,8 +96,12 @@ export type MantecaPrice = {
daily: string
}
}
- effectiveBuy: string
- effectiveSell: string
+ // Manteca nests the effective (company) rate here as of 2026-07-01.
+ effectivePrice?: { buy: string; sell: string }
+ // Legacy top-level effective rate (pre-2026-07-01). Optional — kept only so a
+ // provider rollback stays parseable; new reads should prefer `effectivePrice`.
+ effectiveBuy?: string
+ effectiveSell?: string
}
// withdraw init response - contains locked price for withdraw flow
diff --git a/src/services/rain.ts b/src/services/rain.ts
index b494ab2a6a..5124582aef 100644
--- a/src/services/rain.ts
+++ b/src/services/rain.ts
@@ -485,6 +485,13 @@ export const rainApi = {
method: 'POST',
path: '/rain/cards',
body,
+ // The first-time-application path runs 7 sequential Sumsub calls, a
+ // deliberate 2.5s readiness sleep, the Rain createApplication call,
+ // and an optional inline issueCard — routinely 7-13s. The default
+ // 10s fetch timeout clips that tail, aborting client-side while the
+ // backend completes (user sees a false failure on a card that was
+ // actually submitted). Give this one call generous headroom.
+ timeoutMs: 60_000,
})
},