diff --git a/e2e/flows/share-asset-capture.spec.ts b/e2e/flows/share-asset-capture.spec.ts new file mode 100644 index 000000000..096879388 --- /dev/null +++ b/e2e/flows/share-asset-capture.spec.ts @@ -0,0 +1,147 @@ +/** + * Share-asset capture regression — the card face must NOT be blank. + * + * 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 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 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 + * logo and bottom-left card number. If that region is ENTIRELY the card + * pink (#FF90E8) the hand never rendered → the blank-card bug. We assert + * it contains non-background pixels (the hand was captured). + * + * No harness auth needed — /dev/share-builder is a pure client-render dev page. + */ + +import { test, expect } from '@playwright/test' +import { readFileSync } from 'fs' + +// Asset + card geometry — mirror of shareAssetLayout.ts (CANVAS_W/H, CARD_*). +// Hardcoded (not imported) to match the e2e convention of not pulling app code +// through the '@/' alias into the Playwright tsconfig. +const CANVAS_W = 1200 +const CANVAS_H = 900 + +// Card-pink (PixelatedCardFace background) and asset-blue (ShareAssetD3 bg) as +// [r,g,b]. "Background" = either of these; the hand is neither. +const CARD_PINK: [number, number, number] = [0xff, 0x90, 0xe8] +const ASSET_BLUE: [number, number, number] = [0x90, 0xa8, 0xed] +const COLOR_TOL = 12 + +// Central card region in 1200×900 canvas coords. The card is centred +// (CARD_LEFT 220, CARD_TOP 210, CARD_W 760, CARD_H ≈ 479 → centre ≈ 600,449). +// This window sits well inside the card and is HAND-ONLY: it excludes the +// peanut logo (top-left ~248–300 px) and the "????" number (bottom-left, +// canvas-y ≳ 615). So a blank card → this window is pure pink; the hand +// present → many non-background pixels. Small enough that the card's -8° +// final rotation can't rotate any background (blue) into it. +const REGION = { x0: 430, y0: 330, x1: 770, y1: 570 } +const SAMPLE_STEP = 6 // sample every 6th canvas-px → a dense grid + +test.describe('Share-asset capture (card face is not blank)', () => { + test('captured PNG card region contains the hand, not just background', async ({ page }, testInfo) => { + await page.goto('/dev/share-builder', { waitUntil: 'domcontentloaded' }) + + const saveBtn = page.getByTestId('save-image') + + // The readiness gate: Save is disabled until the card face's hand + // loads (onReady). If it never enables, the gate is broken OR the card + // never painted — both are failures this spec must catch. + await expect(saveBtn, 'Save must enable once the card face signals ready').toBeEnabled({ timeout: 60_000 }) + + // Let the card-slide / sticker-drop animations settle to their final + // frame so the card sits at its designed (centred, -8°) position before + // we capture. The capture itself overrides only the root scale; the + // card's own transform is whatever frame it's on. + await page.waitForTimeout(2_500) + + // Click Save → captureShareAsset(node) → downloadBlob() fires a real + // download of the native-resolution PNG. + const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await saveBtn.click() + const download = await downloadPromise + const pngPath = testInfo.outputPath('share-asset-capture.png') + await download.saveAs(pngPath) + + // Decode + sample IN-BROWSER: load the PNG into an , 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 / canvasW + const sy = height / canvasH + const near = (r: number, g: number, b: number, c: number[]): boolean => + Math.abs(r - c[0]) <= tol && Math.abs(g - c[1]) <= tol && Math.abs(b - c[2]) <= tol + + let sampled = 0 + let nonBackground = 0 + for (let cy = region.y0; cy <= region.y1; cy += step) { + for (let cx = region.x0; cx <= region.x1; cx += step) { + const px = Math.min(width - 1, Math.round(cx * sx)) + const py = Math.min(height - 1, Math.round(cy * sy)) + const idx = (py * width + px) * 4 + const r = buf[idx] + const g = buf[idx + 1] + const b = buf[idx + 2] + sampled++ + if (!(near(r, g, b, pink) || near(r, g, b, blue))) nonBackground++ + } + } + return { width, height, sampled, nonBackground } + }, + { + b64: base64, + canvasW: CANVAS_W, + canvasH: CANVAS_H, + region: REGION, + pink: CARD_PINK as number[], + blue: ASSET_BLUE as number[], + tol: COLOR_TOL, + step: SAMPLE_STEP, + } + ) + + const fraction = result.nonBackground / result.sampled + // Blank card → fraction ≈ 0 (pure pink). Hand present → a large chunk of + // the centre is non-pink. A 2% floor cleanly separates the two while + // staying far below the hand's real coverage (avoids flake). + expect( + fraction, + `card centre is ${(fraction * 100).toFixed(1)}% non-background ` + + `(${result.nonBackground}/${result.sampled} px; output ${result.width}×${result.height}). ` + + `≈0% means the pixelated hand never rendered — the blank-card capture bug.` + ).toBeGreaterThan(0.02) + }) +}) 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..63c669149 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,10 +79,25 @@ 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, + reset: resetUpliftFunnel, + } = useEeaUpliftFunnel('deposit') + const sumsubFlow = useMultiPhaseKycFlow({ + // Fire completed at Sumsub approval (verification submitted), not at + // end-of-flow — so it isn't lost if the user drops during the + // post-approval ToS / preparing steps. + onKycApproved: () => trackUpliftCompleted(), onKycSuccess: () => { setUrlState({ step: 'inputAmount' }) }, + // Abandoned attempt: clear the pending start so a later unrelated KYC + // success on this page can't mis-fire eea_uplift_completed. + onManualClose: resetUpliftFunnel, }) // read country from path params (web) or query params (native/capacitor) @@ -115,8 +131,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 +141,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 +267,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)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index dce8827c7..7d80a6aca 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -13,7 +13,6 @@ import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' import CardTermsScreen from '@/components/Card/CardTermsScreen' import CardRejectionScreen from '@/components/Card/CardRejectionScreen' -import CardWaitlistJoinedScreen from '@/components/Card/CardWaitlistJoinedScreen' import BadgeSkipCelebration from '@/components/Card/BadgeSkipCelebration' import CardEligibilityCheckScreen from '@/components/Card/CardEligibilityCheckScreen' import YourCardScreen from '@/components/Card/YourCardScreen' @@ -470,18 +469,17 @@ const CardPage: FC = () => { /> ) case 'waitlist': { - // Joined vs not-joined are two distinct screens — keeps each - // tight to its own purpose. Not-joined is the Berghain-style - // "not tonight" rejection: a shareable door let-down (tags - // @joinpeanut) that doubles as the waitlist-join CTA. Once - // they join, the state machine flips to the friendly - // cooldown. - if (cardInfo!.waitlistJoinedAt) { - return - } + // The Berghain-style "not tonight" rejection is the TERMINAL + // waitlist screen — a shareable door let-down (tags @joinpeanut) + // that doubles as the waitlist-join CTA. Once they join we keep + // them here (`alreadyJoined`) so the asset + "Tweet to appeal" + // stay grabbable — no separate cooldown screen to dead-end on. return ( @@ -553,19 +551,32 @@ const CardPage: FC = () => { onPrev={onBack} /> ) - case 'rejected': + case 'rejected': { // No retry CTA: Rain denials are terminal on our side. The // only path forward is support reviewing the case manually // (PEP / sanctions / fraud-pattern flags need a human in the // loop on Rain's end). Open Crisp directly — sending the user // to /support's FAQ first adds a step for no upside. + // + // Surface the specific, vetted rejection reason from the + // capabilities read-model (`rail.reason.userMessage` — e.g. + // "Peanut cards aren't available in your state yet."). Render + // immediately rather than spinner-gating: unlike requires-info + // (meaningless without its reason), the rejected screen is useful + // on its own (reassurance + support CTA), so show it now and let + // the reason fill in once capabilities resolve. + const cardRailReason = capabilitiesLoading + ? undefined + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} onPrev={onBack} /> ) + } case 'active': { const card = findActiveCard(overview)! return diff --git a/src/app/(mobile-ui)/dev/rejection-builder/page.tsx b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx index 544119223..3b229899c 100644 --- a/src/app/(mobile-ui)/dev/rejection-builder/page.tsx +++ b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx @@ -13,6 +13,7 @@ import { useState } from 'react' import NavHeader from '@/components/Global/NavHeader' import CardRejectionScreen from '@/components/Card/CardRejectionScreen' +import { computeDoorTally } from '@/components/Card/doorTally.utils' import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' const MASCOTS: ReadonlyArray<[RejectionMascot, string]> = [ @@ -25,8 +26,13 @@ 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 (
@@ -75,48 +81,81 @@ 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) }} > - 213 / 7 + empty (floor) { - setApplicants(1842) - setAdmitted(11) + setWaitlistTotal(120) + setAdmittedTotal(7) }} > - 1842 / 11 + early beta + + { + setWaitlistTotal(1842) + setAdmittedTotal(140) + }} + > + busy door
+ +
+ +

