Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1522abf
fix(kyc): surface per-label reject copy in action-required drawer
jjramirezn Jun 26, 2026
b0a1b09
fix(kyc): collapse duplicated reject-labels branch + test real copy
jjramirezn Jun 26, 2026
1f7fab0
fix(card): grey + "Soon!" the guest claim-to-bank option (under maint…
Hugo0 Jun 26, 2026
ece9cb5
Merge pull request #2294 from peanutprotocol/hotfix/guest-bank-soon
Hugo0 Jun 27, 2026
cfc9955
Merge pull request #2298 from peanutprotocol/dev
Hugo0 Jun 29, 2026
323fa61
fix(card): bring launch CTA in line with the activation CTAs
Hugo0 Jun 29, 2026
8d78395
fix(card): match activation-CTA border weight (1px) + spell shhhh
Hugo0 Jun 29, 2026
80ffc2d
Merge pull request #2299 from peanutprotocol/hotfix/card-launch-cta-d…
Hugo0 Jun 29, 2026
91be7b6
Merge pull request #2293 from peanutprotocol/fix/kyc-reject-label-pre…
Hugo0 Jun 29, 2026
248c33e
fix(card): wait for the card-face canvas before capturing the share a…
Hugo0 Jun 29, 2026
aeb2c46
Merge pull request #2302 from peanutprotocol/hotfix/share-asset-blank…
Hugo0 Jun 29, 2026
574ce1b
feat(card): end the waitlist flow on the rejection/appeal screen (dro…
Hugo0 Jun 29, 2026
4e8af84
fix(card): match joined-state indicator to button height (no layout s…
Hugo0 Jun 29, 2026
622968b
Merge pull request #2303 from peanutprotocol/hotfix/waitlist-end-on-r…
Hugo0 Jun 29, 2026
eb6646b
fix(card): address CodeRabbit on the terminal waitlist screen
Hugo0 Jun 29, 2026
55f7677
Merge pull request #2304 from peanutprotocol/hotfix/rejection-screen-…
Hugo0 Jun 29, 2026
4b0c8e3
test(card): regression guard for the blank share-asset capture race
Hugo0 Jun 29, 2026
1bd95df
Merge pull request #2305 from peanutprotocol/test/share-asset-capture…
Hugo0 Jun 29, 2026
7cd807a
fix(card): raise apply-for-card fetch timeout to 60s
jjramirezn Jun 29, 2026
2ec8b5d
Merge pull request #2306 from peanutprotocol/hotfix/rain-apply-timeout
Hugo0 Jun 29, 2026
3dbc068
feat(card): real FOMO door tally on rejection screen
Hugo0 Jun 29, 2026
b6e187d
fix(card): bulletproof share-asset capture — faithful shadows + deter…
Hugo0 Jun 29, 2026
2b621d1
fix(card): make waitlistTotal/admittedTotal optional on CardInfoResponse
Hugo0 Jun 29, 2026
4092778
feat(kyc): make EEA-uplift verification a mandatory gate + funnel ana…
kushagrasarathe Jun 29, 2026
06928d6
test(card): decode captured PNG in-browser, drop the sharp dep
Hugo0 Jun 29, 2026
09345b9
tune(card): door-tally FOMO multiplier x3 -> x5
Hugo0 Jun 29, 2026
2793c87
Merge pull request #2307 from peanutprotocol/feat/door-tally-real-counts
Hugo0 Jun 29, 2026
33f7d7e
fix(analytics): clear eea-uplift start on KYC abandon
kushagrasarathe Jun 29, 2026
9de604b
Merge pull request #2308 from peanutprotocol/fix/share-asset-bulletproof
Hugo0 Jun 29, 2026
e6a6aa9
fix(kyc): address review — fire uplift-completed at approval; sync mo…
kushagrasarathe Jun 29, 2026
0185213
feat(card): show the specific reason on the rejected-card screen + re…
jjramirezn Jun 29, 2026
3780741
test(card): silence next/image stub lint warning in rejected-screen test
jjramirezn Jun 29, 2026
216912a
fix(card): render rejected screen immediately + assert reason placeme…
jjramirezn Jun 29, 2026
e187dcc
fix(card): force space in physical-waitlist position copy via templat…
Hugo0 Jun 29, 2026
70272d1
Merge pull request #2310 from peanutprotocol/feat/card-rejection-reason
Hugo0 Jun 29, 2026
ee38e7d
Merge pull request #2311 from peanutprotocol/hotfix/card-waitlist-pos…
Hugo0 Jun 29, 2026
fdda3db
Merge pull request #2309 from peanutprotocol/feat/eea-uplift-hard-gate
Hugo0 Jun 29, 2026
a2e8f33
fix(card): render share-asset hand as <img>, not a runtime <canvas>
Hugo0 Jun 29, 2026
4399a9b
refactor(assets): name the mascot a mascot, not a logo
0xkkonrad Jul 1, 2026
8e5802d
chore(assets): add public/peanutman.svg alias for the mascot
0xkkonrad Jul 1, 2026
0a82a0f
chore(assets): drop redundant public/peanutman.svg alias
kushagrasarathe Jul 2, 2026
ffddcc1
Merge dev card share-asset fix into rename branch
kushagrasarathe Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions e2e/flows/share-asset-capture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Share-asset capture regression — the card face must NOT be blank.
*
* The launch-day bug: <PixelatedCardFace /> painted its pixelated hand into a
* runtime <canvas>, which html-to-image silently dropped when it couldn't
* serialise it (toDataURL() returns empty on iOS Safari for an SVG-sourced
* canvas) → blank pink card, and the capture SUCCEEDED so nothing reached Sentry.
*
* The fix renders the hand as a plain pre-pixelated <img> (the same reliable
* path the badge stickers take), and still gates Save/Share on PixelatedCardFace's
* `onReady` (the hand <img> loading) so a capture can't fire before it's ready.
*
* This spec proves the guard end-to-end against the real html-to-image path:
* 1. /dev/share-builder renders <ShareAssetD3 /> and wires "Save image" to
* captureShareAsset + downloadBlob (the same capture code the in-app
* Share/Save buttons use).
* 2. Save stays disabled until the card face signals ready — we wait for it
* to enable (proves the gate releases only after the hand <img> loads).
* 3. We click Save, intercept the downloaded PNG, decode it IN-BROWSER (an
* <img> + <canvas>.getImageData — no extra Node dep), and sample a region
* in the CENTRE of the card — the hand's territory, away from the top-left
* logo and bottom-left card number. If that region is ENTIRELY the card
* pink (#FF90E8) the hand never rendered → the blank-card bug. We assert
* it contains non-background pixels (the hand was captured).
*
* No harness auth needed — /dev/share-builder is a pure client-render dev page.
*/

import { test, expect } from '@playwright/test'
import { readFileSync } from 'fs'

// Asset + card geometry — mirror of shareAssetLayout.ts (CANVAS_W/H, CARD_*).
// Hardcoded (not imported) to match the e2e convention of not pulling app code
// through the '@/' alias into the Playwright tsconfig.
const CANVAS_W = 1200
const CANVAS_H = 900

// Card-pink (PixelatedCardFace background) and asset-blue (ShareAssetD3 bg) as
// [r,g,b]. "Background" = either of these; the hand is neither.
const CARD_PINK: [number, number, number] = [0xff, 0x90, 0xe8]
const ASSET_BLUE: [number, number, number] = [0x90, 0xa8, 0xed]
const COLOR_TOL = 12

// Central card region in 1200×900 canvas coords. The card is centred
// (CARD_LEFT 220, CARD_TOP 210, CARD_W 760, CARD_H ≈ 479 → centre ≈ 600,449).
// This window sits well inside the card and is HAND-ONLY: it excludes the
// peanut logo (top-left ~248–300 px) and the "????" number (bottom-left,
// canvas-y ≳ 615). So a blank card → this window is pure pink; the hand
// present → many non-background pixels. Small enough that the card's -8°
// final rotation can't rotate any background (blue) into it.
const REGION = { x0: 430, y0: 330, x1: 770, y1: 570 }
const SAMPLE_STEP = 6 // sample every 6th canvas-px → a dense grid

test.describe('Share-asset capture (card face is not blank)', () => {
test('captured PNG card region contains the hand, not just background', async ({ page }, testInfo) => {
await page.goto('/dev/share-builder', { waitUntil: 'domcontentloaded' })

const saveBtn = page.getByTestId('save-image')

// The readiness gate: Save is disabled until the card face's hand <img>
// loads (onReady). If it never enables, the gate is broken OR the card
// never painted — both are failures this spec must catch.
await expect(saveBtn, 'Save must enable once the card face signals ready').toBeEnabled({ timeout: 60_000 })

// Let the card-slide / sticker-drop animations settle to their final
// frame so the card sits at its designed (centred, -8°) position before
// we capture. The capture itself overrides only the root scale; the
// card's own transform is whatever frame it's on.
await page.waitForTimeout(2_500)

// Click Save → captureShareAsset(node) → downloadBlob() fires a real
// download of the native-resolution PNG.
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
await saveBtn.click()
const download = await downloadPromise
const pngPath = testInfo.outputPath('share-asset-capture.png')
await download.saveAs(pngPath)

// Decode + sample IN-BROWSER: load the PNG into an <img>, paint it to a
// <canvas>, read it back with getImageData, and count non-background
// pixels in the central card window. Done in the page (not Node) so we
// need no PNG-decoder dependency.
const base64 = readFileSync(pngPath).toString('base64')
const result = await page.evaluate(
async ({ b64, canvasW, canvasH, region, pink, blue, tol, step }) => {
const img = new Image()
img.src = `data:image/png;base64,${b64}`
await img.decode()

const cv = document.createElement('canvas')
cv.width = img.naturalWidth
cv.height = img.naturalHeight
const ctx = cv.getContext('2d')
if (!ctx) throw new Error('no 2d context')
ctx.drawImage(img, 0, 0)

const width = cv.width
const height = cv.height
const buf = ctx.getImageData(0, 0, width, height).data

// Output is captured at pixelRatio 2 (≈2400×1800) — scale canvas
// coords into output pixels.
const sx = width / canvasW
const sy = height / canvasH
const near = (r: number, g: number, b: number, c: number[]): boolean =>
Math.abs(r - c[0]) <= tol && Math.abs(g - c[1]) <= tol && Math.abs(b - c[2]) <= tol

let sampled = 0
let nonBackground = 0
for (let cy = region.y0; cy <= region.y1; cy += step) {
for (let cx = region.x0; cx <= region.x1; cx += step) {
const px = Math.min(width - 1, Math.round(cx * sx))
const py = Math.min(height - 1, Math.round(cy * sy))
const idx = (py * width + px) * 4
const r = buf[idx]
const g = buf[idx + 1]
const b = buf[idx + 2]
sampled++
if (!(near(r, g, b, pink) || near(r, g, b, blue))) nonBackground++
}
}
return { width, height, sampled, nonBackground }
},
{
b64: base64,
canvasW: CANVAS_W,
canvasH: CANVAS_H,
region: REGION,
pink: CARD_PINK as number[],
blue: ASSET_BLUE as number[],
tol: COLOR_TOL,
step: SAMPLE_STEP,
}
)

const fraction = result.nonBackground / result.sampled
// Blank card → fraction ≈ 0 (pure pink). Hand present → a large chunk of
// the centre is non-pink. A 2% floor cleanly separates the two while
// staying far below the hand's real coverage (avoids flake).
expect(
fraction,
`card centre is ${(fraction * 100).toFixed(1)}% non-background ` +
`(${result.nonBackground}/${result.sampled} px; output ${result.width}×${result.height}). ` +
`≈0% means the pixelated hand never rendered — the blank-card capture bug.`
).toBeGreaterThan(0.02)
})
})
36 changes: 28 additions & 8 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'
Expand Down Expand Up @@ -67,7 +68,7 @@
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState<boolean>(false)
const [showKycModal, setShowKycModal] = useState<boolean>(false)
const [isRiskAccepted, setIsRiskAccepted] = useState<boolean>(false)

Check failure on line 71 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u

Check failure on line 71 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()

const { balance } = useWallet()
Expand All @@ -78,10 +79,25 @@
// regionIntent is NOT passed here to avoid creating a backend record on mount.
// intent is passed at call time, derived from the destination country
// (e.g. /add-money/usa → NA → bridge-requirements).
// 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('deposit')

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(),
onKycSuccess: () => {
setUrlState({ step: 'inputAmount' })
},
// Abandoned attempt: clear the pending start so a later unrelated KYC
// success on this page can't mis-fire eea_uplift_completed.
onManualClose: resetUpliftFunnel,
})
Comment on lines +84 to 101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect the surrounding code.
git ls-files 'src/app/(mobile-ui)/add-money/[country]/bank/page.tsx' 'src/**/useMultiPhaseKycFlow.ts' 'src/**/useEeaUpliftFunnel*' 'src/**/Kyc*' | sed 's#^`#FILE` #'

echo
echo "=== page.tsx outline ==="
ast-grep outline 'src/app/(mobile-ui)/add-money/[country]/bank/page.tsx' --view expanded || true

echo
echo "=== useMultiPhaseKycFlow outline ==="
fd 'useMultiPhaseKycFlow.*' src -t f -a | head -n 20 | while read -r f; do
  echo "--- $f ---"
  ast-grep outline "$f" --view expanded || true
done

echo
echo "=== Relevant references in page.tsx ==="
rg -n "useMultiPhaseKycFlow|onKycApproved|onKycSuccess|onManualClose|KYC_REJECTED|ACTION_REQUIRED|fixable-rejection|trackUpliftStarted|trackUpliftCompleted|resetUpliftFunnel|eea_uplift_completed" 'src/app/(mobile-ui)/add-money/[country]/bank/page.tsx'

echo
echo "=== Relevant references in useMultiPhaseKycFlow files ==="
for f in $(fd 'useMultiPhaseKycFlow.*' src -t f -a); do
  echo "--- $f ---"
  rg -n "onManualClose|ACTION_REQUIRED|KYC_REJECTED|onKycApproved|onKycSuccess|reset|fetchUser|liveKycStatus" "$f"
done

echo
echo "=== Nearby code around the relevant area in page.tsx ==="
sed -n '1,220p' 'src/app/(mobile-ui)/add-money/[country]/bank/page.tsx'

echo
echo "=== Relevant useMultiPhaseKycFlow implementation slice(s) ==="
for f in $(fd 'useMultiPhaseKycFlow.*' src -t f -a); do
  echo "--- $f (first 260 lines) ---"
  sed -n '1,260p' "$f"
done

Repository: peanutprotocol/peanut-ui

Length of output: 27691


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== useMultiPhaseKycFlow.ts around rejection / close / manual-close paths ==="
sed -n '220,460p' 'src/hooks/useMultiPhaseKycFlow.ts'

echo
echo "=== useSumsubKycFlow.ts outline and relevant callbacks ==="
fd 'useSumsubKycFlow.*' src -t f -a | while read -r f; do
  echo "--- $f ---"
  ast-grep outline "$f" --view expanded || true
  echo
  rg -n "onManualClose|handleClose|ACTION_REQUIRED|REJECTED|handleRestartIdentity|handleSelfHealResubmit|handleStartAction|handleSdkComplete|handleInitiateKyc|closeVerificationProgressModal" "$f"
done

echo
echo "=== page.tsx around the fixable-rejection branch ==="
sed -n '430,500p' 'src/app/(mobile-ui)/add-money/[country]/bank/page.tsx'

echo
echo "=== useEeaUpliftFunnel.ts ==="
sed -n '1,220p' 'src/hooks/useEeaUpliftFunnel.ts'

Repository: peanutprotocol/peanut-ui

Length of output: 17036


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '360,470p' 'src/hooks/useSumsubKycFlow.ts'

Repository: peanutprotocol/peanut-ui

Length of output: 4754


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== useSumsubKycFlow.ts status-transition block ==="
sed -n '60,120p' 'src/hooks/useSumsubKycFlow.ts'

echo
echo "=== useSumsubKycFlow.ts close/manual-close handlers ==="
sed -n '300,345p' 'src/hooks/useSumsubKycFlow.ts'

Repository: peanutprotocol/peanut-ui

Length of output: 4740


Reset the uplift latch on terminal KYC failures

REJECTED / ACTION_REQUIRED closes the KYC modal without clearing the EEA uplift start state, so a later approved resubmission on the same page can still emit eea_uplift_completed for the earlier attempt. Reset the funnel on those terminal outcomes, or scope completion to the specific launch that started it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(mobile-ui)/add-money/[country]/bank/page.tsx around lines 84 - 101,
The EEA uplift funnel in the bank page KYC flow is not being cleared when Sumsub
ends in terminal failure states, so a later approval can complete an earlier
pending start. Update the `useMultiPhaseKycFlow` handling around
`onKycApproved`, `onKycSuccess`, and `onManualClose` so that terminal outcomes
like `REJECTED` and `ACTION_REQUIRED` also call `resetUpliftFunnel`, or
otherwise tie `trackUpliftCompleted` to the specific launch that started the
funnel.


// read country from path params (web) or query params (native/capacitor)
Expand Down Expand Up @@ -115,17 +131,21 @@
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
// A ready bank rail can still carry a future-dated requirement (the gate's
// `advisory`). Offer it as a skippable pre-empt at the proceed step.
// A ready bank rail can still carry a pending Bridge requirement (the gate's
// `advisory`). Enforce it as a mandatory, non-skippable pre-empt at the
// proceed step — the deposit cannot continue until it's completed.
const advisory = gate.kind === 'ready' ? gate.advisory : undefined
const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({
advisory,
isLoading: sumsubFlow.isLoading,
// 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 { guardWithTos, showBridgeTos, hideTos } = useTosGuard()
const { setIsSupportModalOpen } = useModalsContext()
Expand All @@ -137,7 +157,7 @@

useEffect(() => {
fetchUser()
}, [])

Check warning on line 160 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

Check warning on line 160 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
Expand Down Expand Up @@ -247,10 +267,10 @@
return
}

// ready — offer the skippable advisory pre-empt once; on proceed (now, or
// after "Not now") record the amount-entered event and open the
// confirmation modal. Firing inside the proceed avoids double-counting if
// the user dismisses the advisory and re-clicks.
// ready — enforce the mandatory verification pre-empt. The proceed body
// (record the amount-entered event, open the confirmation modal) only
// runs once there's no pending requirement; while one exists the modal
// blocks and this never fires, so the event can't double-count.
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
Expand Down
33 changes: 22 additions & 11 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen'
import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen'
import CardTermsScreen from '@/components/Card/CardTermsScreen'
import CardRejectionScreen from '@/components/Card/CardRejectionScreen'
import CardWaitlistJoinedScreen from '@/components/Card/CardWaitlistJoinedScreen'
import BadgeSkipCelebration from '@/components/Card/BadgeSkipCelebration'
import CardEligibilityCheckScreen from '@/components/Card/CardEligibilityCheckScreen'
import YourCardScreen from '@/components/Card/YourCardScreen'
Expand Down Expand Up @@ -470,18 +469,17 @@ const CardPage: FC = () => {
/>
)
case 'waitlist': {
// Joined vs not-joined are two distinct screens — keeps each
// tight to its own purpose. Not-joined is the Berghain-style
// "not tonight" rejection: a shareable door let-down (tags
// @joinpeanut) that doubles as the waitlist-join CTA. Once
// they join, the state machine flips to the friendly
// <CardWaitlistJoinedScreen /> cooldown.
if (cardInfo!.waitlistJoinedAt) {
return <CardWaitlistJoinedScreen onPrev={onBack} />
}
// The Berghain-style "not tonight" rejection is the TERMINAL
// waitlist screen — a shareable door let-down (tags @joinpeanut)
// that doubles as the waitlist-join CTA. Once they join we keep
// them here (`alreadyJoined`) so the asset + "Tweet to appeal"
// stay grabbable — no separate cooldown screen to dead-end on.
return (
<CardRejectionScreen
username={user?.user?.username ?? undefined}
waitlistTotal={cardInfo!.waitlistTotal}
admittedTotal={cardInfo!.admittedTotal}
alreadyJoined={!!cardInfo!.waitlistJoinedAt}
onPrev={onBack}
onJoined={refetchCardInfo}
/>
Expand Down Expand Up @@ -553,19 +551,32 @@ const CardPage: FC = () => {
onPrev={onBack}
/>
)
case 'rejected':
case 'rejected': {
// No retry CTA: Rain denials are terminal on our side. The
// only path forward is support reviewing the case manually
// (PEP / sanctions / fraud-pattern flags need a human in the
// loop on Rain's end). Open Crisp directly — sending the user
// to /support's FAQ first adds a step for no upside.
//
// Surface the specific, vetted rejection reason from the
// capabilities read-model (`rail.reason.userMessage` — e.g.
// "Peanut cards aren't available in your state yet."). Render
// immediately rather than spinner-gating: unlike requires-info
// (meaningless without its reason), the rejected screen is useful
// on its own (reassurance + support CTA), so show it now and let
// the reason fill in once capabilities resolve.
const cardRailReason = capabilitiesLoading
? undefined
: railsForProvider('rain')[0]?.reason?.userMessage
return (
<ApplicationStatusScreen
variant="rejected"
reasonMessage={cardRailReason}
onContactSupport={() => setIsSupportModalOpen(true)}
onPrev={onBack}
/>
)
}
case 'active': {
const card = findActiveCard(overview)!
return <YourCardScreen overview={overview!} card={card} onPrev={onBack} />
Expand Down
Loading
Loading