diff --git a/public/badges/founding_pioneer.svg b/public/badges/founding_pioneer.svg new file mode 100644 index 000000000..b1bd44fba --- /dev/null +++ b/public/badges/founding_pioneer.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/peanut-pioneer.png b/public/badges/peanut-pioneer.png deleted file mode 100644 index d355bce29..000000000 Binary files a/public/badges/peanut-pioneer.png and /dev/null differ diff --git a/public/badges/psyops_division.svg b/public/badges/psyops_division.svg new file mode 100644 index 000000000..55fc2dec5 --- /dev/null +++ b/public/badges/psyops_division.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index dab8fa04b..dce8827c7 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -12,7 +12,7 @@ import { pollUntilApplyAdvances, pollUntilReady } from '@/components/Card/cardAp import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' import CardTermsScreen from '@/components/Card/CardTermsScreen' -import CardWaitlistScreen from '@/components/Card/CardWaitlistScreen' +import CardRejectionScreen from '@/components/Card/CardRejectionScreen' import CardWaitlistJoinedScreen from '@/components/Card/CardWaitlistJoinedScreen' import BadgeSkipCelebration from '@/components/Card/BadgeSkipCelebration' import CardEligibilityCheckScreen from '@/components/Card/CardEligibilityCheckScreen' @@ -471,13 +471,21 @@ const CardPage: FC = () => { ) case 'waitlist': { // Joined vs not-joined are two distinct screens — keeps each - // tight to its own purpose (let-down + CTA vs confirmation + - // exit). The skip-badge gallery was dropped per design: the - // not-joined view is a conversion moment, not a hunt prompt. + // 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 } - return + return ( + + ) } case 'waitlist-skip-celebration': { // Pick the freshest skip badge for the celebration headline. diff --git a/src/app/(mobile-ui)/dev/home-ctas/page.tsx b/src/app/(mobile-ui)/dev/home-ctas/page.tsx new file mode 100644 index 000000000..1a900385a --- /dev/null +++ b/src/app/(mobile-ui)/dev/home-ctas/page.tsx @@ -0,0 +1,233 @@ +'use client' + +import { type ReactNode } from 'react' +import type { StaticImageData } from 'next/image' +import NavHeader from '@/components/Global/NavHeader' +import { Card } from '@/components/0_Bruddle/Card' +import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' +import CarouselCTA from '@/components/Home/HomeCarouselCTA/CarouselCTA' +import ActivationCTAs from '@/components/Home/ActivationCTAs' +import { type ActivationStep } from '@/hooks/useActivationStatus' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' + +/** + * /dev/home-ctas — force-renders every home-screen CTA in isolation so they can + * be reviewed visually on demand, ignoring real auth/state/launch gating. + * + * Each CTA's container (CardLaunchCTA/index, HomeCarouselCTA/index, + * ActivationCTAs' parent) self-gates and would return null. This page renders + * the *presentational* pieces directly with mock props, so what's below is the + * full visual catalogue regardless of who's logged in. + * + * Handlers are wired to no-op console.logs — nothing here navigates or mutates. + */ + +const noop = (label: string) => () => console.log(`[dev/home-ctas] ${label}`) + +// Representative carousel CTAs, mirroring the variants built in +// useHomeCarouselCTAs + the Card Pioneer perk-claim path in HomeCarouselCTA. +type CarouselPreview = { + id: string + label: string + icon: IconName + title: ReactNode + description: ReactNode + iconContainerClassName?: string + iconSize?: number + logo?: StaticImageData + logoSize?: number + isPerkClaim?: boolean +} + +const CAROUSEL_PREVIEWS: CarouselPreview[] = [ + { + id: 'perk-claim', + label: 'Perk claim (pink-dot, no X) — Card Pioneer reward', + icon: 'gift', + iconContainerClassName: 'bg-primary-1', + iconSize: 16, + isPerkClaim: true, + title: ( +

+ +$5 reward ready! +

+ ), + description: ( +

+ Alice used Peanut. Tap to claim. +

+ ), + }, + { + id: 'card-pioneer', + label: 'Card Pioneer — get your Peanut Card', + icon: 'credit-card', + iconContainerClassName: 'bg-purple-1', + iconSize: 16, + title: ( + + Get your Peanut Card + + ), + description: ( + + Closed beta. Badges skip the line. $10 unlocks on your first $100 spend. + + ), + }, + { + id: 'qr-payment', + label: 'QR payment nudge (KYC-approved user)', + icon: 'qr-code', + iconContainerClassName: 'bg-secondary-1', + iconSize: 16, + title: ( + + Pay with QR code payments + + ), + description: ( + + Get the best exchange rate, pay like a local and earn rewards. + + ), + }, + { + id: 'kyc-prompt', + label: 'KYC prompt — unlock QR (un-verified user)', + icon: 'qr-code', + iconContainerClassName: 'bg-secondary-1', + iconSize: 16, + title: ( + + Unlock QR code payments + + ), + description: ( + + Confirm your ID to pay with Mercado Pago and PIX QR codes + + ), + }, + { + id: 'invite-friends', + label: 'Invite friends (logo variant)', + icon: 'invite-heart', + logo: STAR_STRAIGHT_ICON, + logoSize: 30, + title: 'Invite friends. Earn rewards', + description: 'Earn rewards every time your friends use Peanut.', + }, + { + id: 'bug-bounty', + label: 'Bug bounty', + icon: 'bug', + iconContainerClassName: 'bg-primary-1', + iconSize: 20, + title: ( + + Help us improve and get $5! + + ), + description: 'Report a bug. Get rewarded! No questions asked.', + }, + { + id: 'notification-prompt', + label: 'Notification prompt', + icon: 'bell', + title: 'Stay in the loop!', + description: 'Turn on notifications and get alerts for all your wallet activity.', + }, + { + id: 'ios-pwa-install', + label: 'iOS PWA install', + icon: 'mobile-install', + iconContainerClassName: 'bg-secondary-1', + iconSize: 16, + title: 'Add Peanut to your home screen', + description: 'Follow a quick guide to add the app to your home screen, no download needed.', + }, +] + +// Each activation-funnel step (one CTA shown at a time on the real home screen). +const ACTIVATION_STEPS: { step: Exclude; label: string }[] = [ + { step: 'verify', label: "STEPS.verify — 'Unlock payments'" }, + { step: 'deposit', label: "STEPS.deposit — 'Deposit'" }, + { step: 'card', label: "STEPS.card — 'Get your card' (dismissable)" }, + { step: 'outbound', label: "STEPS.outbound — 'Make your first payment'" }, +] + +function SectionLabel({ children }: { children: ReactNode }) { + return

{children}

