-
Notifications
You must be signed in to change notification settings - Fork 14
feat(badges): fullscreen badge-receipt celebration (TASK-19791) [PARKED] #2297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/card-share-sticker-collage
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| 'use client' | ||
|
|
||
| /** | ||
| * <BadgeReceiptCelebration /> — the fullscreen "badge unlocked!" moment (TASK-19791). | ||
| * | ||
| * Globally mounted (ClientProviders), self-contained: it asks | ||
| * useBadgeReceiptCelebration() whether the signed-in user has a fresh, | ||
| * not-yet-celebrated badge and, if so, takes over the screen with the badge | ||
| * art, celebratory copy, confetti + haptics, and a single Continue button. | ||
| * Continue stamps the per-user localStorage seen-set so the moment fires | ||
| * exactly once. Renders nothing when there's no pending badge. | ||
| * | ||
| * WAITLIST_SKIP is excluded upstream — it keeps its bespoke card-flow | ||
| * celebration (BadgeSkipCelebration). | ||
| */ | ||
|
|
||
| import { useEffect } from 'react' | ||
| import { AnimatePresence, motion } from 'framer-motion' | ||
| import posthog from 'posthog-js' | ||
| import { useHaptic } from 'use-haptic' | ||
| import { Button } from '@/components/0_Bruddle/Button' | ||
| import { getBadgeDisplayName, getBadgeIcon, getPublicBadgeDescription } from '@/components/Badges/badge.utils' | ||
| import { useBadgeReceiptCelebration } from '@/components/Badges/useBadgeReceiptCelebration' | ||
| import { shootDoubleStarConfetti } from '@/utils/confetti' | ||
| import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' | ||
|
|
||
| export default function BadgeReceiptCelebration() { | ||
| const { pending, dismiss } = useBadgeReceiptCelebration() | ||
| const { triggerHaptic } = useHaptic() | ||
| const code = pending?.code | ||
|
|
||
| // Fire confetti + haptic + the shown event once per distinct badge reveal. | ||
| useEffect(() => { | ||
| if (!code) return | ||
| triggerHaptic() | ||
| shootDoubleStarConfetti({ origin: { x: 0.5, y: 0.35 } }) | ||
| posthog.capture(ANALYTICS_EVENTS.BADGE_CELEBRATION_SHOWN, { badge_code: code }) | ||
| }, [code, triggerHaptic]) | ||
|
|
||
| const handleContinue = () => { | ||
| if (code) posthog.capture(ANALYTICS_EVENTS.BADGE_CELEBRATION_DISMISSED, { badge_code: code }) | ||
| dismiss() | ||
| } | ||
|
|
||
| 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> | ||
|
Comment on lines
+45
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| )} | ||
| </AnimatePresence> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import { | ||
| FRESHNESS_WINDOW_MS, | ||
| celebrationStorageKey, | ||
| isFresh, | ||
| loadSeenCodes, | ||
| persistSeenCodes, | ||
| pickCelebrationBadge, | ||
| type CelebrationBadge, | ||
| } from '@/components/Badges/badgeCelebration.utils' | ||
|
|
||
| const NOW = 1_700_000_000_000 // fixed reference time | ||
| const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString() | ||
|
|
||
| function badge(over: Partial<CelebrationBadge> & { code: string; earnedAt: string | Date }): CelebrationBadge { | ||
| return { name: over.code, description: null, ...over } | ||
| } | ||
|
|
||
| describe('badgeCelebration.utils', () => { | ||
| describe('isFresh', () => { | ||
| it('true for a badge earned just now', () => { | ||
| expect(isFresh(iso(0), NOW)).toBe(true) | ||
| }) | ||
| it('true just inside the window', () => { | ||
| expect(isFresh(iso(FRESHNESS_WINDOW_MS - 1000), NOW)).toBe(true) | ||
| }) | ||
| it('false just past the window', () => { | ||
| expect(isFresh(iso(FRESHNESS_WINDOW_MS + 1000), NOW)).toBe(false) | ||
| }) | ||
| it('true for a future timestamp (clock skew)', () => { | ||
| expect(isFresh(new Date(NOW + 60_000).toISOString(), NOW)).toBe(true) | ||
| }) | ||
| it('false for an invalid date', () => { | ||
| expect(isFresh('not-a-date', NOW)).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('pickCelebrationBadge', () => { | ||
| it('returns null for empty/undefined input', () => { | ||
| expect(pickCelebrationBadge(undefined, new Set(), NOW)).toBeNull() | ||
| expect(pickCelebrationBadge([], new Set(), NOW)).toBeNull() | ||
| }) | ||
| it('picks the newest fresh, unseen, visible badge', () => { | ||
| const badges = [badge({ code: 'OLDER', earnedAt: iso(1000) }), badge({ code: 'NEWER', earnedAt: iso(100) })] | ||
| expect(pickCelebrationBadge(badges, new Set(), NOW)?.code).toBe('NEWER') | ||
| }) | ||
| it('skips WAITLIST_SKIP (it has its own card-flow celebration)', () => { | ||
| const badges = [badge({ code: 'WAITLIST_SKIP', earnedAt: iso(0) })] | ||
| expect(pickCelebrationBadge(badges, new Set(), NOW)).toBeNull() | ||
| }) | ||
| it('skips already-seen codes', () => { | ||
| const badges = [badge({ code: 'OG_2025_10_12', earnedAt: iso(0) })] | ||
| expect(pickCelebrationBadge(badges, new Set(['OG_2025_10_12']), NOW)).toBeNull() | ||
| }) | ||
| it('skips stale badges (outside the window)', () => { | ||
| const badges = [badge({ code: 'OG_2025_10_12', earnedAt: iso(FRESHNESS_WINDOW_MS + 1) })] | ||
| expect(pickCelebrationBadge(badges, new Set(), NOW)).toBeNull() | ||
| }) | ||
| it('skips invisible badges', () => { | ||
| const badges = [badge({ code: 'HIDDEN', earnedAt: iso(0), isVisible: false })] | ||
| expect(pickCelebrationBadge(badges, new Set(), NOW)).toBeNull() | ||
| }) | ||
| it('falls through to an older fresh badge when the newest is already seen', () => { | ||
| const badges = [badge({ code: 'NEWER', earnedAt: iso(100) }), badge({ code: 'OLDER', earnedAt: iso(1000) })] | ||
| expect(pickCelebrationBadge(badges, new Set(['NEWER']), NOW)?.code).toBe('OLDER') | ||
| }) | ||
| }) | ||
|
|
||
| describe('seen-set persistence (per user)', () => { | ||
| beforeEach(() => window.localStorage.clear()) | ||
|
|
||
| it('round-trips codes under a per-user key', () => { | ||
| persistSeenCodes('user-a', new Set(['OG_2025_10_12', 'BETA_TESTER'])) | ||
| expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('OG_2025_10_12') | ||
| expect(loadSeenCodes('user-a')).toEqual(new Set(['OG_2025_10_12', 'BETA_TESTER'])) | ||
| }) | ||
| it('isolates users on the same device', () => { | ||
| persistSeenCodes('user-a', new Set(['OG_2025_10_12'])) | ||
| expect(loadSeenCodes('user-b')).toEqual(new Set()) | ||
| }) | ||
| it('returns an empty set on corrupt JSON', () => { | ||
| window.localStorage.setItem(celebrationStorageKey('user-a'), '{not json') | ||
| expect(loadSeenCodes('user-a')).toEqual(new Set()) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { renderHook, act } from '@testing-library/react' | ||
| import { useUserStore } from '@/redux/hooks' | ||
| import { useBadgeReceiptCelebration } from '@/components/Badges/useBadgeReceiptCelebration' | ||
| import { celebrationStorageKey } from '@/components/Badges/badgeCelebration.utils' | ||
|
|
||
| jest.mock('@/redux/hooks', () => ({ useUserStore: jest.fn() })) | ||
| const mockUseUserStore = useUserStore as jest.Mock | ||
|
|
||
| type TestBadge = { code: string; name: string; description: string | null; earnedAt: string; isVisible?: boolean } | ||
| const freshIso = () => new Date().toISOString() | ||
|
|
||
| function setUser(userId: string | undefined, badges: TestBadge[]): void { | ||
| mockUseUserStore.mockReturnValue({ user: userId ? { user: { userId, badges } } : null }) | ||
| } | ||
|
|
||
| describe('useBadgeReceiptCelebration', () => { | ||
| beforeEach(() => { | ||
| window.localStorage.clear() | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| it('returns null pending when signed out', () => { | ||
| setUser(undefined, []) | ||
| const { result } = renderHook(() => useBadgeReceiptCelebration()) | ||
| expect(result.current.pending).toBeNull() | ||
| }) | ||
|
|
||
| it('surfaces a freshly-earned badge', () => { | ||
| setUser('user-a', [{ code: 'OG_2025_10_12', name: 'OG', description: null, earnedAt: freshIso() }]) | ||
| const { result } = renderHook(() => useBadgeReceiptCelebration()) | ||
| expect(result.current.pending?.code).toBe('OG_2025_10_12') | ||
| }) | ||
|
|
||
| it('dismiss marks the badge seen (persisted) and clears pending', () => { | ||
| setUser('user-a', [{ code: 'OG_2025_10_12', name: 'OG', description: null, earnedAt: freshIso() }]) | ||
| const { result } = renderHook(() => useBadgeReceiptCelebration()) | ||
| act(() => result.current.dismiss()) | ||
| expect(result.current.pending).toBeNull() | ||
| expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('OG_2025_10_12') | ||
| }) | ||
|
|
||
| it('does not resurface a badge already in the seen-set', () => { | ||
| window.localStorage.setItem(celebrationStorageKey('user-a'), JSON.stringify(['OG_2025_10_12'])) | ||
| setUser('user-a', [{ code: 'OG_2025_10_12', name: 'OG', description: null, earnedAt: freshIso() }]) | ||
| const { result } = renderHook(() => useBadgeReceiptCelebration()) | ||
| expect(result.current.pending).toBeNull() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| // Pure helpers for the fullscreen badge-receipt celebration (TASK-19791). | ||
| // | ||
| // "Celebrate once, while fresh." A badge fires the fullscreen celebration on | ||
| // the next /users/me refetch iff it was earned within FRESHNESS_WINDOW_MS and | ||
| // hasn't been celebrated yet on this device (a per-user localStorage seen-set). | ||
| // | ||
| // Why localStorage + a freshness window instead of a backend `celebratedAt`: | ||
| // it mirrors the existing one-time-celebration gate (card/page.tsx's | ||
| // `card_skip_celebration_seen_v2`, per-device by design). The window is what | ||
| // makes that safe — a badge earned long ago is never "fresh", so it never | ||
| // retro-fires on a new device or on the day the feature ships. That removes | ||
| // the need for a DB column, a mark-celebrated endpoint, and a deploy-day | ||
| // backfill. Trade-off: a badge earned in the last week can re-celebrate on a | ||
| // second device within that week — recent + positive, not spam. | ||
|
|
||
| export type CelebrationBadge = { | ||
| code: string | ||
| name: string | ||
| description: string | null | ||
| earnedAt: string | Date | ||
| isVisible?: boolean | ||
| } | ||
|
|
||
| // 7 days: generous enough that nearly everyone opens the app within a week of | ||
| // earning (covers badges granted by async webhooks, e.g. KYC/event claims), | ||
| // short enough that an old badge never re-celebrates on a new device. | ||
| export const FRESHNESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 | ||
|
|
||
| // WAITLIST_SKIP keeps its bespoke card-flow celebration (BadgeSkipCelebration), | ||
| // so it's excluded here to avoid a double-celebration. Other card-access | ||
| // "skip" badges (OG/Devconnect/Arbiverse, …) are server-driven and historical | ||
| // — the freshness window already keeps them from double-firing. The only | ||
| // residual overlap is a card-access badge minted within the last 7 days; that | ||
| // edge is left for QA to rule on (extend this set if it ever annoys). | ||
| const EXCLUDED_CODES = new Set<string>(['WAITLIST_SKIP']) | ||
|
|
||
| const STORAGE_PREFIX = 'badge_celebration_seen' | ||
|
|
||
| // Per-user key so a shared browser doesn't leak one account's seen-set onto | ||
| // another (the skip-celebration precedent is global; this is one better). | ||
| export function celebrationStorageKey(userId: string): string { | ||
| return `${STORAGE_PREFIX}:${userId}` | ||
| } | ||
|
|
||
| export function loadSeenCodes(userId: string): Set<string> { | ||
| if (typeof window === 'undefined') return new Set() | ||
| try { | ||
| const raw = window.localStorage.getItem(celebrationStorageKey(userId)) | ||
| if (!raw) return new Set() | ||
| const parsed: unknown = JSON.parse(raw) | ||
| if (!Array.isArray(parsed)) return new Set() | ||
| return new Set(parsed.filter((c): c is string => typeof c === 'string')) | ||
| } catch { | ||
| return new Set() | ||
| } | ||
| } | ||
|
|
||
| export function persistSeenCodes(userId: string, codes: ReadonlySet<string>): void { | ||
| if (typeof window === 'undefined') return | ||
| try { | ||
| window.localStorage.setItem(celebrationStorageKey(userId), JSON.stringify([...codes])) | ||
| } catch { | ||
| // localStorage can throw (private mode / quota). A missed write just | ||
| // means the celebration may re-show — never block the UI on it. | ||
| } | ||
| } | ||
|
|
||
| // Fresh = earned within the last window (future timestamps from clock skew | ||
| // count as fresh too; only genuinely-old badges are excluded). | ||
| export function isFresh(earnedAt: string | Date, now: number): boolean { | ||
| const earned = new Date(earnedAt).getTime() | ||
| if (!Number.isFinite(earned)) return false | ||
| return earned >= now - FRESHNESS_WINDOW_MS | ||
| } | ||
|
|
||
| // The newest visible, fresh, not-yet-celebrated badge to celebrate (or null). | ||
| // Returns one at a time; the next surfaces on the following render once this | ||
| // one is marked seen — a natural queue with no extra state. | ||
| export function pickCelebrationBadge( | ||
| badges: readonly CelebrationBadge[] | undefined, | ||
| seen: ReadonlySet<string>, | ||
| now: number | ||
| ): CelebrationBadge | null { | ||
| if (!badges?.length) return null | ||
| return ( | ||
| badges | ||
| .filter((b) => b.isVisible !== false) | ||
| .filter((b) => !EXCLUDED_CODES.has(b.code)) | ||
| .filter((b) => !seen.has(b.code)) | ||
| .filter((b) => isFresh(b.earnedAt, now)) | ||
| .sort((a, b) => new Date(b.earnedAt).getTime() - new Date(a.earnedAt).getTime())[0] ?? null | ||
| ) | ||
| } |
There was a problem hiding this comment.
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
ClientProvidersmeans any fresh badge can interrupt those routes as soon as/users/merefetches. Please keep this behind a feature flag or route gate before merge.🤖 Prompt for AI Agents