Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7d295fc
feat(card): redesign launch share asset as a sticker collage
0xkkonrad Jun 23, 2026
abc0faf
fix(card): match share-asset keep-outs to rendered hero + pill
0xkkonrad Jun 23, 2026
8cd3cb3
feat(card): Berghain-style "not tonight" rejection on the waitlist path
0xkkonrad Jun 23, 2026
444f480
chore(card): silence styled-jsx eslint error on rejection asset
0xkkonrad Jun 23, 2026
3728bb1
fix(card): don't drop the appeal silently when capture/share fails
0xkkonrad Jun 23, 2026
57ab76f
feat(card): force-directed sticker layout so badges stop overlapping
0xkkonrad Jun 24, 2026
1aaf95e
fix(card): restore keep-out fixes on force-directed layout + kill cor…
0xkkonrad Jun 24, 2026
57b13c4
feat(card): "hide username" anti-dox toggle on the win share asset
0xkkonrad Jun 24, 2026
693d26f
fix(card): hide-username toggle now restores the pill instantly
0xkkonrad Jun 24, 2026
1e295d6
feat(card): rotate the win-share caption from a 16-line pool
0xkkonrad Jun 24, 2026
49e51e8
chore(card): straight apostrophes in win captions for consistency
0xkkonrad Jun 24, 2026
337a5e7
fix(card): bake @joinpeanut handle into rejection asset image
0xkkonrad Jun 24, 2026
2c1bb95
chore(card): address CodeRabbit nitpicks
0xkkonrad Jun 24, 2026
aff35ea
chore: drop accidentally-committed scratch state log (local/ not giti…
0xkkonrad Jun 24, 2026
e2332cb
feat(card): appeal also joins the waitlist (manual-grant queue, no au…
Hugo0 Jun 24, 2026
cc3995a
feat(card): tag @joinpeanut in every win caption; drop baked-in image…
0xkkonrad Jun 25, 2026
5afc214
feat(card): tweak win + rejection share captions
0xkkonrad Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
// <CardWaitlistJoinedScreen /> cooldown.
if (cardInfo!.waitlistJoinedAt) {
return <CardWaitlistJoinedScreen onPrev={onBack} />
}
return <CardWaitlistScreen cardInfo={cardInfo!} onPrev={onBack} onJoined={refetchCardInfo} />
return (
<CardRejectionScreen
username={user?.user?.username ?? undefined}
onPrev={onBack}
onJoined={refetchCardInfo}
/>
)
}
case 'waitlist-skip-celebration': {
// Pick the freshest skip badge for the celebration headline.
Expand Down
176 changes: 176 additions & 0 deletions src/app/(mobile-ui)/dev/rejection-builder/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
'use client'

/**
* /dev/rejection-builder — iterator for the full mobile rejection screen
* (CardRejectionScreen): the "not tonight, <username>" 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<RejectionMascot>('cool')
const [applicants, setApplicants] = useState(213)
const [admitted, setAdmitted] = useState(7)

return (
<div className="flex min-h-screen flex-col">
<NavHeader title="Rejection screen builder" />

<div className="flex flex-1 flex-col gap-8 px-6 py-6 lg:flex-row">
{/* ─── LEFT: Controls ──────────────────────────────────── */}
<aside className="flex flex-col gap-6 lg:w-[360px] lg:flex-shrink-0">
<Section title="Identity">
<Field label={`Username (${username.length})`}>
<input
type="text"
value={username}
maxLength={20}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
className="custom-input"
placeholder="kkonrad"
/>
</Field>
<div className="flex flex-wrap gap-2">
<PresetButton onClick={() => setUsername('me')}>2-char</PresetButton>
<PresetButton onClick={() => setUsername('kkonrad')}>kkonrad</PresetButton>
<PresetButton onClick={() => setUsername('thisistwentyplus_chars')}>20+ chars</PresetButton>
</div>
</Section>

<Section title="Bouncer mascot (asset, left side)">
<div className="flex flex-wrap gap-2">
{MASCOTS.map(([key, label]) => (
<button
key={key}
onClick={() => setMascot(key)}
className={`rounded-full border-2 border-black px-3 py-1 text-xs font-bold transition-colors ${
mascot === key
? 'bg-primary-1 text-n-1'
: 'bg-white text-grey-1 hover:bg-grey-2'
}`}
>
{label}
</button>
))}
</div>
<p className="text-[11px] leading-snug text-grey-1">
No dedicated “laughing” peanut exists yet — these are the closest mocking/cool poses. Say
the word and I’ll generate a true laughing one via the badges pipeline.
</p>
</Section>

<Section title="Door tally (screen copy, not on asset)">
<Field label={`Applicants tonight (${applicants})`}>
<input
type="range"
min={20}
max={5000}
step={1}
value={applicants}
onChange={(e) => setApplicants(Number(e.target.value))}
className="w-full"
/>
</Field>
<Field label={`Admitted (${admitted})`}>
<input
type="range"
min={1}
max={50}
step={1}
value={admitted}
onChange={(e) => setAdmitted(Number(e.target.value))}
className="w-full"
/>
</Field>
<div className="flex flex-wrap gap-2">
<PresetButton
onClick={() => {
setApplicants(213)
setAdmitted(7)
}}
>
213 / 7
</PresetButton>
<PresetButton
onClick={() => {
setApplicants(1842)
setAdmitted(11)
}}
>
1842 / 11
</PresetButton>
</div>
</Section>
</aside>

{/* ─── RIGHT: Phone-frame preview of the whole screen ──── */}
<main className="flex flex-1 flex-col items-center gap-4">
<div className="self-stretch rounded-sm border border-n-1 bg-grey-3 p-2 text-center font-mono text-xs text-grey-1">
mobile screen · CardRejectionScreen
</div>
<div
className="shadow-4 w-full max-w-[392px] overflow-hidden rounded-[28px] border-2 border-n-1 bg-white"
style={{ height: 800 }}
>
<div className="flex h-full flex-col px-5 py-4" style={{ minHeight: 740 }}>
<CardRejectionScreen
username={username || 'anon'}
mascot={mascot}
applicants={applicants}
admitted={admitted}
onJoined={() => alert('→ joined: would advance to the friendly waitlist-joined screen')}
/>
</div>
</div>
</main>
</div>
</div>
)
}

// ─── Small UI primitives ────────────────────────────────────────────────

function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="shadow-4 rounded-sm border-2 border-n-1 bg-white p-4">
<h2 className="mb-3 text-xs font-extrabold uppercase tracking-wider text-grey-1">{title}</h2>
<div className="flex flex-col gap-3">{children}</div>
</section>
)
}

function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-bold text-grey-1">{label}</span>
{children}
</label>
)
}

function PresetButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className="rounded-full border border-n-1 bg-white px-2 py-1 text-xs font-bold transition-colors hover:bg-grey-2"
>
{children}
</button>
)
}
Loading
Loading