+ 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 ──── */} @@ -132,9 +171,10 @@ export default function RejectionBuilderPage() { alert('→ joined: would advance to the friendly waitlist-joined screen')} + waitlistTotal={waitlistTotal} + admittedTotal={admittedTotal} + alreadyJoined={alreadyJoined} + onJoined={() => setAlreadyJoined(true)} />
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/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/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index ec7045921..060ddb02c 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -32,6 +32,7 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' +import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -89,9 +90,26 @@ export default function WithdrawBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) - const sumsubFlow = useMultiPhaseKycFlow({}) - // A ready bank rail can still carry a future-dated requirement (the gate's - // `advisory`). Offer it as a skippable pre-empt before the withdrawal. + // EEA-uplift funnel events (PostHog): started on launch, completed on KYC + // success. trackCompleted no-ops unless an uplift was started this session. + const { + trackStarted: trackUpliftStarted, + trackCompleted: trackUpliftCompleted, + reset: resetUpliftFunnel, + } = useEeaUpliftFunnel('withdraw') + + const sumsubFlow = useMultiPhaseKycFlow({ + // Fire completed at Sumsub approval (verification submitted), not at + // end-of-flow — so it isn't lost if the user drops during the + // post-approval ToS / preparing steps. + onKycApproved: () => trackUpliftCompleted(), + // Abandoned attempt: clear the pending start so a later unrelated KYC + // success on this page can't mis-fire eea_uplift_completed. + onManualClose: resetUpliftFunnel, + }) + // A ready bank rail can still carry a pending Bridge requirement (the gate's + // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the + // withdrawal — the offramp cannot proceed until it's completed. const advisory = gate.kind === 'ready' ? gate.advisory : undefined const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, @@ -99,8 +117,11 @@ export default function WithdrawBankPage() { // Route through the self-heal resubmit path (reheal-tagged action) so the // completed submission round-trips to Bridge. start-action mints a plain // token whose webhook completion has no Bridge relay → answers are dropped. - onCompleteNow: () => - advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + onCompleteNow: () => { + if (!advisory) return Promise.resolve() + trackUpliftStarted(advisory) + return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) + }, }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() @@ -322,8 +343,8 @@ export default function WithdrawBankPage() { } } - // Offer the skippable advisory pre-empt once, then run the offramp. When the - // gate isn't `ready` (or nothing is future-dated) this is a no-op and + // Enforce the mandatory verification pre-empt, then run the offramp. When the + // gate isn't `ready` (or there's no pending requirement) this is a no-op and // proceedWithOfframp runs straight away (it handles the not-ready cases). const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp()) diff --git a/src/app/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/cards/peanut-card-hand-pixel.png b/src/assets/cards/peanut-card-hand-pixel.png new file mode 100644 index 000000000..76cfbc07f Binary files /dev/null and b/src/assets/cards/peanut-card-hand-pixel.png differ 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/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/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/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/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx index 488a6c3d1..c2525f60c 100644 --- a/src/components/Card/CardRejectionScreen.tsx +++ b/src/components/Card/CardRejectionScreen.tsx @@ -8,8 +8,12 @@ * a shareable door rejection: a dark "not tonight, " asset with a * smug peanut bouncer, the scarcity tally as screen copy, and a primary * "Tweet to appeal" CTA that shares the asset (random caption tagging - * @joinpeanut). The friendly waitlist-joined screen is the cooldown AFTER - * they share, so they don't rage-quit. + * @joinpeanut). + * + * This is the TERMINAL waitlist screen (no separate cooldown): once the user + * has joined (`alreadyJoined`) the secondary "Join anyway" button becomes an + * "on the list" confirmation, but the shareable asset + "Tweet to appeal" + * stay so they can keep appealing / re-grab the asset. */ import { type FC, useEffect, useRef, useState } from 'react' @@ -17,43 +21,61 @@ import * as Sentry from '@sentry/nextjs' import { Button } from '@/components/0_Bruddle/Button' import NavHeader from '@/components/Global/NavHeader' import ErrorAlert from '@/components/Global/ErrorAlert' +import { Icon } from '@/components/Global/Icons/Icon' import { ScaledRejectionAsset } from '@/components/Card/share-asset/ScaledRejectionAsset' import { captureShareAsset, canShareImageFiles } from '@/components/Card/share-asset/captureShareAsset' import { pickRejectionCaption } from '@/components/Card/share-asset/rejectionCaptions' import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' +import { computeDoorTally } from '@/components/Card/doorTally.utils' import { cardApi } from '@/services/card' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' interface Props { username?: string - /** Door tally — scarcity flex, rendered as screen copy (not on the asset). */ - applicants?: number - admitted?: number + /** Real waitlist size (total who joined). The screen inflates this for the + * FOMO "tried" tally — mirrors the /shhhhh ScarcityCounter flex. Undefined + * while /card is still loading → a sane floor renders. */ + waitlistTotal?: number + /** Real number admitted (released/granted). Shown verbatim as "got in". */ + admittedTotal?: number /** Which smug mascot the asset shows. */ mascot?: RejectionMascot + /** True once the user is already on the waitlist — swaps the "Join anyway" + * button for an "on the list" confirmation while keeping the asset + appeal. */ + alreadyJoined?: boolean onPrev?: () => void - /** Called after the user joins the waitlist. Parent should refetch /card, - * which flips the state machine to (cooldown). */ + /** Called after the user joins the waitlist. Parent refetches /card; the + * user stays on this screen, now in its `alreadyJoined` state. */ onJoined?: () => void } const CardRejectionScreen: FC = ({ username, - applicants = 213, - admitted = 7, + waitlistTotal, + admittedTotal, mascot = 'cool', + alreadyJoined = false, onPrev, onJoined, }) => { + // "tried" = real waitlist size, inflated for FOMO; "got in" = real admitted. + // Deterministic (pure fn of the counts) so it never jitters between renders. + const { applicants, admitted } = computeDoorTally(waitlistTotal, admittedTotal) const captureRef = useRef(null) const [sharing, setSharing] = useState(false) const [joining, setJoining] = useState(false) const [joinError, setJoinError] = useState(null) + // Local "just joined" override so the CTA swaps to the on-the-list state + // immediately on a confirmed join, without waiting for the parent's /card + // refetch (CodeRabbit). OR'd with the `alreadyJoined` prop. + const [locallyJoined, setLocallyJoined] = useState(false) + const showJoined = alreadyJoined || locallyJoined const safeUsername = (username || '').trim() || 'anon' useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: alreadyJoined }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once on mount with the entry state }, []) const handleJoin = async (): Promise => { @@ -62,6 +84,7 @@ const CardRejectionScreen: FC = ({ try { const res = await cardApi.joinWaitlist() posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) + setLocallyJoined(true) onJoined?.() } catch (e) { // Keep the user-facing message generic; raw BE text can leak @@ -77,6 +100,9 @@ const CardRejectionScreen: FC = ({ } const handleAppeal = async (): Promise => { + // Clear any stale "failed to join" error from an earlier handleJoin so + // the success state can't render alongside it (CodeRabbit). + setJoinError(null) const caption = pickRejectionCaption() // Appeal = tweet AND join the waitlist. Joining is NOT access — release @@ -91,6 +117,7 @@ const CardRejectionScreen: FC = ({ .joinWaitlist() .then((res) => { posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position, source: 'appeal' }) + setLocallyJoined(true) }) .catch((e) => { // Non-fatal: the tweet still goes out, and the CARD_SHARE_ASSET_SHARED @@ -184,15 +211,22 @@ const CardRejectionScreen: FC = ({ > Tweet to appeal - + {showJoined ? ( +
+ + You're on the list — we'll holler when it's your turn +
+ ) : ( + + )}
) 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/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 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.`}

) : ( diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx new file mode 100644 index 000000000..fd3f414ce --- /dev/null +++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx @@ -0,0 +1,59 @@ +/** + * ApplicationStatusScreen — rejected-card copy contract. + * + * The rejected variant must (1) surface the specific, vetted reason from the + * capability read-model when present (e.g. "…available in your state yet."), + * (2) always reassure the user that the rest of Peanut still works, and + * (3) keep the Contact-support path. The reason line is optional — older + * rejections without a persisted reason fall back to the body alone. + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' + +// NavHeader reads useAuth; stub it so the presentational screen renders alone. +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), +})) +// next/image → plain img so jsdom doesn't choke on the optimizer. +jest.mock('next/image', () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element -- test stub, not real markup + default: (props: Record) => {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() + const reason = screen.getByText("Peanut cards aren't available in your state yet.") + const body = screen.getByText(REASSURANCE) + // Contract: the specific reason renders ABOVE the reassurance body. + expect(reason.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + // Both paragraphs live in the same copy block (reason + body). + expect(body.parentElement?.querySelectorAll('p')).toHaveLength(2) + }) + + it('falls back to the reassurance body alone when no reason is provided', () => { + render() + const body = screen.getByText(REASSURANCE) + expect(body).toBeInTheDocument() + // No phantom reason paragraph — the copy block holds only the body

. + expect(body.parentElement?.querySelectorAll('p')).toHaveLength(1) + }) + + it('keeps the Contact support action', () => { + const onContactSupport = jest.fn() + render() + fireEvent.click(screen.getByText('Contact support')) + expect(onContactSupport).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/Card/__tests__/doorTally.utils.test.ts b/src/components/Card/__tests__/doorTally.utils.test.ts new file mode 100644 index 000000000..96c101a38 --- /dev/null +++ b/src/components/Card/__tests__/doorTally.utils.test.ts @@ -0,0 +1,57 @@ +import { + inflateApplicants, + computeDoorTally, + DOOR_TALLY_APPLICANTS_FLOOR, + DOOR_TALLY_ADMITTED_FALLBACK, + DOOR_TALLY_FOMO_MULTIPLIER, +} from '../doorTally.utils' + +describe('inflateApplicants', () => { + test('multiplies the real waitlist size by the FOMO factor', () => { + // 1000 * 5 = 5000, comfortably above the floor. + expect(inflateApplicants(1000)).toBe(1000 * DOOR_TALLY_FOMO_MULTIPLIER) + }) + + test('clamps to the floor when the inflated value is small', () => { + // 10 * 5 = 50 < floor → floor wins. + expect(inflateApplicants(10)).toBe(DOOR_TALLY_APPLICANTS_FLOOR) + }) + + test('clears the 213 floor at the current prod waitlist size (~55)', () => { + // The whole point of ×5: 55 * 5 = 275 > 213, so the real (inflated) + // number renders instead of the masked floor. + expect(inflateApplicants(55)).toBe(275) + }) + + test('returns a whole number above the floor', () => { + expect(Number.isInteger(inflateApplicants(101))).toBe(true) + // 101 * 5 = 505. + expect(inflateApplicants(101)).toBe(505) + }) + + test.each([undefined, NaN, 0, -5, Infinity])('falls back to the floor for %p', (input) => { + expect(inflateApplicants(input as number)).toBe(DOOR_TALLY_APPLICANTS_FLOOR) + }) + + test('is deterministic — same input, same output (no jitter)', () => { + expect(inflateApplicants(742)).toBe(inflateApplicants(742)) + }) +}) + +describe('computeDoorTally', () => { + test('inflates "tried" but shows "got in" verbatim', () => { + // 500 * 5 = 2500 tried; 42 admitted shown as-is. + expect(computeDoorTally(500, 42)).toEqual({ applicants: 2500, admitted: 42 }) + }) + + test('uses loading fallbacks when counts are missing', () => { + expect(computeDoorTally(undefined, undefined)).toEqual({ + applicants: DOOR_TALLY_APPLICANTS_FLOOR, + admitted: DOOR_TALLY_ADMITTED_FALLBACK, + }) + }) + + test('admitted=0 is real and shown as 0 (not the fallback)', () => { + expect(computeDoorTally(1000, 0).admitted).toBe(0) + }) +}) diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts new file mode 100644 index 000000000..8f5dec469 --- /dev/null +++ b/src/components/Card/doorTally.utils.ts @@ -0,0 +1,60 @@ +/** + * Door-tally math for the Berghain rejection screen ("N tried · M got in"). + * + * The backend (/card → waitlistTotal, admittedTotal) reports the REAL counts. + * "tried" gets inflated for FOMO — the same fake-scarcity flex the /shhhhh + * landing already does with its "only 20 a week" ScarcityCounter — while + * "got in" is shown verbatim (the real number admitted). + * + * The inflation is a pure function of the real count (a constant multiplier + * with a floor), so it's deterministic per render and never jitters between + * frames. No randomness on purpose. + */ + +/** + * FOMO inflation factor applied to the real waitlist size for "tried". ×5 (not + * ×3) so the real number clears the 213 floor at the current prod waitlist size + * (~55 → 55×5 = 275 > 213) — otherwise the floor masks the real count and the + * tally looks frozen at 213. + */ +export const DOOR_TALLY_FOMO_MULTIPLIER = 5 + +/** + * Minimum "tried" — keeps the door looking busy when the real waitlist is + * still small, and doubles as the loading fallback. 213 mirrors the original + * hardcoded tally so the copy reads identically before counts land. + */ +export const DOOR_TALLY_APPLICANTS_FLOOR = 213 + +/** Loading fallback for "got in" (real admitted count, before it lands). */ +export const DOOR_TALLY_ADMITTED_FALLBACK = 7 + +export type DoorTally = { + /** Inflated "tried" count (FOMO). */ + applicants: number + /** Real "got in" count, verbatim. */ + admitted: number +} + +/** + * Inflate the real waitlist size into the FOMO "tried" number. Falls back to + * the floor for missing/zero/invalid input (e.g. counts still loading). + */ +export function inflateApplicants(waitlistTotal?: number): number { + if (typeof waitlistTotal !== 'number' || !Number.isFinite(waitlistTotal) || waitlistTotal <= 0) { + return DOOR_TALLY_APPLICANTS_FLOOR + } + return Math.max(DOOR_TALLY_APPLICANTS_FLOOR, Math.round(waitlistTotal * DOOR_TALLY_FOMO_MULTIPLIER)) +} + +/** + * Build the door tally from the real backend counts: inflated "tried" + real + * "got in" (with sane fallbacks while the counts are loading). + */ +export function computeDoorTally(waitlistTotal?: number, admittedTotal?: number): DoorTally { + const admitted = + typeof admittedTotal === 'number' && Number.isFinite(admittedTotal) && admittedTotal >= 0 + ? Math.round(admittedTotal) + : DOOR_TALLY_ADMITTED_FALLBACK + return { applicants: inflateApplicants(waitlistTotal), admitted } +} diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index d699f842e..c8af37514 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -1,6 +1,6 @@ /** - * — the pink peanut card with a runtime-pixelated - * `peanut-card-hand.svg` overlay. + * — the pink peanut card with a pre-pixelated + * peanut-card-hand overlay. * * Renders at native 620×391 (CARD_W/CARD_H). Wrap in `transform: scale(...)` * if you need a smaller preview — the inner layout uses absolute pixel @@ -8,10 +8,10 @@ * . * * When `blurAll` is true (the closed-beta tease), the visa logo, peanut - * logo, card number, and Virtual pill all get rasterised through the - * SAME canvas → image-rendering:pixelated pipeline as the hand, with a - * shared CELL_PX cell size — so every detail on the card is hidden by - * consistent chunky pixels instead of a mix of pixelation + CSS blur. + * logo, card number, and Virtual pill all get rasterised through a + * canvas → image-rendering:pixelated pipeline, with a shared CELL_PX cell + * size — so every detail on the card is hidden by consistent chunky pixels + * instead of a mix of pixelation + CSS blur. * * Shared between , the eligibility-check screen, and the * `/shhhhh` LP hero. Keep this file the single source of truth for the @@ -23,13 +23,13 @@ import { type FC, type CSSProperties } from 'react' import { CARD_W, CARD_H } from './shareAssetLayout' -import { PEANUTMAN_LOGO } from '@/assets/mascot' -import PEANUT_CARD_HAND_ASSET from '@/assets/cards/peanut-card-hand.svg' +import { PEANUTMAN } from '@/assets/mascot' +import PEANUT_CARD_HAND_PIXEL_ASSET from '@/assets/cards/peanut-card-hand-pixel.png' import VISA_BRAND_MARK_ASSET from '@/assets/cards/visa-brand-mark.png' -const ASSET_PEANUTMAN_LOGO = PEANUTMAN_LOGO.src +const ASSET_PEANUTMAN = PEANUTMAN.src const ASSET_VISA_BRAND = VISA_BRAND_MARK_ASSET.src -const ASSET_CARD_HAND = PEANUT_CARD_HAND_ASSET.src +const ASSET_CARD_HAND_PIXEL = PEANUT_CARD_HAND_PIXEL_ASSET.src // Shared pixel-cell target across every element on the card. Each // element rasterises to (displayPx / CELL_PX) pixels then stretches back @@ -37,18 +37,15 @@ const ASSET_CARD_HAND = PEANUT_CARD_HAND_ASSET.src // uniform regardless of element size. const CELL_PX = 14 -// The hand is much bigger and benefits from a finer raster — keep it at -// its original 36px so the silhouette stays recognisable. -const HAND_RASTER_PX = 36 - export interface PixelatedCardFaceProps { /** Kept in the type for in-app surfaces that render the real card * number — the share-asset rendering deliberately ignores this and * always shows "????" so a screenshot can't leak the PAN. */ last4?: string - /** Extra classes for the outer card div (the pink rounded box). */ + /** Extra classes for the pink rounded card face. Sizing is fixed at + * CARD_W×CARD_H on the wrapper — scale via . */ className?: string - /** Extra inline styles merged on top of the defaults (width/height/shadow). */ + /** Extra inline styles merged onto the pink card face (e.g. background). */ style?: CSSProperties /** When true, every element on the card (logos, number, pill) is run * through the same canvas-rasterisation pipeline as the hand so the @@ -58,6 +55,10 @@ export interface PixelatedCardFaceProps { * display the Visa wordmark for compliance reasons (it renders crisp * there since the share asset is the one surface that doesn't `blurAll`). */ hideVisa?: boolean + /** Fires once the pixelated hand has loaded — i.e. the card face is + * fully painted. Capture surfaces gate the Share/Save buttons on this so a + * snapshot can never fire before the hand is ready. */ + onReady?: () => void } export const PixelatedCardFace: FC = ({ @@ -65,39 +66,50 @@ export const PixelatedCardFace: FC = ({ style, blurAll = false, hideVisa = false, + onReady, }) => ( -

- + // The drop-shadow is an offset sibling, NOT a CSS box-shadow: html-to-image + // renders box-shadow on a rounded element as a SQUARE block, so the captured + // PNG showed a square shadow behind the rounded card. A real black rounded + // element shifted by the shadow distance captures faithfully. +
+
+
+ - {/* Top row: peanut logo (left) + visa logo (right) */} -
- {blurAll ? ( - - ) : ( - - )} - {!hideVisa && - (blurAll ? ( - + {/* Top row: peanut logo (left) + visa logo (right) */} +
+ {blurAll ? ( + ) : ( - 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 +117,29 @@ export const PixelatedCardFace: FC = ({ card face — share-asset + tease callers just don't pass it.) Lowered toward the card edge now that the Virtual pill is gone — gives the layout room to breathe. */} -
- {blurAll ? ( - - ) : ( -
- ???? -
- )} +
+ {blurAll ? ( + + ) : ( +
+ ???? +
+ )} +
) @@ -264,23 +277,27 @@ const PixelatedText: FC = ({ text, displayW, displayH, font, } // --------------------------------------------------------------------------- -// The hand — same algorithm, just keeps its larger 36px raster so the -// silhouette stays recognisable. +// The hand — a pre-pixelated PNG rendered as a plain , NOT a runtime +// . html-to-image silently drops a live it can't serialise +// (canvas.toDataURL() returns empty on iOS Safari for an SVG-sourced canvas → +// blank card, no error: the launch-day "blank share asset" bug), but inlines +// reliably — same path the badge stickers take, which never blank. The +// PNG is authored at the 36px raster and upscaled by image-rendering:pixelated, +// so it reads identically to the old canvas. // --------------------------------------------------------------------------- -const PixelatedHand: FC = () => ( -
{ - if (!node || node.firstChild) return - const handRatio = 560 / 471 // hand display w/h - const rasterW = handRatio > 1 ? HAND_RASTER_PX : Math.max(1, Math.round(HAND_RASTER_PX * handRatio)) - const rasterH = handRatio > 1 ? Math.max(1, Math.round(HAND_RASTER_PX / handRatio)) : HAND_RASTER_PX - rasterImg(ASSET_CARD_HAND, rasterW, rasterH, (canvas) => { - canvas.style.width = '100%' - canvas.style.height = '100%' - canvas.style.imageRendering = 'pixelated' - node.appendChild(canvas) - }) +const PixelatedHand: FC<{ onReady?: () => void }> = ({ onReady }) => ( + onReady?.()} + ref={(img) => { + // A cached image can already be `complete` before React attaches + // onLoad, so the load event never fires — that would leave the + // capture gate stuck disabled. Fire onReady directly in that case. + if (img?.complete) onReady?.() }} className="pointer-events-none absolute select-none" style={{ @@ -290,6 +307,7 @@ const PixelatedHand: FC = () => ( height: 471, transform: 'rotate(-15deg)', transformOrigin: 'center', + imageRendering: 'pixelated', }} /> ) diff --git a/src/components/Card/share-asset/ShareAssetActions.tsx b/src/components/Card/share-asset/ShareAssetActions.tsx index f8ef100d0..372c64e62 100644 --- a/src/components/Card/share-asset/ShareAssetActions.tsx +++ b/src/components/Card/share-asset/ShareAssetActions.tsx @@ -69,9 +69,14 @@ interface Props { source: string /** Optional filename for the downloaded PNG. */ filename?: string + /** Whether the share asset has finished painting its card face (the async + * hand has mounted — see PixelatedCardFace.onReady). Until then, + * capturing would snapshot a blank card, so both buttons stay disabled. + * Defaults to true so callers that don't wire the signal aren't blocked. */ + ready?: boolean } -export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png' }) => { +export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png', ready = true }) => { const [isSharing, setIsSharing] = useState(false) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(null) @@ -163,7 +168,7 @@ export const ShareAssetActions: FC = ({ captureRef, source, filename = 'p shadowSize="4" className="w-full" loading={isSharing} - disabled={isSharing || isSaving} + disabled={isSharing || isSaving || !ready} icon={} > Share @@ -173,7 +178,7 @@ export const ShareAssetActions: FC = ({ captureRef, source, filename = 'p variant="stroke" className="w-full" loading={isSaving} - disabled={isSharing || isSaving} + disabled={isSharing || isSaving || !ready} icon={} > Save image diff --git a/src/components/Card/share-asset/ShareAssetD3.tsx b/src/components/Card/share-asset/ShareAssetD3.tsx index e4d3ce034..cd73d18fa 100644 --- a/src/components/Card/share-asset/ShareAssetD3.tsx +++ b/src/components/Card/share-asset/ShareAssetD3.tsx @@ -100,6 +100,7 @@ const ShareAssetD3: FC = ({ usernameStyle, hideUsername = false, animate = true, + onReady, }) => { const safeUsername = (username || '').trim() || 'anon' const safeLast4 = (cardLast4 || '').trim().padStart(4, '•').slice(-4) || '5695' @@ -219,7 +220,7 @@ const ShareAssetD3: FC = ({ : 'none', }} > - +
{/* ─── Stickers (z-index 4) — raw badge art collaged ON TOP of the @@ -256,26 +257,36 @@ const ShareAssetD3: FC = ({ animation: animate ? `fadeUp 600ms ease-out ${ANIM_ATTRIBUTION_DELAY}ms both` : 'none', }} > - - peanut.me/ - - {safeUsername} + {/* The pill's drop-shadow is an offset sibling, NOT a CSS + box-shadow: html-to-image renders box-shadow on a `rounded-full` + element as a SQUARE block, so the captured PNG showed a square + shadow behind the rounded pill. The rotation lives on the + wrapper so the black rounded shadow tracks the pill's tilt. */} +
+
+ + peanut.me/ + + {safeUsername} + - +
{/* ─── Hero "I got in" message sticker (top, z-index 5 — above the diff --git a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts index 87066615c..a2f790887 100644 --- a/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts +++ b/src/components/Card/share-asset/__tests__/captureShareAsset.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { ShareAssetCaptureError } from '../captureShareAsset' +import { ShareAssetCaptureError, waitForAssetReady } from '../captureShareAsset' describe('ShareAssetCaptureError', () => { test('carries failedImages + originalReject + name=ShareAssetCaptureError', () => { @@ -24,3 +24,84 @@ describe('ShareAssetCaptureError', () => { expect(err instanceof Error).toBe(true) }) }) + +/** + * Regression guard for the launch-day "blank share asset" bug. The card-face + * hand used to be a runtime , which html-to-image silently dropped when + * toDataURL() returned empty (iOS Safari, SVG-sourced canvas) → blank card, no + * error. The hand is now a plain , so every visible element of the asset + * is decode-gated the same way the badge stickers (which never blanked) are. + * waitForAssetReady must block the snapshot until fonts + every are ready. + */ +describe('waitForAssetReady — share-asset capture readiness gate', () => { + afterEach(() => { + // The fonts test stubs document.fonts; drop it so later runs see jsdom's + // default (undefined) and don't inherit a slow ready promise. + if (Object.getOwnPropertyDescriptor(document, 'fonts')) { + // @ts-expect-error — test-only cleanup of the stubbed accessor + delete document.fonts + } + }) + + test('does NOT resolve until every has decoded — the hand included', async () => { + const node = document.createElement('div') + let resolveHandDecode!: () => void + const hand = document.createElement('img') + ;(hand as unknown as { decode: () => Promise }).decode = () => + new Promise((resolve) => { + resolveHandDecode = resolve + }) + node.appendChild(hand) + + let resolved = false + const pending = waitForAssetReady(node).then(() => { + resolved = true + }) + await new Promise((r) => setTimeout(r, 20)) + expect(resolved).toBe(false) // still gated — a snapshot here could be blank + + resolveHandDecode() // the hand bitmap is ready + await pending + expect(resolved).toBe(true) + }) + + test('awaits image decode for every before snapshotting', async () => { + const node = document.createElement('div') + const badge = document.createElement('img') + const hand = document.createElement('img') + const badgeDecode = jest.fn().mockResolvedValue(undefined) + const handDecode = jest.fn().mockResolvedValue(undefined) + ;(badge as unknown as { decode: unknown }).decode = badgeDecode + ;(hand as unknown as { decode: unknown }).decode = handDecode + node.appendChild(badge) + node.appendChild(hand) + + await waitForAssetReady(node) + expect(badgeDecode).toHaveBeenCalledTimes(1) + expect(handDecode).toHaveBeenCalledTimes(1) + }) + + test('resolves (never hangs) when images expose no decode() — jsdom / older browsers', async () => { + const node = document.createElement('div') + node.appendChild(document.createElement('img')) // no decode() in jsdom + await expect(waitForAssetReady(node)).resolves.toBeUndefined() + }) + + test('awaits document.fonts.ready before snapshotting', async () => { + let fontsReady = false + Object.defineProperty(document, 'fonts', { + configurable: true, + value: { + ready: new Promise((resolve) => + setTimeout(() => { + fontsReady = true + resolve() + }, 20) + ), + }, + }) + const node = document.createElement('div') + await waitForAssetReady(node) + expect(fontsReady).toBe(true) + }) +}) diff --git a/src/components/Card/share-asset/captureShareAsset.ts b/src/components/Card/share-asset/captureShareAsset.ts index 02ea69216..a4f50b5be 100644 --- a/src/components/Card/share-asset/captureShareAsset.ts +++ b/src/components/Card/share-asset/captureShareAsset.ts @@ -40,8 +40,37 @@ export class ShareAssetCaptureError extends Error { } } +/** + * Wait for the asset's content to be painted before we snapshot. + * + * Every visible element of the asset is a plain now — badge stickers, the + * card logos, AND the card's pixelated hand (see PixelatedCardFace). The hand + * used to be a runtime , which html-to-image silently dropped when it + * couldn't serialise it (canvas.toDataURL() returns empty on iOS Safari for an + * SVG-sourced canvas → blank card, no error: the launch-day "blank share asset" + * bug). With everything as , readiness is simply: + * - document.fonts.ready (the hero/username use a web font) + * - every decoded (badges, logos, the hand) + * so the snapshot only fires once the bitmaps are actually ready. + */ +export async function waitForAssetReady(node: HTMLElement): Promise { + if (typeof document !== 'undefined' && document.fonts?.ready) { + try { + await document.fonts.ready + } catch { + // fonts.ready can reject in odd states — capture anyway. + } + } + await Promise.all( + Array.from(node.querySelectorAll('img')).map((img) => + typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve() + ) + ) +} + export async function captureShareAsset(node: HTMLElement): Promise { try { + await waitForAssetReady(node) const blob = await toBlob(node, { width: CANVAS_W, height: CANVAS_H, diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts index 4a46eafe1..79032881f 100644 --- a/src/components/Card/share-asset/shareAsset.types.ts +++ b/src/components/Card/share-asset/shareAsset.types.ts @@ -105,4 +105,12 @@ export interface ShareAssetD3Props { * snapshots / image export so the final frame renders deterministically. */ animate?: boolean + + /** + * Fires once the card face's async hand has mounted (forwarded + * straight to ). Capture surfaces gate the + * Share/Save buttons on this so a snapshot can never fire before the card + * face paints — the deterministic fix for the blank-card capture bug. + */ + onReady?: () => void } diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 970a57d1e..2e0e699a3 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -42,7 +42,7 @@ import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol } from '@/utils/general.utils' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { GuestVerificationModal } from '@/components/Global/GuestVerificationModal' import { useCapabilities } from '@/hooks/useCapabilities' import MantecaFlowManager from './MantecaFlowManager' @@ -822,7 +822,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
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..3fa6d8754 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -25,10 +25,10 @@ import { useContext, useMemo, useState } from 'react' import ActionModal from '@/components/Global/ActionModal' import Divider from '../../0_Bruddle/Divider' import { Button } from '@/components/0_Bruddle/Button' -import { PEANUT_LOGO_BLACK } from '@/assets/illustrations' +import { PEANUT_LOGO_BLACK } from '@/assets/logos' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' import { tokenSelectorContext } from '@/context' @@ -108,12 +108,18 @@ export default function SendLinkActionList({ return claimType === BankClaimType.GuestKycNeeded || claimType === BankClaimType.ReceiverKycNeeded }, [claimType]) + // Guest claim-to-bank (claimer unverified, but the sender can receive a bank + // off-ramp) is under maintenance: the BE 503s POST /bridge/offramp/create-for-guest. + // Render the bank option greyed + "Soon!" so guests can't enter a flow that would + // fail. The authenticated self off-ramp (UserBankClaim) is unaffected. + const isGuestBankClaim = claimType === BankClaimType.GuestBankClaim + // filter and sort payment methods based on geolocation const { filteredMethods: sortedActionMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({ sortUnavailable: true, isMethodUnavailable: (method) => method.soon || - (method.id === 'bank' && requiresVerification) || + (method.id === 'bank' && (requiresVerification || isGuestBankClaim)) || (['mercadopago', 'pix'].includes(method.id) && !isMantecaPayEnabled), methods: showDevconnectMethod ? DEVCONNECT_CLAIM_METHODS.filter((method) => method.id !== 'devconnect') @@ -237,7 +243,7 @@ export default function SendLinkActionList({ > {showDevconnectMethod ?
Claim on
:
Continue with
}
- Peanut Logo + Peanut Logo Peanut Logo
@@ -273,6 +279,7 @@ export default function SendLinkActionList({ key={method.id} method={method} requiresVerification={methodRequiresVerification} + soon={method.id === 'bank' && isGuestBankClaim} /> ) })} @@ -323,12 +330,17 @@ const MethodCard = ({ onClick, requiresVerification, isDisabled, + soon, }: { method: PaymentMethod onClick: () => void requiresVerification?: boolean isDisabled?: boolean + // forces the "Soon!" badge + greyed/non-interactive state even when the + // static method config has soon=false (e.g. guest claim-to-bank maintenance) + soon?: boolean }) => { + const showSoon = method.soon || soon return ( {method.title} - {(method.soon || requiresVerification) && ( + {(showSoon || requiresVerification) && ( } onClick={onClick} - isDisabled={method.soon || isDisabled} + isDisabled={showSoon || isDisabled} rightContent={} /> ) diff --git a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx new file mode 100644 index 000000000..88f6b1557 --- /dev/null +++ b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx @@ -0,0 +1,159 @@ +/** + * SendLinkActionList — guest claim-to-bank maintenance gating + * + * The GUEST claim-to-bank off-ramp is under maintenance (BE 503s + * POST /bridge/offramp/create-for-guest). The bank method must render greyed + + * "Soon!" and be non-interactive when the claim resolves to GuestBankClaim, + * while the authenticated self off-ramp (UserBankClaim) stays fully clickable. + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' + +// ---------- module-level mocks (before importing the component) ---------- + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => { + const { priority, fill, ...rest } = props + return + }, +})) + +jest.mock('use-haptic', () => ({ + useHaptic: () => ({ triggerHaptic: jest.fn() }), +})) + +// Heavy leaf modals / CTAs that are not under test +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: () => null, +})) +jest.mock('../../../Global/ConfirmInviteModal', () => ({ + __esModule: true, + default: () => null, +})) +jest.mock('../../../Global/SupportCTA', () => ({ + __esModule: true, + default: () => null, +})) + +// Bank claim type — the value under test. `BankClaimType` mirrors the real enum. +let mockClaimType = 'user-bank-claim' +jest.mock('@/hooks/useDetermineBankClaimType', () => ({ + BankClaimType: { + GuestBankClaim: 'guest-bank-claim', + UserBankClaim: 'user-bank-claim', + ReceiverKycNeeded: 'receiver-kyc-needed', + GuestKycNeeded: 'guest-kyc-needed', + }, + useDetermineBankClaimType: () => ({ claimType: mockClaimType, setClaimType: jest.fn() }), +})) + +const mockSetFlowStep = jest.fn() +jest.mock('@/context/ClaimBankFlowContext', () => ({ + ClaimBankFlowStep: { + SavedAccountsList: 'saved-accounts-list', + BankCountryList: 'bank-country-list', + BankDetailsForm: 'bank-details-form', + BankConfirmClaim: 'bank-confirm-claim', + }, + useClaimBankFlow: () => ({ + setClaimToExternalWallet: jest.fn(), + setFlowStep: mockSetFlowStep, + setShowVerificationModal: jest.fn(), + setClaimToMercadoPago: jest.fn(), + setRegionalMethodType: jest.fn(), + setHideTokenSelector: jest.fn(), + }), +})) + +jest.mock('@/hooks/useSavedAccounts', () => ({ + __esModule: true, + default: () => [], +})) + +jest.mock('../../useClaimLink', () => ({ + __esModule: true, + default: () => ({ addParamStep: jest.fn() }), +})) + +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ canDo: () => false }), +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { userId: 'me', hasAppAccess: true } } }), +})) + +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => jest.fn(), +})) + +jest.mock('@/context', () => ({ + tokenSelectorContext: React.createContext({ + setSelectedTokenAddress: jest.fn(), + setSelectedChainID: jest.fn(), + devconnectChainId: '', + devconnectRecipientAddress: '', + devconnectTokenAddress: '', + }), +})) + +// Return a fixed method set so the test is independent of geolocation. +const bankMethod = { id: 'bank', title: 'Bank', description: 'EUR, USD, MXN, ARS & more', icons: [], soon: false } +const walletMethod = { + id: 'exchange-or-wallet', + title: 'Exchange or Wallet', + description: 'Binance, Metamask and more', + icons: [], + soon: false, +} +jest.mock('@/hooks/useGeoFilteredPaymentOptions', () => ({ + useGeoFilteredPaymentOptions: () => ({ filteredMethods: [bankMethod, walletMethod], isLoading: false }), +})) + +// ---------- import component under test AFTER mocks ---------- +import SendLinkActionList from '../SendLinkActionList' + +const claimLinkData = { + amount: BigInt(10000000), // 10 USDC — above the $5 bank minimum + tokenDecimals: 6, + sender: { userId: 'sender-123', username: 'alice' }, +} as any + +function renderList() { + return render() +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('SendLinkActionList — guest claim-to-bank maintenance', () => { + test('GuestBankClaim: bank option is greyed + "Soon!" and non-interactive', () => { + mockClaimType = 'guest-bank-claim' + renderList() + + // SOON badge present on the bank option + expect(screen.getByText('Soon!')).toBeInTheDocument() + + // clicking the disabled bank card does not start the bank flow + fireEvent.click(screen.getByText('Bank')) + expect(mockSetFlowStep).not.toHaveBeenCalled() + }) + + test('UserBankClaim: authenticated self off-ramp stays interactive (no "Soon!")', () => { + mockClaimType = 'user-bank-claim' + renderList() + + expect(screen.queryByText('Soon!')).not.toBeInTheDocument() + + // clicking the enabled bank card enters the bank flow + fireEvent.click(screen.getByText('Bank')) + expect(mockSetFlowStep).toHaveBeenCalledWith('bank-country-list') + }) +}) diff --git a/src/components/Global/ConfirmInviteModal/index.tsx b/src/components/Global/ConfirmInviteModal/index.tsx index a9bc915fb..8a811a8ae 100644 --- a/src/components/Global/ConfirmInviteModal/index.tsx +++ b/src/components/Global/ConfirmInviteModal/index.tsx @@ -1,7 +1,7 @@ 'use client' import { type FC } from 'react' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import Modal from '../Modal' import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' @@ -52,7 +52,7 @@ const ConfirmInviteModal: FC = ({ 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/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx index 4c56d0d0c..fae5b136f 100644 --- a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx +++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx @@ -17,9 +17,9 @@ interface CardLaunchCTABannerProps { * Presentational only — gating + persistence live in the `CardLaunchCTA` * container so this can be force-rendered in the /dev/home-ctas preview. * - * Tone matches the /shhhhh teaser: pink, hard black border + shadow, extra-black - * uppercase headline, provocative "maybe it's for you" framing. The whole card - * is a tap target (parity with CarouselCTA); the X stops propagation. + * In line with the other activation CTAs: white card, black border, no drop + * shadow, standard purple primary CTA. The whole card is a tap target (parity + * with CarouselCTA); the X stops propagation. */ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunchCTABannerProps) { const { triggerHaptic } = useHaptic() @@ -39,7 +39,7 @@ export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunch role="button" tabIndex={0} onClick={handleTryDoor} - className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border-2 border-n-1 bg-primary-1 p-5 shadow-[4px_4px_0_#000]" + className="relative mb-3 cursor-pointer overflow-hidden rounded-sm border border-n-1 bg-white p-5" >