Skip to content

feat(badges): fullscreen badge-receipt celebration (TASK-19791) [PARKED]#2297

Draft
Hugo0 wants to merge 1 commit into
feat/card-share-sticker-collagefrom
feat/badge-fullscreen-celebration
Draft

feat(badges): fullscreen badge-receipt celebration (TASK-19791) [PARKED]#2297
Hugo0 wants to merge 1 commit into
feat/card-share-sticker-collagefrom
feat/badge-fullscreen-celebration

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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/me refetch for any freshly-earned visible badge except WAITLIST_SKIP. FE-only: per-user localStorage seen-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 /shhhhh card 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 shared BadgeDetailModal. 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-collage so the diff is just the badge commit; GitHub retargets to dev when #2274 merges.

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.
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jun 29, 2026 4:42am

Request Review

@notion-workspace

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds 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 ClientProviders.

Badge Receipt Celebration

Layer / File(s) Summary
Celebration utility types, freshness, and seen-set persistence
src/components/Badges/badgeCelebration.utils.ts, src/components/Badges/__tests__/badgeCelebration.utils.test.ts
Defines CelebrationBadge type, FRESHNESS_WINDOW_MS (7 days), EXCLUDED_CODES, and helpers: celebrationStorageKey, loadSeenCodes, persistSeenCodes, isFresh, and pickCelebrationBadge. Tests cover freshness edge cases, badge selection rules, per-user localStorage isolation, and corrupt-JSON fallback.
useBadgeReceiptCelebration hook
src/components/Badges/useBadgeReceiptCelebration.ts, src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts
Derives userId and badges from useUserStore, hydrates the seen set from localStorage on user change, computes pending via pickCelebrationBadge, and exposes dismiss to persist the code and clear state. Tests cover signed-out state, fresh badge surfacing, dismiss persistence, and suppression of already-seen badges.
BadgeReceiptCelebration component, analytics events, and global mount
src/constants/analytics.consts.ts, src/components/Badges/BadgeReceiptCelebration.tsx, src/app/ClientProviders.tsx
Adds BADGE_CELEBRATION_SHOWN and BADGE_CELEBRATION_DISMISSED analytics events. Implements the animated fullscreen dialog with haptics, double-star confetti, PostHog tracking, badge imagery/description, and a Continue button wired to dismiss. Mounts the component globally inside ClientProviders.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • peanutprotocol/peanut-ui#2024: Extends badge description/icon maps for SUPPORT_SURVIVOR, which BadgeReceiptCelebration can render via getPublicBadgeDescription.
  • peanutprotocol/peanut-ui#2086: Refactors getPublicBadgeDescription and related badge display utilities that BadgeReceiptCelebration calls directly.

Suggested labels

enhancement

Suggested reviewers

  • abalinda
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a fullscreen badge-receipt celebration feature for badges.
Description check ✅ Passed The description matches the implemented badge celebration flow and notes why the draft is parked, so it is related.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5afc214 and beb96d0.

📒 Files selected for processing (7)
  • src/app/ClientProviders.tsx
  • src/components/Badges/BadgeReceiptCelebration.tsx
  • src/components/Badges/__tests__/badgeCelebration.utils.test.ts
  • src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts
  • src/components/Badges/badgeCelebration.utils.ts
  • src/components/Badges/useBadgeReceiptCelebration.ts
  • src/constants/analytics.consts.ts

Comment on lines +49 to +52
{/* 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 />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +45 to +96
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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@Hugo0

Hugo0 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Update: the non-blocking badge-earn toast (the fast-follow noted above) is now implemented + tested in #2292 (commit 2c7456c). This PR (fullscreen) stays parked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant