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/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/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/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/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/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; -}