From 1522abfe30ade0bb9af8f32b1fc900919ce71f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 26 Jun 2026 17:31:30 -0300 Subject: [PATCH 01/27] fix(kyc): surface per-label reject copy in action-required drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action-required drawer checked actionMessage first, but the backend always sends a generic "resubmit your documents" actionMessage for every action_required state — so the specific reject-label copy (e.g. DUPLICATE_EMAIL → "Email already in use, sign in to that account or contact support") was never reachable. Users hitting an email collision saw a misleading "verify your ID" prompt with no actionable next step. Prefer RejectLabelsList when reject labels are present; fall back to the generic actionMessage only when there are none. --- .../Kyc/states/KycActionRequired.tsx | 12 ++++++--- .../Kyc/states/__tests__/KycStates.test.tsx | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index c4d7e496a..75d233e39 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -5,8 +5,12 @@ 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 always sends a generic "resubmit your documents" +// actionMessage for action_required, so checking it first would mask the +// specific, actionable copy. export const KycActionRequired = ({ onResume, isLoading, @@ -22,7 +26,9 @@ export const KycActionRequired = ({
- {actionMessage ? ( + {rejectLabels?.length ? ( + + ) : actionMessage ? ( ) : ( 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() From b0a1b0975fbc2d7931ada03a43fdb90427269415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 26 Jun 2026 17:46:35 -0300 Subject: [PATCH 02/27] fix(kyc): collapse duplicated reject-labels branch + test real copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address /code-review findings: collapse the three-branch ternary to two (RejectLabelsList already renders its own empty-state fallback, so the duplicated branch was dead weight), and add an integration test that renders the real RejectLabelsList so the DUPLICATE_EMAIL → 'Email already in use' copy mapping is actually exercised, not mocked away. --- .../Kyc/states/KycActionRequired.tsx | 12 ++--- .../KycActionRequired.rejectCopy.test.tsx | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 75d233e39..1ca19f167 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -8,9 +8,11 @@ import type { IconName } from '@/components/Global/Icons/Icon' // 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 always sends a generic "resubmit your documents" -// actionMessage for action_required, so checking it first would mask the -// specific, actionable copy. +// 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, @@ -26,9 +28,7 @@ export const KycActionRequired = ({
- {rejectLabels?.length ? ( - - ) : 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() + }) +}) From 1f7fab0ee421e37bc88b177d982fd478763a8bc0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 26 Jun 2026 16:04:07 -0700 Subject: [PATCH 03/27] fix(card): grey + "Soon!" the guest claim-to-bank option (under maintenance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest claim-to-bank off-ramp is paused for the Monday Card launch — the backend now 503s POST /bridge/offramp/create-for-guest (pairs with the BE hotfix). Letting unverified claimers enter that flow would dead-end on a 503, so the bank method is now rendered greyed + non-interactive with a "Soon!" badge whenever the claim resolves to GuestBankClaim (claimer unverified, sender can receive a bank off-ramp). The authenticated self off-ramp (UserBankClaim → /users/accounts + /bridge/offramp/create) is untouched and stays fully clickable. --- .../Claim/Link/SendLinkActionList.tsx | 18 +- .../__tests__/SendLinkActionList.test.tsx | 159 ++++++++++++++++++ 2 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 7d87796da..1cf1bae36 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -108,12 +108,18 @@ export default function SendLinkActionList({ return claimType === BankClaimType.GuestKycNeeded || claimType === BankClaimType.ReceiverKycNeeded }, [claimType]) + // Guest claim-to-bank (claimer unverified, but the sender can receive a bank + // off-ramp) is under maintenance: the BE 503s POST /bridge/offramp/create-for-guest. + // Render the bank option greyed + "Soon!" so guests can't enter a flow that would + // fail. The authenticated self off-ramp (UserBankClaim) is unaffected. + const isGuestBankClaim = claimType === BankClaimType.GuestBankClaim + // filter and sort payment methods based on geolocation const { filteredMethods: sortedActionMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({ sortUnavailable: true, isMethodUnavailable: (method) => method.soon || - (method.id === 'bank' && requiresVerification) || + (method.id === 'bank' && (requiresVerification || isGuestBankClaim)) || (['mercadopago', 'pix'].includes(method.id) && !isMantecaPayEnabled), methods: showDevconnectMethod ? DEVCONNECT_CLAIM_METHODS.filter((method) => method.id !== 'devconnect') @@ -273,6 +279,7 @@ export default function SendLinkActionList({ key={method.id} method={method} requiresVerification={methodRequiresVerification} + soon={method.id === 'bank' && isGuestBankClaim} /> ) })} @@ -323,12 +330,17 @@ const MethodCard = ({ onClick, requiresVerification, isDisabled, + soon, }: { method: PaymentMethod onClick: () => void requiresVerification?: boolean isDisabled?: boolean + // forces the "Soon!" badge + greyed/non-interactive state even when the + // static method config has soon=false (e.g. guest claim-to-bank maintenance) + soon?: boolean }) => { + const showSoon = method.soon || soon return ( {method.title} - {(method.soon || requiresVerification) && ( + {(showSoon || requiresVerification) && ( } onClick={onClick} - isDisabled={method.soon || isDisabled} + isDisabled={showSoon || isDisabled} rightContent={} /> ) diff --git a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx new file mode 100644 index 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') + }) +}) From 323fa6130ac6b0012686d053c2d04c4fa5e92af2 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 03:41:16 -0700 Subject: [PATCH 04/27] fix(card): bring launch CTA in line with the activation CTAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch-day design review (Konrad): the banner was off-system — pink (pink=clickable in Peanut), a black drop-shadow that misaligned the bounding box, and a black CTA used nowhere else. Now a white card, no drop shadow, standard purple primary CTA — matching the other activation CTAs. Copy + /shhhhh routing + gating unchanged; cosmetic only. --- .../Home/CardLaunchCTA/CardLaunchCTABanner.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx index 4c56d0d0c..9c831bf45 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-2 border-n-1 bg-white p-5" > +

+ Toggles the post-join state: “Join anyway” becomes an “on the list” confirmation while the + asset + “Tweet to appeal” stay. +

+ {/* ─── RIGHT: Phone-frame preview of the whole screen ──── */} @@ -134,7 +150,8 @@ export default function RejectionBuilderPage() { mascot={mascot} applicants={applicants} admitted={admitted} - onJoined={() => alert('→ joined: would advance to the friendly waitlist-joined screen')} + alreadyJoined={alreadyJoined} + onJoined={() => setAlreadyJoined(true)} />
diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx index 488a6c3d1..4e4b83232 100644 --- a/src/components/Card/CardRejectionScreen.tsx +++ b/src/components/Card/CardRejectionScreen.tsx @@ -8,8 +8,12 @@ * a shareable door rejection: a dark "not tonight, " asset with a * smug peanut bouncer, the scarcity tally as screen copy, and a primary * "Tweet to appeal" CTA that shares the asset (random caption tagging - * @joinpeanut). The friendly waitlist-joined screen is the cooldown AFTER - * they share, so they don't rage-quit. + * @joinpeanut). + * + * This is the TERMINAL waitlist screen (no separate cooldown): once the user + * has joined (`alreadyJoined`) the secondary "Join anyway" button becomes an + * "on the list" confirmation, but the shareable asset + "Tweet to appeal" + * stay so they can keep appealing / re-grab the asset. */ import { type FC, useEffect, useRef, useState } from 'react' @@ -17,6 +21,7 @@ import * as Sentry from '@sentry/nextjs' import { Button } from '@/components/0_Bruddle/Button' import NavHeader from '@/components/Global/NavHeader' import ErrorAlert from '@/components/Global/ErrorAlert' +import { Icon } from '@/components/Global/Icons/Icon' import { ScaledRejectionAsset } from '@/components/Card/share-asset/ScaledRejectionAsset' import { captureShareAsset, canShareImageFiles } from '@/components/Card/share-asset/captureShareAsset' import { pickRejectionCaption } from '@/components/Card/share-asset/rejectionCaptions' @@ -32,9 +37,12 @@ interface Props { admitted?: number /** Which smug mascot the asset shows. */ mascot?: RejectionMascot + /** True once the user is already on the waitlist — swaps the "Join anyway" + * button for an "on the list" confirmation while keeping the asset + appeal. */ + alreadyJoined?: boolean onPrev?: () => void - /** Called after the user joins the waitlist. Parent should refetch /card, - * which flips the state machine to (cooldown). */ + /** Called after the user joins the waitlist. Parent refetches /card; the + * user stays on this screen, now in its `alreadyJoined` state. */ onJoined?: () => void } @@ -43,6 +51,7 @@ const CardRejectionScreen: FC = ({ applicants = 213, admitted = 7, mascot = 'cool', + alreadyJoined = false, onPrev, onJoined, }) => { @@ -184,15 +193,22 @@ const CardRejectionScreen: FC = ({ > Tweet to appeal - + {alreadyJoined ? ( +
+ + You're on the list — we'll holler when it's your turn +
+ ) : ( + + )}
) diff --git a/src/components/Card/CardWaitlistJoinedScreen.tsx b/src/components/Card/CardWaitlistJoinedScreen.tsx deleted file mode 100644 index 8f8f2cb47..000000000 --- a/src/components/Card/CardWaitlistJoinedScreen.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client' - -/** - * — confirmation after the user has joined - * the waitlist. Mirrors the canonical success layout used by - * PaymentSuccessView / Send / Withdraw: chill-peanut gif floating above, - * success Card with check-icon + headline, descriptive paragraph, CTA. - * - * No queue-position display. We intentionally don't surface the position - * because the BE counter is best-effort (re-orders on admin grants / - * churn), so a specific number invites speculation we can't honor. - */ - -import { type FC, useEffect } from 'react' -import Image from 'next/image' -import { Button } from '@/components/0_Bruddle/Button' -import Card from '@/components/Global/Card' -import NavHeader from '@/components/Global/NavHeader' -import { Icon } from '@/components/Global/Icons/Icon' -import { PeanutWhistling } from '@/assets/mascot' -import { useRouter } from 'next/navigation' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' - -interface Props { - onPrev?: () => void -} - -const CardWaitlistJoinedScreen: FC = ({ onPrev }) => { - const router = useRouter() - - useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: true }) - }, []) - - return ( -
- - -
- Peanut Mascot - -
- -
-
-

You're on the waitlist

-

- We'll let you know when it's your turn -

-
-
-
- - -
- ) -} - -export default CardWaitlistJoinedScreen From 4e8af8461962a1591deb6fd3a188c27f4b21b53d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 06:31:08 -0700 Subject: [PATCH 08/27] fix(card): match joined-state indicator to button height (no layout shift) The 'on the list' confirmation that replaces the 'Join anyway' button was py-2 (~36px) vs the button's h-13 (52px), so the CTA block shrank on join and shifted the layout. Fix the indicator to h-13 so the swap is height-stable. --- src/components/Card/CardRejectionScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx index 4e4b83232..2444c66db 100644 --- a/src/components/Card/CardRejectionScreen.tsx +++ b/src/components/Card/CardRejectionScreen.tsx @@ -194,7 +194,7 @@ const CardRejectionScreen: FC = ({ Tweet to appeal {alreadyJoined ? ( -
+
You're on the list — we'll holler when it's your turn
From eb6646b65bda45110312a3e446c03d371381912f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 06:38:43 -0700 Subject: [PATCH 09/27] fix(card): address CodeRabbit on the terminal waitlist screen 1) Local 'locallyJoined' override (set only on a confirmed join) so the 'Join anyway' CTA swaps to the on-the-list state immediately instead of flickering back until the /card refetch resolves. 2) CARD_WAITLIST_VIEWED now reports the real already_joined (the screen can mount already-joined). 3) handleAppeal clears any stale joinError so 'Failed to join' can't render alongside 'You're on the list'. --- src/components/Card/CardRejectionScreen.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx index 2444c66db..97e90df5f 100644 --- a/src/components/Card/CardRejectionScreen.tsx +++ b/src/components/Card/CardRejectionScreen.tsx @@ -59,10 +59,16 @@ const CardRejectionScreen: FC = ({ const [sharing, setSharing] = useState(false) const [joining, setJoining] = useState(false) const [joinError, setJoinError] = useState(null) + // Local "just joined" override so the CTA swaps to the on-the-list state + // immediately on a confirmed join, without waiting for the parent's /card + // refetch (CodeRabbit). OR'd with the `alreadyJoined` prop. + const [locallyJoined, setLocallyJoined] = useState(false) + const showJoined = alreadyJoined || locallyJoined const safeUsername = (username || '').trim() || 'anon' useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: alreadyJoined }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once on mount with the entry state }, []) const handleJoin = async (): Promise => { @@ -71,6 +77,7 @@ const CardRejectionScreen: FC = ({ try { const res = await cardApi.joinWaitlist() posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) + setLocallyJoined(true) onJoined?.() } catch (e) { // Keep the user-facing message generic; raw BE text can leak @@ -86,6 +93,9 @@ const CardRejectionScreen: FC = ({ } const handleAppeal = async (): Promise => { + // Clear any stale "failed to join" error from an earlier handleJoin so + // the success state can't render alongside it (CodeRabbit). + setJoinError(null) const caption = pickRejectionCaption() // Appeal = tweet AND join the waitlist. Joining is NOT access — release @@ -100,6 +110,7 @@ const CardRejectionScreen: FC = ({ .joinWaitlist() .then((res) => { posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position, source: 'appeal' }) + setLocallyJoined(true) }) .catch((e) => { // Non-fatal: the tweet still goes out, and the CARD_SHARE_ASSET_SHARED @@ -193,7 +204,7 @@ const CardRejectionScreen: FC = ({ > Tweet to appeal - {alreadyJoined ? ( + {showJoined ? (
You're on the list — we'll holler when it's your turn From 4b0c8e3bbae70f0c37cd3225334a20c135db3851 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 06:45:34 -0700 Subject: [PATCH 10/27] test(card): regression guard for the blank share-asset capture race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export waitForAssetReady (with an injectable timeout) and add 4 tests proving the capture gate blocks the html-to-image snapshot until the async card-face has mounted — the exact element whose absence produced the launch-day blank share asset (#2302) — plus: bounded so it never hangs, awaits every .decode(), and awaits document.fonts.ready. 6/6 pass. --- .../__tests__/captureShareAsset.test.ts | 69 ++++++++++++++++++- .../Card/share-asset/captureShareAsset.ts | 11 ++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts index 87066615c..1f50f9f1b 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,70 @@ describe('ShareAssetCaptureError', () => { expect(err instanceof Error).toBe(true) }) }) + +/** + * Regression guard for the launch-day "blank share asset" bug: the card-face + * hand is an async-mounted , and capturing before it mounts produced a + * blank card. waitForAssetReady must block the snapshot until that canvas is + * present (+ fonts ready + images decoded), bounded so it never hangs. + */ +describe('waitForAssetReady — share-asset capture readiness gate', () => { + const realRAF = global.requestAnimationFrame + beforeEach(() => { + // jsdom's RAF is ~16ms; make the poll prompt + deterministic. + global.requestAnimationFrame = ((cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 0)) as unknown as typeof requestAnimationFrame + }) + afterEach(() => { + global.requestAnimationFrame = realRAF + }) + + test('does NOT resolve until the card-face has mounted (the blank-asset bug)', async () => { + const node = document.createElement('div') // pink box only — no canvas yet + let resolved = false + const pending = waitForAssetReady(node).then(() => { + resolved = true + }) + await new Promise((r) => setTimeout(r, 40)) + expect(resolved).toBe(false) // still gated — a snapshot here would be blank + node.appendChild(document.createElement('canvas')) // the hand mounts + await new Promise((r) => setTimeout(r, 40)) + await pending + expect(resolved).toBe(true) + }) + + test('resolves (never hangs) if the canvas never mounts — bounded by the timeout', async () => { + const node = document.createElement('div') + await expect(waitForAssetReady(node, 40)).resolves.toBeUndefined() + }) + + test('awaits image decode for every before snapshotting', async () => { + const node = document.createElement('div') + const img = document.createElement('img') + const decode = jest.fn().mockResolvedValue(undefined) + ;(img as unknown as { decode: unknown }).decode = decode + node.appendChild(img) + node.appendChild(document.createElement('canvas')) + await waitForAssetReady(node, 200) + expect(decode).toHaveBeenCalledTimes(1) + }) + + 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') + node.appendChild(document.createElement('canvas')) + await waitForAssetReady(node, 200) + expect(fontsReady).toBe(true) + }) +}) diff --git a/src/components/Card/share-asset/captureShareAsset.ts b/src/components/Card/share-asset/captureShareAsset.ts index 1671a8d41..e0eee61b2 100644 --- a/src/components/Card/share-asset/captureShareAsset.ts +++ b/src/components/Card/share-asset/captureShareAsset.ts @@ -55,9 +55,14 @@ export class ShareAssetCaptureError extends Error { * - the async hand being mounted * bounded by a timeout so a genuinely-stuck asset still captures (never hangs). */ -const CAPTURE_READY_TIMEOUT_MS = 2500 +export const CAPTURE_READY_TIMEOUT_MS = 2500 -async function waitForAssetReady(node: HTMLElement): Promise { +// Exported for unit tests. `timeoutMs` is injectable so the bounded-wait +// behaviour can be asserted without a 2.5s test. +export async function waitForAssetReady( + node: HTMLElement, + timeoutMs: number = CAPTURE_READY_TIMEOUT_MS +): Promise { if (typeof document !== 'undefined' && document.fonts?.ready) { try { await document.fonts.ready @@ -74,7 +79,7 @@ async function waitForAssetReady(node: HTMLElement): Promise { // outside React's tree, so html-to-image can't wait for it on its own). const start = typeof performance !== 'undefined' ? performance.now() : 0 const elapsed = (): number => (typeof performance !== 'undefined' ? performance.now() : Infinity) - start - while (!node.querySelector('canvas') && elapsed() < CAPTURE_READY_TIMEOUT_MS) { + while (!node.querySelector('canvas') && elapsed() < timeoutMs) { await new Promise((resolve) => requestAnimationFrame(() => resolve())) } } From 7cd807a877a28f7443d59453af5b5ae9891e6eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 29 Jun 2026 11:09:17 -0300 Subject: [PATCH 11/27] fix(card): raise apply-for-card fetch timeout to 60s The first-time Rain card application path is structurally 7-13s (7 sequential Sumsub calls + a 2.5s readiness sleep + Rain createApplication + optional inline issueCard). The global 10s fetchWithSentry default aborts that tail client-side while the backend keeps running to completion, so users see a false 'application failed' error on a card that was actually submitted. PostHog: the rain/cards timeout exception fires ~280x/week across ~40 distinct users; of 120 users who hit it in 30d only 28 reached card_apply_succeeded. Override timeoutMs for this one call only (browser-side fetch, so the 15s Vercel function ceiling that caps the global default does not apply here). --- src/services/rain.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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, }) }, From 3dbc068b4943a3f2098c13c2f239134549e79fcd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 07:20:04 -0700 Subject: [PATCH 12/27] feat(card): real FOMO door tally on rejection screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'not tonight' Berghain screen rendered a hardcoded '213 tried · 7 got in' (props defaulted to 213/7 and the page never passed real values). Now /card returns waitlistTotal + admittedTotal; the screen inflates 'tried' for FOMO (real waitlist size x3, floored at 213 — mirrors the /shhhhh ScarcityCounter fake-scarcity flex) and shows the real 'got in'. Inflation is a pure, deterministic fn so it never jitters between renders; a sane 213/7 fallback covers the still-loading window. /dev/rejection-builder now drives the real counts and previews the inflated result. Requires the BE PR (waitlistTotal/admittedTotal on /card) deployed first. --- src/app/(mobile-ui)/card/page.tsx | 2 + .../dev/rejection-builder/page.tsx | 63 +++++++++++++------ src/components/Card/CardRejectionScreen.tsx | 17 +++-- .../Card/__tests__/doorTally.utils.test.ts | 50 +++++++++++++++ src/components/Card/doorTally.utils.ts | 55 ++++++++++++++++ src/services/card.ts | 6 ++ 6 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 src/components/Card/__tests__/doorTally.utils.test.ts create mode 100644 src/components/Card/doorTally.utils.ts diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 022f01968..4e687168d 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -477,6 +477,8 @@ const CardPage: FC = () => { return ( = [ @@ -25,10 +26,14 @@ const MASCOTS: ReadonlyArray<[RejectionMascot, string]> = [ export default function RejectionBuilderPage() { const [username, setUsername] = useState('kkonrad') const [mascot, setMascot] = useState('cool') - const [applicants, setApplicants] = useState(213) - const [admitted, setAdmitted] = useState(7) + // The REAL backend counts (waitlistTotal / admittedTotal). The screen + // inflates "tried" for FOMO; the readout below shows what it renders. + const [waitlistTotal, setWaitlistTotal] = useState(120) + const [admittedTotal, setAdmittedTotal] = useState(7) const [alreadyJoined, setAlreadyJoined] = useState(false) + const tally = computeDoorTally(waitlistTotal, admittedTotal) + return (
@@ -76,45 +81,63 @@ export default function RejectionBuilderPage() {

-
- +
+ setApplicants(Number(e.target.value))} + value={waitlistTotal} + onChange={(e) => setWaitlistTotal(Number(e.target.value))} className="w-full" /> - + setAdmitted(Number(e.target.value))} + value={admittedTotal} + onChange={(e) => setAdmittedTotal(Number(e.target.value))} className="w-full" /> +

+ renders as:{' '} + + {tally.applicants.toLocaleString('en-US')} tried · {tally.admitted} got in + +
+ + “tried” = waitlist × FOMO multiplier (floored); “got in” = real admitted + +

{ - setApplicants(213) - setAdmitted(7) + setWaitlistTotal(0) + setAdmittedTotal(0) + }} + > + empty (floor) + + { + setWaitlistTotal(120) + setAdmittedTotal(7) }} > - 213 / 7 + early beta { - setApplicants(1842) - setAdmitted(11) + setWaitlistTotal(1842) + setAdmittedTotal(140) }} > - 1842 / 11 + busy door
@@ -148,8 +171,8 @@ export default function RejectionBuilderPage() { setAlreadyJoined(true)} /> diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx index 97e90df5f..c2525f60c 100644 --- a/src/components/Card/CardRejectionScreen.tsx +++ b/src/components/Card/CardRejectionScreen.tsx @@ -26,15 +26,19 @@ import { ScaledRejectionAsset } from '@/components/Card/share-asset/ScaledReject import { captureShareAsset, canShareImageFiles } from '@/components/Card/share-asset/captureShareAsset' import { pickRejectionCaption } from '@/components/Card/share-asset/rejectionCaptions' import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' +import { computeDoorTally } from '@/components/Card/doorTally.utils' import { cardApi } from '@/services/card' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' interface Props { username?: string - /** Door tally — scarcity flex, rendered as screen copy (not on the asset). */ - applicants?: number - admitted?: number + /** Real waitlist size (total who joined). The screen inflates this for the + * FOMO "tried" tally — mirrors the /shhhhh ScarcityCounter flex. Undefined + * while /card is still loading → a sane floor renders. */ + waitlistTotal?: number + /** Real number admitted (released/granted). Shown verbatim as "got in". */ + admittedTotal?: number /** Which smug mascot the asset shows. */ mascot?: RejectionMascot /** True once the user is already on the waitlist — swaps the "Join anyway" @@ -48,13 +52,16 @@ interface Props { const CardRejectionScreen: FC = ({ username, - applicants = 213, - admitted = 7, + waitlistTotal, + admittedTotal, mascot = 'cool', alreadyJoined = false, onPrev, onJoined, }) => { + // "tried" = real waitlist size, inflated for FOMO; "got in" = real admitted. + // Deterministic (pure fn of the counts) so it never jitters between renders. + const { applicants, admitted } = computeDoorTally(waitlistTotal, admittedTotal) const captureRef = useRef(null) const [sharing, setSharing] = useState(false) const [joining, setJoining] = useState(false) 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..c63700b61 --- /dev/null +++ b/src/components/Card/__tests__/doorTally.utils.test.ts @@ -0,0 +1,50 @@ +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 * 3 = 3000, 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 * 3 = 30 < floor → floor wins. + expect(inflateApplicants(10)).toBe(DOOR_TALLY_APPLICANTS_FLOOR) + }) + + test('rounds to a whole number', () => { + // 71 * 3 = 213 (== floor here); use a value that rounds to confirm. + expect(Number.isInteger(inflateApplicants(101))).toBe(true) + expect(inflateApplicants(101)).toBe(303) + }) + + 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', () => { + expect(computeDoorTally(500, 42)).toEqual({ applicants: 1500, 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..75c0f8e2e --- /dev/null +++ b/src/components/Card/doorTally.utils.ts @@ -0,0 +1,55 @@ +/** + * 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". */ +export const DOOR_TALLY_FOMO_MULTIPLIER = 3 + +/** + * 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/services/card.ts b/src/services/card.ts index 94b5b3c31..e974557ba 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -35,6 +35,12 @@ 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"). */ + waitlistTotal: number + admittedTotal: number } export interface WaitlistStateResponse { From b6e187dc2d23187755baa828ca1fce794eae7644 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 07:24:33 -0700 Subject: [PATCH 13/27] =?UTF-8?q?fix(card):=20bulletproof=20share-asset=20?= =?UTF-8?q?capture=20=E2=80=94=20faithful=20shadows=20+=20deterministic=20?= =?UTF-8?q?ready=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card launched today; the share asset users post had two capture bugs. 1) Square shadows. html-to-image renders CSS box-shadow on a rounded element as a SQUARE block, so the captured PNG showed square shadows behind the rounded peanut.me/ pill and the card itself. Replace those box-shadows with offset black sibling elements that share the border-radius — they capture as faithful rounded shadows. (Hero stickers use filter:drop-shadow, which captures fine — left as is.) 2) Blank card. PixelatedCardFace paints its pixelated hand into a appended asynchronously, so a capture firing before the canvas mounted snapshotted a blank pink card — and the capture SUCCEEDED, so nothing reached Sentry. The bounded waitForAssetReady wait (PR #2302) can time out under load. Deterministic fix: PixelatedCardFace fires onReady once the hand canvas mounts; it threads up through ShareAssetD3 to the Share/Save buttons, which stay disabled until the asset signals ready. waitForAssetReady stays as a belt-and-suspenders fallback. Also wires the /dev/share-builder "Save image" button to the real capture path and adds an e2e regression guard that decodes the captured PNG and asserts the card centre (the hand's territory) is not entirely background — proving the card face actually renders. --- e2e/flows/share-asset-capture.spec.ts | 115 ++++++++++++++++ .../(mobile-ui)/dev/share-builder/page.tsx | 47 ++++++- src/components/Card/BadgeSkipCelebration.tsx | 6 +- src/components/Card/CardUnlockDrawer.tsx | 6 +- .../Card/share-asset/PixelatedCardFace.tsx | 128 ++++++++++-------- .../Card/share-asset/ShareAssetActions.tsx | 11 +- .../Card/share-asset/ShareAssetD3.tsx | 51 ++++--- .../Card/share-asset/shareAsset.types.ts | 8 ++ 8 files changed, 289 insertions(+), 83 deletions(-) create mode 100644 e2e/flows/share-asset-capture.spec.ts diff --git a/e2e/flows/share-asset-capture.spec.ts b/e2e/flows/share-asset-capture.spec.ts new file mode 100644 index 000000000..f76bafd0b --- /dev/null +++ b/e2e/flows/share-asset-capture.spec.ts @@ -0,0 +1,115 @@ +/** + * Share-asset capture regression — the card face must NOT be blank. + * + * The launch-day bug: paints its pixelated hand into a + * appended ASYNCHRONOUSLY (new Image() → onload → appendChild). If a + * capture fired before that canvas mounted, html-to-image snapshotted a blank + * pink card — and the capture SUCCEEDED, so nothing reached Sentry. + * + * The deterministic fix gates the Save/Share buttons on PixelatedCardFace's + * `onReady` (the hand canvas mounting), so a capture can never fire early. + * + * This spec proves the guard end-to-end against the real html-to-image path: + * 1. /dev/share-builder renders 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 canvas mounts). + * 3. We click Save, intercept the downloaded PNG, decode it, 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 sharp from 'sharp' + +// 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). +// "Background" = either of these; the hand is neither. +const CARD_PINK = { r: 0xff, g: 0x90, b: 0xe8 } +const ASSET_BLUE = { r: 0x90, g: 0xa8, b: 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 } + +function isBackground(r: number, g: number, b: number): boolean { + const near = (c: { r: number; g: number; b: number }): boolean => + Math.abs(r - c.r) <= COLOR_TOL && Math.abs(g - c.g) <= COLOR_TOL && Math.abs(b - c.b) <= COLOR_TOL + return near(CARD_PINK) || near(ASSET_BLUE) +} + +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 async hand + // mounts (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 the captured PNG to raw RGBA and sample the central card window. + const { data, info } = await sharp(pngPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) + const { width, height, channels } = info + + // Output is captured at pixelRatio 2 (≈2400×1800) — scale canvas coords + // into output pixels. + const sx = width / CANVAS_W + const sy = height / CANVAS_H + + let sampled = 0 + let nonBackground = 0 + const STEP = 6 // sample every 6th canvas-px → a dense grid, fast decode + 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) * channels + sampled++ + if (!isBackground(data[idx], data[idx + 1], data[idx + 2])) nonBackground++ + } + } + + const fraction = nonBackground / 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 ` + + `(${nonBackground}/${sampled} px; output ${width}×${height}). ` + + `≈0% means the pixelated hand never rendered — the blank-card capture bug.` + ).toBeGreaterThan(0.02) + }) +}) diff --git a/src/app/(mobile-ui)/dev/share-builder/page.tsx b/src/app/(mobile-ui)/dev/share-builder/page.tsx index dc6e2c17f..fd3c57543 100644 --- a/src/app/(mobile-ui)/dev/share-builder/page.tsx +++ b/src/app/(mobile-ui)/dev/share-builder/page.tsx @@ -14,12 +14,13 @@ * the same user (otherwise deterministic from username). */ -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import NavHeader from '@/components/Global/NavHeader' import { Button } from '@/components/0_Bruddle/Button' import { Checkbox } from '@/components/0_Bruddle/Checkbox' import ShareAssetD3 from '@/components/Card/share-asset/ShareAssetD3' import type { HeroVariant, UsernameBg } from '@/components/Card/share-asset/shareAsset.types' +import { captureShareAsset, downloadBlob } from '@/components/Card/share-asset/captureShareAsset' import { BADGE_CODES, getBadgeDisplayName } from '@/components/Badges/badge.utils' import { CANVAS_W, CANVAS_H } from '@/components/Card/share-asset/shareAssetLayout' @@ -43,6 +44,28 @@ export default function ShareBuilderPage() { const [previewScale, setPreviewScale] = useState(0.8) const [hideUsername, setHideUsername] = useState(false) + // ─── Capture (Save image) ──────────────────────────────────────────── + // Ref points at the native-size asset node (the pre-scale div) so the + // capture renders at full 1200×900 fidelity. `assetReady` flips once the + // card face's async hand mounts (ShareAssetD3.onReady) — Save is + // disabled until then so a capture can never snapshot a blank card. + const assetRef = useRef(null) + const [assetReady, setAssetReady] = useState(false) + const [saving, setSaving] = useState(false) + const handleSave = async () => { + const node = assetRef.current + if (!node) return + setSaving(true) + try { + const blob = await captureShareAsset(node) + downloadBlob(blob, 'peanut-card.png') + } catch (err) { + console.error('[share-builder] save failed', err) + } finally { + setSaving(false) + } + } + // ─── Hero "I got in" message sticker ───────────────────────────────── const [heroVariant, setHeroVariant] = useState('burst') const [heroText, setHeroText] = useState("I'M IN!") @@ -298,7 +321,11 @@ export default function ShareBuilderPage() { variant="purple" shadowSize="4" className="flex-1" - onClick={() => setSeedNonce((n) => n + 1)} + onClick={() => { + // Remounts ShareAssetD3 (key) → card face repaints; re-gate Save. + setAssetReady(false) + setSeedNonce((n) => n + 1) + }} > Reroll seed @@ -306,7 +333,10 @@ export default function ShareBuilderPage() { variant="stroke" shadowSize="4" className="flex-1" - onClick={() => setAnimate((a) => !a)} + onClick={() => { + setAssetReady(false) + setAnimate((a) => !a) + }} > {animate ? '✓ Animate' : 'Animate off'} @@ -343,6 +373,7 @@ export default function ShareBuilderPage() { }} >
setAssetReady(true)} />
@@ -383,7 +415,14 @@ export default function ShareBuilderPage() { -
diff --git a/src/components/Card/BadgeSkipCelebration.tsx b/src/components/Card/BadgeSkipCelebration.tsx index 037787ea3..1156feb2e 100644 --- a/src/components/Card/BadgeSkipCelebration.tsx +++ b/src/components/Card/BadgeSkipCelebration.tsx @@ -61,6 +61,9 @@ type Phase = 'looking-up' | 'shaking' | 'revealed' const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, tier, pointsBalance, onContinue }) => { const [phase, setPhase] = useState('looking-up') const [hideUsername, setHideUsername] = useState(false) + // Gate the Share/Save buttons until the card face's async hand + // mounts — otherwise an early capture snapshots a blank card. + const [assetReady, setAssetReady] = useState(false) const { triggerHaptic } = useHaptic() const captureRef = useRef(null) const hasBadge = !!badgeCode @@ -165,6 +168,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t cardLast4="0420" hideUsername={hideUsername} animate={phase === 'revealed'} + onReady={() => setAssetReady(true)} /> )} @@ -184,7 +188,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t value={hideUsername} onChange={(e) => setHideUsername(e.target.checked)} /> - + diff --git a/src/components/Card/CardUnlockDrawer.tsx b/src/components/Card/CardUnlockDrawer.tsx index 1d106bdd0..871d309bb 100644 --- a/src/components/Card/CardUnlockDrawer.tsx +++ b/src/components/Card/CardUnlockDrawer.tsx @@ -33,6 +33,9 @@ interface Props { export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, badges }) => { const captureRef = useRef(null) const [hideUsername, setHideUsername] = useState(false) + // Gate the Share/Save buttons until the card face's async hand + // mounts — otherwise an early capture snapshots a blank card. + const [assetReady, setAssetReady] = useState(false) useEffect(() => { if (!isOpen) return @@ -68,6 +71,7 @@ export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, cardLast4="0420" hideUsername={hideUsername} animate={false} + onReady={() => setAssetReady(true)} />
@@ -78,7 +82,7 @@ export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, value={hideUsername} onChange={(e) => setHideUsername(e.target.checked)} /> - +
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index d699f842e..596c9cdb7 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -46,9 +46,10 @@ export interface PixelatedCardFaceProps { * 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 +59,11 @@ 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 async-rasterised hand has been appended to the + * DOM — 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 mounts (the launch-day "blank card" bug). */ + onReady?: () => void } export const PixelatedCardFace: FC = ({ @@ -65,39 +71,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 ? ( + ) : ( - Visa - ))} -
+ + )} + {!hideVisa && + (blurAll ? ( + + ) : ( + Visa + ))} +
- {/* 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 +122,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 ? ( + + ) : ( +
+ ???? +
+ )} +
) @@ -268,7 +286,7 @@ const PixelatedText: FC = ({ text, displayW, displayH, font, // silhouette stays recognisable. // --------------------------------------------------------------------------- -const PixelatedHand: FC = () => ( +const PixelatedHand: FC<{ onReady?: () => void }> = ({ onReady }) => (
{ if (!node || node.firstChild) return @@ -280,6 +298,8 @@ const PixelatedHand: FC = () => ( canvas.style.height = '100%' canvas.style.imageRendering = 'pixelated' node.appendChild(canvas) + // Card face is now painted — let capture surfaces unblock. + onReady?.() }) }} className="pointer-events-none absolute select-none" 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/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 } From 2b621d101a9ccddbac496bb54766a3f4b04f1bea Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 07:28:39 -0700 Subject: [PATCH 14/27] fix(card): make waitlistTotal/admittedTotal optional on CardInfoResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI typecheck caught existing CardInfoResponse mocks (e.g. cardState.utils .test.ts) breaking because the new fields were required. Optional is also the correct contract: the BE deploys first, but the FE must tolerate undefined during the rollout window (and for any older API) — computeDoorTally already falls back to 213/7. Full `tsc --noEmit` + `npm test` now green. --- src/services/card.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/services/card.ts b/src/services/card.ts index e974557ba..d1814f16e 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -38,9 +38,13 @@ export interface CardInfoResponse { /** 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"). */ - waitlistTotal: number - admittedTotal: number + * (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 { From 4092778a1da8c0948360ae9cbd4fa49a49e2adf1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:00:13 +0530 Subject: [PATCH 15/27] feat(kyc): make EEA-uplift verification a mandatory gate + funnel analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users were skipping the EEA-uplift KYC via the modal's "Not now" / close and then continuing to transact, so the requirement never got completed before a bank transfer. Make the pre-empt mandatory: the modal is non-closable and non-skippable (preventClose + hideModalCloseButton, "Not now" removed), and the hook blocks the deferred deposit/withdraw action until the requirement clears. Only "Complete now" moves forward. Add dedicated PostHog events (eea_uplift_started / eea_uplift_completed, with channel + requirement_key) so the start->finish funnel can be filtered directly. Completion is ref-guarded so generic KYC successes on the bank pages don't mis-fire it. Scope: the advisory hard-gate path on add-money/[country]/bank and withdraw/[country]/bank. Front-end enforcement only — does not change Bridge's underlying requirement. --- .../add-money/[country]/bank/page.tsx | 26 +++++--- .../withdraw/[country]/bank/page.tsx | 23 +++++-- src/components/Kyc/AdvisoryPreemptModal.tsx | 31 ++++----- src/constants/analytics.consts.ts | 8 +++ src/hooks/useAdvisoryPreempt.test.ts | 64 +++++++++++-------- src/hooks/useAdvisoryPreempt.ts | 46 ++++--------- src/hooks/useEeaUpliftFunnel.test.ts | 62 ++++++++++++++++++ src/hooks/useEeaUpliftFunnel.ts | 48 ++++++++++++++ 8 files changed, 216 insertions(+), 92 deletions(-) create mode 100644 src/hooks/useEeaUpliftFunnel.test.ts create mode 100644 src/hooks/useEeaUpliftFunnel.ts diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index e94ae231a..3975494fc 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -36,6 +36,7 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' +import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -78,8 +79,13 @@ export default function OnrampBankPage() { // 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 } = useEeaUpliftFunnel('deposit') + const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { + trackUpliftCompleted() setUrlState({ step: 'inputAmount' }) }, }) @@ -115,8 +121,9 @@ export default function OnrampBankPage() { 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, @@ -124,8 +131,11 @@ export default function OnrampBankPage() { // 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() @@ -247,10 +257,10 @@ export default function OnrampBankPage() { 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, diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index ec7045921..9b4f64d4c 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -32,6 +32,7 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' +import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -89,9 +90,14 @@ export default function WithdrawBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) - const sumsubFlow = useMultiPhaseKycFlow({}) - // A ready bank rail can still carry a future-dated requirement (the gate's - // `advisory`). Offer it as a skippable pre-empt before the withdrawal. + // EEA-uplift funnel events (PostHog): started on launch, completed on KYC + // success. trackCompleted no-ops unless an uplift was started this session. + const { trackStarted: trackUpliftStarted, trackCompleted: trackUpliftCompleted } = useEeaUpliftFunnel('withdraw') + + const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => trackUpliftCompleted() }) + // A ready bank rail can still carry a pending Bridge requirement (the gate's + // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the + // withdrawal — the offramp cannot proceed until it's completed. const advisory = gate.kind === 'ready' ? gate.advisory : undefined const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, @@ -99,8 +105,11 @@ export default function WithdrawBankPage() { // Route through the self-heal resubmit path (reheal-tagged action) so the // completed submission round-trips to Bridge. start-action mints a plain // token whose webhook completion has no Bridge relay → answers are dropped. - onCompleteNow: () => - advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + onCompleteNow: () => { + if (!advisory) return Promise.resolve() + trackUpliftStarted(advisory) + return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) + }, }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() @@ -322,8 +331,8 @@ export default function WithdrawBankPage() { } } - // Offer the skippable advisory pre-empt once, then run the offramp. When the - // gate isn't `ready` (or nothing is future-dated) this is a no-op and + // Enforce the mandatory verification pre-empt, then run the offramp. When the + // gate isn't `ready` (or there's no pending requirement) this is a no-op and // proceedWithOfframp runs straight away (it handles the not-ready cases). const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp()) diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx index bf0a62a11..39c8f970b 100644 --- a/src/components/Kyc/AdvisoryPreemptModal.tsx +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -5,11 +5,8 @@ interface AdvisoryPreemptModalProps { /** ISO date the requirement becomes blocking; drives the deadline copy. */ effectiveDate?: string isLoading?: boolean - /** Launch the verification flow early. */ + /** Launch the verification flow. */ onCompleteNow: () => void - /** Dismiss and continue with what the user was doing. */ - onSkip: () => void - onClose: () => void } function formatEffectiveDate(iso?: string): string | null { @@ -23,32 +20,33 @@ function formatEffectiveDate(iso?: string): string | null { } /** - * Skippable pre-empt for a future-dated verification requirement on a rail that - * still works today (the gate's `ready` + `advisory`). "Complete now" launches - * the verification early; "Not now" lets the user carry on and resolve it later. - * Once the effective date passes the backend reclassifies the requirement to - * blocking and the non-skippable InitiateKycModal takes over — there is no FE - * cutover logic here. + * Mandatory pre-empt for a pending Bridge verification requirement on the bank + * rails. Non-closable and non-skippable: the user must complete the verification + * before they can continue with a bank transfer. There is no "Not now" / X / + * backdrop dismiss — the only way forward is "Complete now". */ export default function AdvisoryPreemptModal({ visible, effectiveDate, isLoading = false, onCompleteNow, - onSkip, - onClose, }: AdvisoryPreemptModalProps) { const formatted = formatEffectiveDate(effectiveDate) return ( {}} + preventClose + hideModalCloseButton icon="badge" - title="One quick step coming up" + title="One quick step to continue" description={ formatted - ? `To keep using bank transfers, you'll need to complete a short verification by ${formatted}. Take care of it now so nothing pauses later.` - : `To keep using bank transfers, you'll need to complete a short verification soon. Take care of it now so nothing pauses later.` + ? `To continue using bank transfers, please complete a short verification (required by ${formatted}). It only takes a couple of minutes.` + : `To continue using bank transfers, please complete a short verification. It only takes a couple of minutes.` } ctas={[ { @@ -58,7 +56,6 @@ export default function AdvisoryPreemptModal({ shadowSize: '4', disabled: isLoading, }, - { text: 'Not now', onClick: onSkip, variant: 'stroke', disabled: isLoading }, ]} /> ) diff --git a/src/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/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts index d8991d560..b28db670e 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,64 @@ 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 launches the verification and does NOT run the deferred proceed', async () => { - const proceed = jest.fn() - const onCompleteNow = jest.fn() + 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 })) - act(() => result.current.intercept(proceed)) await act(async () => { - await result.current.modalProps.onCompleteNow() + result.current.modalProps.onCompleteNow() + result.current.modalProps.onCompleteNow() + resolve() }) expect(onCompleteNow).toHaveBeenCalledTimes(1) - expect(proceed).not.toHaveBeenCalled() - expect(result.current.modalProps.visible).toBe(false) + }) + + 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) }) }) diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts index 78f29eebb..10c2c46e0 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -4,48 +4,47 @@ 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) 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() } finally { @@ -53,23 +52,6 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = 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 +59,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..f5ed5658a --- /dev/null +++ b/src/hooks/useEeaUpliftFunnel.test.ts @@ -0,0 +1,62 @@ +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) + }) +}) diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts new file mode 100644 index 000000000..076c22954 --- /dev/null +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -0,0 +1,48 @@ +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. + */ +export function useEeaUpliftFunnel(channel: UpliftChannel) { + const startedRef = useRef(null) + + const trackStarted = useCallback( + (advisory: GateAdvisory | undefined) => { + startedRef.current = advisory ?? null + 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]) + + return { trackStarted, trackCompleted } +} From 06928d65164be4325dba88a1b1ef5ff36a734858 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 07:31:14 -0700 Subject: [PATCH 16/27] test(card): decode captured PNG in-browser, drop the sharp dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI typecheck couldn't resolve 'sharp' (not a declared dependency). Decode + sample the captured PNG in-page via an + .getImageData inside page.evaluate instead — same not-empty assertion, zero new dependencies, so the supply-chain min-release-age gate stays untouched. --- e2e/flows/share-asset-capture.spec.ts | 103 +++++++++++++++++--------- 1 file changed, 67 insertions(+), 36 deletions(-) diff --git a/e2e/flows/share-asset-capture.spec.ts b/e2e/flows/share-asset-capture.spec.ts index f76bafd0b..76d44d246 100644 --- a/e2e/flows/share-asset-capture.spec.ts +++ b/e2e/flows/share-asset-capture.spec.ts @@ -15,17 +15,18 @@ * 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 canvas mounts). - * 3. We click Save, intercept the downloaded PNG, decode it, 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). + * 3. We click Save, intercept the downloaded PNG, decode it IN-BROWSER (an + * + .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 sharp from 'sharp' +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 @@ -33,10 +34,10 @@ import sharp from 'sharp' const CANVAS_W = 1200 const CANVAS_H = 900 -// Card-pink (PixelatedCardFace background) and asset-blue (ShareAssetD3 bg). -// "Background" = either of these; the hand is neither. -const CARD_PINK = { r: 0xff, g: 0x90, b: 0xe8 } -const ASSET_BLUE = { r: 0x90, g: 0xa8, b: 0xed } +// 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 @@ -47,12 +48,7 @@ const COLOR_TOL = 12 // 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 } - -function isBackground(r: number, g: number, b: number): boolean { - const near = (c: { r: number; g: number; b: number }): boolean => - Math.abs(r - c.r) <= COLOR_TOL && Math.abs(g - c.g) <= COLOR_TOL && Math.abs(b - c.b) <= COLOR_TOL - return near(CARD_PINK) || near(ASSET_BLUE) -} +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) => { @@ -79,36 +75,71 @@ test.describe('Share-asset capture (card face is not blank)', () => { const pngPath = testInfo.outputPath('share-asset-capture.png') await download.saveAs(pngPath) - // Decode the captured PNG to raw RGBA and sample the central card window. - const { data, info } = await sharp(pngPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) - const { width, height, channels } = info + // Decode + sample IN-BROWSER: load the PNG into an , paint it to a + // , 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 / CANVAS_W - const sy = height / CANVAS_H + // 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 - const STEP = 6 // sample every 6th canvas-px → a dense grid, fast decode - 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) * channels - sampled++ - if (!isBackground(data[idx], data[idx + 1], data[idx + 2])) nonBackground++ + 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 = nonBackground / sampled + 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 ` + - `(${nonBackground}/${sampled} px; output ${width}×${height}). ` + + `(${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) }) From 09345b9e9f747289d6a482fe8d83a881c96ca9f5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 07:43:11 -0700 Subject: [PATCH 17/27] tune(card): door-tally FOMO multiplier x3 -> x5 At x3 the real number is masked by the 213 floor (prod waitlist ~55 -> 55x3 = 165 < 213, so it just renders the hardcoded-looking 213). x5 = 275 clears the floor, so the real inflated count actually shows. Counts source + floor + fallback unchanged. --- .../Card/__tests__/doorTally.utils.test.ts | 19 +++++++++++++------ src/components/Card/doorTally.utils.ts | 9 +++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/Card/__tests__/doorTally.utils.test.ts b/src/components/Card/__tests__/doorTally.utils.test.ts index c63700b61..96c101a38 100644 --- a/src/components/Card/__tests__/doorTally.utils.test.ts +++ b/src/components/Card/__tests__/doorTally.utils.test.ts @@ -8,19 +8,25 @@ import { describe('inflateApplicants', () => { test('multiplies the real waitlist size by the FOMO factor', () => { - // 1000 * 3 = 3000, comfortably above the floor. + // 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 * 3 = 30 < floor → floor wins. + // 10 * 5 = 50 < floor → floor wins. expect(inflateApplicants(10)).toBe(DOOR_TALLY_APPLICANTS_FLOOR) }) - test('rounds to a whole number', () => { - // 71 * 3 = 213 (== floor here); use a value that rounds to confirm. + 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) - expect(inflateApplicants(101)).toBe(303) + // 101 * 5 = 505. + expect(inflateApplicants(101)).toBe(505) }) test.each([undefined, NaN, 0, -5, Infinity])('falls back to the floor for %p', (input) => { @@ -34,7 +40,8 @@ describe('inflateApplicants', () => { describe('computeDoorTally', () => { test('inflates "tried" but shows "got in" verbatim', () => { - expect(computeDoorTally(500, 42)).toEqual({ applicants: 1500, admitted: 42 }) + // 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', () => { diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts index 75c0f8e2e..8f5dec469 100644 --- a/src/components/Card/doorTally.utils.ts +++ b/src/components/Card/doorTally.utils.ts @@ -11,8 +11,13 @@ * frames. No randomness on purpose. */ -/** FOMO inflation factor applied to the real waitlist size for "tried". */ -export const DOOR_TALLY_FOMO_MULTIPLIER = 3 +/** + * 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 From 33f7d7ec501d5bcf75ba8c60c562d341a49b1838 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:19:39 +0530 Subject: [PATCH 18/27] fix(analytics): clear eea-uplift start on KYC abandon The startedRef latch was only cleared on success, so an abandoned uplift attempt could leave it set and a later unrelated KYC success on the same mounted page would mis-fire eea_uplift_completed (inflating the funnel). Reset the pending start via the KYC flow's onManualClose (abandon) on both bank pages. Covers the main mis-attribution path surfaced in review. --- .../(mobile-ui)/add-money/[country]/bank/page.tsx | 9 ++++++++- .../(mobile-ui)/withdraw/[country]/bank/page.tsx | 15 ++++++++++++--- src/hooks/useEeaUpliftFunnel.test.ts | 11 +++++++++++ src/hooks/useEeaUpliftFunnel.ts | 10 +++++++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 3975494fc..d98f5daa4 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -81,13 +81,20 @@ export default function OnrampBankPage() { // (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 } = useEeaUpliftFunnel('deposit') + const { + trackStarted: trackUpliftStarted, + trackCompleted: trackUpliftCompleted, + reset: resetUpliftFunnel, + } = useEeaUpliftFunnel('deposit') const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { trackUpliftCompleted() 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, }) // read country from path params (web) or query params (native/capacitor) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 9b4f64d4c..0f51d7b37 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -92,9 +92,18 @@ export default function WithdrawBankPage() { const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) // 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 } = useEeaUpliftFunnel('withdraw') - - const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => trackUpliftCompleted() }) + const { + trackStarted: trackUpliftStarted, + trackCompleted: trackUpliftCompleted, + reset: resetUpliftFunnel, + } = useEeaUpliftFunnel('withdraw') + + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: () => trackUpliftCompleted(), + // Abandoned attempt: clear the pending start so a later unrelated KYC + // success on this page can't mis-fire eea_uplift_completed. + onManualClose: resetUpliftFunnel, + }) // A ready bank rail can still carry a pending Bridge requirement (the gate's // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the // withdrawal — the offramp cannot proceed until it's completed. diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts index f5ed5658a..f6699601e 100644 --- a/src/hooks/useEeaUpliftFunnel.test.ts +++ b/src/hooks/useEeaUpliftFunnel.test.ts @@ -59,4 +59,15 @@ describe('useEeaUpliftFunnel', () => { 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 index 076c22954..e409adf54 100644 --- a/src/hooks/useEeaUpliftFunnel.ts +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -15,6 +15,10 @@ type UpliftChannel = 'deposit' | 'withdraw' * `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) @@ -44,5 +48,9 @@ export function useEeaUpliftFunnel(channel: UpliftChannel) { }) }, [channel]) - return { trackStarted, trackCompleted } + const reset = useCallback(() => { + startedRef.current = null + }, []) + + return { trackStarted, trackCompleted, reset } } From e6a6aa9a12da8353deab046ce2e0d306d619182b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:27:00 +0530 Subject: [PATCH 19/27] =?UTF-8?q?fix(kyc):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20fire=20uplift-completed=20at=20approval;=20sync=20modal=20li?= =?UTF-8?q?fecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + review findings: - Move eea_uplift_completed off the end-of-flow onKycSuccess onto a new onKycApproved callback fired at the Sumsub approval transition, so it isn't lost when the user drops during post-approval ToS/preparing (undercount) and isn't fired on a ToS-skip without finishing (overcount). - useAdvisoryPreempt: auto-close the modal when the advisory clears while open, and re-show it if the launch fails — the hard gate must not silently vanish or linger over an already-cleared requirement. - useEeaUpliftFunnel: require a real advisory for trackStarted (no partial started payloads). --- .../add-money/[country]/bank/page.tsx | 5 +++- .../withdraw/[country]/bank/page.tsx | 5 +++- src/hooks/useAdvisoryPreempt.test.ts | 28 +++++++++++++++++++ src/hooks/useAdvisoryPreempt.ts | 14 +++++++++- src/hooks/useEeaUpliftFunnel.ts | 13 +++++---- src/hooks/useMultiPhaseKycFlow.ts | 22 +++++++++++++-- 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index d98f5daa4..63c669149 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -88,8 +88,11 @@ export default function OnrampBankPage() { } = 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: () => { - trackUpliftCompleted() setUrlState({ step: 'inputAmount' }) }, // Abandoned attempt: clear the pending start so a later unrelated KYC diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 0f51d7b37..060ddb02c 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -99,7 +99,10 @@ export default function WithdrawBankPage() { } = useEeaUpliftFunnel('withdraw') const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: () => trackUpliftCompleted(), + // Fire completed at Sumsub approval (verification submitted), not at + // end-of-flow — so it isn't lost if the user drops during the + // post-approval ToS / preparing steps. + onKycApproved: () => trackUpliftCompleted(), // Abandoned attempt: clear the pending start so a later unrelated KYC // success on this page can't mis-fire eea_uplift_completed. onManualClose: resetUpliftFunnel, diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts index b28db670e..32ad76147 100644 --- a/src/hooks/useAdvisoryPreempt.test.ts +++ b/src/hooks/useAdvisoryPreempt.test.ts @@ -88,4 +88,32 @@ describe('useAdvisoryPreempt', () => { 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(jest.fn())) + await act(async () => { + await expect(result.current.modalProps.onCompleteNow()).rejects.toThrow('launch failed') + }) + + // 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 10c2c46e0..9f6a7d3ec 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { GateAdvisory } from '@/utils/capability-gate' interface UseAdvisoryPreemptArgs { @@ -27,6 +27,13 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false // 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) => { // Hard gate: a pending requirement blocks the transfer outright. The @@ -47,6 +54,11 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false setVisible(false) 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 } diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts index e409adf54..c6eb11fe7 100644 --- a/src/hooks/useEeaUpliftFunnel.ts +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -24,13 +24,16 @@ export function useEeaUpliftFunnel(channel: UpliftChannel) { const startedRef = useRef(null) const trackStarted = useCallback( - (advisory: GateAdvisory | undefined) => { - startedRef.current = advisory ?? null + // `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, + requirement_key: advisory.requirementKey, + action_key: advisory.actionKey, + effective_date: advisory.effectiveDate, }) }, [channel] 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, From 0185213379ec1c92f95600c2783017cb281a86ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 29 Jun 2026 11:59:34 -0300 Subject: [PATCH 20/27] feat(card): show the specific reason on the rejected-card screen + reassure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "We couldn't issue you a card" screen showed only a generic catch-all body. Surface the vetted, region-aware reason from the capability read-model (e.g. "Peanut cards aren't available in your state yet.") above a reassurance body — a declined card doesn't touch the rest of the account, so point the user back to crypto deposit / withdraw / pay, which still work. --- src/app/(mobile-ui)/card/page.tsx | 18 ++++++- .../Card/ApplicationStatusScreen.tsx | 5 +- .../ApplicationStatusScreen.test.tsx | 53 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/components/Card/__tests__/ApplicationStatusScreen.test.tsx diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index dce8827c7..e4706b3e2 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -553,19 +553,35 @@ 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."), falling + // back to the screen's generic body when capabilities haven't + // resolved. Wait for capabilities so the reason never flashes in. + if (capabilitiesLoading) { + return ( +
+ +
+ ) + } + const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} onPrev={onBack} /> ) + } case 'active': { const card = findActiveCard(overview)! return diff --git a/src/components/Card/ApplicationStatusScreen.tsx b/src/components/Card/ApplicationStatusScreen.tsx index a10240148..ced7d5061 100644 --- a/src/components/Card/ApplicationStatusScreen.tsx +++ b/src/components/Card/ApplicationStatusScreen.tsx @@ -35,8 +35,11 @@ const COPY: Record = { body: "We hit a snag while processing your card application. Our team needs to take a look — message support and we'll get you sorted.", }, rejected: { + // The specific reason (when known) renders above via `reasonMessage`; + // this body stays reassuring — a declined card doesn't touch the rest + // of the account, so point the user back to what still works. title: "We couldn't issue you a card", - body: "Your card application wasn't approved. This might be because of duplicate attempt, incomplete documents, information mismatch, or regional restrictions.", + body: 'You can still use Peanut freely to deposit, withdraw, and pay with crypto.', }, } diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx new file mode 100644 index 000000000..8a48e367f --- /dev/null +++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx @@ -0,0 +1,53 @@ +/** + * 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, + default: (props: Record) => {String(props.alt, +})) + +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() + expect(screen.getByText("Peanut cards aren't available in your state yet.")).toBeInTheDocument() + expect(screen.getByText(REASSURANCE)).toBeInTheDocument() + }) + + it('falls back to the reassurance body alone when no reason is provided', () => { + render() + expect(screen.getByText(REASSURANCE)).toBeInTheDocument() + // No phantom reason paragraph — only the generic body shows. + expect(screen.queryByText(/available in your/i)).not.toBeInTheDocument() + }) + + it('keeps the Contact support action', () => { + const onContactSupport = jest.fn() + render() + fireEvent.click(screen.getByText('Contact support')) + expect(onContactSupport).toHaveBeenCalledTimes(1) + }) +}) From 3780741b35f60d7f6c6e01ca4a1017176fb47511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 29 Jun 2026 12:19:04 -0300 Subject: [PATCH 21/27] test(card): silence next/image stub lint warning in rejected-screen test --- src/components/Card/__tests__/ApplicationStatusScreen.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx index 8a48e367f..56ff4a6ca 100644 --- a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx +++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx @@ -18,6 +18,7 @@ jest.mock('@/context/authContext', () => ({ // 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) => {String(props.alt, })) From 216912a32b922872ca7a466aff7cdf6d65cdd7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 29 Jun 2026 12:30:24 -0300 Subject: [PATCH 22/27] fix(card): render rejected screen immediately + assert reason placement (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't spinner-gate the rejected screen on capabilities — it's useful without the reason (reassurance + support CTA), so show it now and fill reasonMessage once capabilities resolve. Strengthen the test to assert the reason renders ABOVE the body (DOM order) and to count paragraphs generically instead of phrase-matching. --- src/app/(mobile-ui)/card/page.tsx | 19 ++++++++----------- .../ApplicationStatusScreen.test.tsx | 15 ++++++++++----- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index e4706b3e2..3c8121bb0 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -562,17 +562,14 @@ const CardPage: FC = () => { // // 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."), falling - // back to the screen's generic body when capabilities haven't - // resolved. Wait for capabilities so the reason never flashes in. - if (capabilitiesLoading) { - return ( -
- -
- ) - } - const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage + // "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 ( { /> ) expect(screen.getByText("We couldn't issue you a card")).toBeInTheDocument() - expect(screen.getByText("Peanut cards aren't available in your state yet.")).toBeInTheDocument() - expect(screen.getByText(REASSURANCE)).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() - expect(screen.getByText(REASSURANCE)).toBeInTheDocument() - // No phantom reason paragraph — only the generic body shows. - expect(screen.queryByText(/available in your/i)).not.toBeInTheDocument() + 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', () => { From e187dccf5866168c3b5ecf283f9550fa1d2b1313 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 08:35:06 -0700 Subject: [PATCH 23/27] fix(card): force space in physical-waitlist position copy via template literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "You are #N on the list" copy ships to prod AND staging as `["You are #", position, "on the list…"]` — no leading space — so it renders "You are #16on the list". The source has a same-line space (prettier enforces it and reverts `{' '}`), and a clean next-swc compile keeps the space, so the live no-space bundle is a stale build artifact. A template literal bakes the space into one JS string (immune to JSX whitespace handling) and changes the module so the stale artifact can't be reused. Matches the existing pattern in JoinWaitlistPage.tsx. --- src/components/Card/PhysicalCardScreen.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx index 9d6771f62..8154976a6 100644 --- a/src/components/Card/PhysicalCardScreen.tsx +++ b/src/components/Card/PhysicalCardScreen.tsx @@ -76,8 +76,7 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {

You are on the list!

- You are #{data.position} on the list. We'll let you know when cards are ready to be - shipped. + {`You are #${data.position} on the list. We'll let you know when cards are ready to be shipped.`}

) : ( From a2e8f33d36e6282aabfc7728315efb0d910905de Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 09:30:33 -0700 Subject: [PATCH 24/27] fix(card): render share-asset hand as , not a runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the "I'M IN!" / rejection share assets kept capturing with a blank pink card face for some users (ghadi, ubong) even though #2308's readiness gate had been live 57min before the capture. Root cause is NOT timing — the hand was the only in the asset, drawn from an SVG, and html-to-image silently substitutes a blank canvas when canvas.toDataURL() returns empty (node_modules/html-to-image/lib/clone-node.js), which iOS Safari does for an SVG-tainted canvas. The capture "succeeds", so nothing reaches Sentry (confirmed: zero share-asset capture errors despite live blank reports). A mount-gate can't fix a capture-time serialisation failure. Fix: render the hand as a plain pre-pixelated — the same path the badge stickers take, which never blank. html-to-image inlines reliably on every browser. The PNG is baked at the 36px raster and upscaled by image-rendering:pixelated, so it reads identically to the old canvas. This also drops the async-canvas dance from capture (waitForAssetReady now just awaits fonts + decode). onReady still fires on load (with a cached-image fallback) so the Share/Save gate is preserved; the e2e capture spec guards the path end-to-end. --- e2e/flows/share-asset-capture.spec.ts | 21 ++--- src/assets/cards/peanut-card-hand-pixel.png | Bin 0 -> 967 bytes .../Card/share-asset/PixelatedCardFace.tsx | 62 ++++++++------- .../__tests__/captureShareAsset.test.ts | 72 +++++++++++------- .../Card/share-asset/captureShareAsset.ts | 34 +++------ 5 files changed, 93 insertions(+), 96 deletions(-) create mode 100644 src/assets/cards/peanut-card-hand-pixel.png diff --git a/e2e/flows/share-asset-capture.spec.ts b/e2e/flows/share-asset-capture.spec.ts index 76d44d246..096879388 100644 --- a/e2e/flows/share-asset-capture.spec.ts +++ b/e2e/flows/share-asset-capture.spec.ts @@ -1,20 +1,21 @@ /** * Share-asset capture regression — the card face must NOT be blank. * - * The launch-day bug: paints its pixelated hand into a - * appended ASYNCHRONOUSLY (new Image() → onload → appendChild). If a - * capture fired before that canvas mounted, html-to-image snapshotted a blank - * pink card — and the capture SUCCEEDED, so nothing reached Sentry. + * The launch-day bug: painted its pixelated hand into a + * runtime , 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 deterministic fix gates the Save/Share buttons on PixelatedCardFace's - * `onReady` (the hand canvas mounting), so a capture can never fire early. + * The fix renders the hand as a plain pre-pixelated (the same reliable + * path the badge stickers take), and still gates Save/Share on PixelatedCardFace's + * `onReady` (the hand 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 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 canvas mounts). + * to enable (proves the gate releases only after the hand loads). * 3. We click Save, intercept the downloaded PNG, decode it IN-BROWSER (an * + .getImageData — no extra Node dep), and sample a region * in the CENTRE of the card — the hand's territory, away from the top-left @@ -56,9 +57,9 @@ test.describe('Share-asset capture (card face is not blank)', () => { const saveBtn = page.getByTestId('save-image') - // The readiness gate: Save is disabled until the card face's async hand - // mounts (onReady). If it never enables, the gate is broken OR - // the card never painted — both are failures this spec must catch. + // The readiness gate: Save is disabled until the card face's hand + // 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 diff --git a/src/assets/cards/peanut-card-hand-pixel.png b/src/assets/cards/peanut-card-hand-pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..76cfbc07f950610909e8bb54ea6acf40481216ab GIT binary patch literal 967 zcmV;&133JNP)nc}YY;RCt{2mS0R;Wf+H_bJ}iT(j|^G12qy!Km$smH8}ogVvNB# zE)0en4NUEdPDQ^C9FK8yj;PhM{Lk8IzNfF5r7WJ#L}L07p|( z(?TMV_$gaT)_^z30}Ak=)oKm;{r;JZkk99v0^C5!aSN3JSAadh=;`Tk8HSNC3?n`` zIOr@aEDQth9mU3Gzz)0&*s|8Y9UmWe#^dqzm6es5=H_N^O-;>8EEY?JU5>J{vQk2R zDVH*}ilY47(9p0X8`^9(dF!jdi-5PSt!*xmNNi?~%j@+{m6n!nCzHwTROxA2u(-JR zH{khP3Ef^>Tbs?w?19hcb5~SU+yVZOqbe&a+mdGjfq-{$anar0-ab1tG$a+6!r^d8 z<$awippkTkHrR7FfSJ#v@%+}UcPv*f$BytN_0#4;Z z=xv+L7Ld)u;qZd4>-YZYgqjw%+wH5}-QCwTO$%koCW?xR?q=3Pq0oZaY(4-ca@C)n z2EGKYsj3>OtE*cc7#MI$e){|SrLz8m-|wH6BNb=)Ey3^Y?RC}H*DnK&bm}WMr3bq?^Z`g$833d`JGrSc002kjYgM~$>f&g3iyov8N+aFf-TiT6W8>Ek-hX%h z@nYrAz=XvxJQhQ{ZV9~ck}?mB0S|Eun9}1yt|1=a8(_Qjb;WZqYu*+qW6qNN_$_rB z_%2ud>Hl&X=sJ6bP}pIfPf2+!+B+AOAAqmOBd?x5#ogf5zoqm!OXP`1lr7+5KBZoH z_zc0sw0TpccCJ|DD{ZEWByU{0P^b8eIg2h*k}tXJ — the pink peanut card with a runtime-pixelated - * `peanut-card-hand.svg` overlay. + * — the pink peanut card with a pre-pixelated + * peanut-card-hand overlay. * * Renders at native 620×391 (CARD_W/CARD_H). Wrap in `transform: scale(...)` * if you need a smaller preview — the inner layout uses absolute pixel @@ -8,10 +8,10 @@ * . * * When `blurAll` is true (the closed-beta tease), the visa logo, peanut - * logo, card number, and Virtual pill all get rasterised through the - * SAME canvas → image-rendering:pixelated pipeline as the hand, with a - * shared CELL_PX cell size — so every detail on the card is hidden by - * consistent chunky pixels instead of a mix of pixelation + CSS blur. + * logo, card number, and Virtual pill all get rasterised through a + * canvas → image-rendering:pixelated pipeline, with a shared CELL_PX cell + * size — so every detail on the card is hidden by consistent chunky pixels + * instead of a mix of pixelation + CSS blur. * * Shared between , the eligibility-check screen, and the * `/shhhhh` LP hero. Keep this file the single source of truth for the @@ -24,12 +24,12 @@ import { type FC, type CSSProperties } from 'react' import { CARD_W, CARD_H } from './shareAssetLayout' import { PEANUTMAN_LOGO } from '@/assets/mascot' -import PEANUT_CARD_HAND_ASSET from '@/assets/cards/peanut-card-hand.svg' +import PEANUT_CARD_HAND_PIXEL_ASSET from '@/assets/cards/peanut-card-hand-pixel.png' import VISA_BRAND_MARK_ASSET from '@/assets/cards/visa-brand-mark.png' const ASSET_PEANUTMAN_LOGO = PEANUTMAN_LOGO.src const ASSET_VISA_BRAND = VISA_BRAND_MARK_ASSET.src -const ASSET_CARD_HAND = PEANUT_CARD_HAND_ASSET.src +const ASSET_CARD_HAND_PIXEL = PEANUT_CARD_HAND_PIXEL_ASSET.src // Shared pixel-cell target across every element on the card. Each // element rasterises to (displayPx / CELL_PX) pixels then stretches back @@ -37,10 +37,6 @@ 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 @@ -59,10 +55,9 @@ 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 async-rasterised hand has been appended to the - * DOM — 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 mounts (the launch-day "blank card" bug). */ + /** 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 } @@ -282,25 +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<{ onReady?: () => void }> = ({ onReady }) => ( -
{ - 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) - // Card face is now painted — let capture surfaces unblock. - 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={{ @@ -310,6 +307,7 @@ const PixelatedHand: FC<{ onReady?: () => void }> = ({ onReady }) => ( height: 471, transform: 'rotate(-15deg)', transformOrigin: 'center', + imageRendering: 'pixelated', }} /> ) diff --git a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts index 1f50f9f1b..a2f790887 100644 --- a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts +++ b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts @@ -26,50 +26,65 @@ describe('ShareAssetCaptureError', () => { }) /** - * Regression guard for the launch-day "blank share asset" bug: the card-face - * hand is an async-mounted , and capturing before it mounts produced a - * blank card. waitForAssetReady must block the snapshot until that canvas is - * present (+ fonts ready + images decoded), bounded so it never hangs. + * 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', () => { - const realRAF = global.requestAnimationFrame - beforeEach(() => { - // jsdom's RAF is ~16ms; make the poll prompt + deterministic. - global.requestAnimationFrame = ((cb: FrameRequestCallback) => - setTimeout(() => cb(performance.now()), 0)) as unknown as typeof requestAnimationFrame - }) afterEach(() => { - global.requestAnimationFrame = realRAF + // 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 the card-face has mounted (the blank-asset bug)', async () => { - const node = document.createElement('div') // pink box only — no canvas yet + 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, 40)) - expect(resolved).toBe(false) // still gated — a snapshot here would be blank - node.appendChild(document.createElement('canvas')) // the hand mounts - await new Promise((r) => setTimeout(r, 40)) + 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('resolves (never hangs) if the canvas never mounts — bounded by the timeout', async () => { + test('awaits image decode for every before snapshotting', async () => { const node = document.createElement('div') - await expect(waitForAssetReady(node, 40)).resolves.toBeUndefined() + 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('awaits image decode for every before snapshotting', async () => { + test('resolves (never hangs) when images expose no decode() — jsdom / older browsers', async () => { const node = document.createElement('div') - const img = document.createElement('img') - const decode = jest.fn().mockResolvedValue(undefined) - ;(img as unknown as { decode: unknown }).decode = decode - node.appendChild(img) - node.appendChild(document.createElement('canvas')) - await waitForAssetReady(node, 200) - expect(decode).toHaveBeenCalledTimes(1) + node.appendChild(document.createElement('img')) // no decode() in jsdom + await expect(waitForAssetReady(node)).resolves.toBeUndefined() }) test('awaits document.fonts.ready before snapshotting', async () => { @@ -86,8 +101,7 @@ describe('waitForAssetReady — share-asset capture readiness gate', () => { }, }) const node = document.createElement('div') - node.appendChild(document.createElement('canvas')) - await waitForAssetReady(node, 200) + 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 e0eee61b2..a4f50b5be 100644 --- a/src/components/Card/share-asset/captureShareAsset.ts +++ b/src/components/Card/share-asset/captureShareAsset.ts @@ -43,26 +43,17 @@ export class ShareAssetCaptureError extends Error { /** * Wait for the asset's content to be painted before we snapshot. * - * The pink card + its drop-shadow are synchronous, but the card's pixelated - * hand is drawn into a that PixelatedCardFace appends ASYNCHRONOUSLY - * (new Image() → onload → appendChild — see rasterImg / PixelatedHand). Unlike - * an , html-to-image cannot wait for a not-yet-mounted , so - * capturing too early yields a blank card — just the pink box + its floating - * shadow (the launch-day "blank share asset" bug; silent — capture succeeds, - * so nothing reaches Sentry). Gate on: + * 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 (badge stickers + the card's small logo) - * - the async hand being mounted - * bounded by a timeout so a genuinely-stuck asset still captures (never hangs). + * - every decoded (badges, logos, the hand) + * so the snapshot only fires once the bitmaps are actually ready. */ -export const CAPTURE_READY_TIMEOUT_MS = 2500 - -// Exported for unit tests. `timeoutMs` is injectable so the bounded-wait -// behaviour can be asserted without a 2.5s test. -export async function waitForAssetReady( - node: HTMLElement, - timeoutMs: number = CAPTURE_READY_TIMEOUT_MS -): Promise { +export async function waitForAssetReady(node: HTMLElement): Promise { if (typeof document !== 'undefined' && document.fonts?.ready) { try { await document.fonts.ready @@ -75,13 +66,6 @@ export async function waitForAssetReady( typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve() ) ) - // Poll for the async hand canvas to mount (it's appended on image.onload, - // outside React's tree, so html-to-image can't wait for it on its own). - const start = typeof performance !== 'undefined' ? performance.now() : 0 - const elapsed = (): number => (typeof performance !== 'undefined' ? performance.now() : Infinity) - start - while (!node.querySelector('canvas') && elapsed() < timeoutMs) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - } } export async function captureShareAsset(node: HTMLElement): Promise { From 4399a9b271091e996e16b8f4f5af6ccffc79d9b9 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 1 Jul 2026 08:08:42 +0000 Subject: [PATCH 25/27] refactor(assets): name the mascot a mascot, not a logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PEANUTMAN_LOGO was the peanut-character illustration mislabeled as a "logo" — a distinct asset from the app icon (peanut-icon) and the PEANUT wordmark. The name described where it was first used (a header), not what it depicts. - rename export PEANUTMAN_LOGO → PEANUTMAN and file peanut-logo.svg → peanutman.svg; update all 18 call sites, the local ASSET_ var, and the mascot alt text ("logo" → "Peanut mascot") - move PEANUT_LOGO_BLACK out of illustrations/ into logos/ where the brand logo belongs; fix the one subpath import (@/assets/illustrations → logos) No behavior change — same pixels, accurate names. --- src/app/(mobile-ui)/notifications/page.tsx | 4 ++-- .../(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx | 2 +- src/app/dev/loading-words/page.tsx | 4 ++-- src/assets/illustrations/index.ts | 1 - src/assets/logos/index.ts | 1 + src/assets/{illustrations => logos}/peanut-logo-dark.svg | 0 src/assets/mascot/index.ts | 2 +- src/assets/mascot/{peanut-logo.svg => peanutman.svg} | 0 src/components/Badges/badge.utils.ts | 4 ++-- src/components/Card/CardFace.tsx | 4 ++-- src/components/Card/share-asset/PixelatedCardFace.tsx | 8 ++++---- src/components/Claim/Link/Initial.view.tsx | 4 ++-- src/components/Claim/Link/SendLinkActionList.tsx | 6 +++--- src/components/Global/ConfirmInviteModal/index.tsx | 4 ++-- src/components/Global/CreateAccountButton/index.tsx | 4 ++-- src/components/Global/NoMoreJailModal/index.tsx | 4 ++-- src/components/Global/PeanutLoading/CyclingLoading.tsx | 4 ++-- src/components/Global/PeanutLoading/index.tsx | 4 ++-- src/components/Global/QRScanner/index.tsx | 4 ++-- src/components/Profile/components/PublicProfile.tsx | 4 ++-- src/components/TransactionDetails/TransactionCard.tsx | 4 ++-- .../TransactionDetails/TransactionDetailsHeaderCard.tsx | 4 ++-- .../payments/shared/components/SendWithPeanutCta.tsx | 4 ++-- 23 files changed, 40 insertions(+), 40 deletions(-) rename src/assets/{illustrations => logos}/peanut-logo-dark.svg (100%) rename src/assets/mascot/{peanut-logo.svg => peanutman.svg} (100%) diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index c64d7400d..a471b20eb 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -9,7 +9,7 @@ import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import Link from 'next/link' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' @@ -172,7 +172,7 @@ export default function NotificationsPage() { onClick={() => handleNotificationClick(notif.id)} > icon ({ // The page imports PeanutThinking from @/assets/mascot and STAR_STRAIGHT_ICON // from @/assets/icons directly — mock those paths, not the @/assets barrel, and -// keep sibling exports (e.g. PEANUTMAN_LOGO, ETHEREUM_ICON used by QRScanner) intact. +// keep sibling exports (e.g. PEANUTMAN, ETHEREUM_ICON used by QRScanner) intact. jest.mock('@/assets/mascot', () => ({ ...jest.requireActual('@/assets/mascot'), PeanutThinking: '/peanut-guy.gif', diff --git a/src/app/dev/loading-words/page.tsx b/src/app/dev/loading-words/page.tsx index 3f0ae1b91..89a649728 100644 --- a/src/app/dev/loading-words/page.tsx +++ b/src/app/dev/loading-words/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { PAYMENT_LOADING_WORDS } from '@/components/Global/PeanutLoading/words' import { useEffect, useState } from 'react' @@ -41,7 +41,7 @@ export default function LoadingWordsPreview() {
- Peanut + Peanut
diff --git a/src/assets/illustrations/index.ts b/src/assets/illustrations/index.ts index 28f5a8537..9e711b84f 100644 --- a/src/assets/illustrations/index.ts +++ b/src/assets/illustrations/index.ts @@ -8,7 +8,6 @@ export { default as HandThumbsUpV2 } from './hand-thumbs-up-v2.svg' export { default as HandToken } from './new-hand-token.svg' export { default as AboutPeanut } from './new-hero-description.svg' //export { default as PeanutArmHoldingBeer } from './peanut-arm-holding-beer.svg' -export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg' export { default as PeanutsBG } from './peanuts-bg.svg' export { default as Sparkle } from './sparkle.svg' export { default as Star } from './star.svg' diff --git a/src/assets/logos/index.ts b/src/assets/logos/index.ts index 878328846..4cb16fce1 100644 --- a/src/assets/logos/index.ts +++ b/src/assets/logos/index.ts @@ -1,4 +1,5 @@ export { default as PEANUT_LOGO } from './peanut-logo.svg'; +export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg'; export { default as MEPA_ARGENTINA_LOGO } from './mepa-arg.svg'; export { default as PIX_BRZ_LOGO } from './pix-brz.svg'; export { default as DEVCONNECT_LOGO } from './devconnect.svg'; \ No newline at end of file diff --git a/src/assets/illustrations/peanut-logo-dark.svg b/src/assets/logos/peanut-logo-dark.svg similarity index 100% rename from src/assets/illustrations/peanut-logo-dark.svg rename to src/assets/logos/peanut-logo-dark.svg diff --git a/src/assets/mascot/index.ts b/src/assets/mascot/index.ts index 6092e03b1..e11d769ba 100644 --- a/src/assets/mascot/index.ts +++ b/src/assets/mascot/index.ts @@ -15,7 +15,7 @@ export { default as PeanutWalking } from './peanut-walking.webp' // mid-stride, export { default as PeanutPointingDown } from './peanut-pointing-down.webp' // both hands pointing down — marketing CTA // Stills -export { default as PEANUTMAN_LOGO } from './peanut-logo.svg' +export { default as PEANUTMAN } from './peanutman.svg' export { default as PEANUTMAN_PFP } from './peanut-pfp.svg' export { default as PEANUTMAN_HOLDING_BEER } from './peanut-beer.svg' export { default as PEANUTMAN_MOBILE } from './peanut-club.webp' diff --git a/src/assets/mascot/peanut-logo.svg b/src/assets/mascot/peanutman.svg similarity index 100% rename from src/assets/mascot/peanut-logo.svg rename to src/assets/mascot/peanutman.svg diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index a8e442e02..5bfef6423 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -9,7 +9,7 @@ // Codes here must match the backend registry (peanut-api-ts // src/acknowledgments/seed-definitions.ts). How the whole system works + how to // add a badge: peanut-api-ts/docs/BADGES.md. -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export type BadgeMeta = { path: string @@ -236,7 +236,7 @@ export const BADGE_CODES: readonly string[] = Object.keys(BADGES) export function getBadgeIcon(code?: string): string { // .src: the svg import is StaticImageData (typed `any` by the module shim, so the // annotation alone can't enforce this) — raw consumers need a string URL. - return (code && BADGES[code]?.path) || PEANUTMAN_LOGO.src + return (code && BADGES[code]?.path) || PEANUTMAN.src } // returns the public-facing description for a badge code (third-person perspective) diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 14bff2756..8577681ac 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { twMerge } from 'tailwind-merge' import { Icon } from '@/components/Global/Icons/Icon' import { PEANUT_CARD_HAND, VISA_BRAND_MARK } from '@/assets/cards' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export interface RevealedCardDetails { pan: string @@ -86,7 +86,7 @@ const CardFace: FC = ({
{/* Top row: peanut icon (no wordmark) + Visa */}
- + Visa
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index d699f842e..acc75b88a 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -23,11 +23,11 @@ import { type FC, type CSSProperties } from 'react' import { CARD_W, CARD_H } from './shareAssetLayout' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import PEANUT_CARD_HAND_ASSET from '@/assets/cards/peanut-card-hand.svg' 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 @@ -81,9 +81,9 @@ export const PixelatedCardFace: FC = ({ {/* Top row: peanut logo (left) + visa logo (right) */}
{blurAll ? ( - + ) : ( - + )} {!hideVisa && (blurAll ? ( diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 970a57d1e..2e0e699a3 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -42,7 +42,7 @@ import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol } from '@/utils/general.utils' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { GuestVerificationModal } from '@/components/Global/GuestVerificationModal' import { useCapabilities } from '@/hooks/useCapabilities' import MantecaFlowManager from './MantecaFlowManager' @@ -822,7 +822,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
Receive on
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 7d87796da..795a4722e 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' @@ -237,7 +237,7 @@ export default function SendLinkActionList({ > {showDevconnectMethod ?
Claim on
:
Continue with
}
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Global/ConfirmInviteModal/index.tsx b/src/components/Global/ConfirmInviteModal/index.tsx index a9bc915fb..8a811a8ae 100644 --- a/src/components/Global/ConfirmInviteModal/index.tsx +++ b/src/components/Global/ConfirmInviteModal/index.tsx @@ -1,7 +1,7 @@ 'use client' import { type FC } from 'react' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import Modal from '../Modal' import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' @@ -52,7 +52,7 @@ const ConfirmInviteModal: FC = ({ diff --git a/src/components/Global/CreateAccountButton/index.tsx b/src/components/Global/CreateAccountButton/index.tsx index 7cd3e0c48..fb3e2d4dd 100644 --- a/src/components/Global/CreateAccountButton/index.tsx +++ b/src/components/Global/CreateAccountButton/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' @@ -13,7 +13,7 @@ const CreateAccountButton = ({ onClick }: CreateAccountButtonProps) => { diff --git a/src/components/Global/PeanutLoading/CyclingLoading.tsx b/src/components/Global/PeanutLoading/CyclingLoading.tsx index f5e63cc25..3595a741a 100644 --- a/src/components/Global/PeanutLoading/CyclingLoading.tsx +++ b/src/components/Global/PeanutLoading/CyclingLoading.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { useEffect, useState } from 'react' import { PAYMENT_LOADING_WORDS } from './words' @@ -26,7 +26,7 @@ export default function CyclingLoading() {
- logo + logo {word}
diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx index 6d4a2d02f..a2d566fd5 100644 --- a/src/components/Global/PeanutLoading/index.tsx +++ b/src/components/Global/PeanutLoading/index.tsx @@ -1,4 +1,4 @@ -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import { twMerge } from 'tailwind-merge' export default function PeanutLoading({ @@ -18,7 +18,7 @@ export default function PeanutLoading({ )} >
- logo + Peanut mascot {message ?? 'Loading...'}
diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index cfb9ee688..816b9fd46 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom' import { Button } from '@/components/0_Bruddle/Button' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { ETHEREUM_ICON } from '@/assets/icons' import Image from 'next/image' import { Icon } from '../Icons/Icon' @@ -14,7 +14,7 @@ import CameraPermissionModal from './CameraPermissionModal' // ============================================================================ const PAYMENT_METHODS = [ - { src: PEANUTMAN_LOGO, alt: 'Peanut', name: 'Peanut' }, + { src: PEANUTMAN, alt: 'Peanut', name: 'Peanut' }, { src: MERCADO_PAGO, alt: 'Mercado Pago', name: 'Mercado Pago' }, { src: PIX, alt: 'PIX', name: 'PIX' }, { src: ETHEREUM_ICON, alt: 'Ethereum and EVMs', name: 'ETH & EVMs' }, diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 9578ebeba..72b33dfdb 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -1,6 +1,6 @@ 'use client' -import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' @@ -76,7 +76,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa
{!isLoggedIn ? (
- Peanut Logo + Peanut mascot Peanut Text
) : ( diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index c2a14631a..03881c27f 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -26,7 +26,7 @@ import { VerifiedUserLabel } from '../UserHeader' import { PerkIcon } from './PerkIcon' import { useHaptic } from 'use-haptic' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import InvitesIcon from '../Home/InvitesIcon' // Lazy load transaction details drawer (~40KB) to reduce initial bundle size @@ -167,7 +167,7 @@ const TransactionCard: React.FC = ({ {isTestTransaction ? (
Peanut Logo
- Peanut Logo + Peanut Logo

Enjoy Peanut!

diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx index cac78d970..7fb188424 100644 --- a/src/features/payments/shared/components/SendWithPeanutCta.tsx +++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx @@ -8,7 +8,7 @@ * - logged in: "send with peanut" + executes payment */ -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import type { IconName } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' @@ -100,7 +100,7 @@ export default function SendWithPeanutCta({ const peanutLogo = useMemo((): React.ReactNode => { return (
- Peanut Logo + Peanut Logo Peanut Logo
) From 8e5802d2632f606e352783b2f182ac78894782bc Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 1 Jul 2026 08:08:53 +0000 Subject: [PATCH 26/27] chore(assets): add public/peanutman.svg alias for the mascot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the correctly-named public copy of the mascot while KEEPING public/peanutman-logo.svg in place, so any external consumer that hardcodes peanut.me/peanutman-logo.svg (marketing emails, embeds, the native app) keeps resolving. Public asset paths are an external API — rename by adding, never by moving. Note: public/logo-favicon.png is intentionally left untouched — it is the favicon/app-icon (accurately named) and is load-bearing for the pending Card Closed Beta email drip (16 templates hardcode its URL). --- public/peanutman.svg | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 public/peanutman.svg diff --git a/public/peanutman.svg b/public/peanutman.svg new file mode 100644 index 000000000..df72daff4 --- /dev/null +++ b/public/peanutman.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 0a82a0f4ee964d048aa369d60b224fed63aa6713 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:23:19 +0530 Subject: [PATCH 27/27] chore(assets): drop redundant public/peanutman.svg alias It duplicated the existing public/peanutman-logo.svg (identical blob) and was referenced by nothing. The in-app mascot resolves via the src import (@/assets/mascot -> peanutman.svg); external emails/embeds use the existing peanutman-logo.svg alias. A second never-deployed public copy was dead weight. --- public/peanutman.svg | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 public/peanutman.svg diff --git a/public/peanutman.svg b/public/peanutman.svg deleted file mode 100644 index df72daff4..000000000 --- a/public/peanutman.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file