- 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 000000000..fd3f414ce
--- /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 000000000..96c101a38
--- /dev/null
+++ b/src/components/Card/__tests__/doorTally.utils.test.ts
@@ -0,0 +1,57 @@
+import {
+ inflateApplicants,
+ computeDoorTally,
+ 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)
+ })
+})
diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts
new file mode 100644
index 000000000..8f5dec469
--- /dev/null
+++ b/src/components/Card/doorTally.utils.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 }
+}
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx
index d699f842e..c8af37514 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
@@ -23,13 +23,13 @@
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 { 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 = 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 f8ef100d0..372c64e62 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 e4d3ce034..cd73d18fa 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 87066615c..a2f790887 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 02ea69216..a4f50b5be 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 4a46eafe1..79032881f 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/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) => {
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx
index 7d87796da..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'
@@ -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')
@@ -237,7 +243,7 @@ export default function SendLinkActionList({
>
{showDevconnectMethod ? Claim on
: Continue with
}
-
+
@@ -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 000000000..88f6b1557
--- /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/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 = ({
Join
-
+
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) => {
Create a
-
+
account
diff --git a/src/components/Global/NoMoreJailModal/index.tsx b/src/components/Global/NoMoreJailModal/index.tsx
index 663bbe7da..8e005b711 100644
--- a/src/components/Global/NoMoreJailModal/index.tsx
+++ b/src/components/Global/NoMoreJailModal/index.tsx
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
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 { PeanutWhistling } from '@/assets/mascot'
@@ -55,7 +55,7 @@ const NoMoreJailModal = () => {
Start using
-
+
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() {
-
+
{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({
)}
>
-
+
{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/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx
index 4c56d0d0c..fae5b136f 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
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 c4d7e496a..1ca19f167 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 000000000..0f60a64d9
--- /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 1242339d6..8e0d22fe6 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/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 ? (
-
+
) : (
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 ? (
-
+
Enjoy Peanut!
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index b19ec1b3e..b6e2bd74c 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/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 (
-
+
)
diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts
index d8991d560..32ad76147 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 78f29eebb..9f6a7d3ec 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 000000000..f6699601e
--- /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 000000000..c6eb11fe7
--- /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 2e4f83638..d6877ffba 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 94b5b3c31..d1814f16e 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/rain.ts b/src/services/rain.ts
index b494ab2a6..5124582ae 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,
})
},