diff --git a/src/app/(mobile-ui)/dev/journey/EmailCard.tsx b/src/app/(mobile-ui)/dev/journey/EmailCard.tsx new file mode 100644 index 0000000000..81ae14b42f --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/EmailCard.tsx @@ -0,0 +1,34 @@ +'use client' + +import { JOURNEY_API_BASE } from './journeyData' +import type { SpecEmailStep } from './journeyTypes' + +/** + * Compact card for one lifecycle email step. Links to the API's live rendered + * preview (opens in a new tab against the sandbox API). + */ +export default function EmailCard({ step }: { step: SpecEmailStep }) { + return ( + +
+
{step.subject}
+ {typeof step.afterDaysStuck === 'number' && ( + + day {step.afterDaysStuck} stuck + + )} +
+

{step.preview}

+

+ {step.ctaText} + → {step.ctaPath} +

+

{step.type} · preview ↗

+
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/FindingsStrip.tsx b/src/app/(mobile-ui)/dev/journey/FindingsStrip.tsx new file mode 100644 index 0000000000..8d88e87158 --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/FindingsStrip.tsx @@ -0,0 +1,27 @@ +'use client' + +import { FINDINGS } from './journeyData' + +/** + * The inventory's product-issue findings as collapsible warning cards — + * real gaps in the activation journey, kept visible next to the board. + */ +export default function FindingsStrip() { + return ( +
+ {FINDINGS.map((finding) => ( +
+ + ⚠️ {finding.id}. {finding.title} + +
+

{finding.detail}

+

+ {finding.sourceFiles.join(' · ')} +

+
+
+ ))} +
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/JourneyBoard.tsx b/src/app/(mobile-ui)/dev/journey/JourneyBoard.tsx new file mode 100644 index 0000000000..f246ad07d1 --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/JourneyBoard.tsx @@ -0,0 +1,115 @@ +'use client' + +import { FUNNEL_STATES, IN_APP_SURFACES } from './journeyData' +import type { JourneySpec } from './journeyTypes' +import SurfaceCard from './SurfaceCard' +import EmailCard from './EmailCard' + +function GroupHeader({ emoji, label }: { emoji: string; label: string }) { + return ( +
+ {emoji} {label} +
+ ) +} + +/** + * The column-per-funnel-state board: in-app surfaces (static catalog from + * journeyData) + emails/push (live from the API spec) stacked per state. + */ +export default function JourneyBoard({ spec, specError }: { spec: JourneySpec | null; specError: string | null }) { + const mappedStageNames = new Set(FUNNEL_STATES.flatMap((s) => s.specStages)) + const unmappedStages = spec ? spec.stages.filter((st) => !mappedStageNames.has(st.stage)) : [] + + return ( +
+
+ {FUNNEL_STATES.map((state, i) => { + const surfaces = IN_APP_SURFACES.filter((s) => s.states.includes(state.id)) + const stages = spec ? spec.stages.filter((st) => state.specStages.includes(st.stage)) : [] + return ( +
+
+
+ {i + 1}. {state.label} +
+

{state.description}

+
+
+ + {surfaces.map((surface) => ( + + ))} + {surfaces.length === 0 && ( +

No activation-specific surface.

+ )} + + + {specError &&

{specError}

} + {spec && state.includesWelcome && ( + <> +

+ On signup (immediate): +

+ + + )} + {stages.map((stage) => ( +
+

+ stage {stage.stage} —{' '} + {stage.predicate} +

+ {stage.steps.map((step) => ( + + ))} +
+ ))} + {spec && stages.length === 0 && !state.includesWelcome && ( +

+ {state.noEmailReason ?? 'No lifecycle email in this state.'} +

+ )} + + + {specError &&

{specError}

} + {spec && + (state.includesPushReminder ? ( + spec.pushReminders.map((push) => ( +
+
+
{push.title}
+ + after {push.afterMinutes}min + +
+

{push.note}

+

+ {push.type} · {push.channels.join(' + ')} +

+
+ )) + ) : ( +

+ ))} +
+
+ ) + })} + {unmappedStages.length > 0 && ( +
+
Unmapped spec stages
+

+ The API spec reports stages this board doesn't map to a column yet — update + FUNNEL_STATES.specStages in journeyData.ts: +

+

{unmappedStages.map((s) => s.stage).join(', ')}

+
+ )} +
+
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/RulesLegend.tsx b/src/app/(mobile-ui)/dev/journey/RulesLegend.tsx new file mode 100644 index 0000000000..87034fefa9 --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/RulesLegend.tsx @@ -0,0 +1,34 @@ +'use client' + +import type { SpecRules } from './journeyTypes' + +function Chip({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** Compact legend strip for the email machine's global rules (from spec.rules). */ +export default function RulesLegend({ rules, specError }: { rules: SpecRules | null; specError: string | null }) { + if (!rules) { + return ( +
+ {specError ?? 'Loading email-machine rules…'} +
+ ) + } + return ( +
+ + + + + + + +
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/SurfaceCard.tsx b/src/app/(mobile-ui)/dev/journey/SurfaceCard.tsx new file mode 100644 index 0000000000..58c5773f71 --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/SurfaceCard.tsx @@ -0,0 +1,41 @@ +'use client' + +import type { InAppSurface } from './journeyTypes' + +const KIND_LABEL: Record = { + step: 'home step', + carousel: 'carousel', + modal: 'modal', + 'card-screen': '/card', +} + +/** Compact card for one in-app surface inside a journey-board column. */ +export default function SurfaceCard({ surface }: { surface: InAppSurface }) { + return ( +
+
+
{surface.name}
+
+ {surface.isNewInThisPr && ( + + NEW in this PR + + )} + + {KIND_LABEL[surface.kind]} + +
+
+

{surface.copy}

+ {surface.cta && ( +

+ {surface.cta.label} + → {surface.cta.dest} +

+ )} +

{surface.condition}

+ {surface.note &&

{surface.note}

} +

{surface.sourceFile}

+
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/UserInspector.tsx b/src/app/(mobile-ui)/dev/journey/UserInspector.tsx new file mode 100644 index 0000000000..63b13415cc --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/UserInspector.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/0_Bruddle/Button' +import { JOURNEY_API_BASE } from './journeyData' +import type { JourneyInspectResponse } from './journeyTypes' + +/** + * Live user lookup: which lifecycle nudge is due for this user right now, and + * what have they already received (GET /__dev/journey-inspect?userId=…). + */ +export default function UserInspector() { + const [userId, setUserId] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const inspect = async () => { + const id = userId.trim() + if (!id) return + setLoading(true) + setError(null) + setResult(null) + try { + const res = await fetch(`${JOURNEY_API_BASE}/__dev/journey-inspect?userId=${encodeURIComponent(id)}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + setResult((await res.json()) as JourneyInspectResponse) + } catch { + setError('Inspect unavailable — is the sandbox API running with PR #1234 code (DEV_EMAIL_PREVIEW=true)?') + } finally { + setLoading(false) + } + } + + const dueLabel = (() => { + if (!result) return null + if (!result.due) return 'none due (graduated, not in audience, or up to date)' + if (result.due.skip === 'holdout') return `${result.due.type} — HELD (holdout control group)` + if (result.due.skip === 'governor') return `${result.due.type} — HELD (governor: too soon after last email)` + return `${result.due.type} — due now${result.due.hasPendingRewards ? ' (rewards variant)' : ''}` + })() + + return ( +
+
+ setUserId(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void inspect() + }} + placeholder="userId (uuid)" + className="h-10 flex-1 rounded-sm border border-n-1 px-3 font-mono text-sm outline-none" + /> + +
+ + {error &&
{error}
} + + {result && !result.user && ( +
User not found.
+ )} + + {result?.user && ( +
+
+ {result.user.username ?? '(no username)'}{' '} + {result.user.email ?? '(no email)'} +
+

+ signed up {new Date(result.user.createdAt).toLocaleDateString()} · card access{' '} + {result.user.cardAccessGrantedAt + ? new Date(result.user.cardAccessGrantedAt).toLocaleDateString() + : 'not granted'} +

+

+ Current nudge:{' '} + {dueLabel} +

+ +
+
+ Lifecycle email history +
+ {result.history.length === 0 ? ( +

Nothing sent or attempted yet.

+ ) : ( +
+ + + + + + + + + + + + {result.history.map((row, i) => ( + + + + + + + + ))} + +
eventchannelstatusskipsent
{row.eventType}{row.channel}{row.status}{row.skipReason ?? '—'} + {row.sentAt ? new Date(row.sentAt).toLocaleString() : '—'} +
+
+ )} +
+
+ )} +
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/__tests__/journeyData.test.ts b/src/app/(mobile-ui)/dev/journey/__tests__/journeyData.test.ts new file mode 100644 index 0000000000..8dbbf472c3 --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/__tests__/journeyData.test.ts @@ -0,0 +1,62 @@ +import { FINDINGS, FUNNEL_STATES, IN_APP_SURFACES } from '../journeyData' +import type { FunnelStateId } from '../journeyTypes' + +describe('journeyData', () => { + const stateIds = new Set(FUNNEL_STATES.map((s) => s.id)) + + it('has the 7 funnel states in order', () => { + expect(FUNNEL_STATES.map((s) => s.id)).toEqual([ + 'no-access', + 'access-pre-kyc', + 'kycd-no-card', + 'application-in-flight', + 'card-active-unfunded', + 'funded-no-spend', + 'spent', + ]) + }) + + it('every surface maps to at least one valid state and carries a source file', () => { + for (const surface of IN_APP_SURFACES) { + expect(surface.states.length).toBeGreaterThan(0) + for (const state of surface.states) expect(stateIds.has(state)).toBe(true) + expect(surface.sourceFile).toMatch(/^src\//) + expect(surface.copy.length).toBeGreaterThan(0) + expect(surface.condition.length).toBeGreaterThan(0) + } + }) + + it('surface ids are unique', () => { + const ids = IN_APP_SURFACES.map((s) => s.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('every funnel state has at least one in-app surface', () => { + for (const state of FUNNEL_STATES) { + const surfaces = IN_APP_SURFACES.filter((s) => s.states.includes(state.id)) + expect(surfaces.length).toBeGreaterThan(0) + } + }) + + it('the #2475 chooser surfaces are marked NEW in this PR', () => { + const newOnes = IN_APP_SURFACES.filter((s) => s.isNewInThisPr).map((s) => s.id) + expect(newOnes).toEqual( + expect.arrayContaining(['step-outbound-spend', 'modal-spend-chooser', 'step-email-blocked']) + ) + }) + + it('maps each lifecycle spec stage to exactly one column', () => { + const mapped = FUNNEL_STATES.flatMap((s) => s.specStages) + // the machine's five stages, each mapped once + expect([...mapped].sort()).toEqual(['create_card', 'finish_setup', 'first_spend', 'fund', 'verify']) + }) + + it('carries all 7 inventory findings, each source-file-annotated', () => { + expect(FINDINGS).toHaveLength(7) + expect(FINDINGS.map((f) => f.id)).toEqual([1, 2, 3, 4, 5, 6, 7]) + for (const finding of FINDINGS) { + expect(finding.sourceFiles.length).toBeGreaterThan(0) + for (const file of finding.sourceFiles) expect(file).toMatch(/^src\//) + } + }) +}) diff --git a/src/app/(mobile-ui)/dev/journey/journeyData.ts b/src/app/(mobile-ui)/dev/journey/journeyData.ts new file mode 100644 index 0000000000..3989de2c0b --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/journeyData.ts @@ -0,0 +1,415 @@ +/** + * In-app surface catalog for the /dev/journey Activation Journey Explorer. + * + * Transcribed from the activation journey UI inventory (local/scratch/ + * journey-ui-inventory.md, 2026-07-23) INCLUDING source-file annotations so + * drift is traceable: when a surface's copy or gating changes in its source + * file, update the matching entry here. + * + * The email/push half of the board is NOT here — it is fetched live from + * peanut-api-ts GET /__dev/journey-spec (api PR #1234) so the Explorer always + * renders the real machine, never a hand-copied description. + */ + +import type { FunnelState, InAppSurface, JourneyFinding } from './journeyTypes' + +// Dev-only tool: the sandbox API origin is hardcoded on purpose (the __dev +// endpoints set CORS * and only exist when DEV_EMAIL_PREVIEW=true locally). +export const JOURNEY_API_BASE = 'http://localhost:5050' + +export const FUNNEL_STATES: FunnelState[] = [ + { + id: 'no-access', + label: 'No access', + description: 'No card access granted; pre-KYC. Waitlist-era default.', + specStages: [], + noEmailReason: 'Email machine requires cardAccessGrantedAt — silent here.', + includesPushReminder: true, + }, + { + id: 'access-pre-kyc', + label: 'Access, pre-KYC', + description: 'Card access granted, Sumsub not approved yet.', + specStages: ['verify'], + includesWelcome: true, + includesPushReminder: true, + }, + { + id: 'kycd-no-card', + label: "KYC'd, no card", + description: 'Sumsub approved, no (non-canceled) Rain card yet.', + specStages: ['create_card'], + }, + { + id: 'application-in-flight', + label: 'Application in flight', + description: 'Card exists but none ACTIVE — pending / review / RFI.', + specStages: ['finish_setup'], + }, + { + id: 'card-active-unfunded', + label: 'Card active, unfunded', + description: 'ACTIVE card, no completed ONRAMP/CRYPTO_DEPOSIT.', + specStages: ['fund'], + }, + { + id: 'funded-no-spend', + label: 'Funded, no spend', + description: 'Money in, no genuine card spend (> $0, not FAILED).', + specStages: ['first_spend'], + }, + { + id: 'spent', + label: 'Spent — graduated', + description: 'First real spend done. Out of the activation machine.', + specStages: [], + noEmailReason: 'Graduated — the lifecycle machine never emails again.', + }, +] + +export const IN_APP_SURFACES: InAppSurface[] = [ + // ---------- 🏠 home activation steps (ActivationCTAs) ---------- + { + id: 'step-verify', + kind: 'step', + name: 'Activation step: verify', + copy: '"Unlock payments" — "Bank deposits, QR codes, and local payment methods"', + cta: { label: 'Unlock now', dest: '/profile/identity-verification' }, + condition: '!isActivated && step=verify (useActivationStatus); hidden while identity is mid-flight', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['no-access'], + }, + { + id: 'step-card-banner', + kind: 'step', + name: 'Activation step: card → launch banner', + copy: '"shhhh" — "Tap to find out if you\'re in" + "Maybe later" dismiss (localStorage)', + cta: { label: 'Try the door →', dest: '/shhhhh' }, + condition: 'hasCardAccess && !hasCard && !dismissed && !disableCardLaunchCTA — overrides deposit/outbound', + sourceFile: 'src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx (via ActivationCTAs.tsx)', + states: ['access-pre-kyc', 'kycd-no-card'], + }, + { + id: 'step-deposit', + kind: 'step', + name: 'Activation step: deposit', + copy: '"Deposit" — "Add money to make your first payment"', + cta: { label: 'Add money', dest: '/add-money' }, + condition: 'step=deposit — KYC done, no balance yet, card step not applicable/dismissed', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card'], + }, + { + id: 'step-outbound-qr', + kind: 'step', + name: 'Activation step: outbound (no card access)', + copy: '"Make your first payment" — "Start paying to Pix and MercadoPago QR codes"', + cta: { label: 'Start Spending', dest: 'QR scanner overlay' }, + condition: "step=outbound && !hasCardAccess — QR-only framing, never teases a card they can't get", + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card'], + }, + { + id: 'step-outbound-spend', + kind: 'step', + name: 'Activation step: outbound (card access)', + copy: '"Spend with Peanut" — "Pay with your card or scan Pix and MercadoPago QR codes"', + cta: { label: 'Start Spending', dest: 'spend chooser modal' }, + condition: 'step=outbound && hasCardAccess — card spend counts as activation too (TASK-20471)', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card', 'card-active-unfunded', 'funded-no-spend'], + isNewInThisPr: true, + note: 'Shows wherever the outbound step renders for a not-yet-activated card-access user.', + }, + { + id: 'modal-spend-chooser', + kind: 'modal', + name: 'Spend chooser (ActionModal)', + copy: '"How do you want to spend?" — "Both count as your first payment." Card → /card, QR → scanner', + cta: { label: 'Pay with your card / Scan a QR code', dest: '/card | QR scanner' }, + condition: 'Opened by the outbound step CTA when hasCardAccess; auto-closes if access is revoked mid-open', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card', 'card-active-unfunded', 'funded-no-spend'], + isNewInThisPr: true, + }, + { + id: 'step-email-blocked', + kind: 'step', + name: 'Activation step: email-blocked override', + copy: '"Add your email" — "We need an email address to finish setting up your account."', + cta: { label: 'Add email', dest: 'ProvideEmailStep sheet (inline)' }, + condition: 'Rail blocked with selfHealKind=provide-email — outranks fixable RFI', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card'], + isNewInThisPr: true, + }, + { + id: 'step-rejection-fixable', + kind: 'step', + name: 'Activation step: provider rejection (fixable)', + copy: '"Complete your setup" — rail-specific message, else "We need an updated document before you can add money."', + cta: { label: 'Upload document', dest: 'inline Sumsub self-heal resubmit' }, + condition: 'Sumsub approved but a bank/qr rail is fixable-rejected; suppressed when canAlreadyTransact', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card'], + }, + { + id: 'step-rejection-blocked', + kind: 'step', + name: 'Activation step: provider rejection (blocked)', + copy: '"Verification issue" — "Contact support for help with your verification."', + cta: { label: 'Contact support', dest: 'Crisp (pre-filled failure context)' }, + condition: 'Terminal-blocked rail, no fixable path; suppressed when canAlreadyTransact', + sourceFile: 'src/components/Home/ActivationCTAs.tsx', + states: ['kycd-no-card'], + }, + + // ---------- 🏠 home carousel (activated users, 7d dismiss) ---------- + { + id: 'carousel-kyc-prompt', + kind: 'carousel', + name: 'Carousel: KYC prompt', + copy: '"Unlock QR code payments"', + cta: { label: 'tap', dest: '/profile/identity-verification' }, + condition: '!hasKycApproval && !inFlight && no card access', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['no-access'], + }, + { + id: 'carousel-card-pioneer', + kind: 'carousel', + name: 'Carousel: Card Pioneer', + copy: '"Get your Peanut Card" — "Closed beta. Badges skip the line. $10 unlocks on your first $100 spend."', + cta: { label: 'tap', dest: '/shhhhh' }, + condition: '!disableCardPioneers && hasCardAccessGranted === false (targets NO-access users — see finding 1)', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['kycd-no-card'], + }, + { + id: 'carousel-qr-payment', + kind: 'carousel', + name: 'Carousel: QR payment', + copy: '"Pay with QR code payments"', + cta: { label: 'tap', dest: 'QR scanner overlay' }, + condition: 'hasKycApproval && !hasMadeQrPayment', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['kycd-no-card'], + }, + { + id: 'carousel-invite-friends', + kind: 'carousel', + name: 'Carousel: invite friends', + copy: '"Invite friends. Earn rewards"', + cta: { label: 'tap', dest: '/rewards' }, + condition: '!latam && activated && !hasSentInvites', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, + { + id: 'carousel-latam-cashback', + kind: 'carousel', + name: 'Carousel: LatAm cashback invite', + copy: '"Earn rewards on QR payments"', + cta: { label: 'tap', dest: '/rewards' }, + condition: 'latam && activated && !hasSentInvites', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, + { + id: 'carousel-bug-bounty', + kind: 'carousel', + name: 'Carousel: bug bounty', + copy: '"Help us improve and get $5!"', + cta: { label: 'tap', dest: 'Crisp' }, + condition: 'activated && !SupportSurvivor badge', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, + { + id: 'carousel-notification-prompt', + kind: 'carousel', + name: 'Carousel: notification prompt', + copy: '"Stay in the loop!"', + cta: { label: 'tap', dest: 'push permission prompt' }, + condition: 'PWA install without push permissions', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, + { + id: 'carousel-ios-pwa-install', + kind: 'carousel', + name: 'Carousel: iOS PWA install', + copy: '"Add Peanut to your home screen"', + condition: 'iOS Safari, not installed as PWA', + sourceFile: 'src/hooks/useHomeCarouselCTAs.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, + + // ---------- 🏠 modals & celebrations ---------- + { + id: 'modal-initiate-kyc', + kind: 'modal', + name: 'InitiateKycModal (variants)', + copy: 'default "Unlock your account"; provider_rejection "We need extra documents"; + blocked / restart_identity / cross_region variants', + condition: 'Opened by KYC entry points across the app', + sourceFile: 'src/components/Kyc/InitiateKycModal.tsx', + states: ['no-access'], + }, + { + id: 'modal-welcome-unlock', + kind: 'modal', + name: 'WelcomeUnlockModal', + copy: '"🎉 You\'re unlocked" + channel bullets', + cta: { label: 'Start sending money', dest: 'closes modal (once)' }, + condition: 'home; isKycApproved && !activationCelebratedAt', + sourceFile: 'src/components/Home/WelcomeUnlockModal/index.tsx', + states: ['kycd-no-card'], + }, + { + id: 'modal-kyc-in-progress-terminal', + kind: 'modal', + name: 'KycVerificationInProgressModal (terminal)', + copy: '"All set" — "Your account is ready to go."', + note: 'Copy neutralized on this branch (was a second "You\'re unlocked" celebration — see finding 4).', + condition: 'KYC flow reaches terminal approved state', + sourceFile: 'src/components/Kyc/KycVerificationInProgressModal.tsx', + states: ['kycd-no-card'], + }, + { + id: 'modal-badge-skip-celebration', + kind: 'modal', + name: 'BadgeSkipCelebration', + copy: 'per-badge headlines (OG / Devconnect / Arbiverse / "You\'re in.") — hold-reveal + confetti + share', + cta: { label: 'Continue to your card', dest: '/card add-card flow' }, + condition: '/card computeCardState=waitlist-skip-celebration (badge holder skips the line)', + sourceFile: 'src/components/Card/BadgeSkipCelebration.tsx', + states: ['access-pre-kyc'], + }, + { + id: 'modal-rain-cooldown', + kind: 'modal', + name: 'RainCooldownIntroModal', + copy: '"Please wait" + link to /en/help/card-collateral', + condition: 'post-spend collateral cooldown', + sourceFile: 'src/components/Global/RainCooldown/IntroModal.tsx', + states: ['spent'], + }, + { + id: 'toast-badge-earn', + kind: 'modal', + name: 'BadgeEarnToast', + copy: '"Badge unlocked: {name}" (WAITLIST_SKIP excluded)', + condition: 'global on /home when a badge is newly earned', + sourceFile: 'src/components/Badges/BadgeEarnToast.tsx', + states: ['spent'], + }, + + // ---------- 💳 /card states (computeCardState precedence) ---------- + { + id: 'card-404', + kind: 'card-screen', + name: '/card: no flow access', + copy: '404 — page pretends not to exist', + condition: 'computeCardState=no-flow-access (no flowEarlyAccess / hasCardAccess)', + sourceFile: 'src/components/Card/cardState.utils.ts + src/app/(mobile-ui)/card/page.tsx', + states: ['no-access'], + }, + { + id: 'card-eligibility-check', + kind: 'card-screen', + name: '/card: eligibility check', + copy: 'hold-to-check button gate before the application flow', + condition: 'computeCardState=eligibility-check', + sourceFile: 'src/components/Card/cardState.utils.ts + src/app/(mobile-ui)/card/page.tsx', + states: ['access-pre-kyc'], + }, + { + id: 'card-add-entry', + kind: 'card-screen', + name: '/card: AddCardEntryScreen', + copy: 'card application entry (KYC → card creation flow)', + condition: 'computeCardState=add-card', + sourceFile: 'src/components/Card/AddCardEntryScreen.tsx', + states: ['access-pre-kyc', 'kycd-no-card'], + }, + { + id: 'card-waitlist-rejection', + kind: 'card-screen', + name: '/card: waitlist ("Berghain" rejection)', + copy: 'rejection-styled waitlist screen; 213-ahead / 7-behind scarcity numbers are hardcoded defaults (see finding 5)', + condition: 'computeCardState=waitlist (no skip badge)', + sourceFile: 'src/components/Card/CardRejectionScreen.tsx', + states: ['kycd-no-card'], + }, + { + id: 'card-application-in-flight', + kind: 'card-screen', + name: '/card: application in flight', + copy: 'pending / manual-review / requires-info / requires-support / rejected(FAILED) screens', + condition: 'computeCardState ∈ {pending, manual-review, requires-info, requires-support, rejected}', + sourceFile: 'src/components/Card/cardState.utils.ts + src/app/(mobile-ui)/card/page.tsx', + states: ['application-in-flight'], + }, + { + id: 'card-your-card', + kind: 'card-screen', + name: '/card: YourCardScreen', + copy: 'live card — balance, card art, freeze, details', + condition: 'computeCardState=active', + sourceFile: 'src/components/Card/YourCardScreen.tsx', + states: ['card-active-unfunded', 'funded-no-spend', 'spent'], + }, +] + +export const FINDINGS: JourneyFinding[] = [ + { + id: 1, + title: 'Three card CTAs, three divergent gating fields', + detail: 'Home step gates on cardInfo.hasCardAccess; the /shhhhh splash on isEligible+isPublicLaunched; /card on flowEarlyAccess+hasCardAccess. All three route to /shhhhh with overlapping/inverted audiences — card-pioneer explicitly targets NO-access users.', + sourceFiles: [ + 'src/hooks/useActivationStatus.ts', + 'src/app/shhhhh/ShhhhhLandingPage.tsx', + 'src/app/(mobile-ui)/card/page.tsx', + 'src/hooks/useHomeCarouselCTAs.tsx', + ], + }, + { + id: 2, + title: 'Maintenance flags can dark the whole home card funnel', + detail: 'underMaintenanceConfig.disableCardLaunchCTA / disableCardPioneers silently remove both home card CTAs — no fallback step renders in their place.', + sourceFiles: ['src/config/underMaintenance.config.ts'], + }, + { + id: 3, + title: 'Stale badge TODOs falsely claimed missing award triggers (CORRECTED)', + detail: 'Stale FE TODO comments in badge.utils.ts falsely claimed SHHHHH / CARD_FIRST_SWIPE / CARD_SPENT_1K lack award triggers — all three are live in the API (acknowledgments/card-spend-badges.ts + routes/card/waitlist.ts; prod: 1,499 / 274 / 12 awards). Severity: low — comment cleanup only (done on this branch).', + sourceFiles: ['src/components/Badges/badge.utils.ts'], + }, + { + id: 4, + title: 'Two "You\'re unlocked" celebrations can double-fire', + detail: 'WelcomeUnlockModal (home) and KycVerificationInProgressModal\'s terminal state both celebrated "You\'re unlocked" around KYC approval — a user could see both (the flow terminal never stamps activationCelebratedAt). FIXED on this branch: in-flow terminal neutralized to "All set"; home\'s WelcomeUnlockModal is the single celebration.', + sourceFiles: [ + 'src/components/Home/WelcomeUnlockModal/index.tsx', + 'src/components/Kyc/KycVerificationInProgressModal.tsx', + ], + }, + { + id: 5, + title: "'waitlist' state renders the REJECTION screen", + detail: 'computeCardState "waitlist" renders CardRejectionScreen (the "Berghain" treatment) — a naming/UX mismatch; the 213/7 scarcity numbers are hardcoded defaults.', + sourceFiles: ['src/components/Card/cardState.utils.ts', 'src/components/Card/CardRejectionScreen.tsx'], + }, + { + id: 6, + title: 'Spend chooser + canAlreadyTransact only exist on this branch', + detail: 'The #2475 chooser ("How do you want to spend?") and the canAlreadyTransact nag suppression are only on feat/spend-with-peanut-cta, not dev — dev still jumps straight to the QR scanner.', + sourceFiles: ['src/components/Home/ActivationCTAs.tsx'], + }, + { + id: 7, + title: 'Unreachable "also for activated users" card-override branch', + detail: "useActivationStatus's card-override branch for activated users is unreachable on home — activated users never render ActivationCTAs (they get HomeCarouselCTA). Needs runtime verification before deletion — inference-only, from the same audit that produced the retracted finding 3.", + sourceFiles: ['src/hooks/useActivationStatus.ts'], + }, +] diff --git a/src/app/(mobile-ui)/dev/journey/journeyTypes.ts b/src/app/(mobile-ui)/dev/journey/journeyTypes.ts new file mode 100644 index 0000000000..8d4560bc0d --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/journeyTypes.ts @@ -0,0 +1,138 @@ +/** + * Types for the /dev/journey Activation Journey Explorer. + * + * Two halves: + * - In-app surface catalog types (data lives in journeyData.ts, transcribed + * from the activation journey UI inventory with source-file annotations). + * - Live API spec types, mirroring peanut-api-ts GET /__dev/journey-spec and + * /__dev/journey-inspect (feat/unsubscribe-analytics, api PR #1234). + */ + +export type FunnelStateId = + | 'no-access' + | 'access-pre-kyc' + | 'kycd-no-card' + | 'application-in-flight' + | 'card-active-unfunded' + | 'funded-no-spend' + | 'spent' + +export type SurfaceKind = 'step' | 'carousel' | 'modal' | 'card-screen' + +export interface InAppSurface { + id: string + kind: SurfaceKind + /** Display name of the surface. */ + name: string + /** Verbatim copy snippet the user sees. */ + copy: string + /** CTA label → destination, when the surface has one. */ + cta?: { label: string; dest: string } + /** When this surface renders (plain-language condition). */ + condition: string + /** Source file(s), repo-relative — the drift-tracing anchor. */ + sourceFile: string + /** Funnel columns this surface appears in. */ + states: FunnelStateId[] + /** Shipped on this very branch (PR #2475). */ + isNewInThisPr?: boolean + note?: string +} + +export interface JourneyFinding { + id: number + title: string + detail: string + sourceFiles: string[] +} + +export interface FunnelState { + id: FunnelStateId + label: string + description: string + /** Lifecycle-machine stage names (from the API spec) whose emails land here. */ + specStages: string[] + /** The signup welcome email fires on entry to this column. */ + includesWelcome?: boolean + /** The kyc.reminder push (spec.pushReminders) applies in this column. */ + includesPushReminder?: boolean + /** Why the email machine is silent here, when specStages is empty. */ + noEmailReason?: string +} + +// ---------- live API spec (GET /__dev/journey-spec) ---------- + +export interface SpecEmailStep { + type: string + afterDaysStuck?: number + subject: string + preview: string + title: string + paragraphs: string[] + ctaText: string + ctaPath: string + paragraphsWithRewards?: string[] +} + +export interface SpecStage { + stage: string + order: number + predicate: string + steps: SpecEmailStep[] +} + +export interface SpecRules { + step1AfterDays: number + step2AfterDays: number + governorDays: number + freshnessDays: number + holdoutFraction: number + sendWindowUtc: { startHour: number; endHour: number } + maxSendsPerCycle: number +} + +export interface SpecPushReminder { + type: string + channels: string[] + afterMinutes: number + title: string + note: string +} + +export interface JourneySpec { + generatedFrom: string + rules: SpecRules + welcome: SpecEmailStep + stages: SpecStage[] + pushReminders: SpecPushReminder[] + emailPreviewBase: string +} + +// ---------- live user inspector (GET /__dev/journey-inspect?userId=…) ---------- + +export interface InspectDue { + userId: string + type: string + hasPendingRewards?: boolean + skip?: 'holdout' | 'governor' +} + +export interface InspectHistoryRow { + eventType: string + channel: string + status: string + skipReason: string | null + sentAt: string | null + createdAt: string +} + +export interface JourneyInspectResponse { + user: { + username: string | null + email: string | null + createdAt: string + cardAccessGrantedAt: string | null + } | null + due: InspectDue | null + history: InspectHistoryRow[] +} diff --git a/src/app/(mobile-ui)/dev/journey/page.tsx b/src/app/(mobile-ui)/dev/journey/page.tsx new file mode 100644 index 0000000000..dcc7b4638a --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import NavHeader from '@/components/Global/NavHeader' +import JourneyBoard from './JourneyBoard' +import RulesLegend from './RulesLegend' +import FindingsStrip from './FindingsStrip' +import UserInspector from './UserInspector' +import { useJourneySpec } from './useJourneySpec' + +/** + * /dev/journey — Activation Journey Explorer. + * + * Per funnel state: everything a user sees in-app (static catalog, transcribed + * from the journey UI inventory with source-file annotations) AND every + * email/push the lifecycle machine sends (fetched LIVE from the sandbox API's + * __dev/journey-spec endpoint, api PR #1234). Internal tool; desktop-ok. + */ +export default function JourneyExplorerPage() { + const { spec, error, loading } = useJourneySpec() + + return ( +
+ + +
+

Email-machine rules

+ +
+ +
+

Journey board

+ +
+ +
+

+ Findings — real product issues +

+ +
+ +
+

User inspector

+ +
+
+ ) +} diff --git a/src/app/(mobile-ui)/dev/journey/useJourneySpec.ts b/src/app/(mobile-ui)/dev/journey/useJourneySpec.ts new file mode 100644 index 0000000000..ebfde5e3ff --- /dev/null +++ b/src/app/(mobile-ui)/dev/journey/useJourneySpec.ts @@ -0,0 +1,44 @@ +'use client' + +import { useEffect, useState } from 'react' +import { JOURNEY_API_BASE } from './journeyData' +import type { JourneySpec } from './journeyTypes' + +/** + * Fetches the live email-machine spec from the sandbox API. Degrades + * gracefully: when the running API predates api PR #1234 (no __dev/journey-spec + * route) or isn't up at all, `error` is set and the board renders the in-app + * half with a "spec unavailable" note in the email panels. + */ +export function useJourneySpec(): { spec: JourneySpec | null; error: string | null; loading: boolean } { + const [spec, setSpec] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + fetch(`${JOURNEY_API_BASE}/__dev/journey-spec`) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as JourneySpec + }) + .then((data) => { + if (cancelled) return + if (!Array.isArray(data.stages) || !Array.isArray(data.pushReminders) || !data.rules || !data.welcome) + throw new Error('unexpected spec shape') + setSpec(data) + }) + .catch(() => { + if (!cancelled) + setError('API spec unavailable — start the sandbox with PR #1234 code (DEV_EMAIL_PREVIEW=true)') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + return { spec, error, loading } +} diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx index 4afde58e0d..5cfc99d6a1 100644 --- a/src/app/(mobile-ui)/dev/page.tsx +++ b/src/app/(mobile-ui)/dev/page.tsx @@ -40,6 +40,13 @@ export default function DevToolsPage() { path: '/dev/debug', icon: 'dollar', }, + { + name: 'Activation Journey', + description: + 'Per funnel state: every in-app surface (verbatim copy + source file) and every lifecycle email/push, fetched live from the sandbox API journey-spec.', + path: '/dev/journey', + icon: 'split', + }, { name: 'Peanut Welcome Club', description: diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index f02a7bfa61..f20d9ee141 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -92,12 +92,10 @@ export const BADGES: Record = { description: 'They found a real bug, reported it, and stayed. We owe them a beer.', displayName: 'Bug Whisperer', }, - // Card-launch badges — assets only. No backend trigger yet; entries are here - // so the icons render the moment the API starts awarding the codes. + // ── card-launch badges (awarded server-side: waitlist signup + card-spend milestones) ── SHHHHH: { path: '/badges/shhhhh.svg', description: 'They know the secret.', - // TODO(card-launch): award on shhhhh-waitlist signup }, NOT_SO_SHHHH: { path: '/badges/not_so_shhhh.svg', @@ -106,12 +104,10 @@ export const BADGES: Record = { CARD_FIRST_SWIPE: { path: '/badges/happy_card.svg', description: 'First swipe. They put their card to work.', - // TODO(card-launch): award on first settled Rain card payment }, CARD_SPENT_1K: { path: '/badges/money_stack.svg', description: '$1K swiped. They put their money where their card is.', - // TODO(card-launch): award on cumulative card spend ≥ $1K }, // ── growth · invite ladder (awarded by invites_accepted count) ────────── FIRST_INVITE: { diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index e1d59c9363..b1a60f254e 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -12,7 +12,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' +import { useCardInfo } from '@/hooks/useCardInfo' import { useIdentityVerification } from '@/hooks/useIdentityVerification' +import ActionModal from '@/components/Global/ActionModal' import { useAuth } from '@/context/authContext' import { buildContactSupportMessage } from '@/utils/contact-support.utils' import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' @@ -82,6 +84,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() const { rails, channelOf, nextActions } = useCapabilities() const { user } = useAuth() + // Card spend counts as activation too — card-access users get a card+QR + // chooser on the outbound step instead of jumping straight to the scanner. + // `undefined` while loading collapses to false → scanner behavior (never + // tease the card to a user we can't confirm has access). + const { hasCardAccess } = useCardInfo() // Suppress the "Unlock payments" verify CTA while identity is mid-flight // (Sumsub processing / action_required). The user already took the verify // action; the identity-verification page surfaces the in-progress modal, @@ -135,6 +142,13 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa }, [rails, channelOf, nextActions]) const [showProvideEmail, setShowProvideEmail] = useState(false) + const [showSpendChooser, setShowSpendChooser] = useState(false) + + // If card access is revoked (or the card-info refetch flips it) while the + // chooser is open, close it — a no-access user must never see the card option. + useEffect(() => { + if (hasCardAccess !== true) setShowSpendChooser(false) + }, [hasCardAccess]) // Inline self-heal so the home "Upload document" CTA opens the Sumsub document // re-upload directly, instead of routing to /profile/identity-verification (which @@ -220,6 +234,18 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa } } + // Card-access users can activate by swiping too — broaden the QR-only + // framing. Users without card access keep the QR copy untouched so we + // never tease a card they can't get. + if (activationStep === 'outbound' && hasCardAccess) { + return { + ...STEPS.outbound, + icon: 'credit-card', + title: 'Spend with Peanut', + description: 'Pay with your card or scan Pix and MercadoPago QR codes', + } + } + return STEPS[activationStep as Exclude] }, [ activationStep, @@ -229,6 +255,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa primaryRejectionMessage, isIdentityProcessing, isIdentityActionRequired, + hasCardAccess, ]) if (!step) return null @@ -282,7 +309,12 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa } else if (hasProviderRejection && hasFixableRejection && fixableProvider) { void kycFlow.handleSelfHealResubmit(fixableProvider) } else if (activationStep === 'outbound' && !hasProviderRejection) { - setIsQRScannerOpen(true) + if (hasCardAccess) { + posthog.capture(ANALYTICS_EVENTS.ACTIVATION_SPEND_CHOOSER_SHOWN) + setShowSpendChooser(true) + } else { + setIsQRScannerOpen(true) + } } else { router.push(step.href) } @@ -301,6 +333,33 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa onComplete={() => setShowProvideEmail(false)} onSkip={() => setShowProvideEmail(false)} /> + setShowSpendChooser(false)} + icon="credit-card" + title="How do you want to spend?" + description="Both count as your first payment." + ctas={[ + { + text: 'Pay with your card', + shadowSize: '4', + onClick: () => { + posthog.capture(ANALYTICS_EVENTS.ACTIVATION_SPEND_CHOOSER_SELECTED, { choice: 'card' }) + setShowSpendChooser(false) + router.push('/card') + }, + }, + { + text: 'Scan a QR code', + variant: 'stroke', + onClick: () => { + posthog.capture(ANALYTICS_EVENTS.ACTIVATION_SPEND_CHOOSER_SELECTED, { choice: 'qr' }) + setShowSpendChooser(false) + setIsQRScannerOpen(true) + }, + }, + ]} + /> ) diff --git a/src/components/Home/__tests__/ActivationCTAs.test.tsx b/src/components/Home/__tests__/ActivationCTAs.test.tsx index 03152f0975..5f76fea905 100644 --- a/src/components/Home/__tests__/ActivationCTAs.test.tsx +++ b/src/components/Home/__tests__/ActivationCTAs.test.tsx @@ -22,8 +22,10 @@ let mockRails: Array<{ reason?: { userMessage: string } }> = [] let mockUser: { user?: { isActivated?: boolean; userId?: string } } | null = null +let mockHasCardAccess: boolean | undefined = false const mockHeal = jest.fn() const mockPush = jest.fn() +const mockSetIsQRScannerOpen = jest.fn() jest.mock('@/hooks/useCapabilities', () => ({ useCapabilities: () => ({ @@ -40,7 +42,24 @@ jest.mock('@/hooks/useIdentityVerification', () => ({ useIdentityVerification: () => ({ isProcessing: false, needsAction: false }), })) jest.mock('@/context/ModalsContext', () => ({ - useModalsContext: () => ({ setIsQRScannerOpen: jest.fn(), openSupportWithMessage: jest.fn() }), + useModalsContext: () => ({ setIsQRScannerOpen: mockSetIsQRScannerOpen, openSupportWithMessage: jest.fn() }), +})) +jest.mock('@/hooks/useCardInfo', () => ({ + useCardInfo: () => ({ hasCardAccess: mockHasCardAccess }), +})) +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: (props: { visible: boolean; title?: string; ctas?: { text: string; onClick: () => void }[] }) => + props.visible ? ( +
+

{props.title}

+ {props.ctas?.map((c) => ( + + ))} +
+ ) : null, })) jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), @@ -60,6 +79,7 @@ jest.mock('@/components/Kyc/SumsubKycModals', () => ({ })) import ActivationCTAs from '../ActivationCTAs' +import posthog from 'posthog-js' const bankRejected = { id: 'bridge.sepa_eu', @@ -74,6 +94,7 @@ beforeEach(() => { jest.clearAllMocks() mockRails = [] mockUser = { user: { isActivated: false, userId: 'u1' } } + mockHasCardAccess = false }) describe('ActivationCTAs — rejection override respects existing transacting ability', () => { @@ -107,3 +128,68 @@ describe('ActivationCTAs — rejection override respects existing transacting ab expect(mockPush).not.toHaveBeenCalled() }) }) + +/** + * The outbound step was QR-only ("Make your first payment" → scanner) while + * card spend counts as activation too. Card-access users now get card-inclusive + * "Spend with Peanut" copy and a card/QR chooser; users without card access + * keep the exact old behavior so a gated card is never teased. + */ +describe('ActivationCTAs — outbound step spend chooser (card + QR)', () => { + it('without card access: QR-only copy unchanged, CTA goes straight to the scanner, no chooser', () => { + render() + expect(screen.getByText('Make your first payment')).toBeInTheDocument() + expect(screen.getByText('Start paying to Pix and MercadoPago QR codes')).toBeInTheDocument() + fireEvent.click(screen.getByText('Start Spending')) + expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) + expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() + }) + + it('while card access is still loading (undefined): treated as no access — scanner, no chooser', () => { + mockHasCardAccess = undefined + render() + expect(screen.getByText('Make your first payment')).toBeInTheDocument() + fireEvent.click(screen.getByText('Start Spending')) + expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) + expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() + }) + + it('with card access: card-inclusive copy, CTA opens the chooser (not the scanner) and tracks it', () => { + mockHasCardAccess = true + render() + expect(screen.getByText('Spend with Peanut')).toBeInTheDocument() + fireEvent.click(screen.getByText('Start Spending')) + expect(screen.getByTestId('spend-chooser')).toBeInTheDocument() + expect(mockSetIsQRScannerOpen).not.toHaveBeenCalled() + expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_shown') + }) + + it('chooser → card navigates to /card and tracks the choice', () => { + mockHasCardAccess = true + render() + fireEvent.click(screen.getByText('Start Spending')) + fireEvent.click(screen.getByText('Pay with your card')) + expect(mockPush).toHaveBeenCalledWith('/card') + expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_selected', { choice: 'card' }) + }) + + it('chooser → QR opens the existing scanner and tracks the choice', () => { + mockHasCardAccess = true + render() + fireEvent.click(screen.getByText('Start Spending')) + fireEvent.click(screen.getByText('Scan a QR code')) + expect(mockSetIsQRScannerOpen).toHaveBeenCalledWith(true) + expect(mockPush).not.toHaveBeenCalled() + expect(posthog.capture).toHaveBeenCalledWith('activation_spend_chooser_selected', { choice: 'qr' }) + }) + + it('card access revoked while the chooser is open: chooser closes (no stale card option)', () => { + mockHasCardAccess = true + const { rerender } = render() + fireEvent.click(screen.getByText('Start Spending')) + expect(screen.getByTestId('spend-chooser')).toBeInTheDocument() + mockHasCardAccess = false + rerender() + expect(screen.queryByTestId('spend-chooser')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index dd71115b38..c07618d0a8 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -159,13 +159,16 @@ export const KycVerificationInProgressModal = ({ } // phase === 'complete' + // Deliberately neutral (not "You're unlocked"): the rich WelcomeUnlockModal + // on home is THE single celebration — it lists what unlocked. This terminal + // must not stamp activationCelebratedAt, or home's celebration never shows. return (