+} + +export default function HomeCTAsPreviewPage() { + return ( +
+
+ +
+ +
+

+ Every home-screen CTA, force-rendered in isolation — no auth, state, or launch gating. Handlers are + no-op console.logs. +

+ + {/* Card launch banner */} +
+ Card launch banner (CardLaunchCTABanner) + +
+ + {/* Carousel CTAs */} +
+ Carousel CTAs (CarouselCTA) + {CAROUSEL_PREVIEWS.map((cta) => ( +
+

{cta.label}

+ +
+ ))} +
+ + {/* Activation funnel steps */} +
+ Activation funnel steps (ActivationCTAs) + {ACTIVATION_STEPS.map(({ step, label }) => ( +
+

{label}

+ +
+ ))} +
+ + +
+ + Note +
+

+ Activation steps read defensive hooks (useCapabilities / useIdentityVerification) that return + empty defaults when logged out, so every step renders here regardless of real KYC state. +

+
+
+
+ ) +} diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx index 31f49f9f2..640a25356 100644 --- a/src/app/(mobile-ui)/dev/page.tsx +++ b/src/app/(mobile-ui)/dev/page.tsx @@ -39,6 +39,13 @@ export default function DevToolsPage() { path: '/dev/debug', icon: 'dollar', }, + { + name: 'Home CTAs', + description: + 'Force-renders every home-screen CTA in isolation (card launch banner, carousel CTAs, activation steps) ignoring auth/state/launch gating.', + path: '/dev/home-ctas', + icon: 'credit-card', + }, ] return ( diff --git a/src/app/(mobile-ui)/dev/rejection-builder/page.tsx b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx new file mode 100644 index 000000000..544119223 --- /dev/null +++ b/src/app/(mobile-ui)/dev/rejection-builder/page.tsx @@ -0,0 +1,176 @@ +'use client' + +/** + * /dev/rejection-builder — iterator for the full mobile rejection screen + * (CardRejectionScreen): the "not tonight, " asset + the scarcity + * explainer copy + the "Tweet to appeal" CTA, previewed inside a phone frame. + * + * Knobs feed the whole screen so we can dial in the copy, the door tally, and + * which smug peanut bouncer shows on the asset. "Tweet to appeal" fires the + * real share path with a random caption (rejectionCaptions.ts). + */ + +import { useState } from 'react' +import NavHeader from '@/components/Global/NavHeader' +import CardRejectionScreen from '@/components/Card/CardRejectionScreen' +import type { RejectionMascot } from '@/components/Card/share-asset/shareAsset.types' + +const MASCOTS: ReadonlyArray<[RejectionMascot, string]> = [ + ['none', 'none'], + ['cool', 'cool (shades)'], + ['mock', 'mock (point + laugh)'], + ['chill', 'chill (whistling)'], +] + +export default function RejectionBuilderPage() { + const [username, setUsername] = useState('kkonrad') + const [mascot, setMascot] = useState('cool') + const [applicants, setApplicants] = useState(213) + const [admitted, setAdmitted] = useState(7) + + return ( +
+ + +
+ {/* ─── LEFT: Controls ──────────────────────────────────── */} + + + {/* ─── RIGHT: Phone-frame preview of the whole screen ──── */} +
+
+ mobile screen · CardRejectionScreen +
+
+
+ alert('→ joined: would advance to the friendly waitlist-joined screen')} + /> +
+
+
+
+
+ ) +} + +// ─── Small UI primitives ──────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} + +function PresetButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} diff --git a/src/app/(mobile-ui)/dev/share-builder/page.tsx b/src/app/(mobile-ui)/dev/share-builder/page.tsx index 0e5123fe8..dc6e2c17f 100644 --- a/src/app/(mobile-ui)/dev/share-builder/page.tsx +++ b/src/app/(mobile-ui)/dev/share-builder/page.tsx @@ -1,25 +1,27 @@ 'use client' /** - * /dev/share-builder — iterator for the D3 card-waitlist share asset. + * /dev/share-builder — iterator for the card share asset (sticker collage). * * Controls feed `` so we can stress-test edge cases: - * - 0 → 9 badges - * - usernames 2 → 20+ chars - * - missing / zero stats - * - tier 0 → 3 + * - 0 → all badges (sticker count drives the force-directed layout) + * - usernames 2 → 20+ chars (the @username pill auto-shrinks) * - * The "reroll seed" button forces a new layout for the same user (the - * component is otherwise deterministic from username). + * The asset is now a pure sticker collage — card in the middle, badges + * slapped around it, @username pill. There are no stats / tier / points / + * card-number inputs anymore; the layout is driven entirely by the badge + * set + username seed. "Reroll seed" forces a new force-directed layout for + * the same user (otherwise deterministic from username). */ import { useMemo, 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 { BADGE_CODES, getBadgeDisplayName } from '@/components/Badges/badge.utils' import { CANVAS_W, CANVAS_H } from '@/components/Card/share-asset/shareAssetLayout' -import type { TierLevel } from '@/components/Card/share-asset/shareAsset.types' const ALL_CODES = BADGE_CODES @@ -36,38 +38,43 @@ export default function ShareBuilderPage() { 'SUPPORT_SURVIVOR', ]) ) - const [tier, setTier] = useState(3) - const [points, setPoints] = useState(1247) - const [last4, setLast4] = useState('5695') - const [joinedAt, setJoinedAt] = useState('2025-10-12') - const [movedUsd, setMovedUsd] = useState(4287) - const [txns, setTxns] = useState(142) - const [invited, setInvited] = useState(12) const [animate, setAnimate] = useState(true) const [seedNonce, setSeedNonce] = useState(0) const [previewScale, setPreviewScale] = useState(0.8) + const [hideUsername, setHideUsername] = useState(false) - // Derived props — memoized so ShareAssetD3's `useMemo(...,[badges,stats])` doesn't - // re-run on every parent render (it would, otherwise, because object/array literals + // ─── Hero "I got in" message sticker ───────────────────────────────── + const [heroVariant, setHeroVariant] = useState('burst') + const [heroText, setHeroText] = useState("I'M IN!") + const [heroScale, setHeroScale] = useState(1.15) + const [heroTilt, setHeroTilt] = useState(5) + const heroMessage = + heroVariant === 'none' ? null : { text: heroText, variant: heroVariant, scale: heroScale, tilt: heroTilt } + + // ─── Username pill colour + typography ─────────────────────────────── + const [unameBg, setUnameBg] = useState('white') + const [unamePrefix, setUnamePrefix] = useState(0.5) + const [unameScale, setUnameScale] = useState(1) + const [unameTracking, setUnameTracking] = useState(0) + const usernameStyle = { + bg: unameBg, + prefixRatio: unamePrefix, + scale: unameScale, + letterSpacing: unameTracking, + } + + // Derived props — memoized so ShareAssetD3's `useMemo(...,[badges])` doesn't + // re-run on every parent render (it would, otherwise, because array literals // are fresh references each render). const badgesArray = useMemo( () => [...selectedBadges].map((code, i) => ({ code, - // Stagger earnedAt so sorting is stable + variety in stamp year denominations. + // Stagger earnedAt so the most-recent-first sort is stable. earnedAt: new Date(2024 + (i % 3), i % 12, 1).toISOString(), })), [selectedBadges] ) - const statsProp = useMemo( - () => ({ - joinedAt: joinedAt || null, - totalMovedUsd: movedUsd, - totalTxns: txns, - invitedCount: invited, - }), - [joinedAt, movedUsd, txns, invited] - ) const toggleBadge = (code: string) => { setSelectedBadges((prev) => { @@ -87,6 +94,64 @@ export default function ShareBuilderPage() {
{/* ─── LEFT: Controls ──────────────────────────────────── */}
-
- -
- {[0, 1, 2, 3].map((t) => ( - - ))} -
-
- - setPoints(Number(e.target.value))} - className="w-full" - /> - -
- -
- - setJoinedAt(e.target.value)} - className="custom-input" - /> - - - setMovedUsd(Number(e.target.value))} - className="w-full" - /> - - - setTxns(Number(e.target.value))} - className="w-full" - /> - - - setInvited(Number(e.target.value))} - className="w-full" - /> - -
-
-
+
setUsername('me')}>2-char user setUsername('twelvechars1')}>12 chars (max) setUsername('thisistwentyplus_chars')}>20+ chars - { - setMovedUsd(0) - setTxns(0) - setInvited(0) - setJoinedAt('') - }} - > - Zero stats - - { - setMovedUsd(0) - setTxns(0) - setInvited(0) - setJoinedAt('') - setSelectedBadges(new Set()) - setTier(0) - setPoints(0) - }} - > - Brand-new user - - { - setMovedUsd(1_500_000) - setTxns(9999) - setInvited(50) - setPoints(9999) - setTier(3) - setSelectedBadges(new Set(ALL_CODES)) - }} - > - Whale user - + setUsername('kkonrad')}>reset
- {username.length > 12 && ( -

- ⚠️ Username > 12 chars · production caps at 12. The asset shrinks the @username pill - defensively, but check the input gate in your caller. -

- )}
@@ -334,17 +357,37 @@ export default function ShareBuilderPage() { key={`${seedNonce}-${animate}`} username={username || 'anon'} badges={badgesArray} - stats={statsProp} - tier={tier} - pointsBalance={points} - cardLast4={last4} seedOverride={seedOverride} + heroMessage={heroMessage} + usernameStyle={usernameStyle} + hideUsername={hideUsername} animate={animate} /> + {/* Faithful "in the share flow" strip — mirrors how the asset, + the anti-dox toggle, and the share buttons stack in + BadgeSkipCelebration / CardUnlockDrawer. */} +
+
+ ↑ asset · how it stacks in the real flow ↓ +
+ setHideUsername(e.target.checked)} + /> + + +
+
Resulting props
@@ -352,11 +395,11 @@ export default function ShareBuilderPage() {
                                 {
                                     username,
                                     badges: badgesArray.map((b) => b.code),
-                                    stats: statsProp,
-                                    tier,
-                                    pointsBalance: points,
-                                    cardLast4: last4,
                                     seedOverride,
+                                    heroMessage,
+                                    usernameStyle,
+                                    hideUsername,
+                                    animate,
                                 },
                                 null,
                                 2
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index b25cadca6..9174375ae 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -26,6 +26,7 @@ import { useNotifications } from '@/hooks/useNotifications'
 import { useCapabilities } from '@/hooks/useCapabilities'
 import { useCardInfo } from '@/hooks/useCardInfo'
 import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA'
+import CardLaunchCTA from '@/components/Home/CardLaunchCTA'
 import EnableAutoBalanceBanner from '@/components/Home/EnableAutoBalanceBanner'
 import InvitesIcon from '@/components/Home/InvitesIcon'
 import NavigationArrow from '@/components/Global/NavigationArrow'
@@ -208,6 +209,11 @@ export default function Home() {
 
                 
+ {/* Public-launch splash. Self-gating (see CardLaunchCTA): shows post- + launch to activated, geo-eligible, card-less, non-waitlisted users; + dismiss/click hides it forever. Rendered above the carousel/activation + CTAs so it leads the home stack on launch day. */} + {isActivated ? ( ) : ( diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 444385677..07b8d9ee5 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -17,6 +17,8 @@ import type { TRequestResponse, } from '@/services/services.types' import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' +import { isWithdrawFeeDisproportionate } from '@/utils/cross-chain-fee.utils' +import { isAmountWithinBalance } from '@/utils/balance.utils' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useRouter } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' @@ -39,7 +41,7 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' export default function WithdrawCryptoPage() { const router = useRouter() const onBack = useSafeBack('/withdraw') - const { isConnected: isPeanutWallet, address, sendTransactions, sendMoney } = useWallet() + const { isConnected: isPeanutWallet, address, sendTransactions, sendMoney, spendableBalance } = useWallet() const { resetTokenContextProvider } = useContext(tokenSelectorContext) const { amountToWithdraw, @@ -437,6 +439,32 @@ export default function WithdrawCryptoPage() { // bridge-fee in USD — no slippage distinction. const networkFee = useMemo(() => feeUsd ?? 0, [feeUsd]) + // Non-blocking heads-up when the bridge fee is a large share of the amount + // (flat mainnet gas dominating a small withdraw). The user can still proceed + // — the fee is shown honestly; we just flag it so a tiny mainnet withdrawal + // isn't a silent footgun. See cross-chain-fee.utils.ts. + const showHighFeeWarning = useMemo( + () => isCrossChainWithdrawal && isWithdrawFeeDisproportionate(networkFee, parseFloat(usdAmount)), + [isCrossChainWithdrawal, networkFee, usdAmount] + ) + + // Pre-sign affordability gate for cross-chain. The input-time gate only + // checked the principal, but the kernel must spend principal + bridge fee + // (`payAmount`), so a withdraw that fit the balance at input can fall short + // here once the fee is known — and the send would surface the misleading + // "balance isn't fully available yet" (settling) error instead of an honest + // "not enough balance". Block it here with the right message. Only once the + // quote has resolved `payAmount` (skipped while calculating; CTA is disabled + // by isCalculating anyway). + const insufficientForFee = useMemo( + () => + isCrossChainWithdrawal && + payAmount != null && + spendableBalance !== undefined && + !isAmountWithinBalance(payAmount, spendableBalance), + [isCrossChainWithdrawal, payAmount, spendableBalance] + ) + if (!amountToWithdraw && currentView !== 'STATUS') { // Redirect to main withdraw page for amount input // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw, @@ -470,6 +498,9 @@ export default function WithdrawCryptoPage() { isCrossChain={isCrossChainWithdrawal} isCalculating={isCalculating} receiveAmount={receiveAmount} + payAmount={payAmount} + showHighFeeWarning={showHighFeeWarning} + insufficientBalance={insufficientForFee} /> )} diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 207e3a00e..b5ba0247f 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -8,6 +8,7 @@ */ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' +import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { PeanutProvider } from '@/config' @@ -45,6 +46,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { explainer also covers public pay/send/request pages — the rain:cooldown event fires on every spend path. */} + {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). + Global so it surfaces wherever the user lands after earning. */} + {HarnessBootstrap && ( diff --git a/src/app/actions/supported-chains.ts b/src/app/actions/supported-chains.ts index 5a57ca3af..0b94ecc5b 100644 --- a/src/app/actions/supported-chains.ts +++ b/src/app/actions/supported-chains.ts @@ -1,5 +1,13 @@ import type { ChainWithTokens } from '@/interfaces/chain-meta' import { supportedPeanutChains, peanutTokenDetails } from '@/constants/general.consts' +import ARBITRUM_ICON from '@/assets/chains/arbitrum.svg' + +// Some chains ship an explorer-hosted icon URL that blocks hotlinking (e.g. +// Arbitrum's arbiscan.io SVG), so next/image fails to load it and the selector +// falls back to initials ("AO"). Prefer a bundled local asset for those. +const CHAIN_ICON_OVERRIDES: Record = { + '42161': ARBITRUM_ICON, +} export async function getSupportedChainsAndTokens(): Promise> { const result: Record = {} @@ -7,7 +15,7 @@ export async function getSupportedChainsAndTokens(): Promise void +}) { return (
-
✓ You're on the waitlist{typeof position === 'number' ? ` · #${position}` : ''} -
+

We'll holler when your turn comes up.

@@ -174,11 +184,14 @@ export default function ShhhhhLandingPage() { setCtaBusy(true) setJoinError(false) try { - // The door actually OPENS for users who already hold card access - // (skip badge / admin grant) — telling them "you're on the - // waitlist" would be wrong, and joinWaitlist no-ops for them. + // The door OPENS to /card for access-holders (skip badge / admin + // grant) AND, post-launch, for everyone: /card runs the press-and-hold + // eligibility moment → the celebration (access) or the Berghain "not + // tonight" rejection (no access), and that rejection screen is where a + // no-access user joins the waitlist. Only PRE-launch (when /card 404s + // for no-access users) do we join inline here. const info = await cardApi.getInfo() - if (info.hasCardAccess) { + if (info.hasCardAccess || info.isPublicLaunched) { router.push('/card') return } @@ -193,14 +206,23 @@ export default function ShhhhhLandingPage() { } }, [router]) - // Post-signup return: a signed-out door press saved this cookie and routed - // through signup back to /shhhhh. Now the account exists — finish the join. + // On mount (signed in), reflect existing waitlist membership: a returning + // user who already joined sees the inline "you're on the waitlist" pill + // instead of the door. New / not-joined users keep the door, which routes to + // /card for the Berghain moment (see handleCTA) rather than joining inline. useEffect(() => { if (!user) return - if (getFromCookie('joinWaitlistAfterSignup') !== '1') return - removeFromCookie('joinWaitlistAfterSignup') - void joinWaitlist() - }, [user, joinWaitlist]) + let cancelled = false + void cardApi + .getInfo() + .then((info) => { + if (!cancelled && info.waitlistJoinedAt) setJoinedPosition(info.waitlistPosition) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [user]) const handleCTA = async () => { // /shhhhh?campaign=skip → Skip Pass (awards the badge). A bare press is @@ -239,11 +261,13 @@ export default function ShhhhhLandingPage() { return } - // Bare door → JOIN THE WAITLIST. No flow-early-access stamp, no /card - // detour, no badge — visiting /shhhhh is not a bypass (Hugo 2026-06-07). + // Bare door, signed-out → sign up, then land on /card: post-launch the + // press-and-hold eligibility → Berghain "not tonight" rejection IS the + // join moment (no inline auto-join, no flow-early-access stamp — still not + // a bypass; /card does the real gating). Signed-in users route via + // joinWaitlist() above, which sends them to /card too post-launch. if (!user) { - saveToCookie('joinWaitlistAfterSignup', '1') - router.push(`/setup?redirect_uri=${encodeURIComponent('/shhhhh')}`) + router.push(`/setup?redirect_uri=${encodeURIComponent('/card')}`) return } await joinWaitlist() @@ -297,7 +321,10 @@ export default function ShhhhhLandingPage() {

{isJoined ? (
- + router.push('/card')} + />
) : (
@@ -337,7 +364,7 @@ export default function ShhhhhLandingPage() {

)}
-
+
{/* Match ShareAssetD3 pixelation: same PixelatedCardFace component scaled into the hero column. Native dims are 760×479 (shared CARD_W/CARD_H); the inner absolute-positioned layout scales @@ -564,7 +591,11 @@ export default function ShhhhhLandingPage() {

{isJoined ? ( - + router.push('/card')} + /> ) : ( )} diff --git a/src/components/Badges/BadgeDetailModal.tsx b/src/components/Badges/BadgeDetailModal.tsx new file mode 100644 index 000000000..f08cbbbf5 --- /dev/null +++ b/src/components/Badges/BadgeDetailModal.tsx @@ -0,0 +1,33 @@ +import Image from 'next/image' +import type { StaticImageData } from 'next/image' +import ActionModal from '../Global/ActionModal' + +type BadgeDetailModalProps = { + isOpen: boolean + onClose: () => void + title: string + description: string + logo: string | StaticImageData +} + +// the focal badge detail popup — large badge image + name + description. +// shared by the Your Badges list and the badge-unlock drawer so both +// surfaces show the exact same modal. +export const BadgeDetailModal = ({ isOpen, onClose, title, description, logo }: BadgeDetailModalProps) => ( + } + iconContainerClassName="bg-transparent min-w-60 h-auto" + modalPanelClassName="m-0" + visible={isOpen} + onClose={onClose} + title={title} + description={description} + ctas={[ + { + text: 'Got it!', + onClick: onClose, + shadowSize: '4', + }, + ]} + /> +) diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx new file mode 100644 index 000000000..9036f37be --- /dev/null +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -0,0 +1,124 @@ +'use client' + +/** + * — the non-intrusive "badge unlocked" moment (TASK-19791). + * + * Globally mounted (ClientProviders), self-contained. When the signed-in user + * lands on /home with freshly-earned badges they haven't seen, it fires ONE + * coalesced toast ("Badge unlocked: X" / "You unlocked N badges") that taps + * through to the shared BadgeDetailModal (or the badges list for several). + * + * Why a toast (not a fullscreen): every badge that fires at/around the card + * launch is incidental — BETA_TESTER (signup), SHHHHH (everyone getting the + * card), EVENT_ALUMNI, NOT_SO_SHHHH. A fullscreen would stack 2-3 takeovers + * mid-/shhhhh-registration. The toast surfaces the badge without blocking the + * flow. Gated to /home so it never appears mid-onboarding (/setup, /shhhhh). + * WAITLIST_SKIP is excluded upstream — it keeps its bespoke card celebration. + */ + +import { useEffect, useRef, useState } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import Image from 'next/image' +import posthog from 'posthog-js' +import { useToast } from '@/components/0_Bruddle/Toast' +import { BadgeDetailModal } from '@/components/Badges/BadgeDetailModal' +import { getBadgeDisplayName, getBadgeIcon, getPublicBadgeDescription } from '@/components/Badges/badge.utils' +import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +const HOME_PATH = '/home' + +type ModalBadge = { title: string; description: string; logo: string } + +export default function BadgeEarnToast() { + const pathname = usePathname() + const router = useRouter() + const { toast, dismiss } = useToast() + const { pending, markSeen } = useBadgeEarnToast() + const [modalBadge, setModalBadge] = useState(null) + // Id of the toast currently on screen, so we can dismiss it when the user + // navigates away from /home (it would otherwise linger over the next route). + const liveToastIdRef = useRef(null) + + useEffect(() => { + // Only surface on /home (never mid-onboarding) and only when there's + // something fresh to show. markSeen() empties `pending`, so this effect + // fires the toast exactly once per batch. + if (pathname !== HOME_PATH || pending.length === 0) return + + const badges = pending + const codes = badges.map((b) => b.code) + const count = badges.length + const newest = badges[0] + const newestName = getBadgeDisplayName(newest.code, newest.name) + const newestIcon = getBadgeIcon(newest.code) + // Per-batch id (not a fixed id): a fixed id de-dupes in the Toast layer, + // so a second badge earned within the toast's window would be marked + // seen but never shown. Keying on the codes lets a distinct later batch + // surface, while still de-duping a re-render of the same batch. + const toastId = `badge-earn:${codes.join(',')}` + + const openInspect = () => { + dismiss(toastId) + liveToastIdRef.current = null + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count }) + if (count === 1) { + setModalBadge({ + title: newestName, + description: newest.description || getPublicBadgeDescription(newest.code) || '', + logo: newestIcon, + }) + } else { + router.push('/badges') + } + } + + const label = count === 1 ? `Badge unlocked: ${newestName}` : `You unlocked ${count} badges 🎉` + + toast({ + id: toastId, + type: 'success', + duration: 6000, + className: 'border-yellow-1', + content: ( + + ), + }) + liveToastIdRef.current = toastId + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_SHOWN, { count }) + markSeen(codes) + }, [pathname, pending, toast, dismiss, markSeen, router]) + + // Dismiss the toast when the user leaves /home so it doesn't ride over the + // next route for its remaining duration. Guarded on pathname so the + // markSeen-triggered re-render (still on /home) never kills the live toast. + useEffect(() => { + if (pathname === HOME_PATH) return + if (liveToastIdRef.current) { + dismiss(liveToastIdRef.current) + liveToastIdRef.current = null + } + }, [pathname, dismiss]) + + return modalBadge ? ( + setModalBadge(null)} + title={modalBadge.title} + description={modalBadge.description} + logo={modalBadge.logo} + /> + ) : null +} diff --git a/src/components/Badges/BadgeStatusDrawer.tsx b/src/components/Badges/BadgeStatusDrawer.tsx index 5c1979d44..41b5073c1 100644 --- a/src/components/Badges/BadgeStatusDrawer.tsx +++ b/src/components/Badges/BadgeStatusDrawer.tsx @@ -1,9 +1,11 @@ import { Drawer, DrawerContent } from '@/components/Global/Drawer' import Image from 'next/image' +import { useState } from 'react' import { formatDate } from '@/utils/general.utils' import Card from '../Global/Card' import { PaymentInfoRow } from '../Payment/PaymentInfoRow' import ShareButton from '../Global/ShareButton' +import { BadgeDetailModal } from './BadgeDetailModal' import { getBadgeDisplayName, getBadgeIcon } from './badge.utils' import { BASE_URL } from '@/constants/general.consts' import { useAuth } from '@/context/authContext' @@ -23,6 +25,7 @@ export type BadgeStatusDrawerProps = { // shows a drawer for a newly unlocked badge export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerProps) => { const { user: authUser } = useAuth() + const [isDetailOpen, setIsDetailOpen] = useState(false) const username = authUser?.user.username const earnedAt = badge.earnedAt ? new Date(badge.earnedAt) : undefined const dateStr = earnedAt ? formatDate(earnedAt) : undefined @@ -32,49 +35,67 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP const profileLink = username ? `${BASE_URL}/${username}` : BASE_URL return ( - - -
- -
-
- Icon -
+ <> + + +
+ { + onClose() + setIsDetailOpen(true) + }} + > +
+
+ Icon +
-
-

- Badge unlocked! -

-

{displayName}

+
+

+ Badge unlocked! +

+

{displayName}

+
-
-
+ - - - - + + + + -
- - Promise.resolve( - `I earned ${displayName} badge on Peanut!\n\nJoin Peanut now and start earning points, unlocking achievements and moving money worldwide\n\n${profileLink}` - ) - } - > - Share Achievement - +
+ + Promise.resolve( + `I earned ${displayName} badge on Peanut!\n\nJoin Peanut now and start earning points, unlocking achievements and moving money worldwide\n\n${profileLink}` + ) + } + > + Share Achievement + +
-
-
-
+ + + setIsDetailOpen(false)} + title={displayName} + description={badge.description || ''} + logo={getBadgeIcon(badge.code)} + /> + ) } diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx new file mode 100644 index 000000000..f4e10bbb6 --- /dev/null +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, act } from '@testing-library/react' +import type { ComponentProps } from 'react' +import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' + +// next/navigation — mutable pathname so we can exercise the /home gate; stable +// router object so the effect doesn't re-fire on the tap-triggered re-render. +let mockPathname = '/home' +const mockRouterPush = jest.fn() +const mockRouter = { push: mockRouterPush } +jest.mock('next/navigation', () => ({ + usePathname: () => mockPathname, + useRouter: () => mockRouter, +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, fill, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean; fill?: boolean }) => ( + + ), +})) + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) + +const mockToast = jest.fn() +const mockDismissToast = jest.fn() +jest.mock('@/components/0_Bruddle/Toast', () => ({ + useToast: () => ({ toast: mockToast, dismiss: mockDismissToast }), +})) + +const mockMarkSeen = jest.fn() +let mockPending: Array<{ code: string; name: string; description: string | null; earnedAt: string }> = [] +jest.mock('@/components/Badges/useBadgeEarnToast', () => ({ + useBadgeEarnToast: () => ({ pending: mockPending, markSeen: mockMarkSeen }), +})) + +// Minimal stub: surface the title so we can assert the detail modal opened. +jest.mock('@/components/Badges/BadgeDetailModal', () => ({ + BadgeDetailModal: ({ isOpen, title }: { isOpen: boolean; title: string }) => + isOpen ?
{title}
: null, +})) + +import posthog from 'posthog-js' +const captureMock = (posthog as unknown as { capture: jest.Mock }).capture + +const badge = (code: string, name: string) => ({ + code, + name, + description: null, + earnedAt: new Date().toISOString(), +}) + +beforeEach(() => { + jest.clearAllMocks() + mockPathname = '/home' + mockPending = [] +}) + +describe('BadgeEarnToast', () => { + it('does nothing when not on /home', () => { + mockPathname = '/setup' + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + render() + expect(mockToast).not.toHaveBeenCalled() + expect(mockMarkSeen).not.toHaveBeenCalled() + }) + + it('does nothing when there are no fresh badges', () => { + render() + expect(mockToast).not.toHaveBeenCalled() + }) + + it('fires one toast for a single badge and opens the detail modal on tap', () => { + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + render() + + expect(mockToast).toHaveBeenCalledTimes(1) + expect(mockToast.mock.calls[0][0].id).toBe('badge-earn:PRODUCT_HUNT') + expect(mockMarkSeen).toHaveBeenCalledWith(['PRODUCT_HUNT']) + expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_shown', { count: 1 }) + + const content = mockToast.mock.calls[0][0].content + act(() => content.props.onClick()) + + expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') + expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1 }) + expect(screen.getByTestId('badge-detail-modal')).toHaveTextContent('Product Hunt') + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('coalesces multiple badges and routes to /badges on tap', () => { + mockPending = [badge('SHHHHH', 'Shhh'), badge('PRODUCT_HUNT', 'Product Hunt')] + render() + + expect(mockToast).toHaveBeenCalledTimes(1) + expect(mockMarkSeen).toHaveBeenCalledWith(['SHHHHH', 'PRODUCT_HUNT']) + + const content = mockToast.mock.calls[0][0].content + render(content) + expect(screen.getByText(/You unlocked 2 badges/)).toBeInTheDocument() + + act(() => content.props.onClick()) + expect(mockRouterPush).toHaveBeenCalledWith('/badges') + expect(screen.queryByTestId('badge-detail-modal')).not.toBeInTheDocument() + }) + + it('dismisses the live toast when the user navigates away from /home', () => { + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + const { rerender } = render() + expect(mockToast).toHaveBeenCalledTimes(1) + + mockPathname = '/send' + rerender() + expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') + }) +}) diff --git a/src/components/Badges/__tests__/badgeCelebration.utils.test.ts b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts new file mode 100644 index 000000000..d81f03a36 --- /dev/null +++ b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts @@ -0,0 +1,96 @@ +import { + FRESHNESS_WINDOW_MS, + celebrationStorageKey, + isFresh, + loadSeenCodes, + persistSeenCodes, + pickCelebrationBadges, + type CelebrationBadge, +} from '@/components/Badges/badgeCelebration.utils' + +const NOW = 1_700_000_000_000 // fixed reference time +const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString() + +function badge(over: Partial & { code: string; earnedAt: string | Date }): CelebrationBadge { + return { name: over.code, description: null, ...over } +} + +describe('badgeCelebration.utils', () => { + describe('isFresh', () => { + it('true for a badge earned just now', () => { + expect(isFresh(iso(0), NOW)).toBe(true) + }) + it('true just inside the window', () => { + expect(isFresh(iso(FRESHNESS_WINDOW_MS - 1000), NOW)).toBe(true) + }) + it('false just past the window', () => { + expect(isFresh(iso(FRESHNESS_WINDOW_MS + 1000), NOW)).toBe(false) + }) + it('true for a future timestamp (clock skew)', () => { + expect(isFresh(new Date(NOW + 60_000).toISOString(), NOW)).toBe(true) + }) + it('false for an invalid date', () => { + expect(isFresh('not-a-date', NOW)).toBe(false) + }) + }) + + describe('pickCelebrationBadges', () => { + it('returns [] for empty/undefined input', () => { + expect(pickCelebrationBadges(undefined, new Set(), NOW)).toEqual([]) + expect(pickCelebrationBadges([], new Set(), NOW)).toEqual([]) + }) + it('returns all fresh, unseen, visible badges newest-first', () => { + const badges = [badge({ code: 'OLDER', earnedAt: iso(1000) }), badge({ code: 'NEWER', earnedAt: iso(100) })] + expect(pickCelebrationBadges(badges, new Set(), NOW).map((b) => b.code)).toEqual(['NEWER', 'OLDER']) + }) + it('excludes WAITLIST_SKIP (card celebration) and BETA_TESTER (universal)', () => { + const badges = [ + badge({ code: 'WAITLIST_SKIP', earnedAt: iso(0) }), + badge({ code: 'BETA_TESTER', earnedAt: iso(0) }), + badge({ code: 'SHHHHH', earnedAt: iso(0) }), + ] + expect(pickCelebrationBadges(badges, new Set(), NOW).map((b) => b.code)).toEqual(['SHHHHH']) + }) + it('excludes already-seen, stale, and invisible badges', () => { + const badges = [ + badge({ code: 'SEEN', earnedAt: iso(0) }), + badge({ code: 'STALE', earnedAt: iso(FRESHNESS_WINDOW_MS + 1) }), + badge({ code: 'HIDDEN', earnedAt: iso(0), isVisible: false }), + badge({ code: 'GOOD', earnedAt: iso(0) }), + ] + expect(pickCelebrationBadges(badges, new Set(['SEEN']), NOW).map((b) => b.code)).toEqual(['GOOD']) + }) + }) + + describe('seen-set persistence (per user)', () => { + beforeEach(() => window.localStorage.clear()) + + it('round-trips codes under a per-user key', () => { + persistSeenCodes('user-a', new Set(['OG_2025_10_12', 'BETA_TESTER'])) + expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('OG_2025_10_12') + expect(loadSeenCodes('user-a')).toEqual(new Set(['OG_2025_10_12', 'BETA_TESTER'])) + }) + it('isolates users on the same device', () => { + persistSeenCodes('user-a', new Set(['OG_2025_10_12'])) + expect(loadSeenCodes('user-b')).toEqual(new Set()) + }) + it('returns an empty set on corrupt JSON', () => { + window.localStorage.setItem(celebrationStorageKey('user-a'), '{not json') + expect(loadSeenCodes('user-a')).toEqual(new Set()) + }) + }) + + describe('localStorage-failure fallback (private mode)', () => { + afterEach(() => jest.restoreAllMocks()) + + it('holds the seen-set in memory when writes throw, so it does not re-nag', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceeded') + }) + persistSeenCodes('pm-user', new Set(['SHHHHH'])) + // The localStorage write failed, but the in-memory fallback keeps the + // badge seen for the session so the toast doesn't re-fire every visit. + expect(loadSeenCodes('pm-user')).toEqual(new Set(['SHHHHH'])) + }) + }) +}) diff --git a/src/components/Badges/__tests__/useBadgeEarnToast.test.ts b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts new file mode 100644 index 000000000..8a4b7f7e2 --- /dev/null +++ b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react' +import { useUserStore } from '@/redux/hooks' +import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' +import { celebrationStorageKey } from '@/components/Badges/badgeCelebration.utils' + +jest.mock('@/redux/hooks', () => ({ useUserStore: jest.fn() })) +const mockUseUserStore = useUserStore as jest.Mock + +type TestBadge = { code: string; name: string; description: string | null; earnedAt: string; isVisible?: boolean } +const freshIso = () => new Date().toISOString() + +function setUser(userId: string | undefined, badges: TestBadge[]): void { + mockUseUserStore.mockReturnValue({ user: userId ? { user: { userId, badges } } : null }) +} + +describe('useBadgeEarnToast', () => { + beforeEach(() => { + window.localStorage.clear() + jest.clearAllMocks() + }) + + it('returns no pending badges when signed out', () => { + setUser(undefined, []) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + }) + + it('reads seen synchronously when the user resolves after mount (no cold-start re-fire)', () => { + window.localStorage.setItem(celebrationStorageKey('user-a'), JSON.stringify(['SHHHHH'])) + setUser(undefined, []) + const { result, rerender } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + // user loads async: on the first render with a userId, seen must already + // reflect localStorage — not lag a render behind (which re-fired the toast). + setUser('user-a', [{ code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }]) + rerender() + expect(result.current.pending).toEqual([]) + }) + + it('surfaces all freshly-earned badges, newest first', () => { + setUser('user-a', [ + { + code: 'EVENT_ALUMNI', + name: 'Alumni', + description: null, + earnedAt: new Date(Date.now() - 1000).toISOString(), + }, + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending.map((b) => b.code)).toEqual(['SHHHHH', 'EVENT_ALUMNI']) + }) + + it('excludes universal/bespoke badges (BETA_TESTER, WAITLIST_SKIP)', () => { + setUser('user-a', [ + { code: 'BETA_TESTER', name: 'Beta', description: null, earnedAt: freshIso() }, + { code: 'WAITLIST_SKIP', name: 'Skip', description: null, earnedAt: freshIso() }, + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending.map((b) => b.code)).toEqual(['SHHHHH']) + }) + + it('markSeen persists the codes and clears them from pending', () => { + setUser('user-a', [ + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + { code: 'EVENT_ALUMNI', name: 'Alumni', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + act(() => result.current.markSeen(['SHHHHH', 'EVENT_ALUMNI'])) + expect(result.current.pending).toEqual([]) + expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('SHHHHH') + }) + + it('does not resurface badges already in the seen-set', () => { + window.localStorage.setItem(celebrationStorageKey('user-a'), JSON.stringify(['SHHHHH'])) + setUser('user-a', [{ code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + }) +}) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 51a301a6d..a8e442e02 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -62,9 +62,20 @@ export const BADGES: Record = { path: '/badges/arbiverse_devconnect.svg', description: 'They found the Arbiverse booth. We found them. Mutual onboarding achieved.', }, + // Rebranded from "Card Pioneer" to "Founding Pioneer". Backend still emits the + // CARD_PIONEER code (it also gates grandfathered card access + cashback), so we + // keep the code and only repoint the FE asset/copy/name. Existing holders now + // render the Founding Pioneer badge. (Same pattern as SUPPORT_SURVIVOR below.) CARD_PIONEER: { - path: '/badges/peanut-pioneer.png', - description: 'A true Card Pioneer. Among the first to pay everywhere with Peanut.', + path: '/badges/founding_pioneer.svg', + description: 'You built Peanut before it had a launch.', + displayName: 'Founding Pioneer', + }, + // New invite-activated community badge for the early crew (invite code "founding"). + FOUNDING_PIONEER: { + path: '/badges/founding_pioneer.svg', + description: 'You built Peanut before it had a launch.', + displayName: 'Founding Pioneer', }, FOUNDER_HOUSE: { path: '/badges/founder_house.svg', @@ -196,6 +207,11 @@ export const BADGES: Record = { path: '/badges/touched_grass.svg', description: 'You logged off and touched real grass with Peanut.', }, + PSYOPS_DIVISION: { + path: '/badges/psyops_division.svg', + description: 'Enlisted in the Psyops Division. Welcome to the influence game.', + displayName: 'Psyops Division', + }, EVENT_ALUMNI: { path: '/badges/event_alumni.svg', description: 'Old school. You were in the room before most.', diff --git a/src/components/Badges/badgeCelebration.utils.ts b/src/components/Badges/badgeCelebration.utils.ts new file mode 100644 index 000000000..83f100bd2 --- /dev/null +++ b/src/components/Badges/badgeCelebration.utils.ts @@ -0,0 +1,102 @@ +// Pure helpers for the badge-earn toast (TASK-19791). +// +// "Surface it once, while fresh, without interrupting." When a user lands on +// /home with a freshly-earned badge they haven't seen yet, we show a single +// non-blocking toast (tap to inspect) — never a fullscreen takeover, which +// collided with onboarding (a /shhhhh card signup awards BETA_TESTER + SHHHHH +// at once → stacked popups). See BadgeEarnToast.tsx. +// +// Persistence is a per-user localStorage seen-set + a 7-day freshness window — +// same house pattern as the card skip celebration (card/page.tsx). The window +// is what makes that safe: an old badge is never "fresh", so it can't re-toast +// on a new device or on the day this ships. No backend column, no migration. + +export type CelebrationBadge = { + code: string + name: string + description: string | null + earnedAt: string | Date + isVisible?: boolean +} + +// 7 days: generous enough that nearly everyone opens the app within a week of +// earning (covers badges granted by async webhooks), short enough that an old +// badge never re-toasts on a new device. +export const FRESHNESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + +// Badges that should NOT trigger the toast: +// - WAITLIST_SKIP keeps its bespoke card-flow celebration (BadgeSkipCelebration). +// - BETA_TESTER is awarded to every signup — too universal to be worth surfacing. +// Other card-access "skip" badges (OG/Devconnect/Arbiverse) are historical, so +// the freshness window already keeps them out. +const EXCLUDED_CODES = new Set(['WAITLIST_SKIP', 'BETA_TESTER']) + +const STORAGE_PREFIX = 'badge_earn_toast_seen' + +// In-memory fallback for environments where localStorage can't persist +// (Safari/iOS private mode, quota exhausted). Without it, a swallowed write +// meant the seen-set re-hydrated empty every launch and the toast re-fired on +// EVERY /home visit for the whole freshness window. Memory keeps it alive for +// the session (lost on reload — one re-show per session, not an infinite nag). +// Only consulted when the localStorage read misses, so a working localStorage +// stays the source of truth. +const memoryFallback = new Map>() + +// Per-user key so a shared browser doesn't leak one account's seen-set onto another. +export function celebrationStorageKey(userId: string): string { + return `${STORAGE_PREFIX}:${userId}` +} + +export function loadSeenCodes(userId: string): Set { + if (typeof window !== 'undefined') { + try { + const raw = window.localStorage.getItem(celebrationStorageKey(userId)) + if (raw) { + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) return new Set(parsed.filter((c): c is string => typeof c === 'string')) + } + } catch { + // fall through to the in-memory fallback + } + } + return new Set(memoryFallback.get(userId)) +} + +export function persistSeenCodes(userId: string, codes: ReadonlySet): void { + try { + if (typeof window === 'undefined') throw new Error('no window') + window.localStorage.setItem(celebrationStorageKey(userId), JSON.stringify([...codes])) + // localStorage is the source of truth when it works — drop any stale + // in-memory copy so it can't shadow a later real read. + memoryFallback.delete(userId) + } catch { + // localStorage unavailable (private mode / quota / SSR) — hold the + // seen-set in memory so the toast doesn't re-nag this session. + memoryFallback.set(userId, new Set(codes)) + } +} + +// Fresh = earned within the last window (future timestamps from clock skew +// count as fresh too; only genuinely-old badges are excluded). +export function isFresh(earnedAt: string | Date, now: number): boolean { + const earned = new Date(earnedAt).getTime() + if (!Number.isFinite(earned)) return false + return earned >= now - FRESHNESS_WINDOW_MS +} + +// All visible, fresh, not-yet-seen, non-excluded badges, newest first. Returned +// as a list so the toast can coalesce ("You unlocked 2 badges") instead of +// stacking one toast per badge. +export function pickCelebrationBadges( + badges: readonly CelebrationBadge[] | undefined, + seen: ReadonlySet, + now: number +): CelebrationBadge[] { + if (!badges?.length) return [] + return badges + .filter((b) => b.isVisible !== false) + .filter((b) => !EXCLUDED_CODES.has(b.code)) + .filter((b) => !seen.has(b.code)) + .filter((b) => isFresh(b.earnedAt, now)) + .sort((a, b) => new Date(b.earnedAt).getTime() - new Date(a.earnedAt).getTime()) +} diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx index 372b68489..ac8f810c5 100644 --- a/src/components/Badges/index.tsx +++ b/src/components/Badges/index.tsx @@ -8,7 +8,7 @@ import { getBadgeDisplayName, getBadgeIcon } from './badge.utils' import { getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { Icon } from '../Global/Icons/Icon' -import ActionModal from '../Global/ActionModal' +import { BadgeDetailModal } from './BadgeDetailModal' import { useMemo, useState, useEffect } from 'react' import { useUserStore } from '@/redux/hooks' import { ActionListCard } from '../ActionListCard' @@ -95,36 +95,15 @@ export const Badges = () => {
{selectedBadge && ( - - } - iconContainerClassName="bg-transparent min-w-30 h-auto" - modalPanelClassName="m-0" - visible={isBadgeModalOpen} + { setIsBadgeModalOpen(false) setSelectedBadge(null) }} title={selectedBadge.title} description={selectedBadge.description} - ctas={[ - { - text: 'Got it!', - onClick: () => { - setIsBadgeModalOpen(false) - setSelectedBadge(null) - }, - shadowSize: '4', - }, - ]} + logo={selectedBadge.logo} /> )}
diff --git a/src/components/Badges/useBadgeEarnToast.ts b/src/components/Badges/useBadgeEarnToast.ts new file mode 100644 index 000000000..514c3e848 --- /dev/null +++ b/src/components/Badges/useBadgeEarnToast.ts @@ -0,0 +1,56 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useUserStore } from '@/redux/hooks' +import { loadSeenCodes, persistSeenCodes, pickCelebrationBadges, type CelebrationBadge } from './badgeCelebration.utils' + +type UseBadgeEarnToast = { + /** Freshly-earned, not-yet-seen, non-excluded badges (newest first). */ + pending: CelebrationBadge[] + /** Mark these codes seen so they don't toast again (per-user localStorage). */ + markSeen: (codes: string[]) => void +} + +/** + * Detects freshly-earned, un-surfaced badges from the signed-in user's badge + * list (from /users/me via the redux user store) for the badge-earn toast. + * Persistence is a per-user localStorage seen-set + a freshness window — see + * badgeCelebration.utils.ts for the why. + */ +export function useBadgeEarnToast(): UseBadgeEarnToast { + const { user } = useUserStore() + const userId = user?.user?.userId + const badges = user?.user?.badges + + // `seen` is derived SYNCHRONOUSLY from storage. The previous + // useState(initial) + useEffect(re-hydrate) lagged one render behind the + // async user load: on the render where `userId` first became defined, the + // seen-set was still empty, so `pending` included already-seen badges and + // the toast re-fired on every cold start until the badge aged out. Reading + // in useMemo keyed on userId closes that gap; `bump` re-reads after markSeen + // persists. + const [bump, setBump] = useState(0) + // `bump` is a deliberate recompute trigger — markSeen() bumps it after + // persisting so `seen` re-reads storage. It isn't used inside the callback, + // which exhaustive-deps can't distinguish from a stray dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + const seen = useMemo(() => (userId ? loadSeenCodes(userId) : new Set()), [userId, bump]) + + const pending = useMemo(() => { + if (!userId) return [] + return pickCelebrationBadges(badges, seen, Date.now()) + }, [userId, badges, seen]) + + const markSeen = useCallback( + (codes: string[]) => { + if (!userId || codes.length === 0) return + const next = new Set(loadSeenCodes(userId)) + codes.forEach((c) => next.add(c)) + persistSeenCodes(userId, next) + setBump((b) => b + 1) + }, + [userId] + ) + + return { pending, markSeen } +} diff --git a/src/components/Card/AddCardEntryScreen.tsx b/src/components/Card/AddCardEntryScreen.tsx index 8eaa976ef..0a075fc5d 100644 --- a/src/components/Card/AddCardEntryScreen.tsx +++ b/src/components/Card/AddCardEntryScreen.tsx @@ -12,7 +12,7 @@ interface Props { applyError?: string | null } -const FEATURES = ['No separate card balance', 'Instant from your wallet', 'Works online & contactless'] as const +const FEATURES = ['One unified balance', 'Under your control', 'Works online & contactless'] as const const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => { const [isApplying, setIsApplying] = useState(false) @@ -37,7 +37,7 @@ const AddCardEntryScreen: FC = ({ onApply, onPrev, applyError }) => {

Spend anywhere Visa is accepted

-

Use your balance at 140M+ merchants. Online, contactless.

+

Use your balance at 150M+ merchants. Online, contactless, yours.

    diff --git a/src/components/Card/BadgeSkipCelebration.tsx b/src/components/Card/BadgeSkipCelebration.tsx index 7b60486a7..037787ea3 100644 --- a/src/components/Card/BadgeSkipCelebration.tsx +++ b/src/components/Card/BadgeSkipCelebration.tsx @@ -19,6 +19,7 @@ import { type FC, useEffect, useRef, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { Button } from '@/components/0_Bruddle/Button' +import { Checkbox } from '@/components/0_Bruddle/Checkbox' import NavHeader from '@/components/Global/NavHeader' import { ScaledShareAsset } from '@/components/Card/share-asset/ScaledShareAsset' import { ScaledPixelatedCardFace } from '@/components/Card/share-asset/ScaledPixelatedCardFace' @@ -59,6 +60,7 @@ 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) const { triggerHaptic } = useHaptic() const captureRef = useRef(null) const hasBadge = !!badgeCode @@ -161,6 +163,7 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t tier={tier ?? 0} pointsBalance={pointsBalance ?? 0} cardLast4="0420" + hideUsername={hideUsername} animate={phase === 'revealed'} /> @@ -174,11 +177,14 @@ const BadgeSkipCelebration: FC = ({ badgeCode, username, badges, stats, t animate={{ opacity: phase === 'revealed' ? 1 : 0, y: phase === 'revealed' ? 0 : 12 }} transition={{ duration: 0.3, ease: 'easeOut', delay: phase === 'revealed' ? 0.1 : 0 }} > - pill on the asset */} + setHideUsername(e.target.checked)} /> + diff --git a/src/components/Card/CardEligibilityCheckScreen.tsx b/src/components/Card/CardEligibilityCheckScreen.tsx index 3546b9106..c7f4ca3ba 100644 --- a/src/components/Card/CardEligibilityCheckScreen.tsx +++ b/src/components/Card/CardEligibilityCheckScreen.tsx @@ -12,7 +12,7 @@ * * On hold-complete the parent state machine decides the next view: * - has card access → BadgeSkipCelebration (share-asset reveal) - * - no card access → CardWaitlistScreen + * - no card access → CardRejectionScreen ("not tonight" door rejection) */ import { type FC, useEffect, useState } from 'react' diff --git a/src/components/Card/CardRejectionScreen.tsx b/src/components/Card/CardRejectionScreen.tsx new file mode 100644 index 000000000..488a6c3d1 --- /dev/null +++ b/src/components/Card/CardRejectionScreen.tsx @@ -0,0 +1,201 @@ +'use client' + +/** + * — the Berghain-style "not tonight" rejection. + * + * Shown to a user who passed the eligibility hold but doesn't hold a card- + * access badge. Instead of a flat "you don't have the badge" wall, they get + * 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. + */ + +import { type FC, useEffect, useRef, useState } from 'react' +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 { 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 { 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 + /** Which smug mascot the asset shows. */ + mascot?: RejectionMascot + onPrev?: () => void + /** Called after the user joins the waitlist. Parent should refetch /card, + * which flips the state machine to (cooldown). */ + onJoined?: () => void +} + +const CardRejectionScreen: FC = ({ + username, + applicants = 213, + admitted = 7, + mascot = 'cool', + onPrev, + onJoined, +}) => { + const captureRef = useRef(null) + const [sharing, setSharing] = useState(false) + const [joining, setJoining] = useState(false) + const [joinError, setJoinError] = useState(null) + const safeUsername = (username || '').trim() || 'anon' + + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) + }, []) + + const handleJoin = async (): Promise => { + setJoinError(null) + setJoining(true) + try { + const res = await cardApi.joinWaitlist() + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) + onJoined?.() + } catch (e) { + // Keep the user-facing message generic; raw BE text can leak + // internals/PII. PostHog gets only the error CLASS for bucketing. + console.error('[card-rejection] join failed', e) + setJoinError('Failed to join waitlist. Please try again.') + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOIN_FAILED, { + error_name: e instanceof Error ? e.name : 'unknown', + }) + } finally { + setJoining(false) + } + } + + const handleAppeal = async (): Promise => { + const caption = pickRejectionCaption() + + // Appeal = tweet AND join the waitlist. Joining is NOT access — release + // stays manual (admin grant) — it just drops the appealer into the + // userId-keyed waitlist queue we grant from by hand, and flips them to + // the friendly cooldown after they share. `source: 'appeal'` lets + // PostHog tell appealers apart from quiet "Join anyway" joiners. Same + // idempotent call the secondary button makes. Fire it in PARALLEL with + // the capture, but defer onJoined() (which unmounts this screen) to the + // finally so we never yank captureRef out from under captureShareAsset. + const joined = cardApi + .joinWaitlist() + .then((res) => { + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position, source: 'appeal' }) + }) + .catch((e) => { + // Non-fatal: the tweet still goes out, and the CARD_SHARE_ASSET_SHARED + // appeal event is the backstop signal. They can also use "Join + // the waitlist anyway". + console.error('[card-rejection] appeal waitlist-join failed', e) + posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOIN_FAILED, { + error_name: e instanceof Error ? e.name : 'unknown', + source: 'appeal', + }) + }) + + // Text-only appeal: opens the X composer with the caption (which tags + // @joinpeanut). The image doesn't attach via the intent URL, but the + // tag — the point of the appeal — still goes out. + const tweetIntent = (method: string): void => { + posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SHARED, { source: 'rejection-appeal', method }) + window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(caption)}`, '_blank', 'noopener') + } + + setSharing(true) + try { + // Desktop / no file-share support, or the asset hasn't rendered yet + // (an unmeasured ref is not an error) → text-only intent. + if (!canShareImageFiles()) return tweetIntent('twitter-intent-fallback') + const node = captureRef.current + if (!node) return tweetIntent('twitter-intent-unmeasured') + + const blob = await captureShareAsset(node) + const file = new File([blob], 'peanut-not-tonight.png', { type: 'image/png' }) + await navigator.share({ text: caption, files: [file] }) + posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SHARED, { + source: 'rejection-appeal', + method: 'native-share-with-file', + }) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + // Capture/native-share genuinely failed (html-to-image error or an + // OS share-sheet refusal). The appeal is the whole point of this + // screen — don't drop it silently; fall back to the text-only tweet. + console.error('[card-rejection] appeal share failed; falling back to intent', err) + Sentry.captureException(err, { tags: { feature: 'rejection-asset', action: 'appeal' } }) + tweetIntent('twitter-intent-error-fallback') + } finally { + setSharing(false) + // Now that the capture/share UI is done, refetch /card. onJoined() + // only shows the cooldown screen if the join actually persisted — + // a failed join leaves them here (no lying "you're in"), able to + // retry via "Join the waitlist anyway". + await joined + onJoined?.() + } + } + + return ( +
    + + +
    + + + {/* Scarcity tally + appeal pitch — screen HTML, not on the asset */} +
    +

    the door's tight tonight.

    +

    + {applicants.toLocaleString('en-US')} tried ·{' '} + {admitted} got in. +

    +

    + tweet and tag @joinpeanut to appeal. +
    + come back tomorrow +

    +
    +
    + +
    + {joinError && } + + +
    +
    + ) +} + +export default CardRejectionScreen diff --git a/src/components/Card/CardUnlockDrawer.tsx b/src/components/Card/CardUnlockDrawer.tsx index 70bd523e8..1d106bdd0 100644 --- a/src/components/Card/CardUnlockDrawer.tsx +++ b/src/components/Card/CardUnlockDrawer.tsx @@ -10,8 +10,9 @@ * (Share + Save image). */ -import { type FC, useEffect, useRef } from 'react' +import { type FC, useEffect, useRef, useState } from 'react' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' +import { Checkbox } from '@/components/0_Bruddle/Checkbox' import { ScaledShareAsset } from '@/components/Card/share-asset/ScaledShareAsset' import { ShareAssetActions } from '@/components/Card/share-asset/ShareAssetActions' import posthog from 'posthog-js' @@ -31,6 +32,7 @@ interface Props { export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, badges }) => { const captureRef = useRef(null) + const [hideUsername, setHideUsername] = useState(false) useEffect(() => { if (!isOpen) return @@ -64,15 +66,19 @@ export const CardUnlockDrawer: FC = ({ isOpen, onClose, entry, username, username={username ?? 'anon'} badges={assetBadges} cardLast4="0420" + hideUsername={hideUsername} animate={false} />
