diff --git a/public/badges/card_alpha.svg b/public/badges/card_alpha.svg new file mode 100644 index 000000000..902f99cdd --- /dev/null +++ b/public/badges/card_alpha.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/badges/festa_junina_2026.svg b/public/badges/festa_junina_2026.svg new file mode 100644 index 000000000..02148a92c --- /dev/null +++ b/public/badges/festa_junina_2026.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 70c931d76..d749e8a0e 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -989,14 +989,12 @@ describe('GROUP 1: Landing / Method Selection', () => { expect(screen.getByText('Select your country')).toBeInTheDocument() }) - test('selecting a country (already in bank flow) navigates straight to the bank step', () => { + test('selecting a country from list navigates to country page', () => { resetQueryState({ method: 'bank' }) renderWithProviders() fireEvent.click(screen.getByTestId('country-argentina')) - // Method was already chosen ('bank'), so skip the redundant per-country - // method picker and go straight to the bank step. - expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina/bank') + expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina') }) test('back from method selection navigates to /home', () => { diff --git a/src/app/(mobile-ui)/add-money/page.tsx b/src/app/(mobile-ui)/add-money/page.tsx index 656c5c56e..495b65140 100644 --- a/src/app/(mobile-ui)/add-money/page.tsx +++ b/src/app/(mobile-ui)/add-money/page.tsx @@ -17,7 +17,7 @@ import { useQueryState, parseAsStringEnum } from 'nuqs' import { checkIfInternalNavigation, getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { addMoneyBankUrl } from '@/utils/native-routes' +import { addMoneyCountryUrl } from '@/utils/native-routes' export default function AddMoneyPage() { const router = useRouter() @@ -68,10 +68,7 @@ export default function AddMoneyPage() { method_type: 'bank', country: country.path, }) - // User already chose Bank Transfer (this handler only renders in the bank - // branch), so go straight to the bank step — don't re-show the method - // picker on /add-money/[country] (that was the double "select bank twice"). - router.push(addMoneyBankUrl(country.path)) + router.push(addMoneyCountryUrl(country.path)) } // native app: render sub-views based on query params diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 207e3a00e..b5ba0247f 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 BadgeEarnToast from '@/components/Badges/BadgeEarnToast' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { PeanutProvider } from '@/config' @@ -45,6 +46,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { explainer also covers public pay/send/request pages — the rain:cooldown event fires on every spend path. */} + {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). + Global so it surfaces wherever the user lands after earning. */} + {HarnessBootstrap && ( diff --git a/src/components/Badges/BadgeDetailModal.tsx b/src/components/Badges/BadgeDetailModal.tsx new file mode 100644 index 000000000..f08cbbbf5 --- /dev/null +++ b/src/components/Badges/BadgeDetailModal.tsx @@ -0,0 +1,33 @@ +import Image from 'next/image' +import type { StaticImageData } from 'next/image' +import ActionModal from '../Global/ActionModal' + +type BadgeDetailModalProps = { + isOpen: boolean + onClose: () => void + title: string + description: string + logo: string | StaticImageData +} + +// the focal badge detail popup — large badge image + name + description. +// shared by the Your Badges list and the badge-unlock drawer so both +// surfaces show the exact same modal. +export const BadgeDetailModal = ({ isOpen, onClose, title, description, logo }: BadgeDetailModalProps) => ( + } + iconContainerClassName="bg-transparent min-w-60 h-auto" + modalPanelClassName="m-0" + visible={isOpen} + onClose={onClose} + title={title} + description={description} + ctas={[ + { + text: 'Got it!', + onClick: onClose, + shadowSize: '4', + }, + ]} + /> +) diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx new file mode 100644 index 000000000..9036f37be --- /dev/null +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -0,0 +1,124 @@ +'use client' + +/** + * — the non-intrusive "badge unlocked" moment (TASK-19791). + * + * Globally mounted (ClientProviders), self-contained. When the signed-in user + * lands on /home with freshly-earned badges they haven't seen, it fires ONE + * coalesced toast ("Badge unlocked: X" / "You unlocked N badges") that taps + * through to the shared BadgeDetailModal (or the badges list for several). + * + * Why a toast (not a fullscreen): every badge that fires at/around the card + * launch is incidental — BETA_TESTER (signup), SHHHHH (everyone getting the + * card), EVENT_ALUMNI, NOT_SO_SHHHH. A fullscreen would stack 2-3 takeovers + * mid-/shhhhh-registration. The toast surfaces the badge without blocking the + * flow. Gated to /home so it never appears mid-onboarding (/setup, /shhhhh). + * WAITLIST_SKIP is excluded upstream — it keeps its bespoke card celebration. + */ + +import { useEffect, useRef, useState } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import Image from 'next/image' +import posthog from 'posthog-js' +import { useToast } from '@/components/0_Bruddle/Toast' +import { BadgeDetailModal } from '@/components/Badges/BadgeDetailModal' +import { getBadgeDisplayName, getBadgeIcon, getPublicBadgeDescription } from '@/components/Badges/badge.utils' +import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +const HOME_PATH = '/home' + +type ModalBadge = { title: string; description: string; logo: string } + +export default function BadgeEarnToast() { + const pathname = usePathname() + const router = useRouter() + const { toast, dismiss } = useToast() + const { pending, markSeen } = useBadgeEarnToast() + const [modalBadge, setModalBadge] = useState(null) + // Id of the toast currently on screen, so we can dismiss it when the user + // navigates away from /home (it would otherwise linger over the next route). + const liveToastIdRef = useRef(null) + + useEffect(() => { + // Only surface on /home (never mid-onboarding) and only when there's + // something fresh to show. markSeen() empties `pending`, so this effect + // fires the toast exactly once per batch. + if (pathname !== HOME_PATH || pending.length === 0) return + + const badges = pending + const codes = badges.map((b) => b.code) + const count = badges.length + const newest = badges[0] + const newestName = getBadgeDisplayName(newest.code, newest.name) + const newestIcon = getBadgeIcon(newest.code) + // Per-batch id (not a fixed id): a fixed id de-dupes in the Toast layer, + // so a second badge earned within the toast's window would be marked + // seen but never shown. Keying on the codes lets a distinct later batch + // surface, while still de-duping a re-render of the same batch. + const toastId = `badge-earn:${codes.join(',')}` + + const openInspect = () => { + dismiss(toastId) + liveToastIdRef.current = null + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count }) + if (count === 1) { + setModalBadge({ + title: newestName, + description: newest.description || getPublicBadgeDescription(newest.code) || '', + logo: newestIcon, + }) + } else { + router.push('/badges') + } + } + + const label = count === 1 ? `Badge unlocked: ${newestName}` : `You unlocked ${count} badges 🎉` + + toast({ + id: toastId, + type: 'success', + duration: 6000, + className: 'border-yellow-1', + content: ( + + ), + }) + liveToastIdRef.current = toastId + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_SHOWN, { count }) + markSeen(codes) + }, [pathname, pending, toast, dismiss, markSeen, router]) + + // Dismiss the toast when the user leaves /home so it doesn't ride over the + // next route for its remaining duration. Guarded on pathname so the + // markSeen-triggered re-render (still on /home) never kills the live toast. + useEffect(() => { + if (pathname === HOME_PATH) return + if (liveToastIdRef.current) { + dismiss(liveToastIdRef.current) + liveToastIdRef.current = null + } + }, [pathname, dismiss]) + + return modalBadge ? ( + setModalBadge(null)} + title={modalBadge.title} + description={modalBadge.description} + logo={modalBadge.logo} + /> + ) : null +} diff --git a/src/components/Badges/BadgeStatusDrawer.tsx b/src/components/Badges/BadgeStatusDrawer.tsx index 5c1979d44..41b5073c1 100644 --- a/src/components/Badges/BadgeStatusDrawer.tsx +++ b/src/components/Badges/BadgeStatusDrawer.tsx @@ -1,9 +1,11 @@ import { Drawer, DrawerContent } from '@/components/Global/Drawer' import Image from 'next/image' +import { useState } from 'react' import { formatDate } from '@/utils/general.utils' import Card from '../Global/Card' import { PaymentInfoRow } from '../Payment/PaymentInfoRow' import ShareButton from '../Global/ShareButton' +import { BadgeDetailModal } from './BadgeDetailModal' import { getBadgeDisplayName, getBadgeIcon } from './badge.utils' import { BASE_URL } from '@/constants/general.consts' import { useAuth } from '@/context/authContext' @@ -23,6 +25,7 @@ export type BadgeStatusDrawerProps = { // shows a drawer for a newly unlocked badge export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerProps) => { const { user: authUser } = useAuth() + const [isDetailOpen, setIsDetailOpen] = useState(false) const username = authUser?.user.username const earnedAt = badge.earnedAt ? new Date(badge.earnedAt) : undefined const dateStr = earnedAt ? formatDate(earnedAt) : undefined @@ -32,49 +35,67 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP const profileLink = username ? `${BASE_URL}/${username}` : BASE_URL return ( - - -
- -
-
- Icon -
+ <> + + +
+ { + onClose() + setIsDetailOpen(true) + }} + > +
+
+ Icon +
-
-

- Badge unlocked! -

-

{displayName}

+
+

+ Badge unlocked! +

+

{displayName}

+
-
-
+ - - - - + + + + -
- - Promise.resolve( - `I earned ${displayName} badge on Peanut!\n\nJoin Peanut now and start earning points, unlocking achievements and moving money worldwide\n\n${profileLink}` - ) - } - > - Share Achievement - +
+ + Promise.resolve( + `I earned ${displayName} badge on Peanut!\n\nJoin Peanut now and start earning points, unlocking achievements and moving money worldwide\n\n${profileLink}` + ) + } + > + Share Achievement + +
-
-
-
+ + + setIsDetailOpen(false)} + title={displayName} + description={badge.description || ''} + logo={getBadgeIcon(badge.code)} + /> + ) } diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx new file mode 100644 index 000000000..f4e10bbb6 --- /dev/null +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, act } from '@testing-library/react' +import type { ComponentProps } from 'react' +import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' + +// next/navigation — mutable pathname so we can exercise the /home gate; stable +// router object so the effect doesn't re-fire on the tap-triggered re-render. +let mockPathname = '/home' +const mockRouterPush = jest.fn() +const mockRouter = { push: mockRouterPush } +jest.mock('next/navigation', () => ({ + usePathname: () => mockPathname, + useRouter: () => mockRouter, +})) + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, fill, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean; fill?: boolean }) => ( + + ), +})) + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) + +const mockToast = jest.fn() +const mockDismissToast = jest.fn() +jest.mock('@/components/0_Bruddle/Toast', () => ({ + useToast: () => ({ toast: mockToast, dismiss: mockDismissToast }), +})) + +const mockMarkSeen = jest.fn() +let mockPending: Array<{ code: string; name: string; description: string | null; earnedAt: string }> = [] +jest.mock('@/components/Badges/useBadgeEarnToast', () => ({ + useBadgeEarnToast: () => ({ pending: mockPending, markSeen: mockMarkSeen }), +})) + +// Minimal stub: surface the title so we can assert the detail modal opened. +jest.mock('@/components/Badges/BadgeDetailModal', () => ({ + BadgeDetailModal: ({ isOpen, title }: { isOpen: boolean; title: string }) => + isOpen ?
{title}
: null, +})) + +import posthog from 'posthog-js' +const captureMock = (posthog as unknown as { capture: jest.Mock }).capture + +const badge = (code: string, name: string) => ({ + code, + name, + description: null, + earnedAt: new Date().toISOString(), +}) + +beforeEach(() => { + jest.clearAllMocks() + mockPathname = '/home' + mockPending = [] +}) + +describe('BadgeEarnToast', () => { + it('does nothing when not on /home', () => { + mockPathname = '/setup' + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + render() + expect(mockToast).not.toHaveBeenCalled() + expect(mockMarkSeen).not.toHaveBeenCalled() + }) + + it('does nothing when there are no fresh badges', () => { + render() + expect(mockToast).not.toHaveBeenCalled() + }) + + it('fires one toast for a single badge and opens the detail modal on tap', () => { + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + render() + + expect(mockToast).toHaveBeenCalledTimes(1) + expect(mockToast.mock.calls[0][0].id).toBe('badge-earn:PRODUCT_HUNT') + expect(mockMarkSeen).toHaveBeenCalledWith(['PRODUCT_HUNT']) + expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_shown', { count: 1 }) + + const content = mockToast.mock.calls[0][0].content + act(() => content.props.onClick()) + + expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') + expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1 }) + expect(screen.getByTestId('badge-detail-modal')).toHaveTextContent('Product Hunt') + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('coalesces multiple badges and routes to /badges on tap', () => { + mockPending = [badge('SHHHHH', 'Shhh'), badge('PRODUCT_HUNT', 'Product Hunt')] + render() + + expect(mockToast).toHaveBeenCalledTimes(1) + expect(mockMarkSeen).toHaveBeenCalledWith(['SHHHHH', 'PRODUCT_HUNT']) + + const content = mockToast.mock.calls[0][0].content + render(content) + expect(screen.getByText(/You unlocked 2 badges/)).toBeInTheDocument() + + act(() => content.props.onClick()) + expect(mockRouterPush).toHaveBeenCalledWith('/badges') + expect(screen.queryByTestId('badge-detail-modal')).not.toBeInTheDocument() + }) + + it('dismisses the live toast when the user navigates away from /home', () => { + mockPending = [badge('PRODUCT_HUNT', 'Product Hunt')] + const { rerender } = render() + expect(mockToast).toHaveBeenCalledTimes(1) + + mockPathname = '/send' + rerender() + expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT') + }) +}) 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..d81f03a36 --- /dev/null +++ b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts @@ -0,0 +1,96 @@ +import { + FRESHNESS_WINDOW_MS, + celebrationStorageKey, + isFresh, + loadSeenCodes, + persistSeenCodes, + pickCelebrationBadges, + 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('pickCelebrationBadges', () => { + it('returns [] for empty/undefined input', () => { + expect(pickCelebrationBadges(undefined, new Set(), NOW)).toEqual([]) + expect(pickCelebrationBadges([], new Set(), NOW)).toEqual([]) + }) + it('returns all fresh, unseen, visible badges newest-first', () => { + const badges = [badge({ code: 'OLDER', earnedAt: iso(1000) }), badge({ code: 'NEWER', earnedAt: iso(100) })] + expect(pickCelebrationBadges(badges, new Set(), NOW).map((b) => b.code)).toEqual(['NEWER', 'OLDER']) + }) + it('excludes WAITLIST_SKIP (card celebration) and BETA_TESTER (universal)', () => { + const badges = [ + badge({ code: 'WAITLIST_SKIP', earnedAt: iso(0) }), + badge({ code: 'BETA_TESTER', earnedAt: iso(0) }), + badge({ code: 'SHHHHH', earnedAt: iso(0) }), + ] + expect(pickCelebrationBadges(badges, new Set(), NOW).map((b) => b.code)).toEqual(['SHHHHH']) + }) + it('excludes already-seen, stale, and invisible badges', () => { + const badges = [ + badge({ code: 'SEEN', earnedAt: iso(0) }), + badge({ code: 'STALE', earnedAt: iso(FRESHNESS_WINDOW_MS + 1) }), + badge({ code: 'HIDDEN', earnedAt: iso(0), isVisible: false }), + badge({ code: 'GOOD', earnedAt: iso(0) }), + ] + expect(pickCelebrationBadges(badges, new Set(['SEEN']), NOW).map((b) => b.code)).toEqual(['GOOD']) + }) + }) + + 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()) + }) + }) + + describe('localStorage-failure fallback (private mode)', () => { + afterEach(() => jest.restoreAllMocks()) + + it('holds the seen-set in memory when writes throw, so it does not re-nag', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceeded') + }) + persistSeenCodes('pm-user', new Set(['SHHHHH'])) + // The localStorage write failed, but the in-memory fallback keeps the + // badge seen for the session so the toast doesn't re-fire every visit. + expect(loadSeenCodes('pm-user')).toEqual(new Set(['SHHHHH'])) + }) + }) +}) diff --git a/src/components/Badges/__tests__/useBadgeEarnToast.test.ts b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts new file mode 100644 index 000000000..8a4b7f7e2 --- /dev/null +++ b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react' +import { useUserStore } from '@/redux/hooks' +import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' +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('useBadgeEarnToast', () => { + beforeEach(() => { + window.localStorage.clear() + jest.clearAllMocks() + }) + + it('returns no pending badges when signed out', () => { + setUser(undefined, []) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + }) + + it('reads seen synchronously when the user resolves after mount (no cold-start re-fire)', () => { + window.localStorage.setItem(celebrationStorageKey('user-a'), JSON.stringify(['SHHHHH'])) + setUser(undefined, []) + const { result, rerender } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + // user loads async: on the first render with a userId, seen must already + // reflect localStorage — not lag a render behind (which re-fired the toast). + setUser('user-a', [{ code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }]) + rerender() + expect(result.current.pending).toEqual([]) + }) + + it('surfaces all freshly-earned badges, newest first', () => { + setUser('user-a', [ + { + code: 'EVENT_ALUMNI', + name: 'Alumni', + description: null, + earnedAt: new Date(Date.now() - 1000).toISOString(), + }, + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending.map((b) => b.code)).toEqual(['SHHHHH', 'EVENT_ALUMNI']) + }) + + it('excludes universal/bespoke badges (BETA_TESTER, WAITLIST_SKIP)', () => { + setUser('user-a', [ + { code: 'BETA_TESTER', name: 'Beta', description: null, earnedAt: freshIso() }, + { code: 'WAITLIST_SKIP', name: 'Skip', description: null, earnedAt: freshIso() }, + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending.map((b) => b.code)).toEqual(['SHHHHH']) + }) + + it('markSeen persists the codes and clears them from pending', () => { + setUser('user-a', [ + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + { code: 'EVENT_ALUMNI', name: 'Alumni', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + act(() => result.current.markSeen(['SHHHHH', 'EVENT_ALUMNI'])) + expect(result.current.pending).toEqual([]) + expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('SHHHHH') + }) + + it('does not resurface badges already in the seen-set', () => { + window.localStorage.setItem(celebrationStorageKey('user-a'), JSON.stringify(['SHHHHH'])) + setUser('user-a', [{ code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }]) + const { result } = renderHook(() => useBadgeEarnToast()) + expect(result.current.pending).toEqual([]) + }) +}) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 3417b01c9..a8e442e02 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -180,6 +180,11 @@ export const BADGES: Record = { description: 'IYKYK. They were testing the card before you knew it existed.', displayName: 'Closed Beta', }, + CARD_ALPHA: { + path: '/badges/card_alpha.svg', + description: 'You tested the Card while it was still held together with tape and hope.', + displayName: 'Closed Alpha Tester', + }, // ── community (link-granted) ──────────────────────────────────────────── ARBITRUM: { path: '/badges/arbitrum.svg', @@ -193,6 +198,11 @@ export const BADGES: Record = { path: '/badges/token_nation_2026.svg', description: 'São Paulo, baby. They came, they claimed, they tagged the wall.', }, + FESTA_JUNINA_2026: { + path: '/badges/festa_junina_2026.svg', + description: 'You danced the quadrilha with us. Arraiá unlocked.', + displayName: 'Arraiá Approved', + }, TOUCHED_GRASS: { path: '/badges/touched_grass.svg', description: 'You logged off and touched real grass with Peanut.', diff --git a/src/components/Badges/badgeCelebration.utils.ts b/src/components/Badges/badgeCelebration.utils.ts new file mode 100644 index 000000000..83f100bd2 --- /dev/null +++ b/src/components/Badges/badgeCelebration.utils.ts @@ -0,0 +1,102 @@ +// Pure helpers for the badge-earn toast (TASK-19791). +// +// "Surface it once, while fresh, without interrupting." When a user lands on +// /home with a freshly-earned badge they haven't seen yet, we show a single +// non-blocking toast (tap to inspect) — never a fullscreen takeover, which +// collided with onboarding (a /shhhhh card signup awards BETA_TESTER + SHHHHH +// at once → stacked popups). See BadgeEarnToast.tsx. +// +// Persistence is a per-user localStorage seen-set + a 7-day freshness window — +// same house pattern as the card skip celebration (card/page.tsx). The window +// is what makes that safe: an old badge is never "fresh", so it can't re-toast +// on a new device or on the day this ships. No backend column, no migration. + +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), short enough that an old +// badge never re-toasts on a new device. +export const FRESHNESS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + +// Badges that should NOT trigger the toast: +// - WAITLIST_SKIP keeps its bespoke card-flow celebration (BadgeSkipCelebration). +// - BETA_TESTER is awarded to every signup — too universal to be worth surfacing. +// Other card-access "skip" badges (OG/Devconnect/Arbiverse) are historical, so +// the freshness window already keeps them out. +const EXCLUDED_CODES = new Set(['WAITLIST_SKIP', 'BETA_TESTER']) + +const STORAGE_PREFIX = 'badge_earn_toast_seen' + +// In-memory fallback for environments where localStorage can't persist +// (Safari/iOS private mode, quota exhausted). Without it, a swallowed write +// meant the seen-set re-hydrated empty every launch and the toast re-fired on +// EVERY /home visit for the whole freshness window. Memory keeps it alive for +// the session (lost on reload — one re-show per session, not an infinite nag). +// Only consulted when the localStorage read misses, so a working localStorage +// stays the source of truth. +const memoryFallback = new Map>() + +// Per-user key so a shared browser doesn't leak one account's seen-set onto another. +export function celebrationStorageKey(userId: string): string { + return `${STORAGE_PREFIX}:${userId}` +} + +export function loadSeenCodes(userId: string): Set { + if (typeof window !== 'undefined') { + try { + const raw = window.localStorage.getItem(celebrationStorageKey(userId)) + if (raw) { + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) return new Set(parsed.filter((c): c is string => typeof c === 'string')) + } + } catch { + // fall through to the in-memory fallback + } + } + return new Set(memoryFallback.get(userId)) +} + +export function persistSeenCodes(userId: string, codes: ReadonlySet): void { + try { + if (typeof window === 'undefined') throw new Error('no window') + window.localStorage.setItem(celebrationStorageKey(userId), JSON.stringify([...codes])) + // localStorage is the source of truth when it works — drop any stale + // in-memory copy so it can't shadow a later real read. + memoryFallback.delete(userId) + } catch { + // localStorage unavailable (private mode / quota / SSR) — hold the + // seen-set in memory so the toast doesn't re-nag this session. + memoryFallback.set(userId, new Set(codes)) + } +} + +// 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 +} + +// All visible, fresh, not-yet-seen, non-excluded badges, newest first. Returned +// as a list so the toast can coalesce ("You unlocked 2 badges") instead of +// stacking one toast per badge. +export function pickCelebrationBadges( + badges: readonly CelebrationBadge[] | undefined, + seen: ReadonlySet, + now: number +): CelebrationBadge[] { + if (!badges?.length) return [] + 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()) +} diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx index 372b68489..ac8f810c5 100644 --- a/src/components/Badges/index.tsx +++ b/src/components/Badges/index.tsx @@ -8,7 +8,7 @@ import { getBadgeDisplayName, getBadgeIcon } from './badge.utils' import { getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { Icon } from '../Global/Icons/Icon' -import ActionModal from '../Global/ActionModal' +import { BadgeDetailModal } from './BadgeDetailModal' import { useMemo, useState, useEffect } from 'react' import { useUserStore } from '@/redux/hooks' import { ActionListCard } from '../ActionListCard' @@ -95,36 +95,15 @@ export const Badges = () => {
{selectedBadge && ( - - } - iconContainerClassName="bg-transparent min-w-30 h-auto" - modalPanelClassName="m-0" - visible={isBadgeModalOpen} + { setIsBadgeModalOpen(false) setSelectedBadge(null) }} title={selectedBadge.title} description={selectedBadge.description} - ctas={[ - { - text: 'Got it!', - onClick: () => { - setIsBadgeModalOpen(false) - setSelectedBadge(null) - }, - shadowSize: '4', - }, - ]} + logo={selectedBadge.logo} /> )} diff --git a/src/components/Badges/useBadgeEarnToast.ts b/src/components/Badges/useBadgeEarnToast.ts new file mode 100644 index 000000000..514c3e848 --- /dev/null +++ b/src/components/Badges/useBadgeEarnToast.ts @@ -0,0 +1,56 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useUserStore } from '@/redux/hooks' +import { loadSeenCodes, persistSeenCodes, pickCelebrationBadges, type CelebrationBadge } from './badgeCelebration.utils' + +type UseBadgeEarnToast = { + /** Freshly-earned, not-yet-seen, non-excluded badges (newest first). */ + pending: CelebrationBadge[] + /** Mark these codes seen so they don't toast again (per-user localStorage). */ + markSeen: (codes: string[]) => void +} + +/** + * Detects freshly-earned, un-surfaced badges from the signed-in user's badge + * list (from /users/me via the redux user store) for the badge-earn toast. + * Persistence is a per-user localStorage seen-set + a freshness window — see + * badgeCelebration.utils.ts for the why. + */ +export function useBadgeEarnToast(): UseBadgeEarnToast { + const { user } = useUserStore() + const userId = user?.user?.userId + const badges = user?.user?.badges + + // `seen` is derived SYNCHRONOUSLY from storage. The previous + // useState(initial) + useEffect(re-hydrate) lagged one render behind the + // async user load: on the render where `userId` first became defined, the + // seen-set was still empty, so `pending` included already-seen badges and + // the toast re-fired on every cold start until the badge aged out. Reading + // in useMemo keyed on userId closes that gap; `bump` re-reads after markSeen + // persists. + const [bump, setBump] = useState(0) + // `bump` is a deliberate recompute trigger — markSeen() bumps it after + // persisting so `seen` re-reads storage. It isn't used inside the callback, + // which exhaustive-deps can't distinguish from a stray dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + const seen = useMemo(() => (userId ? loadSeenCodes(userId) : new Set()), [userId, bump]) + + const pending = useMemo(() => { + if (!userId) return [] + return pickCelebrationBadges(badges, seen, Date.now()) + }, [userId, badges, seen]) + + const markSeen = useCallback( + (codes: string[]) => { + if (!userId || codes.length === 0) return + const next = new Set(loadSeenCodes(userId)) + codes.forEach((c) => next.add(c)) + persistSeenCodes(userId, next) + setBump((b) => b + 1) + }, + [userId] + ) + + return { pending, markSeen } +} diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 5531e4b85..b492b1b03 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -14,6 +14,8 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { touched_grass: 'TOUCHED_GRASS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', + festajunina: 'FESTA_JUNINA_2026', + cardalpha: 'CARD_ALPHA', psyops: 'PSYOPS_DIVISION', founding: 'FOUNDING_PIONEER', } @@ -28,6 +30,8 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', + 'festa-junina': 'FESTA_JUNINA_2026', + 'card-alpha': 'CARD_ALPHA', } // Bare ?campaign= links (no invite code) that are claimable without an invite — @@ -42,7 +46,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { // bare link, but `/invite` shows generic badge-claim copy (not "skip"). export const SKIP_CAMPAIGN = 'skip' export const WAITLIST_SKIP_CAMPAIGNS: ReadonlySet = new Set([SKIP_CAMPAIGN, 'event_alumni']) -export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set(['touched_grass']) +export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set(['touched_grass', 'card_alpha', 'festa_junina_2026']) export type CampaignClassification = { /** Claimable from a bare link with no invite code (auto-claim + gate bypass). */ diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 0c83fc6a5..7c6928bb8 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', + // Non-intrusive badge-earn toast on /home (TASK-19791) — coalesced; tap + // opens the badge detail modal (or the badges list for several). + BADGE_EARN_TOAST_SHOWN: 'badge_earn_toast_shown', + BADGE_EARN_TOAST_TAPPED: 'badge_earn_toast_tapped', // Admin wave release (BE event, mirrored here so FE doesn't accidentally // step on the namespace). CARD_WAITLIST_RELEASED: 'card_waitlist_released', diff --git a/src/services/rain.ts b/src/services/rain.ts index 73463e25b..b494ab2a6 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -392,12 +392,23 @@ export const rainApi = { * Submit a prepared withdrawal with the user's admin signature. Backend * verifies via ERC-1271 against the user's kernel and broadcasts the * coordinator call through the shared admin relayer. + * + * `/submit` is SYNCHRONOUS: it broadcasts AND awaits on-chain confirmation + * (`waitForUserOperationReceipt` + `confirmIntentByTxHash`) before + * responding, and for a request/charge it settles the charge in the same + * call. That round-trip routinely exceeds the default 10s fetch budget, so + * pass 120s — the same budget the verified-withdrawal path already uses for + * this exact reason (see line ~525). With the 10s default the FE aborts + * while the tx still lands + the charge settles: the user sees an error on a + * payment that actually succeeded, retries, and double-sends. (#2245 routed + * request payments through this path for the first time → the regression.) */ submitWithdrawal: async (input: SubmitRainWithdrawalInput): Promise => { return rainRequest({ method: 'POST', path: '/rain/cards/withdraw/submit', body: input, + timeoutMs: 120_000, }) }, diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 084bc7e4c..86a706bec 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -8904,7 +8904,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "WAITLIST_SKIP"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "WAITLIST_SKIP"; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 94b307be4..f18e9ceb0 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -11786,6 +11786,10 @@ "type": "string", "enum": ["CARD_SPENT_1K"] }, + { + "type": "string", + "enum": ["CARD_ALPHA"] + }, { "type": "string", "enum": ["TOKEN_NATION_SP_2026"] diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 22ad6a654..2f995266f 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -17,7 +17,6 @@ import { chargePayUrl, requestPotUrl, addMoneyCountryUrl, - addMoneyBankUrl, withdrawCountryUrl, withdrawBankUrl, rewriteMethodPath, @@ -69,12 +68,6 @@ describe('native-routes', () => { }) }) - describe('addMoneyBankUrl', () => { - it('should return /add-money with country + view=bank (skips the method picker)', () => { - expect(addMoneyBankUrl('belgium')).toBe('/add-money?country=belgium&view=bank') - }) - }) - describe('withdrawCountryUrl', () => { it('should return /withdraw with country query param', () => { expect(withdrawCountryUrl('be')).toBe('/withdraw?country=be') @@ -206,12 +199,6 @@ describe('native-routes', () => { }) }) - describe('addMoneyBankUrl', () => { - it('should return /add-money/{country}/bank path (skips the method picker)', () => { - expect(addMoneyBankUrl('belgium')).toBe('/add-money/belgium/bank') - }) - }) - describe('addMoneyCountryUrl', () => { it('should return /add-money/{country} path', () => { expect(addMoneyCountryUrl('belgium')).toBe('/add-money/belgium') diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index bb099d64a..11a2708ac 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -39,15 +39,6 @@ export function addMoneyCountryUrl(countryPath: string): string { return isCapacitor() ? `/add-money?country=${encodeURIComponent(countryPath)}` : `/add-money/${countryPath}` } -// Straight to the bank step, skipping the redundant per-country method picker — -// mirrors withdrawBankUrl. Used when the user already chose "Bank Transfer" up -// front, so re-showing the method list on the country page is a double-select. -export function addMoneyBankUrl(countryPath: string): string { - return isCapacitor() - ? `/add-money?country=${encodeURIComponent(countryPath)}&view=bank` - : `/add-money/${countryPath}/bank` -} - export function withdrawCountryUrl(countryPath: string, queryParams?: string): string { if (isCapacitor()) { const qs = queryParams ? `&${queryParams.replace('?', '')}` : ''