feat(badges): fullscreen badge-receipt celebration (TASK-19791) [PARKED]#2297
feat(badges): fullscreen badge-receipt celebration (TASK-19791) [PARKED]#2297Hugo0 wants to merge 1 commit into
Conversation
Earning a badge was too subtle — it only surfaced passively in the profile/activity feed, so the emotional payoff was easy to miss. Add a one-time fullscreen "badge unlocked!" moment that takes over the screen with the badge art, celebratory copy, confetti + haptics, and a single Continue. It fires on the next /users/me refetch for any freshly-earned visible badge — except WAITLIST_SKIP, which keeps its bespoke card-flow celebration (BadgeSkipCelebration). "Fire once, while fresh": gated by a per-user localStorage seen-set plus a 7-day freshness window — the same house pattern as the card skip celebration (card/page.tsx). The window is what makes localStorage safe: an old badge is never "fresh", so it can't retro-fire on a new device or on the day this ships. That removes any need for a backend celebratedAt column, a mark-celebrated endpoint, or a deploy-day backfill — the whole feature is frontend-only. Globally mounted via ClientProviders so it covers whatever route the user lands on after earning. Reuses getBadgeIcon, confetti, and the BadgeSkipCelebration choreography.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a fullscreen badge-receipt celebration flow. New pure utilities handle freshness checks and per-user localStorage seen-set persistence. A new hook computes a pending badge from the user's earned badges. A new animated dialog component renders the celebration with haptics, confetti, and PostHog analytics. The component is globally mounted in Badge Receipt Celebration
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/ClientProviders.tsx`:
- Around line 49-52: The globally mounted BadgeReceiptCelebration in
ClientProviders should not render unconditionally because it can interrupt
onboarding and card-registration flows. Update the ClientProviders integration
to wrap BadgeReceiptCelebration in a release gate, such as an existing feature
flag or a route-based condition, so it only appears when explicitly allowed and
does not fire on sensitive routes after the /users/me refetch.
In `@src/components/Badges/BadgeReceiptCelebration.tsx`:
- Around line 45-96: The BadgeReceiptCelebration modal currently renders a
fullscreen dialog without moving focus into it or trapping focus, so keyboard
users can tab to background content. Update BadgeReceiptCelebration to use the
app’s accessible modal/dialog primitive if available, or add focus management
directly: move initial focus to the dialog or Continue button when pending
opens, trap tab focus within the overlay, and restore focus to the previously
focused element when it closes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 594f4b8a-3e63-425b-abd4-7b30edf84eee
📒 Files selected for processing (7)
src/app/ClientProviders.tsxsrc/components/Badges/BadgeReceiptCelebration.tsxsrc/components/Badges/__tests__/badgeCelebration.utils.test.tssrc/components/Badges/__tests__/useBadgeReceiptCelebration.test.tssrc/components/Badges/badgeCelebration.utils.tssrc/components/Badges/useBadgeReceiptCelebration.tssrc/constants/analytics.consts.ts
| {/* Fullscreen "badge unlocked!" moment — fires once per | ||
| freshly-earned badge (TASK-19791). Globally mounted so it | ||
| covers whatever route the user lands on after earning. */} | ||
| <BadgeReceiptCelebration /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Don’t mount this globally without a release gate.
The PR objective already calls out that this fullscreen takeover collides with onboarding and card-registration flows. Rendering it unconditionally in ClientProviders means any fresh badge can interrupt those routes as soon as /users/me refetches. Please keep this behind a feature flag or route gate before merge.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/ClientProviders.tsx` around lines 49 - 52, The globally mounted
BadgeReceiptCelebration in ClientProviders should not render unconditionally
because it can interrupt onboarding and card-registration flows. Update the
ClientProviders integration to wrap BadgeReceiptCelebration in a release gate,
such as an existing feature flag or a route-based condition, so it only appears
when explicitly allowed and does not fire on sensitive routes after the
/users/me refetch.
| return ( | ||
| <AnimatePresence> | ||
| {pending && ( | ||
| <motion.div | ||
| key={pending.code} | ||
| className="fixed inset-0 z-[60] flex flex-col items-center justify-between gap-8 bg-primary-3 p-6" | ||
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| transition={{ duration: 0.25 }} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-label="Badge unlocked" | ||
| > | ||
| <div className="my-auto flex flex-col items-center gap-6 text-center"> | ||
| <motion.img | ||
| src={getBadgeIcon(pending.code)} | ||
| alt={getBadgeDisplayName(pending.code, pending.name)} | ||
| className="h-40 w-40 object-contain drop-shadow-[0.25rem_0.25rem_0_#000]" | ||
| initial={{ scale: 0.5, rotate: -8, opacity: 0 }} | ||
| animate={{ scale: 1, rotate: 0, opacity: 1 }} | ||
| transition={{ type: 'spring', stiffness: 220, damping: 14, delay: 0.05 }} | ||
| /> | ||
| <motion.div | ||
| className="flex flex-col gap-2" | ||
| initial={{ opacity: 0, y: 10 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.3, delay: 0.2 }} | ||
| > | ||
| <p className="text-sm font-bold uppercase tracking-wide text-n-1">Badge unlocked!</p> | ||
| <h1 className="text-3xl font-extrabold text-n-1"> | ||
| {getBadgeDisplayName(pending.code, pending.name)} | ||
| </h1> | ||
| {(pending.description || getPublicBadgeDescription(pending.code)) && ( | ||
| <p className="text-grey-1"> | ||
| {pending.description || getPublicBadgeDescription(pending.code)} | ||
| </p> | ||
| )} | ||
| </motion.div> | ||
| </div> | ||
|
|
||
| <motion.div | ||
| className="w-full max-w-md" | ||
| initial={{ opacity: 0, y: 12 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.3, delay: 0.35 }} | ||
| > | ||
| <Button onClick={handleContinue} variant="purple" shadowSize="4" className="w-full"> | ||
| Continue | ||
| </Button> | ||
| </motion.div> | ||
| </motion.div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Move focus into the dialog and keep it there.
This renders as a modal, but nothing shifts focus to it or prevents tabbing into the page behind it. Keyboard and screen-reader users can remain on background controls while the fullscreen takeover is open. Please use the app’s accessible modal primitive here, or add initial focus plus focus trapping/restoration before shipping.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/Badges/BadgeReceiptCelebration.tsx` around lines 45 - 96, The
BadgeReceiptCelebration modal currently renders a fullscreen dialog without
moving focus into it or trapping focus, so keyboard users can tab to background
content. Update BadgeReceiptCelebration to use the app’s accessible modal/dialog
primitive if available, or add focus management directly: move initial focus to
the dialog or Continue button when pending opens, trap tab focus within the
overlay, and restore focus to the previously focused element when it closes.
Status: PARKED (draft) — do NOT merge for the card launch. Tracked so the work isn't lost; see #2292 for the direction we're shipping instead.
What this is
TASK-19791 — a one-time fullscreen "badge unlocked!" moment (badge art + confetti + haptics + Continue), fired on the next
/users/merefetch for any freshly-earned visible badge exceptWAITLIST_SKIP. FE-only: per-userlocalStorageseen-set + a 7-day freshness window (no backend, no migration). Done + tested — 19 unit tests, full suite green.Why it's parked
Fullscreen-every-badge collides with onboarding. Every badge that fires at/around launch is incidental:
BETA_TESTER(signup),SHHHHH(everyone getting the card),EVENT_ALUMNI(event attendees),NOT_SO_SHHHH(influencers). A user going through/shhhhhcard registration would get 2–3 fullscreen takeovers stacked — "wtf, I wanted my card." A fullscreen moment only earns its interruption for badges from deliberate achievement (Card Pioneer after real spend, invite milestones) — none of which fire at launch.What we're shipping instead
Non-interruptive: the history item is already tappable (
BadgeStatusItem→ drawer), and #2292 adds the improved sharedBadgeDetailModal. That covers TASK-19791's "too subtle" intent without the collision. (A non-blocking earn toast is under consideration as a fast-follow.)Reviving later
For a genuine achievement badge that warrants a moment: rebase onto
dev, gate the trigger to an allowlist of achievement codes (exclude all onboarding/event badges), then re-open for review.Stacked on #2274 — base is
feat/card-share-sticker-collageso the diff is just the badge commit; GitHub retargets todevwhen #2274 merges.