- pill on the asset */} + setHideUsername(e.target.checked)} /> +
diff --git a/src/components/Card/CardWaitlistScreen.tsx b/src/components/Card/CardWaitlistScreen.tsx deleted file mode 100644 index 6bbc4f6fb..000000000 --- a/src/components/Card/CardWaitlistScreen.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -/** - * — shown to users who passed the eligibility - * check but don't hold a skip badge AND haven't joined the waitlist yet. - * - * Tone: gentle redirect, not a wall. Uses the pointing-peanut mascot - * (PeanutPointing — grinning, nudging toward the waitlist), - * one-line "you don't have the required badge" headline, soft "but you - * can join the waitlist" pitch, single CTA. No skip-the-line gallery - * here — the goal is conversion to waitlist, not badge-hunting. - * - * Once joined, the /card state machine routes to . - */ - -import { type FC, useEffect, useState } from 'react' -import Image from 'next/image' -import { Button } from '@/components/0_Bruddle/Button' -import NavHeader from '@/components/Global/NavHeader' -import ErrorAlert from '@/components/Global/ErrorAlert' -import { PeanutPointing } from '@/assets/mascot' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { cardApi, type CardInfoResponse } from '@/services/card' - -interface Props { - cardInfo: CardInfoResponse - onPrev?: () => void - /** Called after the user joins. Parent should refetch /card. */ - onJoined?: () => void -} - -const CardWaitlistScreen: FC = ({ onPrev, onJoined }) => { - const [joining, setJoining] = useState(false) - const [joinError, setJoinError] = useState(null) - - useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_VIEWED, { already_joined: false }) - }, []) - - const handleJoin = async (): Promise => { - setJoinError(null) - setJoining(true) - try { - const res = await cardApi.joinWaitlist() - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOINED, { position: res.position }) - onJoined?.() - } catch (e) { - // User-facing message stays generic; raw BE error text can leak - // internals/PII. PostHog gets the error CLASS only — enough to - // bucket failures without exposing details. - console.error('[card-waitlist] join failed', e) - setJoinError('Failed to join waitlist. Please try again.') - posthog.capture(ANALYTICS_EVENTS.CARD_WAITLIST_JOIN_FAILED, { - error_name: e instanceof Error ? e.name : 'unknown', - }) - } finally { - setJoining(false) - } - } - - return ( -
- - -
- Peanut pointing the way - -
-

You don't have the required badge :(

-

- Instead, you can join the waitlist. We let in a few people every week. -

-
-
- - {joinError && } - - -
- ) -} - -export default CardWaitlistScreen diff --git a/src/components/Card/__tests__/cardState.utils.test.ts b/src/components/Card/__tests__/cardState.utils.test.ts index 3a764770f..da072f9cc 100644 --- a/src/components/Card/__tests__/cardState.utils.test.ts +++ b/src/components/Card/__tests__/cardState.utils.test.ts @@ -15,6 +15,7 @@ const cardInfo = ( hasCardAccess: opts.hasCardAccess ?? false, isEligible: true, flowEarlyAccess: opts.flowEarlyAccess ?? true, + isPublicLaunched: true, waitlistJoinedAt: opts.waitlistJoinedAt ?? null, waitlistPosition: opts.waitlistPosition ?? null, waitlistReleasedAt: opts.waitlistReleasedAt ?? null, diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index ccba991ef..d699f842e 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -54,9 +54,18 @@ export interface PixelatedCardFaceProps { * through the same canvas-rasterisation pipeline as the hand so the * whole card reads as chunky pixels, not a mix of pixels + blur. */ blurAll?: boolean + /** When true, omit the Visa brand mark entirely. The share asset can't + * 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 } -export const PixelatedCardFace: FC = ({ className, style, blurAll = false }) => ( +export const PixelatedCardFace: FC = ({ + className, + style, + blurAll = false, + hideVisa = false, +}) => (
= ({ className, style ) : ( )} - {blurAll ? ( - - ) : ( - Visa - )} + {!hideVisa && + (blurAll ? ( + + ) : ( + Visa + ))}
{/* Bottom: card number — same `•••• ????` pattern in both modes. diff --git a/src/components/Card/share-asset/RejectionAssetD3.tsx b/src/components/Card/share-asset/RejectionAssetD3.tsx new file mode 100644 index 000000000..6dc3cbd0a --- /dev/null +++ b/src/components/Card/share-asset/RejectionAssetD3.tsx @@ -0,0 +1,193 @@ +/** + * — the "not tonight" rejection share asset. + * + * Shown to a user who passed the eligibility hold but doesn't hold a card- + * access badge: instead of the celebration collage, they get a Berghain- + * style door rejection they can share. The rejection markets the door's + * exclusivity, and the share tags @joinpeanut via the caption (every caption + * in rejectionCaptions.ts carries the handle). + * + * Visual: stark, near-black field. "not tonight, " in big white and + * an optional smug peanut mascot on the left (the bouncer, mocking you). The + * @joinpeanut tag is intentionally NOT drawn on the asset — it rides the + * caption, not the pixels. The scarcity tally ("applicants tonight…") also + * lives in the screen HTML around the asset, NOT on the image. + * + * Authored at native 1200×900 (same frame as the win asset) so it flows + * through the same capture/share pipeline. + */ + +'use client' + +import { type FC } from 'react' +import { CANVAS_W, CANVAS_H } from './shareAssetLayout' +import type { RejectionMascot } from './shareAsset.types' +import { PeanutTooCool, PeanutPointing, PeanutWhistling } from '@/assets/mascot' + +const MASCOT_SRC: Record, string> = { + cool: PeanutTooCool.src, // pixel shades, hand on hip — "not cool enough" + mock: PeanutPointing.src, // grinning + pointing — point and laugh + chill: PeanutWhistling.src, // whistling, peace-sign — dismissive "whatever" +} + +interface RejectionAssetProps { + username: string + /** Leading line. Default "not tonight,". */ + prefix?: string + /** Which mascot to slap on the left. 'none' hides it. */ + mascot?: RejectionMascot + /** Mascot size multiplier. */ + mascotScale?: number + /** Background fill (near-black). */ + bg?: string + /** Headline cap height in px + tracking in em. */ + headlineSize?: number + headlineTracking?: number + /** Subtle edge darkening for depth, 0–1. */ + vignette?: number + animate?: boolean +} + +const DEFAULT_BG = '#0a0b0f' // near-black, faint cold-blue tint + +const ANIM_HEADLINE_DELAY = 150 +const ANIM_MASCOT_DELAY = 350 + +const RejectionAssetD3: FC = ({ + username, + prefix = 'not tonight,', + mascot = 'cool', + mascotScale = 1, + bg = DEFAULT_BG, + headlineSize = 130, + headlineTracking = -0.02, + vignette = 0.35, + animate = true, +}) => { + const safeUsername = (username || '').trim() || 'anon' + const hasMascot = mascot !== 'none' + const mascotSrc = hasMascot ? MASCOT_SRC[mascot] : null + + // Mascot eats the left third; the headline lives in the remaining space. + const mascotH = 640 * mascotScale + const textLeft = hasMascot ? 470 : 80 + const textRight = 80 + + // Auto-shrink the username so long handles still fit the text column. + const colW = CANVAS_W - textLeft - textRight + const unameSize = Math.min(headlineSize, (colW / Math.max(safeUsername.length, 5)) * 1.7) + + return ( +
+ {/* eslint-disable-next-line react/no-unknown-property -- styled-jsx `jsx` attr, same pattern as ShareAssetD3 */} + + + {/* ─── Subtle vignette for depth (no spotlight) ─── */} + {vignette > 0 && ( +
+ )} + + {/* ─── Mascot (left) — the bouncer, mocking you ─── */} + {mascotSrc && ( + + )} + + {/* ─── Headline — "not tonight, " ─── */} +
+ + {prefix} + + + {safeUsername} + +
+
+ ) +} + +export default RejectionAssetD3 diff --git a/src/components/Card/share-asset/ScaledRejectionAsset.tsx b/src/components/Card/share-asset/ScaledRejectionAsset.tsx new file mode 100644 index 000000000..96469db4d --- /dev/null +++ b/src/components/Card/share-asset/ScaledRejectionAsset.tsx @@ -0,0 +1,55 @@ +'use client' + +/** + * — renders scaled to fit its + * container width, forwarding a capture ref at the native 1200×900 node so + * capture/share works at full fidelity. Mirrors . + */ + +import { type ComponentProps, type ForwardedRef, forwardRef } from 'react' +import RejectionAssetD3 from './RejectionAssetD3' +import { useFitToWidth } from '@/hooks/useFitToWidth' +import { CANVAS_W, CANVAS_H } from './shareAssetLayout' + +type Props = ComponentProps & { + /** Extra className for the outer host (the one ResizeObserver measures). */ + className?: string +} + +export const ScaledRejectionAsset = forwardRef(function ScaledRejectionAsset( + { className, ...assetProps }: Props, + captureRef: ForwardedRef +) { + const { hostRef, scale } = useFitToWidth(CANVAS_W) + + return ( +
+ {scale > 0 && ( +
+ +
+ )} +
+ ) +}) + +export default ScaledRejectionAsset diff --git a/src/components/Card/share-asset/ShareAssetActions.tsx b/src/components/Card/share-asset/ShareAssetActions.tsx index e4ee48785..f8ef100d0 100644 --- a/src/components/Card/share-asset/ShareAssetActions.tsx +++ b/src/components/Card/share-asset/ShareAssetActions.tsx @@ -23,6 +23,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import { captureShareAsset, canShareImageFiles, downloadBlob, ShareAssetCaptureError } from './captureShareAsset' import { shareCardOnTwitter } from './share.utils' +import { pickWinCaption } from './winCaptions' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -68,16 +69,18 @@ interface Props { source: string /** Optional filename for the downloaded PNG. */ filename?: string - /** Caption text passed to the native share sheet (e.g. "I got my Peanut card. shhhh."). */ - shareText: string } -export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png', shareText }) => { +export const ShareAssetActions: FC = ({ captureRef, source, filename = 'peanut-card.png' }) => { const [isSharing, setIsSharing] = useState(false) const [isSaving, setIsSaving] = useState(false) - const [error, setError] = useState(null) + // One random win caption per mount (rotation lives in winCaptions.ts), so + // shared timelines don't fill with one identical line. Stable for this + // mount so Share + the desktop intent post the same caption. + const [caption] = useState(pickWinCaption) + const handleShare = async (): Promise => { setError(null) setIsSharing(true) @@ -93,14 +96,14 @@ export const ShareAssetActions: FC = ({ captureRef, source, filename = 'p source, method: 'twitter-intent-fallback', }) - shareCardOnTwitter() + shareCardOnTwitter(caption) return } const node = captureRef.current if (!node) throw new Error('share asset not yet rendered — try again in a moment') const blob = await captureShareAsset(node) const file = new File([blob], filename, { type: 'image/png' }) - await navigator.share({ text: shareText, files: [file] }) + await navigator.share({ text: caption, files: [file] }) posthog.capture(ANALYTICS_EVENTS.CARD_SHARE_ASSET_SHARED, { source, method: 'native-share-with-file', diff --git a/src/components/Card/share-asset/ShareAssetD3.tsx b/src/components/Card/share-asset/ShareAssetD3.tsx index 80949e537..e4d3ce034 100644 --- a/src/components/Card/share-asset/ShareAssetD3.tsx +++ b/src/components/Card/share-asset/ShareAssetD3.tsx @@ -2,17 +2,15 @@ * — the in-app share asset that users can post or save * after they get their Peanut card. * - * Asset is 1200×900 (4:3 postage-stamp proportions — see CANVAS_W / - * CANVAS_H in shareAssetLayout.ts). It's NOT a Twitter card image: the - * X intent (see share.utils.ts) is text-only and doesn't include a URL, - * so there's no OG/twitter:image scrape path. Capture is via - * captureShareAsset.ts (html-to-image at native 1200×900, attached to - * navigator.share or downloaded by the user). + * Asset is 1200×900 (4:3 — see CANVAS_W / CANVAS_H in shareAssetLayout.ts). + * It's NOT a Twitter card image: the X intent (see share.utils.ts) is + * text-only and doesn't include a URL, so there's no OG/twitter:image + * scrape path. Capture is via captureShareAsset.ts (html-to-image at + * native 1200×900, attached to navigator.share or downloaded). * - * Type hierarchy is tuned for readability at the captured PNG's typical - * downscale on social timelines: every meaningful text is ≥22px native - * (~7px at 3.4× downscale, bold-readable), with the username pill + - * EDITION header far larger. + * Composition is a sticker collage: the pixelated card sits in the middle + * and the user's badges are slapped around it as big raw stickers. The + * only text is the @username pill. */ 'use client' @@ -26,96 +24,130 @@ import { CARD_TOP, CARD_ROTATION_DEG, placeStamps, - placeDecorations, - buildStatColumns, + pillKeepoutBox, usernameFontSize, type StampPlacement, - type DecorationPlacement, + type KeepoutEllipse, } from './shareAssetLayout' -import type { ShareAssetD3Props, TierLevel } from './shareAsset.types' -import { TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE } from '@/assets/badges' -import { PEANUTMAN_HOLDING_BEER } from '@/assets/mascot' -import { STAR_STRAIGHT_ICON } from '@/assets/icons' -import { HandPeace, HandThumbsUp, HandThumbsUpV2, Eyes, Cloud, Star } from '@/assets/illustrations' +import type { ShareAssetD3Props, HeroMessage } from './shareAsset.types' import { PixelatedCardFace } from './PixelatedCardFace' -const ASSET_STAR = STAR_STRAIGHT_ICON.src -const ASSET_STAR_ALT = Star.src -const ASSET_HAND_THUMBS = HandThumbsUp.src -const ASSET_HAND_THUMBS_V2 = HandThumbsUpV2.src -const ASSET_HAND_PEACE = HandPeace.src -const ASSET_EYES = Eyes.src -const ASSET_CLOUD = Cloud.src -// The peanut character art style is intentionally legless — the body -// tapers to a rounded point. Rather than dropping the character, we -// place him BEHIND the card with his bottom inside the card's bbox so -// only his head + arms peek out above the card edge. Of the available -// poses, peanut-holding-beer is the most expressive (both arms visible, -// holding a beer, peace sign) and reads cleanly at small sizes. -const ASSET_PEANUT_CHAR = PEANUTMAN_HOLDING_BEER.src - -const TIER_SVG: Record = { - 0: TIER_0_BADGE, - 1: TIER_1_BADGE, - 2: TIER_2_BADGE, - 3: TIER_3_BADGE, -} - -const TIER_LABEL: Record = { - 0: 'TIER 0', - 1: 'TIER 1', - 2: 'TIER 2', - 3: 'TIER 3', -} - -const DECO_ASSET: Record = { - star: ASSET_STAR, - starAlt: ASSET_STAR_ALT, - thumbsUp: ASSET_HAND_THUMBS, - thumbsUpV2: ASSET_HAND_THUMBS_V2, - peace: ASSET_HAND_PEACE, - eyes: ASSET_EYES, - cloud: ASSET_CLOUD, - peanutChar: ASSET_PEANUT_CHAR, -} - // Peanut blue — the brand section colour reused from the prod landing page // (LP `businessBgColor` in dropLink.tsx + the global `--background-color`). -// Replaces the off-brandbook lavender so the asset pops on a timeline. const ASSET_BG = '#90A8ED' const ANIM_CARD_DELAY = 100 const ANIM_STAMP_BASE_DELAY = 600 -const ANIM_STAMP_STAGGER = 250 -const ANIM_TIER_DELAY = 1500 +const ANIM_STAMP_STAGGER = 200 +const ANIM_HERO_DELAY = 350 const ANIM_ATTRIBUTION_DELAY = 1700 -const ANIM_EARNED_DELAY = 1900 + +// Hero message sits centred near the top of the canvas. +const HERO_TOP = 26 +const HERO_CX = CANVAS_W / 2 + +// Max rendered width of the username pill (caps very long handles). Shared by +// the pill render and the layout keep-out estimate so they stay in lockstep. +const PILL_MAX_W = 780 + +/** Nominal pixel footprint of the hero sticker, used both to render it and to + * reserve a keep-out so badges don't cover it. Width grows with the copy. */ +function heroGeometry(msg: HeroMessage): { w: number; h: number; fontSize: number } { + const scale = msg.scale ?? 1 + const len = Math.max(msg.text.length, 4) + if (msg.variant === 'burst') { + const fontSize = 58 * scale + const w = Math.max(320, len * fontSize * 0.58 + 180) + const h = Math.max(240, fontSize + 150) * scale + return { w, h, fontSize } + } + // pill + banner are single-line labels + const fontSize = 60 * scale + const h = fontSize + 40 + const w = Math.max(240, len * fontSize * 0.6 + 96) + return { w, h, fontSize } +} + +/** Resolved tilt (deg) for a hero message — explicit override, else a small + * per-variant lean. Shared by the renderer (HeroMessageEl) and the keep-out + * calc so the reserved region matches the *rotated* sticker. */ +function heroTilt(msg: HeroMessage): number { + return msg.tilt ?? (msg.variant === 'banner' ? -2 : msg.variant === 'pill' ? -3 : -4) +} + +const USERNAME_BG: Record['bg'], string> = { + white: '#FFFFFF', + pink: '#FF90E8', + blue: '#6E8BEF', +} + +// The shipped look. Callers that omit these props (the real card-unlock share +// surfaces) get this; the /dev/share-builder passes its own to iterate. +const DEFAULT_HERO: HeroMessage = { variant: 'burst', text: "I'M IN!", scale: 1.15, tilt: 5 } +const DEFAULT_USERNAME_STYLE: Required> = { + bg: 'white', + prefixRatio: 0.5, + scale: 1, + letterSpacing: 0, +} const ShareAssetD3: FC = ({ username, badges, - stats, - tier = 0, - pointsBalance = 0, cardLast4, seedOverride, + heroMessage, + usernameStyle, + hideUsername = false, animate = true, }) => { const safeUsername = (username || '').trim() || 'anon' const safeLast4 = (cardLast4 || '').trim().padStart(4, '•').slice(-4) || '5695' - const { stamps, decorations, statCols } = useMemo(() => { - const rng = new SeededRandom(seedOverride ?? safeUsername) - return { - stamps: placeStamps(badges, rng), - decorations: placeDecorations(rng), - statCols: buildStatColumns(stats), - } - }, [seedOverride, safeUsername, badges, stats]) + // `undefined` (real callers) → the shipped default hero; `null` (builder + // "none") → no hero. + const resolvedHero = heroMessage === undefined ? DEFAULT_HERO : heroMessage + const hero = resolvedHero && resolvedHero.text.trim() ? resolvedHero : null + const heroGeo = useMemo(() => (hero ? heroGeometry(hero) : null), [hero?.text, hero?.variant, hero?.scale]) - // "Truly new" users (tier 0 + 0 pts) hide the whole tier block — "TIER 0 / 0" - // reads as "you have nothing", wrong vibe for a celebration asset. - const showTierBlock = tier > 0 || pointsBalance > 0 + // Username pill: "peanut.me/" — the prefix is rendered much smaller + // than the handle. Colour + typography come from usernameStyle. + const uHandleSize = usernameFontSize(safeUsername) * (usernameStyle?.scale ?? DEFAULT_USERNAME_STYLE.scale) + const uPrefixSize = uHandleSize * (usernameStyle?.prefixRatio ?? DEFAULT_USERNAME_STYLE.prefixRatio) + const uTracking = usernameStyle?.letterSpacing ?? DEFAULT_USERNAME_STYLE.letterSpacing + const uBg = USERNAME_BG[usernameStyle?.bg ?? DEFAULT_USERNAME_STYLE.bg] + + // Estimate the rendered pill footprint so the layout engine keeps badges + // off the *whole* pill (its width varies with the handle + typography), not + // just its right edge. Mirrors the pill's render geometry below. + const pillBox = useMemo(() => { + // Pill hidden → push the keep-out off-canvas so badges reclaim the + // bottom-right corner the pill would have reserved. + if (hideUsername) return { x0: CANVAS_W + 1000, y0: CANVAS_H + 1000 } + const PREFIX_CHARS = 'peanut.me/'.length + const PAD_X = 80 // 40px horizontal padding each side + const BORDER = 10 // 5px border each side + const prefixW = PREFIX_CHARS * uPrefixSize * 0.6 + const handleW = safeUsername.length * uHandleSize * (0.62 + Math.max(0, uTracking)) + const pillW = Math.min(PILL_MAX_W, prefixW + handleW + PAD_X + BORDER) + const pillH = uHandleSize * 1.1 + 30 + return pillKeepoutBox(pillW, pillH) + }, [hideUsername, safeUsername, uPrefixSize, uHandleSize, uTracking]) + + const stickers = useMemo(() => { + const extraKeepouts: KeepoutEllipse[] = [] + if (heroGeo && hero) { + // Reserve the *rotated* hero bounding box — the sticker is tilted, + // so its corners reach beyond the unrotated w×h ellipse. + const rad = Math.abs((heroTilt(hero) * Math.PI) / 180) + const cos = Math.abs(Math.cos(rad)) + const sin = Math.abs(Math.sin(rad)) + const rw = (heroGeo.w * cos + heroGeo.h * sin) / 2 + const rh = (heroGeo.w * sin + heroGeo.h * cos) / 2 + extraKeepouts.push({ cx: HERO_CX, cy: HERO_TOP + heroGeo.h / 2, rx: rw, ry: rh }) + } + return placeStamps(badges, new SeededRandom(seedOverride ?? safeUsername), extraKeepouts, pillBox) + }, [seedOverride, safeUsername, badges, heroGeo, hero, pillBox]) return (
= ({ fontFamily: 'var(--font-roboto), system-ui, sans-serif', }} > + {/* eslint-disable-next-line react/no-unknown-property -- styled-jsx `jsx` attr */} {/* ─── Background pattern (faint pink polka — texture, no content) ─── */} @@ -184,116 +207,7 @@ const ShareAssetD3: FC = ({ }} /> - {/* ─── Decorations (stars + thumbs-up + peanut chars) ─── */} - {decorations.map((deco, i) => ( - - ))} - - {/* ─── EDITION header (top-left) — consolidated single banner. - Was 2 lines + a rotated vertical spine; now one tighter - line that reads cleanly at thumbnail scale and stops - fighting the EARNED stamp on the right. */} -
- EDITION · 01 - - PEANUT CARD ARRIVES - -
- - {/* ─── Tier badge + stats anchor (left mid) ─── */} - {(showTierBlock || statCols.length > 0) && ( -
- {showTierBlock && ( -
- -
- - {TIER_LABEL[tier]} · PEANUT PTS - - - {pointsBalance.toLocaleString('en-US')} - -
-
- )} - {/* Stats strip — renders only the cols present */} - {statCols.length > 0 && ( -
-
- {statCols.map((col, i) => ( - - {i > 0 && ( - - · - - )} - - - {col.label} - - - {col.value} - - - - ))} -
-
- )} -
- )} - - {/* ─── Stamps BEHIND the card (z-index 2) ─── */} - {stamps - .filter((s) => s.behind) - .map((s, i) => ( - - ))} - - {/* ─── The card (z-index 3) ─── */} + {/* ─── The card (z-index 3) — sits in the middle of the collage ─── */}
= ({ : 'none', }} > - +
- {/* ─── Stamps IN FRONT (z-index 4) ─── */} - {stamps - .filter((s) => !s.behind) - .map((s, i, frontList) => { - const behindCount = stamps.length - frontList.length - return ( - - ) - })} + {/* ─── Stickers (z-index 4) — raw badge art collaged ON TOP of the + card. No frames, no chrome: the badge SVGs already carry a + white sticker border + thick outline, so they read as + peel-off stickers slapped over the card. ─── */} + {stickers.map((s, i) => ( + + ))} - {/* ─── EARNED ✓ rubber stamp (top-right corner, tasteful) ─── */} -
-
- EARNED ✓ -
-
+ {/* ─── @username pill — the sharer's own handle, the asset's + "this is ME" anchor. Sits below the stickers (z-index 4) so a + sticker that lands over it reads as slapped on top; the pill's + repulsion keep-out keeps it mostly clear regardless. - {/* ─── @username pill — the sharer's own handle, displayed for - pride. No "who invited you?" caption: this is the asset's - "this is ME" anchor, not a referral nudge to the viewer. ─── */} + The anti-dox "hide username" toggle hides it via `visibility` + (not conditional unmount): the entrance animation plays once on + the initial reveal, so toggling back is INSTANT instead of + replaying the 1.7s staggered fade-in. `visibility: hidden` is + also excluded from the html-to-image capture, so a hidden handle + never lands in the shared PNG. ─── */}
- @{safeUsername} + peanut.me/ + + {safeUsername} +
+ + {/* ─── Hero "I got in" message sticker (top, z-index 5 — above the + collage). Its keep-out (computed above) keeps badges clear. ─── */} + {hero && heroGeo && ( +
+ +
+ )}
) } // ─── Sub-components ──────────────────────────────────────────────────── -const HeroCaps: FC<{ children: React.ReactNode; style?: React.CSSProperties; className?: string }> = ({ - children, - style, - className, -}) => ( -
- {children} -
-) - -interface StampElProps { - stamp: StampPlacement +interface StickerElProps { + sticker: StampPlacement animate: boolean delay: number } -const StampEl: FC = ({ stamp, animate, delay }) => { - const restTransform = `rotate(${stamp.rotation}deg)` - return ( -
- {stamp.withTape && ( -
- )} - PEANUT - {/* Year denomination — dynamic, derived from badge.earnedAt at - layout time (see placeStamps in shareAssetLayout.ts). */} - {stamp.badge.year && {stamp.badge.year}} - {/* Badge icon fills the stamp. Caption row was dropped (Hugo: - "remove the colored subtitle from the stamps, just the - badge svg, a bit larger") — icon now uses the full - stamp interior below the issuer/year row. */} -
- -
-
- ) -} - -const DecorationEl: FC<{ deco: DecorationPlacement; animate: boolean; delay: number }> = ({ deco, animate, delay }) => { - const src = DECO_ASSET[deco.kind] - const restTransform = `rotate(${deco.rotation}deg)` - const opacity = deco.kind === 'star' ? 0.85 : 0.95 +// Raw badge sticker — just the SVG, rotated, with a hard offset shadow so +// it lifts off the card like a real peel-off sticker. No frame, no issuer +// text, no year: the badge art already has its own white sticker border. +const StickerEl: FC = ({ sticker, animate, delay }) => { + const restTransform = `rotate(${sticker.rotation}deg)` return ( ) } +// Hero "I got in" message — three sticker treatments. All share the thick +// black outline + hard offset shadow of the collage so they read as one more +// (bigger) sticker slapped on top. +const HeroMessageEl: FC<{ hero: HeroMessage; geo: { w: number; h: number; fontSize: number } }> = ({ hero, geo }) => { + const { text, variant } = hero + const { w, h, fontSize } = geo + // Tilt via the shared helper so the render matches the layout keep-out. + const rot = `rotate(${heroTilt(hero)}deg)` + + if (variant === 'pill') { + return ( + + {text} + + ) + } + + if (variant === 'banner') { + return ( + + {text} + + ) + } + + // burst — a yellow seal/starburst behind the text + const points = burstSealPoints(w / 2, h / 2, 16, 0.6) + return ( +
+ + + + + {text} + +
+ ) +} + +/** Build an N-spike seal/starburst polygon centred on (0,0), to be translated + * to the sticker centre. Alternates outer (rx,ry) and inner radii. */ +function burstSealPoints(rx: number, ry: number, spikes: number, innerRatio: number): string { + const pts: string[] = [] + for (let i = 0; i < spikes * 2; i++) { + const angle = (i * Math.PI) / spikes - Math.PI / 2 + const r = i % 2 === 0 ? 1 : innerRatio + pts.push(`${(Math.cos(angle) * rx * r).toFixed(1)},${(Math.sin(angle) * ry * r).toFixed(1)}`) + } + return pts.join(' ') +} + export default ShareAssetD3 diff --git a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts index 6d49d3628..b55d59c8a 100644 --- a/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts +++ b/src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts @@ -15,7 +15,6 @@ import { CARD_LEFT, CARD_TOP, placeStamps, - placeDecorations, buildStatColumns, usernameFontSize, } from '../shareAssetLayout' @@ -87,17 +86,17 @@ describe('placeStamps', () => { expect(single[0].width).toBeGreaterThan(six[0].width) }) - it('keeps stamps within canvas bounds (no clipping past edges)', () => { + it('keeps stickers within canvas bounds (bounded off-edge peek)', () => { const badges = Array.from({ length: 6 }, (_, i) => badge(`B${i}`)) const placed = placeStamps(badges, rng()) for (const s of placed) { - // Allow stamps to overlap card edges (collage layout intentionally - // peeks/overlaps) but every stamp's top-left anchor + its size - // should land within the canvas with generous tolerance. - expect(s.top).toBeGreaterThanOrEqual(-20) - expect(s.left).toBeGreaterThanOrEqual(-20) - expect(s.top + s.height).toBeLessThanOrEqual(CANVAS_H + 20) - expect(s.left + s.width).toBeLessThanOrEqual(CANVAS_W + 20) + // Stickers intentionally overlap the card and may peek off-edge, + // but only within a bounded tolerance — half-off-canvas was the + // regression Hugo flagged in QA. + expect(s.top).toBeGreaterThanOrEqual(-40) + expect(s.left).toBeGreaterThanOrEqual(-40) + expect(s.top + s.height).toBeLessThanOrEqual(CANVAS_H + 40) + expect(s.left + s.width).toBeLessThanOrEqual(CANVAS_W + 40) } }) @@ -127,43 +126,47 @@ describe('placeStamps', () => { expect(placed[0].badge.iconUrl).toBeTruthy() }) - // Strict non-overlap invariant for the "unique-slot" range (1..6 - // unique slots in the table). For every count 1..6 and across many - // seeds, no two placed stamps' rotation-aware bboxes may touch. - // - // We use the diagonal sqrt(w² + h²) as a worst-case radius from the - // stamp's center. Two stamps "overlap" if the distance between their - // centers is less than the sum of their radii. This is conservative - // (treats the rotated rect as a circumscribing circle) — if it - // passes, the actual rotated rects definitely don't overlap. - // - // For counts > 6 we deliberately stack (Hugo: "just stack them") so - // the invariant does not apply; the layout still stays within canvas - // (separate test below). - it('stamps never overlap for counts 1..6 (any seed)', () => { - const seeds = ['kkonrad', 'hugo', 'asfsfsf', 'a', 'longusername', '0', 'seed-42', '🥜'] - for (let n = 1; n <= 6; n++) { + // Light overlap is fine for the collage, but the force-directed placer must + // never let two stickers pile up. Across the realistic range (2..12) and a + // BROAD seed sweep, every pair of centres must stay at least this fraction + // of the sticker size apart — i.e. no "heavy" overlap. The sweep is wide on + // purpose: a bottom-right "corner trap" (edge + pill keep-out deadlocking + // pairwise separation) only surfaced on specific numeric seeds that a small + // hand-picked seed list missed — the final separation pass fixes it, and + // this sweep guards the regression. + it('never places two stickers in heavy overlap (broad seed sweep)', () => { + const seeds = [ + 'kkonrad', + 'hugo', + 'asfsfsf', + 'a', + 'longusername', + '0', + 'seed-42', + 'zzz', + 'mara', + '🥜', + // numeric sweep — exercises the corner-trap regime the named seeds miss + ...Array.from({ length: 150 }, (_, i) => `seed${i}`), + ] + const MIN_CENTER_GAP = 0.4 // × size; below this is a heavy pile-up + for (let n = 2; n <= 12; n++) { const badges = Array.from({ length: n }, (_, i) => badge(`B${i}`)) for (const seed of seeds) { const placed = placeStamps(badges, new SeededRandom(seed)) + const size = placed[0].width for (let i = 0; i < placed.length; i++) { for (let j = i + 1; j < placed.length; j++) { const a = placed[i] const b = placed[j] - const cxA = a.left + a.width / 2 - const cyA = a.top + a.height / 2 - const cxB = b.left + b.width / 2 - const cyB = b.top + b.height / 2 - const dist = Math.hypot(cxA - cxB, cyA - cyB) - const radA = Math.hypot(a.width, a.height) / 2 - const radB = Math.hypot(b.width, b.height) / 2 - const required = radA + radB - if (dist < required) { + const dist = Math.hypot( + a.left + a.width / 2 - (b.left + b.width / 2), + a.top + a.height / 2 - (b.top + b.height / 2) + ) + if (dist < MIN_CENTER_GAP * size) { throw new Error( - `Stamp overlap at count=${n} seed="${seed}": ` + - `slots ${i} (${a.badge.code} @ ${a.left.toFixed(0)},${a.top.toFixed(0)}) ` + - `and ${j} (${b.badge.code} @ ${b.left.toFixed(0)},${b.top.toFixed(0)}) ` + - `— distance ${dist.toFixed(0)} < required ${required.toFixed(0)}` + `Heavy overlap at count=${n} seed="${seed}": centres ${dist.toFixed(0)}px apart ` + + `(< ${(MIN_CENTER_GAP * size).toFixed(0)} = ${MIN_CENTER_GAP}×${size})` ) } } @@ -172,13 +175,13 @@ describe('placeStamps', () => { } }) - // For any count (including 15+, which stacks), every stamp's axis- + // For any count (including 15+, which stacks), every sticker's axis- // aligned bbox must fit within the canvas with at most a small // overhang tolerance — half-outside-the-canvas was the regression // Hugo flagged in QA. - it('every stamp stays within canvas at any count', () => { + it('every sticker stays within canvas at any count', () => { const seeds = ['kkonrad', 'hugo', 'asfsfsf', 'longusername', 'seed-42'] - const overhang = 20 // peeking off-edge is part of the design, but only this much + const overhang = 40 // peeking off-edge is part of the design, but only this much for (let n = 1; n <= 15; n++) { const badges = Array.from({ length: n }, (_, i) => badge(`B${i}`)) for (const seed of seeds) { @@ -200,21 +203,6 @@ describe('placeStamps', () => { }) }) -describe('placeDecorations', () => { - it('returns at least one star and one character', () => { - const placed = placeDecorations(new SeededRandom('kkonrad')) - const kinds = new Set(placed.map((d) => d.kind)) - expect(kinds.has('star')).toBe(true) - expect(placed.some((d) => d.kind !== 'star')).toBe(true) - }) - - it('is deterministic per seed', () => { - const a = placeDecorations(new SeededRandom('kkonrad')) - const b = placeDecorations(new SeededRandom('kkonrad')) - expect(a).toEqual(b) - }) -}) - describe('buildStatColumns', () => { it('returns empty array when all stats are missing', () => { expect(buildStatColumns(undefined)).toEqual([]) diff --git a/src/components/Card/share-asset/rejectionCaptions.ts b/src/components/Card/share-asset/rejectionCaptions.ts new file mode 100644 index 000000000..45ed1ab39 --- /dev/null +++ b/src/components/Card/share-asset/rejectionCaptions.ts @@ -0,0 +1,42 @@ +/** + * Caption pool for the "NOT TONIGHT" rejection share asset. + * + * The waitlist rejection screen lets a turned-away user "Tweet to appeal" — + * the share fires with a RANDOM caption from this pool so the timeline + * doesn't fill with one identical tweet. Every caption tags @joinpeanut so + * the rejection itself markets the door's exclusivity — the handle rides the + * caption (it is intentionally not drawn on the asset image). + */ + +export const REJECTION_CAPTIONS: readonly string[] = [ + 'rejected by @joinpeanut 🚫 the door policy is insane.', + '@joinpeanut has a bouncer now??', + "@joinpeanut told me i'm not on the list 💀 the AUDACITY", + "the @joinpeanut bouncer said come back when i'm somebody. ok bet.", + 'got read for filth by the @joinpeanut door', + 'officially rejected by @joinpeanut.', + 'collected my first @joinpeanut badge: REJECTED 💀', + 'took an L at the @joinpeanut door. building my comeback arc.', + "denied at the @joinpeanut door. the bouncer didn't even blink 🚫", + "@joinpeanut said NOT TONIGHT. guess i'll fix my whole life", + 'turned away from @joinpeanut 💀', + "couldn't get past the @joinpeanut velvet rope. humbling.", + '@joinpeanut rejection #1. framing this one.', + 'the @joinpeanut door looked me up and down and said no. respect.', + 'not on the @joinpeanut list tonight. villain arc starts now.', + "got bounced from @joinpeanut. it's giving exclusive.", + '@joinpeanut denied me with zero explanation. iconic behavior honestly', + '213 tried, 7 got in. @joinpeanut said not me. yet.', + "@joinpeanut said come back when i'm somebody. challenge accepted 🥜", + 'the @joinpeanut bouncer has no notes. just no. devastating.', + 'tried the @joinpeanut door. NOT TONIGHT. comeback arc loading.', + "@joinpeanut rejected me and i've never wanted in more 💀", + 'took an L at the @joinpeanut door tonight. tomorrow we ride again.', + "remember @joinpeanut from devconnect?? they've rejected me from their beta programme omg", + "rember @joinpeanut from devconnect?? seems they're back but also they don't want users ??? WHAT?", +] + +/** Pick a random caption. Browser-only (Math.random) — fine at share time. */ +export function pickRejectionCaption(): string { + return REJECTION_CAPTIONS[Math.floor(Math.random() * REJECTION_CAPTIONS.length)] +} diff --git a/src/components/Card/share-asset/share.utils.ts b/src/components/Card/share-asset/share.utils.ts index 006b4fbff..d8df21135 100644 --- a/src/components/Card/share-asset/share.utils.ts +++ b/src/components/Card/share-asset/share.utils.ts @@ -1,15 +1,9 @@ /** - * Twitter share intent — opens twitter.com/intent/tweet in a new tab - * with the canonical "I got my Peanut card" copy. - * - * Single source of truth so the celebration screen + the history-replay - * drawer share identical copy. If we ever A/B test the share text or - * route through a server-rendered OG image, this is the choke point. + * Twitter share intent — opens twitter.com/intent/tweet in a new tab with + * the given caption. The caption is picked from the win-caption rotation + * (winCaptions.ts) by the caller so desktop and mobile share the same line. */ -const SHARE_TEXT = 'I got my Peanut card. shhhh.' - -export function shareCardOnTwitter(): void { - const text = encodeURIComponent(SHARE_TEXT) - window.open(`https://twitter.com/intent/tweet?text=${text}`, '_blank') +export function shareCardOnTwitter(text: string): void { + window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`, '_blank', 'noopener') } diff --git a/src/components/Card/share-asset/shareAsset.types.ts b/src/components/Card/share-asset/shareAsset.types.ts index 7f16f8025..4a46eafe1 100644 --- a/src/components/Card/share-asset/shareAsset.types.ts +++ b/src/components/Card/share-asset/shareAsset.types.ts @@ -28,6 +28,39 @@ export interface ShareAssetStats { export type TierLevel = 0 | 1 | 2 | 3 +/** Visual treatment for the hero "I got in" message sticker at the top. */ +export type HeroVariant = 'burst' | 'pill' | 'banner' + +export interface HeroMessage { + /** The headline copy, e.g. "I'M IN" or "shhhh, i'm in". */ + text: string + /** Sticker shape/treatment. */ + variant: HeroVariant + /** Size multiplier (1 = default). The builder exposes this as a slider. */ + scale?: number + /** Tilt in degrees (clockwise). Defaults to a small per-variant lean. */ + tilt?: number +} + +/** Which smug peanut mascot to slap on the rejection ("not tonight") asset. + * 'none' hides it. cool = pixel-shades flex, mock = grinning point-and-laugh, + * chill = whistling "whatever". */ +export type RejectionMascot = 'none' | 'cool' | 'mock' | 'chill' + +/** Background colour for the username pill. */ +export type UsernameBg = 'white' | 'pink' | 'blue' + +/** Typography + colour controls for the `peanut.me/` pill. */ +export interface UsernameStyle { + bg: UsernameBg + /** "peanut.me/" prefix size as a fraction of the handle font size (~0.2–0.7). */ + prefixRatio?: number + /** Handle font-size multiplier (1 = the auto-fit default). */ + scale?: number + /** Handle letter spacing, in em. */ + letterSpacing?: number +} + export interface ShareAssetD3Props { /** Lowercase username, max 12 chars per current peanut constraint. */ username: string @@ -47,6 +80,19 @@ export interface ShareAssetD3Props { /** Last 4 of card PAN — rendered on the card face. */ cardLast4?: string + /** Optional hero "I got in" message sticker at the top. + * Omitted (`undefined`) = the shipped default hero; `null` = no hero. */ + heroMessage?: HeroMessage | null + + /** Username pill colour + typography. Defaults to a white pill with + * auto-fit sizing. */ + usernameStyle?: UsernameStyle + + /** Hide the "peanut.me/" pill entirely (anti-dox toggle). When + * true the pill doesn't render and badges reclaim the bottom-right + * corner it would have kept clear. */ + hideUsername?: boolean + /** * Override the username-derived seed. Used by the /dev/share-builder * "reroll" button to dice-roll positions without changing user input. diff --git a/src/components/Card/share-asset/shareAssetLayout.ts b/src/components/Card/share-asset/shareAssetLayout.ts index 4e88b61cf..49e09d383 100644 --- a/src/components/Card/share-asset/shareAssetLayout.ts +++ b/src/components/Card/share-asset/shareAssetLayout.ts @@ -33,94 +33,131 @@ export const CARD_LEFT = Math.round((CANVAS_W - CARD_W) / 2) // 220 — centred export const CARD_TOP = Math.round((CANVAS_H - CARD_H) / 2) // 210 — centred export const CARD_ROTATION_DEG = -8 -// ─── Stamp positions ──────────────────────────────────────────────────── -// Slots that stamps can land in, in priority order. Each carries a -// "behind" flag — true = z-index BELOW the card (stamp peeks out from -// behind), false = z-index ABOVE the card (stamp lands on top, overlapping). -// PRNG jiggles within `jitter` bounds. +// ─── Sticker placement (force-directed) ───────────────────────────────── +// Stickers fill the blue field AROUND the card using a small position-based +// relaxation (a constraint solver, not a fixed ring). Three forces settle +// them into the 2D background instead of crowding a single ellipse: +// • a strong keep-clear ellipse over the card centre repels stickers off +// the card's middle, so the pixel logo stays readable; +// • every sticker repels every other, so they spread out and barely +// overlap, however many there are; +// • a soft inward margin pulls them off the extreme canvas edge. +// They still render on top of the card (z-index above it) and may cover its +// outer edges; only its centre and the bottom-right @username pill are kept +// clear. +const FIELD_CX = CANVAS_W / 2 // 600 +const FIELD_CY = CANVAS_H / 2 // 450 -export interface StampSlot { - /** Anchor in canvas coords (top-left of stamp). */ - top: number - left: number - /** ±px wiggle applied via PRNG. */ - jitterXY: number - /** Base rotation in deg. */ - rotation: number - /** ±deg rotation wiggle. */ - jitterRot: number - /** Behind the card (peeks out) or in front (overlaps card edges). */ - behind: boolean - /** Optional: tape strip pinning the stamp. */ - withTape?: boolean +// Seed ellipse — only used to scatter the initial positions around the card +// before relaxation. The actual "don't cover this" repulsion lives in +// CARD_KEEPOUTS (the centred hand keep-out doubles as the card-centre guard). +const KEEP_A = 300 +const KEEP_B = 205 + +// Specific card graphics that must never be covered. Unlike the soft centre +// ellipse above, each of these is inflated by the sticker's half-size in the +// solver, so no sticker overlaps the mark at all (not just its centre). +// Positions are canvas coords: the card renders at (220,210) sized 760×479, +// rotated −8° about its centre (600,450) — these are the peanut logo +// (card-local ~54,50) and the pixel hand (card-local ~460,200) mapped through +// that transform. +const CARD_KEEPOUTS: readonly { cx: number; cy: number; rx: number; ry: number }[] = [ + { cx: 251, cy: 307, rx: 40, ry: 40 }, // peanut logo (top-left of the card) + { cx: 660, cy: 405, rx: 142, ry: 100 }, // pixelated hand (centre, leaning up-right) +] + +// Soft inward margin from the canvas edge — pulls stickers off the extreme +// edge into the background; the hard clamp keeps the bbox within OVERHANG. +const EDGE_MARGIN = 32 +const STICKER_OVERHANG = 24 + +// Repulsion keep-out for the @username pill (bottom-right corner). Its rect +// extends to the bottom-right canvas corner; PILL_PAD adds a margin so the +// repulsion leaves a gap, not just a touch. Any sticker pushed inside is +// shoved back out each relaxation pass — the pill repels like everything else. +// The component passes a box sized to the *rendered* pill (see pillKeepoutBox); +// this default is the fallback for callers that don't measure it. +const DEFAULT_PILL_KEEPOUT = { x0: 690, y0: 712 } as const +const PILL_PAD = 26 + +// The pill renders bottom-right at `right: PILL_RIGHT, bottom: PILL_BOTTOM`. +// Exported so the component keeps the keep-out box in lockstep with the render. +export const PILL_RIGHT = 56 +export const PILL_BOTTOM = 48 + +/** Top-left corner of the pill keep-out for a pill of the given rendered size, + * pinned to the bottom-right canvas corner. The component measures the pill's + * footprint (it varies with the handle + typography) so badges are kept off + * the *whole* pill, not just its right edge. */ +export function pillKeepoutBox(pillW: number, pillH: number): { x0: number; y0: number } { + return { + x0: CANVAS_W - PILL_RIGHT - pillW, + y0: CANVAS_H - PILL_BOTTOM - pillH, + } } -// Slots are positioned to never overlap each other AND never overlap -// fixed chrome on the 1200×900 canvas: -// - EARNED rubber stamp: x[920..1200], y[20..200] -// - EDITION + tier block: x[20..360], y[20..460] -// - @username pill + tagline: x[400..800], y[770..900] -// - Card bbox (axis-aligned): x[264..884], y[254..645] -// -// Stamps with `behind:true` may sit ON TOP of the card bbox — they're -// rendered z-index BELOW the card and peek out from behind. Their visible -// portion must still land mostly OUTSIDE the card so the icon reads. -// -// Non-overlap is enforced by shareAssetLayout.test.ts (`stamps never -// overlap`) — DO NOT edit positions without re-running that test. -// -// The catalogue is intentionally larger than 6 (the prior cap). For -// N ≥ 7 we wrap with a per-cycle offset so stacked stamps look like a -// pile rather than perfectly aligned duplicates. -const STAMP_SLOTS: readonly StampSlot[] = [ - // 1. HERO behind, top-center — peeks down from above the card. - { top: 140, left: 504, jitterXY: 12, rotation: -12, jitterRot: 5, behind: true, withTape: true }, - // 2. Front bottom-left. - { top: 720, left: 80, jitterXY: 10, rotation: -10, jitterRot: 4, behind: false }, - // 3. Behind upper-right of card (clear of EARNED). - { top: 220, left: 940, jitterXY: 10, rotation: 14, jitterRot: 5, behind: true }, - // 4. Front mid-left — peeks at card's left edge. - { top: 440, left: 60, jitterXY: 10, rotation: -16, jitterRot: 4, behind: false }, - // 5. Behind right-of-card, lower half. - { top: 480, left: 940, jitterXY: 10, rotation: 18, jitterRot: 5, behind: true }, - // 6. Front bottom-right (opposite slot 2 across the pill). - { top: 720, left: 900, jitterXY: 10, rotation: 8, jitterRot: 4, behind: false }, -] as const +// Relaxation iterations + how close two sticker centres may sit (× combined +// radii). 1.0 = just touching; below 1.0 allows a bit of overlap while the +// solver still actively repels them apart each pass. SEPARATION_PASSES is a +// final separation-only cleanup (see below) that breaks corner deadlocks. +const RELAX_ITERS = 120 +const SEPARATION_PASSES = 28 +const STICKER_GAP = 0.82 export interface StampPlacement { - badge: { code: string; iconUrl: string; year?: string } + badge: { code: string; iconUrl: string } top: number left: number rotation: number - behind: boolean - withTape: boolean - /** Size: shrinks as badge count grows (so 6 badges aren't huge). */ + /** Size: shrinks as badge count grows (so 6 badges aren't huge). + * Stickers are square — width === height. */ width: number height: number } -// Inversely scale stamp size with count: 1 stamp gets a hero treatment, -// 10+ stamps shrink to fit. Beyond the table, clamp to the smallest size. -// -// Heights are bounded so bottom slots (top≈720) + max-jitter (10) + height -// stay within the canvas + a 20px overhang tolerance (900 + 20 - 730 = 190). -// Low counts (1–3, the common case) get a Twitter-legibility bump; counts -// 4–10 stay near original since the collage crowds and the non-overlap -// invariant (circumscribing-circle metric + jitter) leaves little headroom. -const STAMP_SIZE_BY_COUNT: ReadonlyArray = [ - [248, 290], // 1 — only slot 1 (hero, top=140) used; canvas-safe. - [182, 188], // 2 — slot 2 (top=720) activates here; height capped at 188. - [176, 186], // 3 - [168, 182], // 4 - [156, 170], // 5 - [144, 158], // 6 - [136, 150], // 7 - [128, 142], // 8 - [120, 134], // 9 - [112, 126], // 10 -] as const +// Sticker size shrinks as the badge count grows, but stays large enough that +// the repulsion packs them across the whole field (not a thin scatter). Past +// the table it eases down a 2700/count curve with a legibility floor. +// Stickers are square (raw badge art, no frame). +const STICKER_SIZE_BY_COUNT: readonly number[] = [ + 520, // 1 — single hero sticker slapped on the card + 460, // 2 + 415, // 3 + 385, // 4 + 360, // 5 + 340, // 6 + 322, // 7 + 306, // 8 + 292, // 9 + 280, // 10 +] + +function stickerSize(count: number): number { + if (count <= STICKER_SIZE_BY_COUNT.length) return STICKER_SIZE_BY_COUNT[count - 1] + return Math.max(180, Math.round(2700 / count)) +} + +/** Does a size×size sticker centred at (cx,cy) intersect the pill keep-out + * (which extends to the bottom-right canvas corner)? */ +function hitsPill(cx: number, cy: number, half: number, pill: { x0: number; y0: number }): boolean { + return cx + half > pill.x0 - PILL_PAD && cy + half > pill.y0 - PILL_PAD +} + +/** An extra keep-out region (e.g. the hero message sticker at the top) that + * badge stickers must not cover. Canvas-coord ellipse. */ +export interface KeepoutEllipse { + cx: number + cy: number + rx: number + ry: number +} -export function placeStamps(badges: ShareAssetBadge[], rng: SeededRandom): StampPlacement[] { +export function placeStamps( + badges: ShareAssetBadge[], + rng: SeededRandom, + extraKeepouts: readonly KeepoutEllipse[] = [], + pill: { x0: number; y0: number } = DEFAULT_PILL_KEEPOUT +): StampPlacement[] { const sorted = [...badges].sort((a, b) => { const aT = a.earnedAt ? new Date(a.earnedAt).getTime() : 0 const bT = b.earnedAt ? new Date(b.earnedAt).getTime() : 0 @@ -129,196 +166,156 @@ export function placeStamps(badges: ShareAssetBadge[], rng: SeededRandom): Stamp const count = sorted.length if (count === 0) return [] - // Size table is indexed 1..10; clamp anything above to the smallest tier. - const [width, height] = STAMP_SIZE_BY_COUNT[Math.min(count, STAMP_SIZE_BY_COUNT.length) - 1] + const size = stickerSize(count) + const half = size / 2 + const minC = half - STICKER_OVERHANG + const maxCx = CANVAS_W - half + STICKER_OVERHANG + const maxCy = CANVAS_H - half + STICKER_OVERHANG + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) + const keepouts = [...CARD_KEEPOUTS, ...extraKeepouts] - // Shuffle once so two users with identical badge sets but different - // seeds get visually distinct layouts. - const shuffled = rng.shuffle(sorted) - - return shuffled.map((badge, i): StampPlacement => { - // For i < STAMP_SLOTS.length, use a unique base slot. For overflow - // (i ≥ slot count), wrap with a per-cycle offset so the extras - // stack on existing slots as a natural pile (Hugo: "just stack - // them"). Per-cycle x/y shift + rotation tweak avoid pixel- - // identical duplicates. - const slotIdx = i % STAMP_SLOTS.length - const cycle = Math.floor(i / STAMP_SLOTS.length) - const base = STAMP_SLOTS[slotIdx] - const stackOffset = cycle * 22 - // `'25`-style year denomination. (An earlier pass switched this to the - // full 4-digit year chasing Konrad's "the ''' look buggy" note — that - // was actually about sparkle.svg's slash-strokes, not the year. Hugo - // 2026-06-11: the apostrophe year looked fine; bring it back.) - const year = badge.earnedAt ? `'${String(new Date(badge.earnedAt).getFullYear()).slice(-2)}` : undefined + // ── Seed positions: spread around the card by angle (so even a few + // stickers surround it) with a varied radius (so the relaxation has 2D + // room to fill, not just a ring). The first sticker starts up top. + const pos = sorted.map((_, i) => { + const theta = -Math.PI / 2 + ((i === 0 ? 0 : i) / count) * Math.PI * 2 + rng.float(-0.4, 0.4) + const rad = rng.float(1.05, 1.95) return { - badge: { - code: badge.code, - iconUrl: getBadgeIcon(badge.code), - year, - }, - top: base.top + stackOffset + rng.float(-base.jitterXY, base.jitterXY), - left: base.left + stackOffset + rng.float(-base.jitterXY, base.jitterXY), - rotation: base.rotation + cycle * 6 + rng.float(-base.jitterRot, base.jitterRot), - behind: base.behind, - withTape: !!base.withTape, - width, - height, + x: clamp(FIELD_CX + Math.cos(theta) * KEEP_A * rad, minC, maxCx), + y: clamp(FIELD_CY + Math.sin(theta) * KEEP_B * rad, minC, maxCy), } }) -} - -// ─── Decorations (stars + thumbs-up + peanut chars) ───────────────────── -// Pool of candidate positions in safe negative-space zones. -// `kind` selects which SVG. PRNG picks which subset to actually render. - -interface DecorationCandidate { - kind: 'star' | 'starAlt' | 'thumbsUp' | 'thumbsUpV2' | 'peace' | 'eyes' | 'cloud' | 'peanutChar' - top?: number - bottom?: number - left?: number - right?: number - size: number - rotation: number - /** Whether this position is "safe" when card has stamps behind it. - * Currently always true — pool is curated to avoid stamp slots. */ - safe: boolean -} - -// Decoration pool. No peanut characters — both peanut-raising-hands.svg -// and peanutman-waving.svg crop the lower body at the SVG source (it's -// the art style, not a bug we can fix). No sparkle.svg either — it's -// three loose slash-strokes that read as stray tick marks / apostrophes -// on the asset (Konrad's "the ''' look a bit buggy"). The pool sticks to -// stars, hands, eyes, and clouds so every decoration reads as a clearly -// formed shape. -// -// Layout-zone map on the 1200×900 canvas: -// - EARNED stamp: x[920..1200], y[20..200] (top-right) -// - EDITION + tier block: x[20..360], y[20..460] (left band) -// - @username pill: x[400..1144], y[770..900] (bottom-right) -// - Card bbox: x[264..884], y[254..645] (centre) -// - Stamp slots: see STAMP_SLOTS above -// All slots below sit OUTSIDE the chrome zones AND outside stamp slots. -const DECORATION_POOL: readonly DecorationCandidate[] = [ - // ── Top strip (y=16..160) — above the card, between EDITION left and - // EARNED right. Two natural columns left/right of the centre stamp. - { kind: 'cloud', top: 16, left: 880, size: 64, rotation: -4, safe: true }, - { kind: 'star', top: 36, left: 380, size: 72, rotation: 8, safe: true }, - { kind: 'peace', top: 52, left: 720, size: 56, rotation: -12, safe: true }, - { kind: 'thumbsUp', top: 56, left: 232, size: 92, rotation: -10, safe: true }, - { kind: 'thumbsUpV2', top: 80, left: 700, size: 76, rotation: 12, safe: true }, - { kind: 'eyes', top: 28, left: 580, size: 48, rotation: 6, safe: true }, - { kind: 'star', top: 130, left: 980, size: 40, rotation: 18, safe: true }, - { kind: 'starAlt', top: 110, left: 320, size: 52, rotation: -20, safe: true }, - - // ── Top-LEFT quadrant (Hugo flagged this as empty) — fits between - // the EDITION header (y<120) and the tier block (y>158, x<360). - // Tight space; small accents only. - { kind: 'star', top: 14, left: 56, size: 32, rotation: 22, safe: true }, - { kind: 'starAlt', top: 8, left: 460, size: 36, rotation: -16, safe: true }, - { kind: 'eyes', top: 130, left: 180, size: 36, rotation: 14, safe: true }, - - // ── Mid-right gap between stamp slot 3 (y≤396) and slot 5 (y≥470). - { kind: 'star', top: 410, right: 30, size: 44, rotation: 45, safe: true }, - { kind: 'peace', top: 420, right: 140, size: 42, rotation: -10, safe: true }, - - // ── Mid-left / mid-right margins outside the card. - { kind: 'cloud', top: 380, left: 26, size: 48, rotation: 10, safe: true }, - { kind: 'eyes', top: 540, right: 220, size: 38, rotation: 22, safe: true }, - { kind: 'starAlt', top: 480, right: 200, size: 44, rotation: -8, safe: true }, - - // ── Bottom-CENTRE (Hugo flagged this as empty) — between card-bottom - // (y=645) and the username pill top (y≥770). The pill ends at - // x≈400 worst case, so anything in x[120..380] is safely in the - // gutter to the LEFT of the pill. - { kind: 'star', top: 660, left: 200, size: 44, rotation: -14, safe: true }, - { kind: 'starAlt', top: 700, left: 320, size: 48, rotation: 18, safe: true }, - { kind: 'eyes', top: 670, left: 60, size: 40, rotation: -6, safe: true }, - { kind: 'thumbsUpV2', top: 690, left: 130, size: 64, rotation: 8, safe: true }, - - // ── Lower-right (small sprinkles between EARNED-zone clear point and - // the pill's top edge at y≈770). - { kind: 'peace', bottom: 220, right: 280, size: 52, rotation: -8, safe: true }, - { kind: 'star', bottom: 60, right: 360, size: 34, rotation: -12, safe: true }, - { kind: 'cloud', bottom: 80, right: 60, size: 56, rotation: 8, safe: true }, - - // ── Peanut character — peeks up from BEHIND the card. The peanut - // art style is legless (body tapers to a rounded point), so we - // place him with his bottom inside the card's bbox (y≥254) and - // only the head/arms visible above the card edge. Decorations - // render at z-index 1; the card at z-index 3, with overflow: - // hidden — so the body inside the card region is naturally - // clipped without any extra masking. Two candidate positions - // flanking the centre HERO stamp slot (top:140, left:504). - { kind: 'peanutChar', top: 120, left: 320, size: 180, rotation: -6, safe: true }, - { kind: 'peanutChar', top: 130, left: 760, size: 170, rotation: 8, safe: true }, -] as const - -export interface DecorationPlacement { - kind: 'star' | 'starAlt' | 'thumbsUp' | 'thumbsUpV2' | 'peace' | 'eyes' | 'cloud' | 'peanutChar' - top?: number - bottom?: number - left?: number - right?: number - size: number - rotation: number -} - -/** Axis-aligned bounding-box of a placed decoration in canvas coords. - * Used by the non-overlap check below — treats every decoration as a - * size × size square (conservative; tall assets won't be over-tight). */ -function decorationBbox(d: { top?: number; bottom?: number; left?: number; right?: number; size: number }): { - x0: number - y0: number - x1: number - y1: number -} { - const left = d.left ?? CANVAS_W - (d.right ?? 0) - d.size - const top = d.top ?? CANVAS_H - (d.bottom ?? 0) - d.size - return { x0: left, y0: top, x1: left + d.size, y1: top + d.size } -} - -/** AABB intersection with a small padding so decorations aren't just-not-touching. */ -function bboxesOverlap( - a: { x0: number; y0: number; x1: number; y1: number }, - b: { x0: number; y0: number; x1: number; y1: number }, - pad = 8 -): boolean { - return !(a.x1 + pad < b.x0 || a.x0 - pad > b.x1 || a.y1 + pad < b.y0 || a.y0 - pad > b.y1) -} -/** Greedy non-overlap placement. Walks the shuffled pool and accepts a - * candidate only if its bbox doesn't intersect any already-placed - * decoration. Target count 12-15; if the pool can't satisfy that - * (collisions), returns whatever fit — never compromises the no-overlap - * invariant. */ -export function placeDecorations(rng: SeededRandom): DecorationPlacement[] { - const target = rng.int(12, 15) - const shuffled = rng.shuffle([...DECORATION_POOL]) - const accepted: DecorationPlacement[] = [] - const acceptedBboxes: ReturnType[] = [] + // ── Relax: position-based constraints, Gauss–Seidel. Each pass separates + // overlapping stickers, shoves any inside the keep-clear ellipse back + // out, nudges them off the edges and out of the pill, then clamps to + // the canvas. Converges to an even spread filling the field. + const minDist = size * STICKER_GAP + for (let step = 0; step < RELAX_ITERS; step++) { + // sticker ↔ sticker separation + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + let dx = pos[j].x - pos[i].x + let dy = pos[j].y - pos[i].y + let dist = Math.hypot(dx, dy) + if (dist === 0) { + dx = rng.float(-1, 1) + dy = rng.float(-1, 1) + dist = Math.hypot(dx, dy) || 1 + } + if (dist < minDist) { + const corr = (minDist - dist) / 2 + const ux = dx / dist + const uy = dy / dist + pos[i].x -= ux * corr + pos[i].y -= uy * corr + pos[j].x += ux * corr + pos[j].y += uy * corr + } + } + } + // unary constraints + for (let i = 0; i < count; i++) { + const p = pos[i] + // targeted keep-outs (inflated by the sticker half so the mark is + // never covered) — peanut logo + pixel hand, plus any caller extras + // (the hero message sticker at the top). + for (const ko of keepouts) { + const rx = ko.rx + half + const ry = ko.ry + half + const kex = (p.x - ko.cx) / rx + const key = (p.y - ko.cy) / ry + const ke = Math.hypot(kex, key) + if (ke === 0) { + p.x += rx + } else if (ke < 1) { + const s = 1 / ke + p.x = ko.cx + (p.x - ko.cx) * s + p.y = ko.cy + (p.y - ko.cy) * s + } + } + // soft inward margin off the canvas edges + const loX = EDGE_MARGIN + half + const hiX = CANVAS_W - EDGE_MARGIN - half + const loY = EDGE_MARGIN + half + const hiY = CANVAS_H - EDGE_MARGIN - half + if (p.x < loX) p.x += (loX - p.x) * 0.5 + else if (p.x > hiX) p.x -= (p.x - hiX) * 0.5 + if (p.y < loY) p.y += (loY - p.y) * 0.5 + else if (p.y > hiY) p.y -= (p.y - hiY) * 0.5 + // pill keep-out: shove out along the shallower axis + if (hitsPill(p.x, p.y, half, pill)) { + const exitLeft = p.x + half - (pill.x0 - PILL_PAD) + const exitUp = p.y + half - (pill.y0 - PILL_PAD) + if (exitLeft < exitUp) p.x -= exitLeft + else p.y -= exitUp + } + // hard clamp to canvas (within overhang) + p.x = clamp(p.x, minC, maxCx) + p.y = clamp(p.y, minC, maxCy) + } + } - for (const d of shuffled) { - if (accepted.length >= target) break - const sizeJitter = rng.float(-4, 4) - const rotJitter = rng.float(-6, 6) - const placement: DecorationPlacement = { - kind: d.kind, - top: d.top, - bottom: d.bottom, - left: d.left, - right: d.right, - size: d.size + sizeJitter, - rotation: d.rotation + rotJitter, + // ── Final separation pass. The soft edge/pill pulls above can deadlock two + // stickers against a corner (a Gauss–Seidel local minimum: the edge + + // pill shove keeps cramming them back together faster than the pairwise + // push can spread them). Run a short cleanup of separation + hard keep- + // outs + clamp ONLY — no edge/pill pull — so pairwise spread gets the + // last word. Card marks / hero stay clear; a sticker may drift under the + // pill (which renders on top), but no two stickers pile up. + for (let step = 0; step < SEPARATION_PASSES; step++) { + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + let dx = pos[j].x - pos[i].x + let dy = pos[j].y - pos[i].y + let dist = Math.hypot(dx, dy) + if (dist === 0) { + dx = rng.float(-1, 1) + dy = rng.float(-1, 1) + dist = Math.hypot(dx, dy) || 1 + } + if (dist < minDist) { + const corr = (minDist - dist) / 2 + const ux = dx / dist + const uy = dy / dist + pos[i].x -= ux * corr + pos[i].y -= uy * corr + pos[j].x += ux * corr + pos[j].y += uy * corr + } + } + } + for (let i = 0; i < count; i++) { + const p = pos[i] + for (const ko of keepouts) { + const rx = ko.rx + half + const ry = ko.ry + half + const kex = (p.x - ko.cx) / rx + const key = (p.y - ko.cy) / ry + const ke = Math.hypot(kex, key) + if (ke === 0) { + p.x += rx + } else if (ke < 1) { + const s = 1 / ke + p.x = ko.cx + (p.x - ko.cx) * s + p.y = ko.cy + (p.y - ko.cy) * s + } + } + p.x = clamp(p.x, minC, maxCx) + p.y = clamp(p.y, minC, maxCy) } - const bbox = decorationBbox(placement) - const collides = acceptedBboxes.some((other) => bboxesOverlap(bbox, other)) - if (collides) continue - accepted.push(placement) - acceptedBboxes.push(bbox) } - return accepted + + return pos.map( + (p, i): StampPlacement => ({ + badge: { code: sorted[i].code, iconUrl: getBadgeIcon(sorted[i].code) }, + top: p.y - half, + left: p.x - half, + rotation: rng.float(-15, 15), + width: size, + height: size, + }) + ) } // ─── Stats inline block ───────────────────────────────────────────────── diff --git a/src/components/Card/share-asset/winCaptions.ts b/src/components/Card/share-asset/winCaptions.ts new file mode 100644 index 000000000..386ab540d --- /dev/null +++ b/src/components/Card/share-asset/winCaptions.ts @@ -0,0 +1,36 @@ +/** + * Caption pool for the "I got in" win share asset. + * + * When a user shares their Peanut card win, the share fires with a RANDOM + * caption from this pool so the timeline doesn't fill with one identical + * tweet. Mix of pure hype, invite/FOMO, a Devconnect callback, the brand + * "shhhh" motif, and cheeky anti-bank lines — picked by Hugo from the copy + * picker. Every caption tags @joinpeanut so the win post credits the brand + * (the handle rides the caption — the asset image stays clean). + */ + +export const WIN_CAPTIONS: readonly string[] = [ + 'yay! i got into @joinpeanut 🎉', + "shhhh, i'm in @joinpeanut.", + 'i got into @joinpeanut. holy shhh.', + 'i got into @joinpeanut, will you?', + "i'm in @joinpeanut. you?", + "remember @joinpeanut from devconnect? they're back.", + "met @joinpeanut at devconnect. now i've got the card.", + 'devconnect was just the start — the @joinpeanut card is here.', + 'from a devconnect booth to my wallet. @joinpeanut card 🥜', + 'you saw @joinpeanut at devconnect. now watch this.', + "can't talk — got the @joinpeanut card 🤫", + "shhhh. you didn't see this. @joinpeanut 🤫", + "the nuttiest card in crypto, and it's mine. @joinpeanut 🥜", + 'banked by @joinpeanut. unbothered.', + "who needs a bank? i've got @joinpeanut.", + "broke up with my bank. it's @joinpeanut now.", + "remember @joinpeanut from argentina? they're global now. just got into their closed beta", + "saw @joinpeanut in argentina. they went global — and i'm in the closed beta.", +] + +/** Pick a random caption. Browser-only (Math.random) — fine at share time. */ +export function pickWinCaption(): string { + return WIN_CAPTIONS[Math.floor(Math.random() * WIN_CAPTIONS.length)] +} diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 8d824234f..5fe592ff0 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -311,7 +311,7 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c {typeof destinationAmount === 'number' && destinationAmount > 0 && ( -
+

{deliveryTimeText}

diff --git a/src/components/Global/TokenSelector/TokenSelector.consts.ts b/src/components/Global/TokenSelector/TokenSelector.consts.ts index b6347c3bc..1201cdfee 100644 --- a/src/components/Global/TokenSelector/TokenSelector.consts.ts +++ b/src/components/Global/TokenSelector/TokenSelector.consts.ts @@ -75,3 +75,29 @@ const networksToExclude: readonly number[] = [celo.id, linea.id, worldchain.id] export const TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS = networks .filter((network) => !networksToExclude.includes(Number(network.id))) .map((network) => network.id.toString()) + +/** + * Rhino-supported withdrawal destinations: chainId → token symbols Rhino can + * actually deliver. Cross-chain withdraw routes through Rhino (stables via SDA, + * ETH/native via swaps), and Rhino supports a different, smaller set than the + * Squid-era token selector — and toggles chains over time (it has Scroll + * disabled, which 400s `SCROLL is disabled` on preview). Derived from Rhino's + * live SDA + bridge config (2026-06-26); update when Rhino enables/disables a + * chain or token. + * + * Notes: + * - Scroll (534352) is intentionally absent — Rhino has it disabled. + * - Native gas tokens Rhino doesn't bridge are omitted: Polygon (POL) and Gnosis + * (xDAI) keep only USDC/USDT; Arbitrum/Ethereum/Optimism native is ETH; BNB on + * BNB Chain is supported. + * - EVM only (the withdraw flow uses 0x addresses); matches the current + * selectable chain set rather than every Rhino chain. + */ +export const RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN: Record = { + '42161': ['ETH', 'USDC', 'USDT'], // Arbitrum + '1': ['ETH', 'USDC', 'USDT'], // Ethereum + '10': ['ETH', 'USDC', 'USDT'], // Optimism + '137': ['USDC', 'USDT'], // Polygon (native POL not bridged by Rhino) + '100': ['USDC', 'USDT'], // Gnosis (native xDAI not bridged by Rhino) + '56': ['BNB', 'USDC', 'USDT'], // BNB Chain +} diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index f8e99df08..2f7214cf8 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -28,6 +28,7 @@ import ScrollableList from './Components/ScrollableList' import SearchInput from './Components/SearchInput' import TokenListItem from './Components/TokenListItem' import { + RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN, TOKEN_SELECTOR_COMING_SOON_NETWORKS, TOKEN_SELECTOR_POPULAR_NETWORK_IDS, TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS, @@ -67,6 +68,17 @@ const TokenSelector: React.FC = ({ classNameButton, viewT // combined flag for any cross-chain disabled state const isCrossChainDisabled = isXchainWithdrawDisabled || isXchainSendDisabled + // When cross-chain withdraw is live, restrict destinations to what Rhino + // actually supports — the Squid-era selector lists chains/tokens Rhino + // rejects (e.g. USDC on Scroll → "SCROLL is disabled"). See + // RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN. + const restrictToRhino = viewType === 'withdraw' && !isXchainWithdrawDisabled + const isRhinoSupported = useCallback( + (chainId: string, tokenSymbol: string) => + RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN[chainId]?.includes(tokenSymbol.toUpperCase()) ?? false, + [] + ) + // state to track content height const contentRef = useRef(null) const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -157,7 +169,15 @@ const TokenSelector: React.FC = ({ classNameButton, viewT } } - const allowedChainIds = useMemo(() => new Set(TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS), []) + const allowedChainIds = useMemo( + () => + new Set( + restrictToRhino + ? TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS.filter((id) => RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN[id]) + : TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS + ), + [restrictToRhino] + ) const popularChainsForButtons = useMemo(() => { if (!supportedChainsAndTokens) return [] @@ -165,6 +185,8 @@ const TokenSelector: React.FC = ({ classNameButton, viewT const chain = supportedChainsAndTokens[popularNetwork.chainId] // skip if the chain ID isn't in supportedChainsAndTokens if (!chain) return null + // for withdraw, only surface chains Rhino can deliver to + if (restrictToRhino && !RHINO_WITHDRAW_SUPPORTED_TOKENS_BY_CHAIN[chain.chainId]) return null return { chainId: chain.chainId, @@ -172,7 +194,7 @@ const TokenSelector: React.FC = ({ classNameButton, viewT iconURI: chain.chainIconURI || '', } }).filter((chain): chain is { chainId: string; name: string; iconURI: string } => Boolean(chain)) // type guard filter nulls - }, [supportedChainsAndTokens]) + }, [supportedChainsAndTokens, restrictToRhino]) // build list of popular tokens (usdc, usdt, native) for display const popularTokensList = useMemo(() => { @@ -243,6 +265,9 @@ const TokenSelector: React.FC = ({ classNameButton, viewT const chainData = supportedChainsAndTokens[chainId] if (chainData?.tokens) { const processToken = (token: IToken) => { + // withdraw: drop tokens Rhino can't deliver on this chain + // (e.g. native POL/xDAI, any token on a disabled chain). + if (restrictToRhino && !isRhinoSupported(chainId, token.symbol)) return if (filterSymbol) { if ( token.symbol.toUpperCase() === filterSymbol.toUpperCase() || @@ -263,9 +288,20 @@ const TokenSelector: React.FC = ({ classNameButton, viewT chainData.tokens.forEach(processToken) } }) - const uniqueTokens = Array.from( - new Map(tokens.map((t) => [`${t.address.toLowerCase()}-${t.chainId}`, t])).values() - ) + // Dedupe by address normally; for the Rhino-restricted withdraw list + // collapse per (symbol, chain) so chains with two same-symbol variants + // (e.g. native USDC + bridged USDC.e on Optimism) show a single entry — + // Rhino resolves the token by symbol anyway. Keep the FIRST occurrence + // (token data lists the native/canonical token before bridged variants). + const seenKeys = new Set() + const uniqueTokens = tokens.filter((t) => { + const key = restrictToRhino + ? `${t.symbol.toUpperCase()}-${t.chainId}` + : `${t.address.toLowerCase()}-${t.chainId}` + if (seenKeys.has(key)) return false + seenKeys.add(key) + return true + }) return sortTokensByPriority(uniqueTokens) } @@ -280,7 +316,15 @@ const TokenSelector: React.FC = ({ classNameButton, viewT // default: popular tokens on popular chains const popularChainIds = popularChainsForButtons.map((pc) => pc.chainId) return buildTokensForChainArray(popularChainIds) - }, [searchValue, selectedChainID, supportedChainsAndTokens, popularChainsForButtons, isCrossChainDisabled]) + }, [ + searchValue, + selectedChainID, + supportedChainsAndTokens, + popularChainsForButtons, + isCrossChainDisabled, + restrictToRhino, + isRhinoSupported, + ]) // filter popular tokens by search const filteredPopularTokensToDisplay = useMemo(() => { diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 549a9fe0c..6e6f7db8c 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -6,6 +6,7 @@ import { Icon, type IconName } from '@/components/Global/Icons/Icon' import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' +import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' import { useEffect, useMemo, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -51,7 +52,7 @@ const STEPS: Record, StepConfig> = { icon: 'credit-card', iconBg: 'bg-yellow-1', title: 'Spend anywhere Visa is accepted', - description: 'Use your balance at 40m+ merchants. Online, contactless.', + description: 'Use your balance at 150M+ merchants. Online, contactless.', ctaLabel: 'Get your card', href: '/card', dismissable: true, @@ -159,6 +160,24 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (!step) return null + // The card step renders the mysterious /shhhhh-tone launch banner (#2295's + // CardLaunchCTABanner) instead of the plain funnel card — so non-activated + // card-eligible users get the same CTA as the activated launch splash. + // Keeps the funnel's /card routing + the "Maybe later" dismissal. + if (activationStep === 'card') { + return ( + { + posthog.capture(ANALYTICS_EVENTS.CARD_LAUNCH_CTA_CLICKED) + // /shhhhh (not /card): the landing page explains the feature + // and funnels into the canonical flow — /card alone is confusing. + router.push('/shhhhh') + }} + onDismiss={() => onDismissCard?.()} + /> + ) + } + return (
diff --git a/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx new file mode 100644 index 000000000..4c56d0d0c --- /dev/null +++ b/src/components/Home/CardLaunchCTA/CardLaunchCTABanner.tsx @@ -0,0 +1,71 @@ +'use client' + +import { Icon } from '@/components/Global/Icons/Icon' +import { Button } from '@/components/0_Bruddle/Button' +import { useHaptic } from 'use-haptic' + +interface CardLaunchCTABannerProps { + /** Tap-through: routes the user into the /card eligibility flow. */ + onTryDoor: () => void + /** Close (X): permanently hides the banner. */ + onDismiss: () => void +} + +/** + * Fat home launch banner for the Peanut Card public launch. + * + * 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. + */ +export default function CardLaunchCTABanner({ onTryDoor, onDismiss }: CardLaunchCTABannerProps) { + const { triggerHaptic } = useHaptic() + + const handleTryDoor = () => { + triggerHaptic() + onTryDoor() + } + + const handleDismiss = (e: React.MouseEvent) => { + e.stopPropagation() + onDismiss() + } + + return ( +
+ + +
+

shhh

+

Tap to find out if you're in

+ +
+
+ ) +} diff --git a/src/components/Home/CardLaunchCTA/__tests__/cardLaunchCTA.utils.test.ts b/src/components/Home/CardLaunchCTA/__tests__/cardLaunchCTA.utils.test.ts new file mode 100644 index 000000000..e62ed8a1c --- /dev/null +++ b/src/components/Home/CardLaunchCTA/__tests__/cardLaunchCTA.utils.test.ts @@ -0,0 +1,28 @@ +import { dismissCardLaunchCTA, isCardLaunchCTADismissed } from '../cardLaunchCTA.utils' + +describe('cardLaunchCTA dismiss persistence', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('defaults to not dismissed', () => { + expect(isCardLaunchCTADismissed()).toBe(false) + }) + + it('persists a dismissal', () => { + dismissCardLaunchCTA() + expect(isCardLaunchCTADismissed()).toBe(true) + }) + + it('is permanent — a re-read still reports dismissed (no cooldown)', () => { + dismissCardLaunchCTA() + // simulate a later mount reading the flag again + expect(isCardLaunchCTADismissed()).toBe(true) + }) + + it('is idempotent', () => { + dismissCardLaunchCTA() + dismissCardLaunchCTA() + expect(isCardLaunchCTADismissed()).toBe(true) + }) +}) diff --git a/src/components/Home/CardLaunchCTA/cardLaunchCTA.utils.ts b/src/components/Home/CardLaunchCTA/cardLaunchCTA.utils.ts new file mode 100644 index 000000000..6f4bd9104 --- /dev/null +++ b/src/components/Home/CardLaunchCTA/cardLaunchCTA.utils.ts @@ -0,0 +1,32 @@ +/** + * Permanent ("gone forever") dismissal for the home card-launch CTA. + * + * Mirrors useActivationStatus' `peanut_card_activation_dismissed` localStorage + * flag — NOT the carousel's `dismissedCarouselCTAs` map, which re-surfaces a CTA + * after a 7-day cooldown. The launch CTA is a one-shot splash: once the user + * clicks through OR closes it, it never comes back. + */ + +const CARD_LAUNCH_CTA_DISMISSED_KEY = 'peanut_card_launch_cta_dismissed' + +/** True if the user has already clicked through or dismissed the launch CTA. */ +export function isCardLaunchCTADismissed(): boolean { + if (typeof window === 'undefined') return false + try { + return localStorage.getItem(CARD_LAUNCH_CTA_DISMISSED_KEY) === 'true' + } catch { + // Private-mode / storage-disabled browsers: treat as not-dismissed so the + // CTA still shows; worst case it re-appears on next mount (no crash). + return false + } +} + +/** Permanently hide the launch CTA. Idempotent. */ +export function dismissCardLaunchCTA(): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(CARD_LAUNCH_CTA_DISMISSED_KEY, 'true') + } catch { + // No-op if storage is unavailable. + } +} diff --git a/src/components/Home/CardLaunchCTA/index.tsx b/src/components/Home/CardLaunchCTA/index.tsx new file mode 100644 index 000000000..ebcca244d --- /dev/null +++ b/src/components/Home/CardLaunchCTA/index.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import posthog from 'posthog-js' +import { useAuth } from '@/context/authContext' +import { useCardInfo } from '@/hooks/useCardInfo' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useActivationStatus } from '@/hooks/useActivationStatus' +import { findActiveCard } from '@/components/Card/cardState.utils' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import underMaintenanceConfig from '@/config/underMaintenance.config' +import CardLaunchCTABanner from './CardLaunchCTABanner' +import { dismissCardLaunchCTA, isCardLaunchCTADismissed } from './cardLaunchCTA.utils' + +/** + * Home launch CTA for the Peanut Card public launch (2026-06-29). + * + * Self-gating once `isPublicLaunched` flips. Audience = the active base who + * could plausibly want a card and haven't engaged yet: ACTIVATED users who are + * geo-eligible, have no active card, and aren't already on the waitlist (and + * haven't dismissed/clicked it). Non-activated users are left to the activation + * funnel; on-waitlist / card-holding / ineligible users are excluded so the + * "find out if you're in" tap never dead-ends. + * + * Gates on `/card`'s `isPublicLaunched` — NOT `hasCardAccess` (the inner gate + * that excludes most users). The point is to drive the eligible base to /card to + * "test if they can get their card"; /card itself does the final eligibility gate. + * + * Dismissal is permanent (localStorage flag, no cooldown): clicking through OR + * closing the X both hide it forever. + */ +export default function CardLaunchCTA() { + const router = useRouter() + const { user } = useAuth() + const { isPublicLaunched, isEligible, cardInfo, isLoading } = useCardInfo() + const { overview, isLoading: isOverviewLoading } = useRainCardOverview() + const { isActivated } = useActivationStatus() + + // Read the permanent dismissal after mount to avoid a hydration mismatch — + // localStorage is client-only. Starts null (unknown) so a dismissed user + // never flashes the banner / fires VIEWED before hydration resolves. + const [dismissed, setDismissed] = useState(null) + useEffect(() => { + setDismissed(isCardLaunchCTADismissed()) + }, []) + + const hasActiveCard = !!findActiveCard(overview) + const isOnWaitlist = !!cardInfo?.waitlistJoinedAt + // Audience (per Hugo): the launch splash targets the active base who haven't engaged + // with the card yet. Activated-only (non-activated users have the activation funnel); + // exclude anyone who already has a card or already joined the waitlist; skip geo- + // ineligible users so the "find out if you're in" tap never dead-ends on "not your country". + const visible = + !!user && + !isLoading && + !isOverviewLoading && + dismissed === false && + isPublicLaunched === true && + isActivated && + isEligible === true && + !hasActiveCard && + !isOnWaitlist && + !underMaintenanceConfig.disableCardPioneers && + !underMaintenanceConfig.disableCardLaunchCTA + + // Fire "viewed" exactly once, when the banner first becomes visible. + const viewedRef = useRef(false) + useEffect(() => { + if (visible && !viewedRef.current) { + viewedRef.current = true + posthog.capture(ANALYTICS_EVENTS.CARD_LAUNCH_CTA_VIEWED) + } + }, [visible]) + + if (!visible) return null + + const handleTryDoor = () => { + dismissCardLaunchCTA() + setDismissed(true) + posthog.capture(ANALYTICS_EVENTS.CARD_LAUNCH_CTA_CLICKED) + // Route to /shhhhh, not /card: the landing page explains the feature and + // funnels into the canonical flow (Konrad — /card alone is confusing). + router.push('/shhhhh') + } + + const handleDismiss = () => { + dismissCardLaunchCTA() + setDismissed(true) + posthog.capture(ANALYTICS_EVENTS.CARD_LAUNCH_CTA_DISMISSED) + } + + return +} diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index c9309d392..b492b1b03 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -16,6 +16,8 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { notsoshhh: 'NOT_SO_SHHHH', festajunina: 'FESTA_JUNINA_2026', cardalpha: 'CARD_ALPHA', + psyops: 'PSYOPS_DIVISION', + founding: 'FOUNDING_PIONEER', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. diff --git a/src/components/Marketing/ArticleBackNav.tsx b/src/components/Marketing/ArticleBackNav.tsx index a310cfe38..c27fa7e1b 100644 --- a/src/components/Marketing/ArticleBackNav.tsx +++ b/src/components/Marketing/ArticleBackNav.tsx @@ -75,7 +75,7 @@ export function ArticleBackNav({ parentLabel, parentHref, backToTemplate, curren {open && (
    {LOCALE_ORDER.map((loc) => { const meta = LOCALE_META[loc] diff --git a/src/components/Profile/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx index e1bb97bec..95880ae65 100644 --- a/src/components/Profile/components/ProfileMenuItem.tsx +++ b/src/components/Profile/components/ProfileMenuItem.tsx @@ -6,9 +6,11 @@ import NavigationArrow from '@/components/Global/NavigationArrow' import { Tooltip } from '@/components/Tooltip' import Link from 'next/link' import React from 'react' +import { type SVGProps } from 'react' interface ProfileMenuItemProps { icon: IconName | React.ReactNode + iconClassName?: SVGProps['className'] label: string href?: string onClick?: () => void @@ -25,6 +27,7 @@ interface ProfileMenuItemProps { const ProfileMenuItem: React.FC = ({ icon, + iconClassName, label, href, onClick, @@ -42,7 +45,7 @@ const ProfileMenuItem: React.FC = ({
    {typeof icon === 'string' ? ( - + ) : (
    {icon} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 7e4d829fc..4e7c06a70 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -117,6 +117,7 @@ export const Profile = () => { label="Exchange rates and fees" href="/profile/exchange-rate" position="single" + iconClassName="size-4" /> {/* Logout Button */}
    diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx index 875cbd80f..c9dc8ae79 100644 --- a/src/components/Send/views/SendRouter.view.tsx +++ b/src/components/Send/views/SendRouter.view.tsx @@ -229,7 +229,7 @@ export const SendRouterView = () => {
    No account needed to receive.
    -
    diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx index 8e02b0935..3111e6e3b 100644 --- a/src/components/Slider/index.tsx +++ b/src/components/Slider/index.tsx @@ -71,7 +71,7 @@ const Slider = React.forwardRef, S setIsDragging(true)} - className="z-20 flex h-12 w-12 cursor-pointer items-center justify-center rounded-r-sm bg-primary-1 transition-colors focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:opacity-50" + className="z-20 flex h-[46px] w-12 cursor-pointer items-center justify-center rounded-r-sm bg-primary-1 transition-colors focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:opacity-50" > diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 4a33340be..ae07ca044 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -500,9 +500,16 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact } // parse the amount from the usdamount string in extradata - const amount = entry.extraData?.usdAmount + const baseAmount = entry.extraData?.usdAmount ? parseFloat(String(entry.extraData.usdAmount).replace(/[^\d.-]/g, '')) : 0 + // Bake the cross-chain network fee into the displayed amount (product + // convention: fees are part of the amount, never a separate line — see the + // `fee: undefined` note below). The BE only sets `networkFeeUsd` for a + // CRYPTO_WITHDRAW whose kernel actually debited principal + fee (SDA path), + // so this shows the true amount deducted instead of just the principal. + const networkFeeUsd = typeof entry.extraData?.networkFeeUsd === 'number' ? entry.extraData.networkFeeUsd : 0 + const amount = baseAmount + networkFeeUsd const { explorerUrlWithTx, addressExplorerUrl, tokenDisplayDetails, rewardData } = computeDerivedFields(entry) diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index e34e92751..4c2bc0e21 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -5,12 +5,14 @@ import AddressLink from '@/components/Global/AddressLink' import Card from '@/components/Global/Card' import DisplayIcon from '@/components/Global/DisplayIcon' import ErrorAlert from '@/components/Global/ErrorAlert' +import InfoCard from '@/components/Global/InfoCard' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' import { type ITokenPriceData } from '@/interfaces' import { formatAmount, isStableCoin } from '@/utils/general.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import type { ChainWithTokens } from '@/interfaces/chain-meta' import { useMemo } from 'react' import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' @@ -36,6 +38,24 @@ interface WithdrawConfirmViewProps { * "they'll receive X" number. */ receiveAmount?: string | null + /** + * The exact USDC the kernel spends (decimal string) — the honest "You pay". + * SDA (receive mode) = principal + fee; bridge (pay mode) = principal (the + * fee comes out of what the recipient receives). Nullable while calculating. + */ + payAmount?: string | null + /** + * True when the bridge fee is a large share of the amount (e.g. a small + * withdraw to Ethereum mainnet where the flat gas floor dominates). Shows a + * non-blocking heads-up — the user can still proceed; the fee is honest. + */ + showHighFeeWarning?: boolean + /** + * True when the balance can't cover amount + cross-chain fee. Blocks the CTA + * with an honest "not enough balance" message instead of letting the user + * sign into the misleading "balance still settling" send error. + */ + insufficientBalance?: boolean } export default function ConfirmWithdrawView({ @@ -52,6 +72,9 @@ export default function ConfirmWithdrawView({ isCrossChain = false, isCalculating = false, receiveAmount, + payAmount, + showHighFeeWarning = false, + insufficientBalance = false, }: WithdrawConfirmViewProps) { const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ chainId: chain.chainId, @@ -61,19 +84,29 @@ export default function ConfirmWithdrawView({ const displayReceived = useMemo(() => { if (!isCrossChain || !receiveAmount || !resolvedTokenSymbol) return null - return isStableCoin(resolvedTokenSymbol) ? `$ ${receiveAmount}` : `${receiveAmount} ${resolvedTokenSymbol}` + return isStableCoin(resolvedTokenSymbol) ? `$${receiveAmount}` : `${receiveAmount} ${resolvedTokenSymbol}` }, [isCrossChain, receiveAmount, resolvedTokenSymbol]) - const networkFeeDisplay = useMemo(() => { - if (networkFee < 0.01) return 'Sponsored by Peanut!' - return ( - <> - $ {networkFee.toFixed(2)} - {' – '} - Sponsored by Peanut! - - ) - }, [networkFee]) + // Honest bridge fee. The Rhino fee (destination gas + 0.07%) is paid by the + // user on top of the amount — it is NOT sponsored. Only the kernel execution + // gas is sponsored by Peanut's paymaster (the "Peanut fee" row below). For + // same-chain (no bridge) there's no Rhino fee, so it stays sponsored. + const networkFeeDisplay = useMemo(() => { + if (!isCrossChain || networkFee <= 0) return 'Sponsored by Peanut!' + return networkFee < 0.01 ? '< $0.01' : `$${networkFee.toFixed(2)}` + }, [isCrossChain, networkFee]) + + // What actually leaves the wallet on a cross-chain withdraw — the exact USDC + // the kernel spends (`payAmount`). This is authoritative for BOTH paths and + // avoids guessing: SDA (receive mode) = principal + fee, bridge (pay mode) = + // principal (the fee comes out of the recipient's amount, not on top). Using + // amount + fee would over-state the bridge path (showing principal + fee when + // the user only pays the principal). + const totalPayDisplay = useMemo(() => { + if (!isCrossChain || !payAmount) return null + const parsed = parseFloat(payAmount) + return Number.isFinite(parsed) ? `$${formatAmount(payAmount)}` : null + }, [isCrossChain, payAmount]) return (
    @@ -90,11 +123,12 @@ export default function ConfirmWithdrawView({ /> - {displayReceived && ( + {isCrossChain && (isCalculating || displayReceived) && ( )} } /> - - + + {isCrossChain && (isCalculating || totalPayDisplay) && ( + + )} + + {showHighFeeWarning && ( + + )} + {error ? ( )} + {insufficientBalance && !error && } {error && }
    diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index abbf63fb8..f7a021dde 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -40,6 +40,11 @@ * - does NOT block deposits — the option stays usable (warn-only) * - set to false when PIX deposits are stable again * + * 8. disableCardLaunchCTA: kill-switch for the in-app "shhh" card CTA (the home nudge) + * - true hides BOTH the activation-funnel card step and the activated-base home splash + * - the /card flow, /shhhhh page, and waitlist pill stay reachable regardless — this only mutes the proactive in-app nudge + * - currently false (CTA live, routes to /shhhhh); set true to dial down in-app load without touching the flow + * * note: if either mode is enabled, the maintenance banner will show everywhere * * I HOPE WE NEVER NEED TO USE THIS... @@ -55,6 +60,7 @@ interface MaintenanceConfig { disableXchainWithdraw: boolean disableXchainSend: boolean disableCardPioneers: boolean + disableCardLaunchCTA: boolean pixBrazilOnrampMaintenance: boolean } @@ -62,9 +68,10 @@ const underMaintenanceConfig: MaintenanceConfig = { enableFullMaintenance: false, // set to true to redirect all pages to /maintenance enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments - disableXchainWithdraw: true, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum) + disableXchainWithdraw: false, // cross-chain withdrawals re-enabled (stables via SDA + non-stables via swaps, fee shown honestly); set true to lock to USDC on Arbitrum disableXchainSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum) disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature + disableCardLaunchCTA: false, // kill-switch for the in-app "shhh" card CTA (funnel card step + activated home splash). Set true to mute it (dial down in-app load); /card flow + /shhhhh + waitlist stay reachable regardless. pixBrazilOnrampMaintenance: true, // set to false when BRL-via-PIX deposits are stable again } diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 0c83fc6a5..b19ec1b3e 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -148,6 +148,12 @@ export const ANALYTICS_EVENTS = { CARD_FLOW_EARLY_ACCESS_GRANTED: 'card_flow_early_access_granted', // Outer-gate fail: user landed on /card without /shhhhh early access pre-launch. CARD_FLOW_GATED: 'card_flow_gated', + // Home launch CTA (shown to everyone post-public-launch who has no active card). + // viewed = banner became visible; clicked = tapped through to /card; + // dismissed = tapped the X. Click and dismiss both hide it permanently. + CARD_LAUNCH_CTA_VIEWED: 'card_launch_cta_viewed', + CARD_LAUNCH_CTA_CLICKED: 'card_launch_cta_clicked', + CARD_LAUNCH_CTA_DISMISSED: 'card_launch_cta_dismissed', // Eligibility-check screen — press-and-hold gate between /shhhhh and the // celebration/waitlist verdict. CARD_ELIGIBILITY_CHECK_VIEWED: 'card_eligibility_check_viewed', @@ -165,6 +171,10 @@ export const ANALYTICS_EVENTS = { // rejection). Lets the funnel distinguish "users not tapping share" // from "users tapping share but it silently fails". CARD_SHARE_ASSET_FAILED: 'card_share_asset_failed', + // Non-intrusive badge-earn toast on /home (TASK-19791) — coalesced; tap + // opens the badge detail modal (or the badges list for several). + BADGE_EARN_TOAST_SHOWN: 'badge_earn_toast_shown', + BADGE_EARN_TOAST_TAPPED: 'badge_earn_toast_tapped', // Admin wave release (BE event, mirrored here so FE doesn't accidentally // step on the namespace). CARD_WAITLIST_RELEASED: 'card_waitlist_released', diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index f5dda4a53..8d807407a 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -39,9 +39,9 @@ export const rpcUrls: Record = { // 'https://rpc.ankr.com/arbitrum', // requires API key ].filter(Boolean) as string[], [arbitrumSepolia.id]: [ - // infuraUrl('arbitrum-sepolia'), - // alchemyUrl('arb-sepolia'), - 'https://sepolia-rollup.arbitrum.io/rpc', // Official Arbitrum Sepolia + 'https://arbitrum-sepolia.publicnode.com', // publicnode (primary) — keyless, CORS *, reliable + 'https://arbitrum-sepolia.drpc.org', // drpc — keyless fallback + 'https://sepolia-rollup.arbitrum.io/rpc', // Official Arbitrum Sepolia (503-prone) — last resort ].filter(Boolean) as string[], [polygon.id]: [ 'https://polygon-mainnet.core.chainstack.com/e8d733c7341e28d98e4cf66c61c42aa6', // Chainstack (primary) diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts index 0927a1e04..018eadf98 100644 --- a/src/constants/rhino.consts.ts +++ b/src/constants/rhino.consts.ts @@ -100,12 +100,17 @@ export const RHINO_SUPPORTED_TOKENS = (Object.keys(TOKEN_LOGOS) as TokenName[]) * — matters for the sandbox harness, where our smart accounts live on Arb * Sepolia while Rhino's endpoints only recognize mainnet chain names. */ -export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = { +// Values are Rhino's API chain names (what `chainIn`/`chainOut` must be), which +// differ from our display ChainName for two chains: Polygon is `MATIC_POS` and +// BNB Chain is `BINANCE` in Rhino's API. Sending the display name (POLYGON/BNB) +// 400s with `Invalid chain`. Keep this in sync with peanut-api-ts +// `src/rhino/consts.ts` CHAINS_CONFIG. +export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = { '1': 'ETHEREUM', '10': 'OPTIMISM', - '56': 'BNB', + '56': 'BINANCE', // Rhino's name for BNB Chain (display: BNB) '100': 'GNOSIS', - '137': 'POLYGON', + '137': 'MATIC_POS', // Rhino's name for Polygon (display: POLYGON) '534352': 'SCROLL', '42161': 'ARBITRUM', '421614': 'ARBITRUM', // Arb Sepolia — same Rhino bucket for sandbox runs @@ -113,6 +118,6 @@ export const EVM_CHAIN_ID_TO_RHINO_NAME: Record = '42220': 'CELO', } -export function evmChainIdToRhinoName(chainId: string | number): ChainName | undefined { +export function evmChainIdToRhinoName(chainId: string | number): string | undefined { return EVM_CHAIN_ID_TO_RHINO_NAME[String(chainId)] } diff --git a/src/constants/routes.ts b/src/constants/routes.ts index f4b834285..201477840 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -111,7 +111,8 @@ export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invit * Regex for dev-only public routes (dev index, gift-test, shake-test) * Only matched when IS_DEV is true */ -export const DEV_ONLY_PUBLIC_ROUTES_REGEX = /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components)/ +export const DEV_ONLY_PUBLIC_ROUTES_REGEX = + /^\/(dev$|dev\/gift-test|dev\/shake-test|dev\/ds|dev\/components|dev\/share-builder|dev\/rejection-builder)/ /** * Matches locale tags with a required subtag to avoid false-positives on short diff --git a/src/constants/token-details.json b/src/constants/token-details.json index a635232d4..df10788e9 100644 --- a/src/constants/token-details.json +++ b/src/constants/token-details.json @@ -1294,6 +1294,20 @@ "decimals": 18, "logoURI": "https://gnosisscan.io/token/images/gnosans_32.png" }, + { + "address": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6, + "logoURI": "https://market-data-images.s3.us-east-1.amazonaws.com/tokenImages/0x10ca7e698fab4eb287d4d33b3886ae17a6d078fbda455cdd673cfec0ca8ef413.png" + }, + { + "address": "0x4ecaba5870353805a9f068101a40e0f32ed605c6", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://market-data-images.s3.us-east-1.amazonaws.com/tokenImages/0x63adcb79842ad73769d6f2350d9cab2c8b8e0d37f6071dee9418cbd53319543d.png" + }, { "address": "0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1", "decimals": 18, diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index 49afa24de..906c5f2e3 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -303,25 +303,30 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { return } - // Run preview + provision in parallel — they don't depend on each other. - const [preview, sda] = await Promise.all([ - previewSdaTransfer({ - chainIn: sourceRhinoChain, - chainOut: destRhinoChain, - token: tokenSymbol, - amount: destination.tokenAmount, - mode: 'receive', // UI always asks "merchant gets X" — user pays X + fee - }), - provisionSdaTransfer({ - context, - contextId, - depositChain: sourceRhinoChain, - destinationChain: destRhinoChain, - destinationAddress: destination.recipientAddress, - tokenOut: tokenSymbol, - senderPeanutWalletAddress, - }), - ]) + // Preview first, then provision — provision now carries the quote + // economics (feeUsd / payAmount / receiveAmount) so the backend can + // persist them onto the charge and book the FEE ledger entry at + // settlement (PRINCIPAL + FEE = real on-chain debit). Sequential + // because provision depends on preview's numbers. + const preview = await previewSdaTransfer({ + chainIn: sourceRhinoChain, + chainOut: destRhinoChain, + token: tokenSymbol, + amount: destination.tokenAmount, + mode: 'receive', // UI always asks "merchant gets X" — user pays X + fee + }) + const sda = await provisionSdaTransfer({ + context, + contextId, + depositChain: sourceRhinoChain, + destinationChain: destRhinoChain, + destinationAddress: destination.recipientAddress, + tokenOut: tokenSymbol, + senderPeanutWalletAddress, + feeUsd: preview.feeUsd, + payAmount: preview.payAmount, + receiveAmount: preview.receiveAmount, + }) applyRhinoResult({ preview, diff --git a/src/hooks/__tests__/useCardReveal.test.ts b/src/hooks/__tests__/useCardReveal.test.ts index a38730935..d5720aea3 100644 --- a/src/hooks/__tests__/useCardReveal.test.ts +++ b/src/hooks/__tests__/useCardReveal.test.ts @@ -1,5 +1,7 @@ import { renderHook, act, waitFor } from '@testing-library/react' +import posthog from 'posthog-js' import { useCardReveal } from '@/hooks/useCardReveal' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { rainApi, RainCardRateLimitError, type RainCardDetailsResponse } from '@/services/rain' jest.mock('@/services/rain', () => { @@ -77,16 +79,28 @@ describe('useCardReveal', () => { expect(result.current.revealed).toBeNull() }) - it('surfaces generic errors', async () => { - mockedGetCardDetails.mockRejectedValueOnce(new Error('boom')) + it('shows a friendly message and reports only a bounded slice of the raw error', async () => { + const captureSpy = jest.spyOn(posthog, 'capture') + // A real backend 500 forwards the upstream Rain body — long and detailed. + const rawError = + 'Rain API error 500 on GET /v1/issuing/cards/abc/secrets: ' + + '{"message":"We had an issue with your request","error":"InternalServerError","correlationId":"deadbeef-cafe"}' + mockedGetCardDetails.mockRejectedValueOnce(new Error(rawError)) const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 })) await act(async () => { await result.current.reveal() }) - expect(result.current.error).toBe('boom') + // The user never sees the raw upstream/internal error text. + expect(result.current.error).toBe('Could not load card details. Please try again or contact support.') expect(result.current.isRateLimited).toBe(false) + // Telemetry gets a bounded slice — enough to segment, but the full + // upstream body (correlationId etc.) never reaches client analytics. + expect(captureSpy).toHaveBeenCalledWith(ANALYTICS_EVENTS.CARD_PAN_FAILED, { + error_message: rawError.slice(0, 120), + }) + expect(captureSpy.mock.calls.at(-1)?.[1]?.error_message).not.toContain('correlationId') }) it('auto-masks after the configured timeout', async () => { diff --git a/src/hooks/useActivationStatus.ts b/src/hooks/useActivationStatus.ts index b89b3458b..995b0b3d1 100644 --- a/src/hooks/useActivationStatus.ts +++ b/src/hooks/useActivationStatus.ts @@ -7,6 +7,7 @@ import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useQuery } from '@tanstack/react-query' import { cardApi, type CardInfoResponse } from '@/services/card' import { findActiveCard } from '@/components/Card/cardState.utils' +import underMaintenanceConfig from '@/config/underMaintenance.config' import { useCallback, useEffect, useMemo, useState } from 'react' export type ActivationStep = 'verify' | 'deposit' | 'card' | 'outbound' | 'completed' @@ -118,7 +119,11 @@ export function useActivationStatus(): ActivationStatus { // never took the card (the funnel would otherwise be `completed`). const hasCardAccess = cardInfo?.hasCardAccess ?? false const hasCard = !!findActiveCard(overview) - if (hasCardAccess && !hasCard && !cardDismissed) { + // The in-app card CTA is delay-gated for launch (see disableCardLaunchCTA): + // muted now, flipped on a few days post-launch. While muted, badge/access + // users fall through to the normal verify → deposit → outbound funnel; + // /card itself stays reachable (door, waitlist pill, direct link). + if (hasCardAccess && !hasCard && !cardDismissed && !underMaintenanceConfig.disableCardLaunchCTA) { activationStep = 'card' } diff --git a/src/hooks/useCardInfo.ts b/src/hooks/useCardInfo.ts index c7af16bd4..07542dfa1 100644 --- a/src/hooks/useCardInfo.ts +++ b/src/hooks/useCardInfo.ts @@ -30,6 +30,7 @@ export const useCardInfo = () => { isEligible: query.isLoading ? undefined : (query.data?.isEligible ?? false), hasCardAccess: query.isLoading ? undefined : (query.data?.hasCardAccess ?? false), flowEarlyAccess: query.isLoading ? undefined : (query.data?.flowEarlyAccess ?? false), + isPublicLaunched: query.isLoading ? undefined : (query.data?.isPublicLaunched ?? false), skipBadges: query.data?.skipBadges ?? [], } } diff --git a/src/hooks/useCardReveal.ts b/src/hooks/useCardReveal.ts index ac0920199..2263ae0c6 100644 --- a/src/hooks/useCardReveal.ts +++ b/src/hooks/useCardReveal.ts @@ -68,9 +68,17 @@ export function useCardReveal({ cardId, autoMaskMs = DEFAULT_AUTO_MASK_MS }: Use setError(e.message) posthog.capture(ANALYTICS_EVENTS.CARD_PAN_RATE_LIMITED) } else { - const message = e instanceof Error ? e.message : 'Failed to load card details' - setError(message) - posthog.capture(ANALYTICS_EVENTS.CARD_PAN_FAILED, { error_message: message }) + // Never surface the raw error to the UI: the backend forwards + // internal/upstream detail in its message (e.g. a raw Rain 500 + // body), and CardFace renders the error string verbatim on the + // card. Show a friendly, actionable message instead. + setError('Could not load card details. Please try again or contact support.') + // Telemetry gets a bounded slice for segmenting failures — not the + // full message, to keep raw upstream error bodies out of client + // analytics. The complete, sanitized detail is already in Sentry + // via fetchWithSentry. + const errorMessage = e instanceof Error ? e.message : 'Failed to load card details' + posthog.capture(ANALYTICS_EVENTS.CARD_PAN_FAILED, { error_message: errorMessage.slice(0, 120) }) } } finally { setIsLoading(false) diff --git a/src/services/card.ts b/src/services/card.ts index 4ffdf4bd2..94b5b3c31 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -25,6 +25,11 @@ export interface CardInfoResponse { /** Outer gate. True iff user can ENTER the /card flow (via /shhhhh * early access or post-public-launch). */ flowEarlyAccess: boolean + /** True once the card flow is public for EVERYONE (now >= CARD_PUBLIC_LAUNCH_DATE). + * Unlike `flowEarlyAccess`, this is NOT true pre-launch for /shhhhh-stamped or + * badge-holding users — it flips for all users at the same instant. Drives the + * home launch CTA. */ + isPublicLaunched: boolean waitlistJoinedAt: string | null waitlistPosition: number | null waitlistReleasedAt: string | null diff --git a/src/services/rhino-sda.ts b/src/services/rhino-sda.ts index a1444242b..8fa610b28 100644 --- a/src/services/rhino-sda.ts +++ b/src/services/rhino-sda.ts @@ -33,6 +33,15 @@ export interface SdaTransferRequest { destinationAddress: Address tokenOut: RhinoSupportedToken senderPeanutWalletAddress?: Address + /** + * Rhino quote economics from the immediately-preceding /preview, echoed so + * the backend persists them onto the charge intent and books the bridge + * FEE ledger entry at settlement. Omitted for claim-xchain (no charge). + * `payAmount`/`receiveAmount` are destination-token decimal strings. + */ + feeUsd?: number + payAmount?: string + receiveAmount?: string } export interface SdaTransferResult { diff --git a/src/styles/globals.css b/src/styles/globals.css index 40a95962b..b9d6a5025 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -808,66 +808,3 @@ input::placeholder { .content-page tbody tr:nth-child(even) { @apply bg-primary-3/30; } - -/* ───────────────────────────────────────────────────────────────── - Share-asset stamp — real postage-stamp appearance for the - ShareAssetD3 component. Scalloped perforated edges, inner thin - frame, paper-warm body, hard shadow. Override `--stamp-bg` per - parent so the perforation dots match the canvas behind it. - - Used in: src/components/Card/share-asset/ShareAssetD3.tsx - ───────────────────────────────────────────────────────────────── */ -.stamp { - --stamp-bg: #ffc900; - - position: relative; - background: #fff; - background-image: radial-gradient(ellipse at top left, #fff8e1 0%, transparent 60%); - filter: drop-shadow(0.18rem 0.18rem 0 #000); -} -.stamp::before { - content: ''; - position: absolute; - inset: -7px; - pointer-events: none; - background: - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 0 / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 100% / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 0 0 / 14px 14px, - radial-gradient(circle 4.5px at center, var(--stamp-bg) 99%, transparent 100%) 100% 0 / 14px 14px; - background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; -} -.stamp::after { - content: ''; - position: absolute; - inset: 7px; - border: 1px solid #000; - pointer-events: none; -} -.stamp-issuer { - position: absolute; - top: 11px; - left: 0; - right: 0; - text-align: center; - font-family: var(--font-roboto), sans-serif; - font-weight: 900; - font-size: 8px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: #000; - opacity: 0.6; - z-index: 2; -} -.stamp-denom { - position: absolute; - top: 11px; - right: 13px; - font-family: var(--font-roboto), sans-serif; - font-weight: 900; - font-size: 9px; - letter-spacing: 0.04em; - color: #000; - opacity: 0.55; - z-index: 2; -} diff --git a/src/utils/cross-chain-fee.utils.test.ts b/src/utils/cross-chain-fee.utils.test.ts new file mode 100644 index 000000000..f2359c108 --- /dev/null +++ b/src/utils/cross-chain-fee.utils.test.ts @@ -0,0 +1,43 @@ +import { isWithdrawFeeDisproportionate, HIGH_WITHDRAW_FEE_RATIO } from './cross-chain-fee.utils' + +describe('isWithdrawFeeDisproportionate', () => { + test('no heads-up for a tiny L2 fee on a normal amount', () => { + // $0.08 fee on $10 → 0.8% + expect(isWithdrawFeeDisproportionate(0.08, 10)).toBe(false) + }) + + test('heads-up for a small mainnet withdraw where flat gas dominates', () => { + // $1.50 fee on $10 → 15% + expect(isWithdrawFeeDisproportionate(1.5, 10)).toBe(true) + }) + + test('no heads-up for the same mainnet fee on a larger amount', () => { + // $1.50 fee on $100 → 1.5% + expect(isWithdrawFeeDisproportionate(1.5, 100)).toBe(false) + }) + + test('is strict at the threshold boundary', () => { + // exactly 5% is below the line; just over triggers the heads-up + expect(isWithdrawFeeDisproportionate(0.5, 10)).toBe(false) // 5.0% + expect(isWithdrawFeeDisproportionate(0.51, 10)).toBe(true) // 5.1% + }) + + test('no fee / zero fee is never disproportionate (same-chain, sponsored)', () => { + expect(isWithdrawFeeDisproportionate(undefined, 10)).toBe(false) + expect(isWithdrawFeeDisproportionate(0, 10)).toBe(false) + }) + + test('guards against a non-positive or non-finite amount', () => { + expect(isWithdrawFeeDisproportionate(1, 0)).toBe(false) + expect(isWithdrawFeeDisproportionate(1, Number.NaN)).toBe(false) + }) + + test('honours a custom threshold', () => { + expect(isWithdrawFeeDisproportionate(0.2, 10, 0.01)).toBe(true) // 2% > 1% + expect(isWithdrawFeeDisproportionate(0.2, 10, 0.1)).toBe(false) // 2% < 10% + }) + + test('default threshold is 5%', () => { + expect(HIGH_WITHDRAW_FEE_RATIO).toBe(0.05) + }) +}) diff --git a/src/utils/cross-chain-fee.utils.ts b/src/utils/cross-chain-fee.utils.ts new file mode 100644 index 000000000..f617cb46d --- /dev/null +++ b/src/utils/cross-chain-fee.utils.ts @@ -0,0 +1,29 @@ +/** + * Cross-chain withdrawal fee heads-up. + * + * Rhino's bridge fee is `flat destination gas + 0.07%`, and gas is flat per + * chain (~$0.01 on L2s, ~$1.50+ on Ethereum mainnet). Because gas is a fixed + * per-chain cost, a small mainnet withdrawal loses a large share to it (a $10 → + * mainnet withdraw is ~15%). We don't block it — the fee is shown honestly and + * the user decides — but we surface a non-blocking heads-up so a tiny mainnet + * withdrawal isn't a silent footgun. L2s and larger amounts stay below the + * threshold and show nothing. + */ + +/** Surface the heads-up when the bridge fee exceeds this share of the amount. */ +export const HIGH_WITHDRAW_FEE_RATIO = 0.05 // 5% + +/** + * True when the bridge fee is a large share of the amount being withdrawn. + * Returns false for no/zero fee (same-chain, sponsored) or a non-positive + * amount (nothing to compare against yet). + */ +export function isWithdrawFeeDisproportionate( + feeUsd: number | undefined, + amountUsd: number, + threshold: number = HIGH_WITHDRAW_FEE_RATIO +): boolean { + if (!feeUsd || feeUsd <= 0) return false + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return false + return feeUsd / amountUsd > threshold +} diff --git a/src/utils/history.utils.ts b/src/utils/history.utils.ts index 3edeca5cf..cf4a51f17 100644 --- a/src/utils/history.utils.ts +++ b/src/utils/history.utils.ts @@ -109,6 +109,10 @@ export interface HistoryEntryExtraData { fulfillmentType?: 'bridge' | 'wallet' bridgeTransferId?: string usdAmount?: string + /** Cross-chain (Rhino) bridge fee in USD the user paid on top of the + * principal — set only for CRYPTO_WITHDRAW that booked a matching FEE + * entry (SDA path). Baked into the displayed amount in the transformer. */ + networkFeeUsd?: number | null haveSentMoneyToUser?: boolean /** Token-transfer block number — `string` from indexer, sometimes `number` * from on-chain webhooks. Treated as a presence signal, not parsed. */