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
+ 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 (
+
- ⚠️ 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 (
-
-
-
{/* 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 (
+
- )}
-
- {/* ─── 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. ─── */}
-)
-
-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 (