From beb96d0ff5f938b1438b683ba76420872266f31e Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 26 Jun 2026 18:47:11 -0700 Subject: [PATCH] feat(badges): fullscreen badge-receipt celebration (TASK-19791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/ClientProviders.tsx | 5 + .../Badges/BadgeReceiptCelebration.tsx | 100 ++++++++++++++++++ .../__tests__/badgeCelebration.utils.test.ts | 85 +++++++++++++++ .../useBadgeReceiptCelebration.test.ts | 48 +++++++++ .../Badges/badgeCelebration.utils.ts | 93 ++++++++++++++++ .../Badges/useBadgeReceiptCelebration.ts | 54 ++++++++++ src/constants/analytics.consts.ts | 4 + 7 files changed, 389 insertions(+) create mode 100644 src/components/Badges/BadgeReceiptCelebration.tsx create mode 100644 src/components/Badges/__tests__/badgeCelebration.utils.test.ts create mode 100644 src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts create mode 100644 src/components/Badges/badgeCelebration.utils.ts create mode 100644 src/components/Badges/useBadgeReceiptCelebration.ts diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 207e3a00e..c1804e8be 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -8,6 +8,7 @@ */ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' +import BadgeReceiptCelebration from '@/components/Badges/BadgeReceiptCelebration' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { PeanutProvider } from '@/config' @@ -45,6 +46,10 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { explainer also covers public pay/send/request pages — the rain:cooldown event fires on every spend path. */} + {/* 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. */} + {HarnessBootstrap && ( diff --git a/src/components/Badges/BadgeReceiptCelebration.tsx b/src/components/Badges/BadgeReceiptCelebration.tsx new file mode 100644 index 000000000..4c280bc92 --- /dev/null +++ b/src/components/Badges/BadgeReceiptCelebration.tsx @@ -0,0 +1,100 @@ +'use client' + +/** + * — 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 ( + + {pending && ( + +
+ + +

Badge unlocked!

+

+ {getBadgeDisplayName(pending.code, pending.name)} +

+ {(pending.description || getPublicBadgeDescription(pending.code)) && ( +

+ {pending.description || getPublicBadgeDescription(pending.code)} +

+ )} +
+
+ + + + +
+ )} +
+ ) +} diff --git a/src/components/Badges/__tests__/badgeCelebration.utils.test.ts b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts new file mode 100644 index 000000000..3fbb9fadb --- /dev/null +++ b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts @@ -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 & { 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()) + }) + }) +}) diff --git a/src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts b/src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts new file mode 100644 index 000000000..dcfad3a29 --- /dev/null +++ b/src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts @@ -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() + }) +}) diff --git a/src/components/Badges/badgeCelebration.utils.ts b/src/components/Badges/badgeCelebration.utils.ts new file mode 100644 index 000000000..fb3e2bc58 --- /dev/null +++ b/src/components/Badges/badgeCelebration.utils.ts @@ -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(['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 { + 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): 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, + 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 + ) +} diff --git a/src/components/Badges/useBadgeReceiptCelebration.ts b/src/components/Badges/useBadgeReceiptCelebration.ts new file mode 100644 index 000000000..6d7f8f8f6 --- /dev/null +++ b/src/components/Badges/useBadgeReceiptCelebration.ts @@ -0,0 +1,54 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useUserStore } from '@/redux/hooks' +import { + loadSeenCodes, + persistSeenCodes, + pickCelebrationBadge, + type CelebrationBadge, +} from '@/components/Badges/badgeCelebration.utils' + +type UseBadgeReceiptCelebration = { + /** The badge whose fullscreen celebration should show now, or null. */ + pending: CelebrationBadge | null + /** Mark the current pending badge celebrated (stamps the per-user seen-set). */ + dismiss: () => void +} + +/** + * Detects a freshly-earned, not-yet-celebrated badge from the signed-in user's + * badge list (sourced from /users/me via the redux user store) and exposes it + * for a one-time fullscreen celebration. Persistence is a per-user localStorage + * seen-set + a freshness window — see badgeCelebration.utils.ts for the why. + */ +export function useBadgeReceiptCelebration(): UseBadgeReceiptCelebration { + const { user } = useUserStore() + const userId = user?.user?.userId + const badges = user?.user?.badges + + // Hydrated from localStorage; re-hydrated when the signed-in user changes + // (logout → a different account on the same device). + const [seen, setSeen] = useState>(() => (userId ? loadSeenCodes(userId) : new Set())) + useEffect(() => { + setSeen(userId ? loadSeenCodes(userId) : new Set()) + }, [userId]) + + const pending = useMemo(() => { + if (!userId) return null + return pickCelebrationBadge(badges, seen, Date.now()) + }, [userId, badges, seen]) + + const dismiss = useCallback(() => { + if (!userId || !pending) return + const code = pending.code + setSeen((prev) => { + const next = new Set(prev) + next.add(code) + persistSeenCodes(userId, next) + return next + }) + }, [userId, pending]) + + return { pending, dismiss } +} diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 0c83fc6a5..2f1f7aea1 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -165,6 +165,10 @@ export const ANALYTICS_EVENTS = { // rejection). Lets the funnel distinguish "users not tapping share" // from "users tapping share but it silently fails". CARD_SHARE_ASSET_FAILED: 'card_share_asset_failed', + // Fullscreen badge-receipt celebration (TASK-19791) — fires once per + // freshly-earned badge, globally, separate from the card skip flow. + BADGE_CELEBRATION_SHOWN: 'badge_celebration_shown', + BADGE_CELEBRATION_DISMISSED: 'badge_celebration_dismissed', // Admin wave release (BE event, mirrored here so FE doesn't accidentally // step on the namespace). CARD_WAITLIST_RELEASED: 'card_waitlist_released',