- ⚠️ Username > 12 chars · production caps at 12. The asset shrinks the @username pill - defensively, but check the input gate in your caller. -
- )}
@@ -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}
/>
- Instead, you can join the waitlist. We let in a few people every week. -
-