From 1b6be68b2924d49002c3e0205dfc8c16c45e0951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 24 Jun 2026 20:45:09 -0300 Subject: [PATCH 01/16] =?UTF-8?q?hotfix(add-money):=20revert=20bank-step?= =?UTF-8?q?=20shortcut=20=E2=80=94=20it=20forced=20AR/BR=20into=20the=20Br?= =?UTF-8?q?idge=20EUR=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2276 made country-click go straight to /add-money/[country]/bank, but that is the Bridge (EUR/SEPA) bank page. Argentina/Brazil bank deposits route through Manteca (Pix/CBU), which the per-country method picker on /add-money/[country] was correctly resolving. Skipping the picker forced every country into the Bridge bank page, so AR/BR users saw a Euro selector. Revert to addMoneyCountryUrl (the method picker). The double-selection #2276 tried to remove is a separate, lower-priority UX nit; a correct fix must be per-country (skip only single-bank EU/NA countries), not a blanket shortcut. --- .../add-money/__tests__/add-money-states.test.tsx | 6 ++---- src/app/(mobile-ui)/add-money/page.tsx | 7 ++----- src/utils/__tests__/native-routes.test.ts | 13 ------------- src/utils/native-routes.ts | 9 --------- 4 files changed, 4 insertions(+), 31 deletions(-) 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/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('?', '')}` : '' From 0c82487b805efc85e0b697c6a0009cb45356b601 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 16:56:42 -0700 Subject: [PATCH 02/16] fix(rain): 120s timeout on collateral withdraw submit (was 10s default) POST /rain/cards/withdraw/submit is synchronous: it broadcasts AND awaits on-chain confirmation (waitForUserOperationReceipt + confirmIntentByTxHash) and, for a request/charge, settles the charge in the same call. The FE's submitWithdrawal sent it with no timeoutMs, so it used fetchWithSentry's default 10s budget. When confirmation exceeds 10s the FE aborts while the tx still lands and the charge settles: the payer sees "there was an issue with your request" on a payment that SUCCEEDED, retries, and double-sends (observed in prod: one payer -> same recipient x3). #2245 (73f73bc53) routed request payments through this submit path for the first time, which is why request-pays only started timing out now. Match the 120s budget the verified-withdrawal path already uses for the same synchronous-confirm reason. Fixes PEANUT-UI-QP5 (rain/cards/withdraw/submit timed out after 10000ms). --- src/services/rain.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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, }) }, From 8872abe7ac2a24b65d8a3f550f38494483d712f3 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 20:35:43 +0000 Subject: [PATCH 03/16] feat(badges): add Festa Junina 2025 event badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quadrilha-corn sticker + catalog entry (Arraiá Approved) for the June Festa Junina activation. Resolvable via UTM (utm_campaign=festa-junina) and invite code (festajunina); campaign-maps.test.ts guards that both resolve to a real BADGES entry. Companion API PR adds the award wiring. --- public/badges/festa_junina_2025.svg | 40 +++++++++++++++++++++++++ src/components/Badges/badge.utils.ts | 5 ++++ src/components/Invites/campaign-maps.ts | 2 ++ 3 files changed, 47 insertions(+) create mode 100644 public/badges/festa_junina_2025.svg diff --git a/public/badges/festa_junina_2025.svg b/public/badges/festa_junina_2025.svg new file mode 100644 index 000000000..02148a92c --- /dev/null +++ b/public/badges/festa_junina_2025.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 68a1ad5aa..672cbb831 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -182,6 +182,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_2025: { + path: '/badges/festa_junina_2025.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/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 59134c94c..05bc795a0 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -14,6 +14,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { touched_grass: 'TOUCHED_GRASS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', + festajunina: 'FESTA_JUNINA_2025', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. @@ -26,6 +27,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', + 'festa-junina': 'FESTA_JUNINA_2025', } // Bare ?campaign= links (no invite code) that are claimable without an invite — From aedab281976cb1b201c4cc13b24f8cd5a69eb085 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 21:06:33 +0000 Subject: [PATCH 04/16] fix(badges): correct Festa Junina badge code + asset to 2026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's 2026 — rename FESTA_JUNINA_2025 → FESTA_JUNINA_2026 in the BADGES catalog + campaign maps, and the asset to festa_junina_2026.svg. Invite/UTM tags unchanged (year-agnostic). --- public/badges/{festa_junina_2025.svg => festa_junina_2026.svg} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename public/badges/{festa_junina_2025.svg => festa_junina_2026.svg} (100%) diff --git a/public/badges/festa_junina_2025.svg b/public/badges/festa_junina_2026.svg similarity index 100% rename from public/badges/festa_junina_2025.svg rename to public/badges/festa_junina_2026.svg From db960f3ac73bfa7a519d6096d7bab7788fe02ae4 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 21:06:55 +0000 Subject: [PATCH 05/16] fix(badges): point Festa Junina catalog + campaign maps at 2026 code Completes the 2026 rename: BADGES key + asset path and the UTM/invite-code maps now reference FESTA_JUNINA_2026 (matching the renamed asset). --- src/components/Badges/badge.utils.ts | 4 ++-- src/components/Invites/campaign-maps.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 672cbb831..1a624956c 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -182,8 +182,8 @@ 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_2025: { - path: '/badges/festa_junina_2025.svg', + FESTA_JUNINA_2026: { + path: '/badges/festa_junina_2026.svg', description: 'You danced the quadrilha with us. Arraiá unlocked.', displayName: 'Arraiá Approved', }, diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 05bc795a0..a25270b84 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -14,7 +14,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { touched_grass: 'TOUCHED_GRASS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', - festajunina: 'FESTA_JUNINA_2025', + festajunina: 'FESTA_JUNINA_2026', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. @@ -27,7 +27,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', - 'festa-junina': 'FESTA_JUNINA_2025', + 'festa-junina': 'FESTA_JUNINA_2026', } // Bare ?campaign= links (no invite code) that are claimable without an invite — From 1a9666aa7bf95c6c17e408cead0bef5e1c81e41f Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 20:35:43 +0000 Subject: [PATCH 06/16] feat(badges): add Festa Junina 2025 event badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quadrilha-corn sticker + catalog entry (Arraiá Approved) for the June Festa Junina activation. Resolvable via UTM (utm_campaign=festa-junina) and invite code (festajunina); campaign-maps.test.ts guards that both resolve to a real BADGES entry. Companion API PR adds the award wiring. --- public/badges/festa_junina_2025.svg | 40 +++++++++++++++++++++++++ src/components/Badges/badge.utils.ts | 5 ++++ src/components/Invites/campaign-maps.ts | 2 ++ 3 files changed, 47 insertions(+) create mode 100644 public/badges/festa_junina_2025.svg diff --git a/public/badges/festa_junina_2025.svg b/public/badges/festa_junina_2025.svg new file mode 100644 index 000000000..02148a92c --- /dev/null +++ b/public/badges/festa_junina_2025.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 68a1ad5aa..672cbb831 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -182,6 +182,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_2025: { + path: '/badges/festa_junina_2025.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/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 59134c94c..05bc795a0 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -14,6 +14,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { touched_grass: 'TOUCHED_GRASS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', + festajunina: 'FESTA_JUNINA_2025', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. @@ -26,6 +27,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', + 'festa-junina': 'FESTA_JUNINA_2025', } // Bare ?campaign= links (no invite code) that are claimable without an invite — From 71715fbbd6e1ca437c546696ff8773b1bb89f453 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 21:06:33 +0000 Subject: [PATCH 07/16] fix(badges): correct Festa Junina badge code + asset to 2026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's 2026 — rename FESTA_JUNINA_2025 → FESTA_JUNINA_2026 in the BADGES catalog + campaign maps, and the asset to festa_junina_2026.svg. Invite/UTM tags unchanged (year-agnostic). --- public/badges/{festa_junina_2025.svg => festa_junina_2026.svg} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename public/badges/{festa_junina_2025.svg => festa_junina_2026.svg} (100%) diff --git a/public/badges/festa_junina_2025.svg b/public/badges/festa_junina_2026.svg similarity index 100% rename from public/badges/festa_junina_2025.svg rename to public/badges/festa_junina_2026.svg From edb59244abdd1ebbb56fbe2382cd654fbeca3a37 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 21:06:55 +0000 Subject: [PATCH 08/16] fix(badges): point Festa Junina catalog + campaign maps at 2026 code Completes the 2026 rename: BADGES key + asset path and the UTM/invite-code maps now reference FESTA_JUNINA_2026 (matching the renamed asset). --- src/components/Badges/badge.utils.ts | 4 ++-- src/components/Invites/campaign-maps.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 672cbb831..1a624956c 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -182,8 +182,8 @@ 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_2025: { - path: '/badges/festa_junina_2025.svg', + FESTA_JUNINA_2026: { + path: '/badges/festa_junina_2026.svg', description: 'You danced the quadrilha with us. Arraiá unlocked.', displayName: 'Arraiá Approved', }, diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 05bc795a0..a25270b84 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -14,7 +14,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { touched_grass: 'TOUCHED_GRASS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', - festajunina: 'FESTA_JUNINA_2025', + festajunina: 'FESTA_JUNINA_2026', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. @@ -27,7 +27,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', - 'festa-junina': 'FESTA_JUNINA_2025', + 'festa-junina': 'FESTA_JUNINA_2026', } // Bare ?campaign= links (no invite code) that are claimable without an invite — From 9f813a39be212dca549e39b75d9c62e74645f7c0 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 25 Jun 2026 21:31:26 +0000 Subject: [PATCH 09/16] feat(badges): make Festa Junina bare-claimable for existing users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add festa_junina_2026 to BARE_VANITY_CAMPAIGNS so a logged-in user landing on a bare UTM (?utm_campaign=festa-junina) or ?campaign link auto-claims the badge — not just new signups. Vanity (no waitlist skip / app access), like touched_grass. Existing it.each tests auto-cover the new entry. --- src/components/Invites/campaign-maps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index a25270b84..074eec500 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -42,7 +42,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', 'festa_junina_2026']) export type CampaignClassification = { /** Claimable from a bare link with no invite code (auto-claim + gate bypass). */ From 9361b5ee7800155714e1808e079952151fc5e72c Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 26 Jun 2026 16:06:33 +0000 Subject: [PATCH 10/16] feat(badges): add Closed Alpha Tester (CARD_ALPHA) badge art + invite maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE side of the Card closed-alpha tester badge: card_alpha.svg sticker asset, BADGES entry (peanut-pink card icon + copy 'You tested the Card while it was still held together with tape and hope.'), and campaign-maps wiring — cardalpha invite code, card-alpha UTM, and card_alpha as a bare-vanity campaign so a plain ?campaign=CARD_ALPHA link is claimable without an invite code. Pairs with the peanut-api-ts CARD_ALPHA registry PR. --- public/badges/card_alpha.svg | 12 ++++++++++++ src/components/Badges/badge.utils.ts | 5 +++++ src/components/Invites/campaign-maps.ts | 4 +++- 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 public/badges/card_alpha.svg 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/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 1a624956c..51a301a6d 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -169,6 +169,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', diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index a25270b84..3bc29cc3d 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -15,6 +15,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', festajunina: 'FESTA_JUNINA_2026', + cardalpha: 'CARD_ALPHA', } // Map inbound `utm_campaign` values to the badge codes the backend whitelists. @@ -28,6 +29,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { 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 +44,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']) export type CampaignClassification = { /** Claimable from a bare link with no invite code (auto-claim + gate bypass). */ From 9082df653573f0c3654b039034eb45c9bc112617 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 26 Jun 2026 17:26:36 +0000 Subject: [PATCH 11/16] chore(badges): sync FE api snapshot + types for CARD_ALPHA enum Mirror the peanut-api-ts openapi refresh: CARD_ALPHA joins the badge-code union; regenerated api.generated.ts via gen:api so check:api stays green. --- src/types/api.generated.ts | 2 +- src/types/api.openapi.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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"] From 23faa0bb61d04bba7cb7a342abe45b2a07b3007c Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 26 Jun 2026 19:56:40 +0000 Subject: [PATCH 12/16] fix(badges): 2x the badge image in the detail modal The badge preview rendered at 120px, which looked tiny relative to the modal panel. Double it to 240px so the badge is the clear focal point. --- src/components/Badges/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx index 372b68489..4e1930803 100644 --- a/src/components/Badges/index.tsx +++ b/src/components/Badges/index.tsx @@ -98,15 +98,15 @@ export const Badges = () => { } - iconContainerClassName="bg-transparent min-w-30 h-auto" + iconContainerClassName="bg-transparent min-w-60 h-auto" modalPanelClassName="m-0" visible={isBadgeModalOpen} onClose={() => { From 57875be790b743c81d092c19fd331d7ebbb3e8b2 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Sat, 27 Jun 2026 07:39:33 +0000 Subject: [PATCH 13/16] feat(badges): make the unlock-drawer badge tap open the detail modal The badge card in the 'Badge unlocked!' drawer was not clickable, so there was no way to inspect a freshly-earned badge. Make it open the badge detail modal. Extract that modal into a shared BadgeDetailModal so the Your Badges list and the unlock drawer render the exact same popup (and both get the larger badge image). Tapping closes the drawer first so the modal (z-20) isn't occluded by the drawer overlay (z-50). --- src/components/Badges/BadgeDetailModal.tsx | 33 +++++++ src/components/Badges/BadgeStatusDrawer.tsx | 99 +++++++++++++-------- src/components/Badges/index.tsx | 29 +----- 3 files changed, 97 insertions(+), 64 deletions(-) create mode 100644 src/components/Badges/BadgeDetailModal.tsx 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/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/index.tsx b/src/components/Badges/index.tsx index 4e1930803..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-60 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} /> )} From 523b046839064bbc37bef7db1a536f01ba4c6562 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 00:04:57 -0700 Subject: [PATCH 14/16] feat(badges): non-intrusive badge-earn toast on /home (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. A fullscreen celebration was the obvious fix but collides with onboarding: a /shhhhh card signup awards BETA_TESTER + SHHHHH at once, stacking 2-3 fullscreen takeovers mid-flow ("I just wanted my card"). Instead, surface it the non-blocking way: when the user lands on /home with freshly-earned, not-yet-seen badges, fire ONE coalesced toast ("Badge unlocked: X" / "You unlocked N badges") that taps through to the shared BadgeDetailModal added in this PR (or the badges list for several). Gated to /home so it never appears mid-onboarding (/setup, /shhhhh). WAITLIST_SKIP is excluded — it keeps its bespoke card celebration. "Surface once, while fresh": a per-user localStorage seen-set + a 7-day freshness window (same house pattern as the card skip celebration). The window means an old badge never re-toasts on a new device or on the day this ships — so no backend column, no migration. Reuses the existing useToast primitive + getBadgeIcon + BadgeDetailModal. --- src/app/ClientProviders.tsx | 4 + src/components/Badges/BadgeEarnToast.tsx | 105 ++++++++++++++++++ .../__tests__/badgeCelebration.utils.test.ts | 81 ++++++++++++++ .../__tests__/useBadgeEarnToast.test.ts | 59 ++++++++++ .../Badges/badgeCelebration.utils.ts | 86 ++++++++++++++ src/components/Badges/useBadgeEarnToast.ts | 51 +++++++++ src/constants/analytics.consts.ts | 4 + 7 files changed, 390 insertions(+) create mode 100644 src/components/Badges/BadgeEarnToast.tsx create mode 100644 src/components/Badges/__tests__/badgeCelebration.utils.test.ts create mode 100644 src/components/Badges/__tests__/useBadgeEarnToast.test.ts create mode 100644 src/components/Badges/badgeCelebration.utils.ts create mode 100644 src/components/Badges/useBadgeEarnToast.ts 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/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx new file mode 100644 index 000000000..6613b5bb2 --- /dev/null +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -0,0 +1,105 @@ +'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, 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 TOAST_ID = 'badge-earn' +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) + + 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 openInspect = () => { + dismiss(TOAST_ID) + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count }) + if (count === 1) { + setModalBadge({ + title: getBadgeDisplayName(newest.code, newest.name), + description: newest.description || getPublicBadgeDescription(newest.code) || '', + logo: getBadgeIcon(newest.code), + }) + } else { + router.push('/badges') + } + } + + const label = + count === 1 + ? `Badge unlocked: ${getBadgeDisplayName(newest.code, newest.name)}` + : `You unlocked ${count} badges 🎉` + + toast({ + id: TOAST_ID, + type: 'success', + duration: 6000, + className: 'border-yellow-1', + content: ( + + ), + }) + posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_SHOWN, { count }) + markSeen(codes) + }, [pathname, pending, toast, dismiss, markSeen, router]) + + return modalBadge ? ( + setModalBadge(null)} + title={modalBadge.title} + description={modalBadge.description} + logo={modalBadge.logo} + /> + ) : null +} 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..f509602f0 --- /dev/null +++ b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts @@ -0,0 +1,81 @@ +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 (it has its own card-flow celebration)', () => { + const badges = [ + badge({ code: 'WAITLIST_SKIP', 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()) + }) + }) +}) diff --git a/src/components/Badges/__tests__/useBadgeEarnToast.test.ts b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts new file mode 100644 index 000000000..561024ce8 --- /dev/null +++ b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts @@ -0,0 +1,59 @@ +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('surfaces all freshly-earned badges, newest first', () => { + setUser('user-a', [ + { + code: 'BETA_TESTER', + name: 'Beta', + 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', 'BETA_TESTER']) + }) + + it('markSeen persists the codes and clears them from pending', () => { + setUser('user-a', [ + { code: 'SHHHHH', name: 'Shhh', description: null, earnedAt: freshIso() }, + { code: 'BETA_TESTER', name: 'Beta', description: null, earnedAt: freshIso() }, + ]) + const { result } = renderHook(() => useBadgeEarnToast()) + act(() => result.current.markSeen(['SHHHHH', 'BETA_TESTER'])) + 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/badgeCelebration.utils.ts b/src/components/Badges/badgeCelebration.utils.ts new file mode 100644 index 000000000..67d8e1367 --- /dev/null +++ b/src/components/Badges/badgeCelebration.utils.ts @@ -0,0 +1,86 @@ +// 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 + +// WAITLIST_SKIP keeps its bespoke card-flow celebration (BadgeSkipCelebration), +// so it's excluded here to avoid a double-surface. Other card-access "skip" +// badges (OG/Devconnect/Arbiverse) are historical — the freshness window +// already keeps them out. +const EXCLUDED_CODES = new Set(['WAITLIST_SKIP']) + +const STORAGE_PREFIX = 'badge_earn_toast_seen' + +// 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') 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 toast 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 +} + +// 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/useBadgeEarnToast.ts b/src/components/Badges/useBadgeEarnToast.ts new file mode 100644 index 000000000..89e765cae --- /dev/null +++ b/src/components/Badges/useBadgeEarnToast.ts @@ -0,0 +1,51 @@ +'use client' + +import { useCallback, useEffect, 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 + + // 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 [] + return pickCelebrationBadges(badges, seen, Date.now()) + }, [userId, badges, seen]) + + const markSeen = useCallback( + (codes: string[]) => { + if (!userId || codes.length === 0) return + setSeen((prev) => { + const next = new Set(prev) + codes.forEach((c) => next.add(c)) + persistSeenCodes(userId, next) + return next + }) + }, + [userId] + ) + + return { pending, markSeen } +} 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', From 2c7456ca8aaf3363f677566668cff4f12bf102a3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 00:09:31 -0700 Subject: [PATCH 15/16] feat(badges): badge-earn toast tests + exclude BETA_TESTER (TASK-19791) On top of the toast core (523b04683): adds the missing BadgeEarnToast component test (the /home gate, single-vs-coalesced label, detail-modal-vs-/badges tap), excludes BETA_TESTER (every signup earns it -> noise, not a moment), and updates the detection tests. 23 badge tests green, typecheck clean. --- .../Badges/__tests__/BadgeEarnToast.test.tsx | 103 ++++++++++++++++++ .../__tests__/badgeCelebration.utils.test.ts | 3 +- .../__tests__/useBadgeEarnToast.test.ts | 20 +++- .../Badges/badgeCelebration.utils.ts | 11 +- 4 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/components/Badges/__tests__/BadgeEarnToast.test.tsx diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx new file mode 100644 index 000000000..0f7f79c74 --- /dev/null +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, act } from '@testing-library/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: (props: any) => { + const { unoptimized, fill, ...rest } = props + return + }, +})) + +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 }: any) => (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(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') + 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() + }) +}) diff --git a/src/components/Badges/__tests__/badgeCelebration.utils.test.ts b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts index f509602f0..08d359cdd 100644 --- a/src/components/Badges/__tests__/badgeCelebration.utils.test.ts +++ b/src/components/Badges/__tests__/badgeCelebration.utils.test.ts @@ -43,9 +43,10 @@ describe('badgeCelebration.utils', () => { 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 (it has its own card-flow celebration)', () => { + 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']) diff --git a/src/components/Badges/__tests__/useBadgeEarnToast.test.ts b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts index 561024ce8..0bf12f48b 100644 --- a/src/components/Badges/__tests__/useBadgeEarnToast.test.ts +++ b/src/components/Badges/__tests__/useBadgeEarnToast.test.ts @@ -28,24 +28,34 @@ describe('useBadgeEarnToast', () => { it('surfaces all freshly-earned badges, newest first', () => { setUser('user-a', [ { - code: 'BETA_TESTER', - name: 'Beta', + 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', 'BETA_TESTER']) + 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: 'BETA_TESTER', name: 'Beta', description: null, earnedAt: freshIso() }, + { code: 'EVENT_ALUMNI', name: 'Alumni', description: null, earnedAt: freshIso() }, ]) const { result } = renderHook(() => useBadgeEarnToast()) - act(() => result.current.markSeen(['SHHHHH', 'BETA_TESTER'])) + act(() => result.current.markSeen(['SHHHHH', 'EVENT_ALUMNI'])) expect(result.current.pending).toEqual([]) expect(window.localStorage.getItem(celebrationStorageKey('user-a'))).toContain('SHHHHH') }) diff --git a/src/components/Badges/badgeCelebration.utils.ts b/src/components/Badges/badgeCelebration.utils.ts index 67d8e1367..d2a04b7a0 100644 --- a/src/components/Badges/badgeCelebration.utils.ts +++ b/src/components/Badges/badgeCelebration.utils.ts @@ -24,11 +24,12 @@ export type CelebrationBadge = { // badge never re-toasts 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-surface. Other card-access "skip" -// badges (OG/Devconnect/Arbiverse) are historical — the freshness window -// already keeps them out. -const EXCLUDED_CODES = new Set(['WAITLIST_SKIP']) +// 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' From 267caf003944dcb062b8b9a74e4793b19c38c00f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 29 Jun 2026 00:34:35 -0700 Subject: [PATCH 16/16] fix(badges): make the earn toast fire exactly once (TASK-19791 review fixes) Addresses the 3 fire-once bugs from the high-effort code review: (1) cold-start re-fire - seen is now derived synchronously via useMemo, not a useState+useEffect that lagged the async user load by one render; (2) staggered drop - per-batch toast id (not a fixed id) so a 2nd badge earned within the window surfaces instead of being marked-seen-but-never-shown; (3) private-mode nag - in-memory fallback when localStorage writes throw so the toast does not re-fire every /home visit. Also dismiss on nav away from /home; compute getBadgeIcon once. +3 tests; 26 badge tests green, typecheck + eslint clean. --- src/components/Badges/BadgeEarnToast.tsx | 41 ++++++++++++++----- .../Badges/__tests__/BadgeEarnToast.test.tsx | 24 ++++++++--- .../__tests__/badgeCelebration.utils.test.ts | 14 +++++++ .../__tests__/useBadgeEarnToast.test.ts | 12 ++++++ .../Badges/badgeCelebration.utils.ts | 39 ++++++++++++------ src/components/Badges/useBadgeEarnToast.ts | 31 ++++++++------ 6 files changed, 119 insertions(+), 42 deletions(-) diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index 6613b5bb2..9036f37be 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -16,7 +16,7 @@ * WAITLIST_SKIP is excluded upstream — it keeps its bespoke card celebration. */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' import Image from 'next/image' import posthog from 'posthog-js' @@ -26,7 +26,6 @@ import { getBadgeDisplayName, getBadgeIcon, getPublicBadgeDescription } from '@/ import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -const TOAST_ID = 'badge-earn' const HOME_PATH = '/home' type ModalBadge = { title: string; description: string; logo: string } @@ -37,6 +36,9 @@ export default function BadgeEarnToast() { 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 @@ -48,35 +50,40 @@ export default function BadgeEarnToast() { 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(TOAST_ID) + dismiss(toastId) + liveToastIdRef.current = null posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count }) if (count === 1) { setModalBadge({ - title: getBadgeDisplayName(newest.code, newest.name), + title: newestName, description: newest.description || getPublicBadgeDescription(newest.code) || '', - logo: getBadgeIcon(newest.code), + logo: newestIcon, }) } else { router.push('/badges') } } - const label = - count === 1 - ? `Badge unlocked: ${getBadgeDisplayName(newest.code, newest.name)}` - : `You unlocked ${count} badges 🎉` + const label = count === 1 ? `Badge unlocked: ${newestName}` : `You unlocked ${count} badges 🎉` toast({ - id: TOAST_ID, + id: toastId, type: 'success', duration: 6000, className: 'border-yellow-1', content: (