Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions src/app/ClientProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */}
<RainCooldownIntroModal />
{/* 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 />
Comment on lines +49 to +52

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.

{HarnessBootstrap && (
<Suspense fallback={null}>
<HarnessBootstrap />
Expand Down
100 changes: 100 additions & 0 deletions src/components/Badges/BadgeReceiptCelebration.tsx
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

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.

)}
</AnimatePresence>
)
}
85 changes: 85 additions & 0 deletions src/components/Badges/__tests__/badgeCelebration.utils.test.ts
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())
})
})
})
48 changes: 48 additions & 0 deletions src/components/Badges/__tests__/useBadgeReceiptCelebration.test.ts
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()
})
})
93 changes: 93 additions & 0 deletions src/components/Badges/badgeCelebration.utils.ts
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
)
}
Loading
Loading