From f028728ab1442550e9fcfb55262690c94b7166f5 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 08:19:28 +0000 Subject: [PATCH] feat(badges): consume declarative acquisition platform Settle typed campaign claims across invite, signup, and send-link paths; preserve published links and normal-app fallbacks; and honor badge capabilities independently of public/internal provenance. --- .../__tests__/add-money-states.test.tsx | 21 + src/app/(mobile-ui)/card/page.tsx | 1 + src/app/(setup)/setup/page.tsx | 17 +- .../__tests__/invites-resolution.test.ts | 89 ++ src/app/actions/invites.ts | 41 +- src/app/invite/page.tsx | 2 +- src/app/shhhhh/ShhhhhLandingPage.tsx | 70 +- src/app/shhhhh/shhhhh-acquisition.test.ts | 68 + src/app/shhhhh/shhhhh-acquisition.ts | 38 + .../views/AddMoneyMethodSelection.view.tsx | 17 +- src/components/Badges/BadgeDetailModal.tsx | 6 +- src/components/Badges/BadgeEarnToast.tsx | 10 +- src/components/Badges/BadgeImage.tsx | 29 + src/components/Badges/BadgeStatusDrawer.tsx | 16 +- src/components/Badges/BadgeStatusItem.tsx | 6 +- src/components/Badges/BadgesRow.tsx | 16 +- .../Badges/__tests__/BadgeEarnToast.test.tsx | 17 +- .../Badges/__tests__/BadgesRow.test.tsx | 58 +- .../Badges/__tests__/badge.utils.test.ts | 58 +- src/components/Badges/badge.utils.ts | 343 +---- .../Badges/badgeCelebration.utils.ts | 1 + src/components/Badges/index.tsx | 10 +- src/components/Card/BadgeSkipCelebration.tsx | 2 +- src/components/Card/CardUnlockDrawer.tsx | 4 +- src/components/Card/CardUnlockHistoryItem.tsx | 2 +- .../Card/share-asset/ShareAssetD3.tsx | 6 + .../Card/share-asset/shareAsset.types.ts | 2 + .../Card/share-asset/shareAssetLayout.ts | 2 +- src/components/Claim/Link/Initial.view.tsx | 16 +- .../Claim/Link/Onchain/Confirm.view.tsx | 9 +- .../Claim/Link/Onchain/Success.view.tsx | 5 +- .../Claim/Link/views/BankFlowManager.view.tsx | 10 +- .../claim-campaign-isolation.test.ts | 133 ++ src/components/Claim/useClaimLink.tsx | 28 +- src/components/Invites/InvitesPage.test.tsx | 608 ++++++++ src/components/Invites/InvitesPage.tsx | 232 ++- .../Invites/JoinWaitlistPage.test.tsx | 146 ++ src/components/Invites/JoinWaitlistPage.tsx | 46 +- .../Invites/badge-campaign-context.test.ts | 254 ++++ .../Invites/badge-campaign-context.ts | 218 +++ src/components/Invites/campaign-maps.test.ts | 133 -- src/components/Invites/campaign-maps.ts | 128 -- .../Profile/components/PublicProfile.tsx | 1 + src/components/Setup/Views/JoinWaitlist.tsx | 2 +- src/context/authContext.tsx | 44 +- .../post-auth-redirect-consumers.test.tsx | 78 + .../useZeroDev-invite-onboarding.test.tsx | 178 +++ src/hooks/useAccountSetup.ts | 37 +- src/hooks/useHomeCarouselCTAs.tsx | 11 +- src/hooks/useLogin.tsx | 17 +- src/hooks/useZeroDev.ts | 123 +- src/interfaces/interfaces.ts | 1 + .../__tests__/badge-campaigns.test.ts | 515 +++++++ .../__tests__/invite-response.test.ts | 48 + .../__tests__/invites-attribution.test.ts | 217 +++ .../__tests__/post-auth-redirect.test.ts | 71 + .../registration-acquisition.test.ts | 41 + src/services/acquisition-navigation.ts | 30 + src/services/badge-campaigns.ts | 370 +++++ src/services/invite-acquisition.ts | 54 + src/services/invite-response.ts | 58 + src/services/invites.ts | 77 +- src/services/post-auth-redirect.ts | 59 + src/services/registration-acquisition.ts | 13 + src/services/users.ts | 1 + src/types/api.generated.ts | 474 +++++- src/types/api.openapi.json | 1300 +++++++++++++++-- src/utils/__tests__/deferred-link.test.ts | 52 +- src/utils/deferred-link.ts | 38 +- 69 files changed, 5810 insertions(+), 1018 deletions(-) create mode 100644 src/app/actions/__tests__/invites-resolution.test.ts create mode 100644 src/app/shhhhh/shhhhh-acquisition.test.ts create mode 100644 src/app/shhhhh/shhhhh-acquisition.ts create mode 100644 src/components/Badges/BadgeImage.tsx create mode 100644 src/components/Claim/__tests__/claim-campaign-isolation.test.ts create mode 100644 src/components/Invites/InvitesPage.test.tsx create mode 100644 src/components/Invites/JoinWaitlistPage.test.tsx create mode 100644 src/components/Invites/badge-campaign-context.test.ts create mode 100644 src/components/Invites/badge-campaign-context.ts delete mode 100644 src/components/Invites/campaign-maps.test.ts delete mode 100644 src/components/Invites/campaign-maps.ts create mode 100644 src/hooks/__tests__/post-auth-redirect-consumers.test.tsx create mode 100644 src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx create mode 100644 src/services/__tests__/badge-campaigns.test.ts create mode 100644 src/services/__tests__/invite-response.test.ts create mode 100644 src/services/__tests__/invites-attribution.test.ts create mode 100644 src/services/__tests__/post-auth-redirect.test.ts create mode 100644 src/services/__tests__/registration-acquisition.test.ts create mode 100644 src/services/acquisition-navigation.ts create mode 100644 src/services/badge-campaigns.ts create mode 100644 src/services/invite-acquisition.ts create mode 100644 src/services/invite-response.ts create mode 100644 src/services/post-auth-redirect.ts create mode 100644 src/services/registration-acquisition.ts 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 40592c3f01..a8573f10f8 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 @@ -997,6 +997,27 @@ describe('GROUP 1: Landing / Method Selection', () => { expect(screen.getByText('Add Money')).toBeInTheDocument() }) + test('an earned Offramp badge keeps its migration entry regardless of provenance', () => { + mockUseAuth.mockReturnValue({ + user: { + user: { + username: 'test-user', + userId: 'user-123', + badges: [{ code: 'OFFRAMP_USER' }], + }, + }, + isFetchingUser: false, + fetchUser: jest.fn(), + }) + + renderWithProviders() + + fireEvent.click(screen.getByTestId('action-card-migrate-from-offramp')) + expect(mockRouterPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp') + expect(screen.getByText('Crypto')).toBeInTheDocument() + expect(screen.getByText('Bank Transfer')).toBeInTheDocument() + }) + test('clicking Crypto opens the network drawer', () => { renderWithProviders() diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 09e67ec47e..7edefd7cb7 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -626,6 +626,7 @@ const CardPage: FC = () => { const allBadges = user?.user?.badges?.map((b) => ({ code: b.code, + iconUrl: b.iconUrl, earnedAt: b.earnedAt, })) ?? cardInfo!.skipBadges.map((code) => ({ code })) return ( diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 59262dc76d..335d5d87f5 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -90,16 +90,15 @@ function SetupPageContent() { // Skip the invite-code gate straight to signup when either: // - an invite code is present (cookie survives the PWA-install hop), or - // - the URL asks for it via ?step=signup — the signal every campaign / - // skip flow already sends (ShhhhhLandingPage, InvitesPage.handleClaim) - // when it pushes to /setup. useZeroDev still reads the campaignTag - // cookie post-signup to award the badge; the step decision no longer - // trusts that cookie. + // - the URL asks for it via ?step=signup — the signal every campaign + // entrypoint sends when it pushes to /setup. After authentication, + // useZeroDev submits the queued opaque campaign list to the canonical + // claim service; the step decision never interprets that cookie. // - // Why not the campaignTag cookie: it's a session cookie cleared only on a - // successful signup, so a returning user who claimed a campaign earlier in - // the same session was routed past Landing (the only screen with Log In) - // onto Signup, unable to log back in (regression from PR #2346). + // Why not the campaignTag cookie: retryable campaign acquisition can + // intentionally persist for 30 days. Using it as onboarding state would + // route a returning user past Landing (the only screen with Log In) onto + // Signup, unable to log back in (regression from PR #2346). const inviteCodeFromCookie = getFromCookie('inviteCode') const userInviteCode = inviteCode || inviteCodeFromCookie const skipInviteGate = !!userInviteCode || searchParams.get('step') === 'signup' diff --git a/src/app/actions/__tests__/invites-resolution.test.ts b/src/app/actions/__tests__/invites-resolution.test.ts new file mode 100644 index 0000000000..a27042ba87 --- /dev/null +++ b/src/app/actions/__tests__/invites-resolution.test.ts @@ -0,0 +1,89 @@ +import { validateInviteCode } from '../invites' +import { serverFetch } from '@/utils/api-fetch' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +describe('validateInviteCode mixed-version resolution', () => { + beforeEach(() => jest.clearAllMocks()) + + it('treats a typed campaign-only HTTP 409 as a settled transport result', async () => { + mockServerFetch.mockResolvedValue( + response(409, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + username: '', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }) + ) + + await expect(validateInviteCode('offramp')).resolves.toEqual({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: '', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + }) + }) + + it('keeps an untyped HTTP 409 as a transport failure', async () => { + mockServerFetch.mockResolvedValue(response(409, { error: 'Invite code is not valid' })) + + await expect(validateInviteCode('not-an-invite')).resolves.toEqual({ + error: 'Invite code is not valid', + }) + }) + + it('supports a pre-discriminator HTTP 200 validation response with a username', async () => { + mockServerFetch.mockResolvedValue(response(200, { username: 'legacy-inviter' })) + + await expect(validateInviteCode('legacy-invite')).resolves.toEqual({ + data: { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'legacy-inviter', + legacyAcquisition: undefined, + }, + }) + }) + + it('lets an explicit false discriminator override the legacy username signal', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + username: 'legacy-inviter', + attributionResolved: false, + onboardingResolved: false, + }) + ) + + await expect(validateInviteCode('legacy-invite')).resolves.toMatchObject({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'legacy-inviter', + }, + }) + }) +}) diff --git a/src/app/actions/invites.ts b/src/app/actions/invites.ts index 48279dc577..d914ba9a76 100644 --- a/src/app/actions/invites.ts +++ b/src/app/actions/invites.ts @@ -1,22 +1,47 @@ import { serverFetch } from '@/utils/api-fetch' +import { parseLegacyInviteAcquisition, type LegacyInviteAcquisition } from '@/services/invite-acquisition' +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from '@/services/invite-response' -export async function validateInviteCode( - inviteCode: string -): Promise<{ data?: { success: boolean; username: string }; error?: string }> { +export async function validateInviteCode(inviteCode: string): Promise<{ + data?: { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + username: string + legacyAcquisition?: LegacyInviteAcquisition + } + error?: string +}> { try { const response = await serverFetch('/invites/validate', { method: 'POST', body: JSON.stringify({ inviteCode }), }) - if (!response.ok) { - const data = await response.json() - return { error: data.error || 'Failed to validate invite code.' } + const data: unknown = await response.json() + const typedCampaignOnly = response.status === 409 && isTypedCampaignOnlyInviteResponse(data) + + if (!response.ok && !typedCampaignOnly) { + const error = + data && typeof data === 'object' && typeof (data as { error?: unknown }).error === 'string' + ? (data as { error: string }).error + : 'Failed to validate invite code.' + return { error } } - const data = await response.json() + const body = data && typeof data === 'object' ? (data as Record) : {} + const username = typeof body.username === 'string' ? body.username : '' + const resolution = resolveInviteResolutionFlags(data, username.trim().length > 0) + const legacyAcquisition = parseLegacyInviteAcquisition(body.legacyAcquisition) - return { data: { success: true, username: data.username } } + return { + data: { + success: true, + ...resolution, + username, + legacyAcquisition, + }, + } } catch (error) { console.error('Error calling validate invite code API:', error) if (error instanceof Error) { diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index dbe935fb8f..27da37bb5b 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -12,7 +12,7 @@ async function getInviteCodeData(inviteCode: string) { const response = await validateInviteCode(inviteCode) - if (response.data?.success) { + if (response.data?.success && response.data.onboardingResolved) { return { username: response.data.username, } diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx index e1ecff2656..ee877b5af9 100644 --- a/src/app/shhhhh/ShhhhhLandingPage.tsx +++ b/src/app/shhhhh/ShhhhhLandingPage.tsx @@ -12,17 +12,18 @@ import { PixelatedCardFace } from '@/components/Card/share-asset/PixelatedCardFa import { inflateWaitlistPosition } from '@/components/Card/doorTally.utils' import { Sparkle, Star } from '@/assets/illustrations' import { cardApi } from '@/services/card' -import { invitesApi } from '@/services/invites' -import { saveToCookie } from '@/utils/general.utils' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { badgeCampaignsFromSearchParams, queuePendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' +import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' +import { captureException } from '@sentry/nextjs' +import { + destinationForShhhhhClaims, + queueShhhhhCampaignContinuation, + shhhhhCampaignSignupRoute, +} from './shhhhh-acquisition' const marqueeMessages = ['IYKYK', 'WORD TRAVELS', 'CLOSED BETA', 'SHHHH', 'PEANUT CLUB'] -// /shhhhh?campaign=skip — the Skip Pass link. Awards the WAITLIST_SKIP badge -// (same contract as /invite?campaign=skip) so friends-of-Peanut skip the line. -// A BARE visit grants nothing: the door joins the waitlist, it is not a bypass. -const SKIP_CAMPAIGN = 'skip' - // Inline "you're on the waitlist" confirmation shown in place of the door CTA // once the user joins (pre-launch, non-skip path). `dark` = on the black §7. function WaitlistJoined({ @@ -227,39 +228,48 @@ export default function ShhhhhLandingPage() { }, [user]) const handleCTA = async () => { - // /shhhhh?campaign=skip → Skip Pass (awards the badge). A bare press is - // NOT a bypass — it joins the waitlist (Hugo 2026-06-07). Read the param - // at click time (client-only) — avoids useSearchParams (which would bail - // /shhhhh out of static prerendering) and any mount-effect race. - const isSkipCampaign = - new URLSearchParams(window.location.search).get('campaign')?.toLowerCase() === SKIP_CAMPAIGN + // Read opaque campaign identities at click time (client-only) to avoid + // bailing static prerendering. The backend resolves award semantics; + // provenance is audit-only and does not weaken an earned skip badge. + const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(window.location.search)) posthog.capture(ANALYTICS_EVENTS.DOOR_TRY, { signed_in: !!user, - campaign: isSkipCampaign ? SKIP_CAMPAIGN : null, + campaign_tags: badgeCampaigns, }) - // Skip Pass link → award the badge; the user is in. (The bare door never - // awards a badge — see below.) - if (isSkipCampaign) { + if (badgeCampaigns.length > 0) { + const queuedBadgeCampaigns = queuePendingBadgeCampaigns(badgeCampaigns, 30) if (!user) { - // useZeroDev's post-signup `campaignTag` branch awards the badge - // (awaited inside registration, before setup redirects) — so - // landing on /card afterwards is race-free. - saveToCookie('campaignTag', SKIP_CAMPAIGN) - router.push(`/setup?step=signup&redirect_uri=${encodeURIComponent('/card')}`) + queueShhhhhCampaignContinuation() + router.push(shhhhhCampaignSignupRoute()) return } + + let destination: '/card' | '/home' = '/home' try { - await invitesApi.awardBadge(SKIP_CAMPAIGN) - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: SKIP_CAMPAIGN }) - await fetchUser() - } catch (err) { - console.error('[shhhhh] awardBadge(skip) failed:', err) + const batch = await claimAndSettlePendingBadgeCampaigns(queuedBadgeCampaigns) + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + destination = destinationForShhhhhClaims(batch.claims) + if (confirmed.length > 0) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + campaign_tags: confirmed.map((claim) => claim.badgeCampaign), + }) + await fetchUser() + } + if (batch.pending.length > 0) { + captureException(new Error('shhhhh campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_claim_unexpected_failure' } }) } - // Straight into the card flow — the badge award just opened both - // gates BE-side; /home would make them hunt for the card CTA. - router.push('/card') + + // Only a confirmed Skip Pass continues to the card flow. Unknown, + // inactive, expired, malformed, and retryable claims fall back home. + router.push(destination) return } diff --git a/src/app/shhhhh/shhhhh-acquisition.test.ts b/src/app/shhhhh/shhhhh-acquisition.test.ts new file mode 100644 index 0000000000..c0a8f9641a --- /dev/null +++ b/src/app/shhhhh/shhhhh-acquisition.test.ts @@ -0,0 +1,68 @@ +import { getRedirectUrl } from '@/utils/general.utils' +import { + destinationForShhhhhClaims, + queueShhhhhCampaignContinuation, + settleShhhhhCampaignContinuation, + shhhhhCampaignSignupRoute, +} from './shhhhh-acquisition' + +describe('Shhhhh campaign continuation', () => { + beforeEach(() => localStorage.clear()) + + it.each(['awarded', 'already_owned'] as const)('continues a confirmed Skip Pass %s to /card', (outcome) => { + expect(destinationForShhhhhClaims([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }])).toBe( + '/card' + ) + }) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'falls back home for %s', + (outcome) => { + expect(destinationForShhhhhClaims([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }])).toBe( + '/home' + ) + } + ) + + it('does not treat an unrelated confirmed badge as a card continuation', () => { + expect( + destinationForShhhhhClaims([{ badgeCampaign: 'event', badgeCode: 'EVENT_ALUMNI', outcome: 'awarded' }]) + ).toBe('/home') + }) + + it('starts signed-out registration without an unconditional card redirect', () => { + expect(shhhhhCampaignSignupRoute()).toBe('/setup?step=signup') + }) + + it.each(['awarded', 'already_owned'] as const)( + 'replaces the safe signed-out marker with /card after confirmed Skip Pass %s', + (outcome) => { + queueShhhhhCampaignContinuation() + expect( + settleShhhhhCampaignContinuation([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }]) + ).toBe('/card') + expect(getRedirectUrl()).toBe('/card') + } + ) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'keeps the signed-out continuation on the normal app after %s', + (outcome) => { + queueShhhhhCampaignContinuation() + expect( + settleShhhhhCampaignContinuation([{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome }]) + ).toBe('/home') + expect(getRedirectUrl()).toBe('/home') + } + ) + + it("does not consume another acquisition flow's stored destination", () => { + localStorage.setItem('redirect', JSON.stringify('/claim?step=claim')) + expect( + settleShhhhhCampaignContinuation([ + { badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome: 'awarded' }, + ]) + ).toBeUndefined() + expect(getRedirectUrl()).toBe('/claim?step=claim') + }) +}) diff --git a/src/app/shhhhh/shhhhh-acquisition.ts b/src/app/shhhhh/shhhhh-acquisition.ts new file mode 100644 index 0000000000..2b849827b4 --- /dev/null +++ b/src/app/shhhhh/shhhhh-acquisition.ts @@ -0,0 +1,38 @@ +import { isConfirmedBadgeCampaignClaim, type BadgeCampaignClaim } from '@/services/badge-campaigns' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' + +const WAITLIST_SKIP_BADGE_CODE = 'WAITLIST_SKIP' +const PENDING_SHHHHH_REDIRECT = '/home?badge_campaign_continuation=shhhhh' + +/** + * Start from a safe normal-app continuation. Registration replaces this marker + * with `/card` only after the API confirms that this intent awarded Skip Pass. + */ +export function queueShhhhhCampaignContinuation(): void { + saveToLocalStorage('redirect', PENDING_SHHHHH_REDIRECT) +} + +export function shhhhhCampaignSignupRoute(): string { + return '/setup?step=signup' +} + +/** + * The Shhhhh link has one established continuation: a confirmed Skip Pass goes + * to the card flow. Every unconfirmed or unrelated campaign uses normal_app. + */ +export function destinationForShhhhhClaims(claims: readonly BadgeCampaignClaim[]): '/card' | '/home' { + return claims.some((claim) => isConfirmedBadgeCampaignClaim(claim) && claim.badgeCode === WAITLIST_SKIP_BADGE_CODE) + ? '/card' + : '/home' +} + +/** + * Resolve the signed-out Shhhhh continuation once, after registration claims + * settle. `undefined` means this registration did not originate at Shhhhh. + */ +export function settleShhhhhCampaignContinuation(claims: readonly BadgeCampaignClaim[]): '/card' | '/home' | undefined { + if (getRedirectUrl() !== PENDING_SHHHHH_REDIRECT) return undefined + const destination = destinationForShhhhhClaims(claims) + saveToLocalStorage('redirect', destination) + return destination +} diff --git a/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx b/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx index d1c663fd70..3935b56a8d 100644 --- a/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx +++ b/src/components/AddMoney/views/AddMoneyMethodSelection.view.tsx @@ -3,13 +3,13 @@ import { ActionListCard } from '@/components/ActionListCard' import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import ChooseNetworkDrawer from '../components/ChooseNetworkDrawer' -// offramp.xyz migrants get this link-granted badge at signup (peanut-api-ts -// invite/badge routes, code `offramp` / utm `offramp`). -import { OFFRAMP_BADGE_CODE } from '@/components/Invites/campaign-maps' import type { RhinoChainType } from '@/services/services.types' import { useAuth } from '@/context/authContext' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { OFFRAMP_MIGRATION_ROUTE } from '@/services/acquisition-navigation' + +const OFFRAMP_BADGE_CODE = 'OFFRAMP_USER' interface AddMoneyMethodSelectionProps { onBankTransferClick: () => void @@ -20,8 +20,9 @@ const AddMoneyMethodSelection = ({ onBankTransferClick }: AddMoneyMethodSelectio const { user } = useAuth() const [isDrawerOpen, setIsDrawerOpen] = useState(false) - // offramp migrants get a tailored, de-cluttered arbitrum deposit entry - const hasOfframpBadge = user?.user?.badges?.some((b) => b.code === OFFRAMP_BADGE_CODE) ?? false + // Existing OFFRAMP_USER possession carries the migration entry. Acquisition + // provenance is audit-only and must not weaken this established benefit. + const hasOfframpMigrationEntry = user?.user?.badges?.some((badge) => badge.code === OFFRAMP_BADGE_CODE) ?? false const handleNetworkSelect = (network: RhinoChainType) => { setIsDrawerOpen(false) @@ -33,7 +34,7 @@ const AddMoneyMethodSelection = ({ onBankTransferClick }: AddMoneyMethodSelectio

How would you like to add money?

- {hasOfframpBadge && ( + {hasOfframpMigrationEntry && ( } - onClick={() => router.push('/add-money/crypto?network=EVM&source=offramp')} + onClick={() => router.push(OFFRAMP_MIGRATION_ROUTE)} /> )} } onClick={() => setIsDrawerOpen(true)} /> diff --git a/src/components/Badges/BadgeDetailModal.tsx b/src/components/Badges/BadgeDetailModal.tsx index f08cbbbf57..b7d8341b5e 100644 --- a/src/components/Badges/BadgeDetailModal.tsx +++ b/src/components/Badges/BadgeDetailModal.tsx @@ -1,6 +1,6 @@ -import Image from 'next/image' import type { StaticImageData } from 'next/image' import ActionModal from '../Global/ActionModal' +import { BadgeImage } from './BadgeImage' type BadgeDetailModalProps = { isOpen: boolean @@ -15,7 +15,9 @@ type BadgeDetailModalProps = { // surfaces show the exact same modal. export const BadgeDetailModal = ({ isOpen, onClose, title, description, logo }: BadgeDetailModalProps) => ( } + icon={ + + } iconContainerClassName="bg-transparent min-w-60 h-auto" modalPanelClassName="m-0" visible={isOpen} diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index 9036f37bed..35a2ffc9e2 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -18,13 +18,13 @@ 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 { getBadgeDescription, getBadgeDisplayName, getBadgeIcon } from '@/components/Badges/badge.utils' import { useBadgeEarnToast } from '@/components/Badges/useBadgeEarnToast' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { BadgeImage } from '@/components/Badges/BadgeImage' const HOME_PATH = '/home' @@ -51,7 +51,7 @@ export default function BadgeEarnToast() { const count = badges.length const newest = badges[0] const newestName = getBadgeDisplayName(newest.code, newest.name) - const newestIcon = getBadgeIcon(newest.code) + const newestIcon = getBadgeIcon(newest.code, newest.iconUrl) // 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 @@ -65,7 +65,7 @@ export default function BadgeEarnToast() { if (count === 1) { setModalBadge({ title: newestName, - description: newest.description || getPublicBadgeDescription(newest.code) || '', + description: getBadgeDescription(newest.description) || '', logo: newestIcon, }) } else { @@ -82,7 +82,7 @@ export default function BadgeEarnToast() { className: 'border-yellow-1', content: ( + ), +})) +jest.mock('./InvitesPageLayout', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) +jest.mock('../Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
Loading
, +})) +jest.mock('../Payment/Views/Error.validation.view', () => ({ + __esModule: true, + default: ({ title, message }: { title: string; message: string }) => ( +
+

{title}

+

{message}

+
+ ), +})) +jest.mock('../Global/UnsupportedBrowserModal', () => ({ + __esModule: true, + default: () => null, +})) + +const awardedBatch = { + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'nita', badgeCode: 'NITA', outcome: 'awarded' }], +} + +describe('invite and badge campaign routing boundaries', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSearch = '' + mockAuth = { + user: { user: { userId: 'user-1', username: 'member', hasAppAccess: true } }, + isFetchingUser: false, + fetchUser: mockFetchUser, + } + mockQueryResult = { isLoading: false, isError: false } + mockClaimBadgeCampaigns.mockResolvedValue(awardedBatch) + mockQueuePendingBadgeCampaigns.mockImplementation((badgeCampaigns: readonly string[]) => [...badgeCampaigns]) + }) + + it('never derives NITA acquisition from the Juana inviter code alone', async () => { + mockSearch = 'code=juanacervio' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'juanacervio', + } + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/juanacervio')) + expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() + }) + + it('uses backend validation metadata for an authenticated code-only Offramp journey', async () => { + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'already_owned', + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['offramp'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it('falls back to the normal app when code-only legacy acquisition is unconfirmed', async () => { + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: ['offramp'], + claims: [{ badgeCampaign: 'offramp', outcome: 'retryable_error' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + }) + + it.each([ + ['token-nation-2026', 'TOKEN_NATION_SP_2026'], + ['nita', 'NITA'], + ])('source-qualifies historic UTM alias %s and follows only the typed backend outcome', async (raw, badgeCode) => { + mockSearch = `utm_campaign=${raw}` + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: `utm:${raw}`, + badgeCode, + outcome: 'awarded', + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([`utm:${raw}`])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + }) + + it.each([ + ['campaign=naija', 'naija', 'NAIJA'], + ['badge_campaign=naija', 'naija', 'NAIJA'], + ['campaign=terere', 'terere', 'TERERE'], + ['badge_campaign=terere', 'terere', 'TERERE'], + ])('keeps the bare campaign URL %s claimable without an inviter', async (search, badgeCampaign, badgeCode) => { + mockSearch = search + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign, badgeCode, outcome: 'awarded' }], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText('Invalid Invite Code')).not.toBeInTheDocument() + }) + + it.each([ + ['badge_campaign=offramp', 'offramp'], + ['campaign=offramp', 'offramp'], + ['utm_campaign=offramp', 'utm:offramp'], + ])('uses confirmed acquisition navigation for badge-campaign-only %s', async (search, badgeCampaign) => { + mockSearch = search + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign, + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + capabilities: [{ key: 'app.offramp_migration_entry' }], + acquisition: { + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + ], + }) + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it.each([ + ['badge_campaign=offramp', 'offramp'], + ['campaign=offramp', 'offramp'], + ['utm_campaign=offramp', 'utm:offramp'], + ['campaign=naija', 'naija'], + ['campaign=terere', 'terere'], + ])('queues signed-out %s for post-registration badge settlement', async (search, badgeCampaign) => { + mockAuth.user = null + mockSearch = search + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })) + + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith([badgeCampaign]) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('processes campaign independently when the inviter code is invalid', async () => { + mockSearch = 'code=bad&badge_campaign=nita' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + mockQueryResult.isError = true + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText('Invalid Invite Code')).not.toBeInTheDocument() + }) + + it('processes code plus campaign while preserving the normal inviter destination', async () => { + mockSearch = 'code=alice&badge_campaign=nita' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) + }) + + it('lets a confirmed bespoke campaign destination override a personal inviter profile', async () => { + mockSearch = 'code=alice&badge_campaign=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { + badgeCampaign: 'offramp', + outcome: 'awarded', + acquisition: { + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + ], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp')) + }) + + it('preserves a safe financial continuation over campaign and inviter navigation', async () => { + mockSearch = + 'code=alice&badge_campaign=nita&redirect_uri=%2Fclaim%3Fstep%3Dclaim%26link%3Dhttps%253A%252F%252Fpeanut.to%252Fclaim' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + + await waitFor(() => expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['nita'])) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/claim?step=claim&link=https://peanut.to/claim')) + expect(mockPush).not.toHaveBeenCalledWith('/profile/alice') + }) + + it('preserves a safe caller continuation while a published compatibility badge settles', async () => { + mockSearch = 'code=offramp&redirect_uri=%2Fclaim%3Fstep%3Dclaim%26id%3Dpayment-1' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'offramp', outcome: 'already_owned' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/claim?step=claim&id=payment-1')) + expect(mockPush).not.toHaveBeenCalledWith('/add-money/crypto?network=EVM&source=offramp') + }) + + it('shows Invalid Invite only for an invalid code with no independent campaign', async () => { + mockSearch = 'code=bad' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + mockQueryResult.isError = true + + render() + + expect(await screen.findByText('Invalid Invite Code')).toBeInTheDocument() + expect(mockClaimBadgeCampaigns).not.toHaveBeenCalled() + }) + + it('falls through to the normal app for a terminal unavailable campaign', async () => { + mockSearch = 'badge_campaign=retired' + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [{ badgeCampaign: 'retired', outcome: 'expired' }], + }) + + render() + + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(screen.queryByText(/badge unlocked/i)).not.toBeInTheDocument() + }) + + it('stores valid inviter attribution and campaign identity separately for signup', async () => { + mockAuth.user = null + mockSearch = 'code=alice&utm_campaign=summer-analytics&badge_campaign=Creator%2FSummer&badge_campaign=second' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'alice', + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'invite/code', payload: 'alice' }) + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'alice') + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['Creator/Summer', 'second']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('leaves code-only compatibility acquisition to signed-out invite acceptance', async () => { + mockAuth.user = null + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Claim your spot' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'offramp') + expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('uses onboardingResolved rather than a username-shaped field for a NONE adapter', async () => { + mockAuth.user = null + mockSearch = 'code=founderhaus' + mockQueryResult.data = { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'legacy-placeholder', + legacyAcquisition: { + campaignTag: 'founderhaus', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + + render() + + expect(await screen.findByText('Continue your Peanut campaign')).toBeInTheDocument() + expect(screen.queryByText(/legacy-placeholder invited you/i)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'founderhaus') + expect(mockQueuePendingBadgeCampaigns).not.toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('keeps a validated system inviter distinct from its generic analytics UTM during signup', async () => { + mockAuth.user = null + mockSearch = 'code=SQUIRRELINVITESYOU&utm_campaign=summer-analytics' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'arbiverseinvitesyou', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + + render() + + expect(await screen.findByText('peanut invited you to Peanut')).toBeInTheDocument() + expect(screen.queryByText('Continue your Peanut campaign')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Claim your spot' })) + + expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'squirrelinvitesyou') + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) + + it('consumes unknown analytics without blocking a typed system acquisition normal fallback', async () => { + mockSearch = 'code=SQUIRRELINVITESYOU&utm_campaign=summer-analytics' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'arbiverseinvitesyou', + fallback: 'normal_app', + destination: 'normal_app', + }, + } + mockClaimBadgeCampaigns.mockResolvedValue({ + transport: 'canonical', + pending: [], + claims: [ + { badgeCampaign: 'utm:summer-analytics', outcome: 'unknown' }, + { badgeCampaign: 'arbiverseinvitesyou', outcome: 'already_owned' }, + ], + }) + + render() + + await waitFor(() => + expect(mockClaimBadgeCampaigns).toHaveBeenCalledWith(['utm:summer-analytics', 'arbiverseinvitesyou']) + ) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/home')) + expect(mockPush).not.toHaveBeenCalledWith('/profile/peanut') + }) + + it('returns existing-user login to code-only badge acquisition before normal fallback', async () => { + mockAuth.user = null + mockSearch = 'code=offramp' + mockQueryResult.data = { + success: true, + attributionResolved: true, + onboardingResolved: true, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + render() + fireEvent.click(await screen.findByRole('button', { name: /already have an account/i })) + + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['offramp'], 30) + expect(mockSaveRedirectUrl).toHaveBeenCalledTimes(1) + expect(mockLogin).toHaveBeenCalledTimes(1) + }) + + it('does not persist a bad inviter alongside a valid campaign', async () => { + mockAuth.user = null + mockSearch = 'code=bad&utm_campaign=analytics&campaign=legacy&badge_campaign=nita' + mockQueryResult.data = { + success: false, + attributionResolved: false, + onboardingResolved: false, + username: '', + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Continue' })) + + expect(mockSaveToCookie).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + expect(mockQueuePendingBadgeCampaigns).toHaveBeenCalledWith(['nita']) + expect(mockPush).toHaveBeenCalledWith('/setup?step=signup') + }) +}) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 9dd2b39bb2..a5010a2344 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import { Suspense, useEffect, useRef, useState } from 'react' +import { Suspense, useEffect, useMemo, useRef, useState } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' @@ -13,40 +13,44 @@ import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' import { useAuth } from '@/context/authContext' import { EInviteType } from '@/services/services.types' -import { saveToCookie } from '@/utils/general.utils' +import { getValidRedirectUrl, saveRedirectUrl, saveToCookie } from '@/utils/general.utils' import { useLogin } from '@/hooks/useLogin' import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { profileUrl } from '@/utils/native-routes' -import { OFFRAMP_BADGE_CODE, classifyBareCampaign, resolveCampaign } from './campaign-maps' +import { captureException } from '@sentry/nextjs' +import { + badgeCampaignsFromSearchParams, + queuePendingBadgeCampaigns, + sanitizeBadgeCampaignIdentities, +} from './badge-campaign-context' +import { + claimAndSettlePendingBadgeCampaigns, + destinationForConfirmedBadgeCampaignAcquisition, + isConfirmedBadgeCampaignClaim, + isUnavailableBadgeCampaignClaim, +} from '@/services/badge-campaigns' +import { destinationForInviteAcquisition } from '@/services/invite-acquisition' function InvitePageContent() { const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end const inviteCode = searchParams.get('code')?.toLowerCase().replace(/\?+$/, '') const redirectUri = searchParams.get('redirect_uri') - // support 'campaign', 'campaignTag', and 'utm_campaign' query parameters - const campaignParam = searchParams.get('campaign') || searchParams.get('campaignTag') - const utmCampaignParam = searchParams.get('utm_campaign')?.toLowerCase() + const safeRedirectUri = redirectUri ? getValidRedirectUrl(redirectUri, '') : '' const { user, isFetchingUser, fetchUser } = useAuth() - // precedence + lowercase-tag mapping live in campaign-maps.ts (unit-tested) - const campaign = resolveCampaign(campaignParam, inviteCode, utmCampaignParam) - - // Bare campaign link (no invite code) that's claimable without an invite. The - // effects below treat these specially so a returning logged-in user still gets - // the badge (useZeroDev's award only fires on new-account registration). The - // copy distinguishes the two flavours: `isWaitlistSkip` (skip + event_alumni) - // promises a card-waitlist skip; vanity badges (touched_grass) are claimable - // but show generic copy. See classifyBareCampaign in campaign-maps.ts. - const { isBareClaimCampaign, isWaitlistSkip } = classifyBareCampaign(campaign, inviteCode) + // Badge campaign identities are opaque and backend-owned. The canonical + // namespace wins over legacy/UTM transports; inviter `code` is unrelated. + const urlBadgeCampaigns = useMemo(() => badgeCampaignsFromSearchParams(searchParams), [searchParams]) + const hasUrlBadgeCampaigns = urlBadgeCampaigns.length > 0 const dispatch = useAppDispatch() const router = useRouter() const { handleLoginClick, isLoggingIn } = useLogin() - const [isAwardingBadge, setIsAwardingBadge] = useState(false) - const hasStartedAwardingRef = useRef(false) + const [isClaimingBadgeCampaigns, setIsClaimingBadgeCampaigns] = useState(false) + const hasStartedBadgeClaimingRef = useRef(false) // Track if we should show content (prevents flash) const [shouldShowContent, setShouldShowContent] = useState(false) @@ -61,10 +65,25 @@ function InvitePageContent() { enabled: !!inviteCode, }) + // A published code-only compatibility path may carry backend-owned + // acquisition metadata. Combining that typed descriptor with explicit URL + // identities is not code inference: the browser forwards both opaque tags + // to the same canonical claim endpoint. + const legacyAcquisition = inviteCodeData?.legacyAcquisition + const acquisitionBadgeCampaigns = useMemo( + () => + sanitizeBadgeCampaignIdentities([ + ...urlBadgeCampaigns, + ...(legacyAcquisition ? [legacyAcquisition.campaignTag] : []), + ]), + [urlBadgeCampaigns, legacyAcquisition] + ) + const hasAcquisitionBadgeCampaigns = acquisitionBadgeCampaigns.length > 0 + // track invite page view (ref guard prevents duplicate fires when shouldShowContent toggles) const hasTrackedPageView = useRef(false) useEffect(() => { - if (shouldShowContent && inviteCodeData?.success && !hasTrackedPageView.current) { + if (shouldShowContent && inviteCodeData?.onboardingResolved && !hasTrackedPageView.current) { hasTrackedPageView.current = true posthog.capture(ANALYTICS_EVENTS.INVITE_PAGE_VIEWED, { invite_code: inviteCode, @@ -84,51 +103,98 @@ function InvitePageContent() { setShouldShowContent(false) return } - // a logged-in visitor on either claim path will be auto-routed by the + // A logged-in visitor on either claim path will be auto-routed by the // effect below — keep the loading spinner so they don't see the CTA flash. + const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns if ( user?.user && - (isBareClaimCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.success)) + (canClaimBadgeCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.onboardingResolved)) ) { setShouldShowContent(false) return } setShouldShowContent(true) - }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, isBareClaimCampaign, inviteCode]) + }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, hasAcquisitionBadgeCampaigns, inviteCode]) - // Logged-in auto-claim: award the campaign badge then push /home, or fall - // back to the inviter's profile when there's a valid invite but no campaign. - // Fires on both the invite-code path (needs hasAppAccess) and the Skip Pass - // path (badge GRANTS access, so no hasAppAccess gate). + // Logged-in auto-claim. Inviter attribution and badge acquisition are + // independent inputs: a bad inviter is ignored without blocking a valid + // badge campaign, and a badge campaign never makes a bad inviter valid. useEffect(() => { - if (!user?.user || isFetchingUser || hasStartedAwardingRef.current) return + if (!user?.user || isFetchingUser || hasStartedBadgeClaimingRef.current) return // wait for invite-code validation when there is one if (inviteCode && (isLoading || !inviteCodeData)) return - const hasValidInvite = !!inviteCodeData?.success && !!inviteCodeData.username + const hasValidInvite = !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username const isInviteAutoClaim = !redirectUri && user.user.hasAppAccess && hasValidInvite - if (!isInviteAutoClaim && !isBareClaimCampaign) return - - hasStartedAwardingRef.current = true - - if (campaign) { - setIsAwardingBadge(true) - invitesApi - .awardBadge(campaign) - .catch((e) => console.error('Error awarding campaign badge', e)) - .finally(async () => { - await fetchUser() - setIsAwardingBadge(false) - // offramp migrants came here to move their balance — land them - // directly on the migration deposit screen, not /home. + const canClaimBadgeCampaign = hasAcquisitionBadgeCampaigns + if (!isInviteAutoClaim && !canClaimBadgeCampaign) return + + hasStartedBadgeClaimingRef.current = true + + if (canClaimBadgeCampaign) { + setIsClaimingBadgeCampaigns(true) + const queuedBadgeCampaigns = queuePendingBadgeCampaigns(acquisitionBadgeCampaigns, 30) + void claimAndSettlePendingBadgeCampaigns(queuedBadgeCampaigns) + .then(async (batch) => { + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + if (confirmed.length > 0) { + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) + } + } + + const unavailable = batch.claims.some(isUnavailableBadgeCampaignClaim) + const retryable = batch.pending.length > 0 + if (unavailable) { + console.warn('Badge campaign unavailable; continuing normally', { + claims: batch.claims, + }) + } + if (retryable) { + captureException(new Error('invite-page campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + + // A validated caller continuation (notably a pending financial + // claim) outranks acquisition navigation. The small backend-owned + // destination enum is honored only after its matching claim is + // confirmed; all other outcomes fall through to the normal app. + const badgeCampaignDestination = destinationForConfirmedBadgeCampaignAcquisition(batch.claims) + const legacyDestination = legacyAcquisition + ? destinationForInviteAcquisition(legacyAcquisition, batch.claims) + : '/home' + const destination = + safeRedirectUri || + (legacyDestination !== '/home' + ? legacyDestination + : badgeCampaignDestination !== '/home' + ? badgeCampaignDestination + : legacyAcquisition + ? '/home' + : hasValidInvite + ? profileUrl(inviteCodeData!.username!) + : '/home') + router.push(destination) + }) + .catch((error) => { + captureException(error, { tags: { error_type: 'campaign_claim_unexpected_failure' } }) router.push( - campaign === OFFRAMP_BADGE_CODE ? '/add-money/crypto?network=EVM&source=offramp' : '/home' + safeRedirectUri || + (legacyAcquisition + ? '/home' + : hasValidInvite + ? profileUrl(inviteCodeData!.username!) + : '/home') ) }) return } - // No campaign on a validated invite → route to inviter profile. + // No badge campaign on a validated invite → route to inviter profile. if (hasValidInvite) { router.push(profileUrl(inviteCodeData!.username!)) } @@ -138,27 +204,33 @@ function InvitePageContent() { isLoading, isFetchingUser, router, - campaign, + acquisitionBadgeCampaigns, + hasAcquisitionBadgeCampaigns, redirectUri, + safeRedirectUri, fetchUser, - isBareClaimCampaign, inviteCode, + legacyAcquisition, ]) const handleClaim = () => { - const eventTag = inviteCode || (isBareClaimCampaign ? campaign : undefined) - posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { invite_code: eventTag }) + posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { + invite_code: inviteCode, + campaign_tags: acquisitionBadgeCampaigns, + }) - if (inviteCode) { + const hasValidInvite = !!inviteCode && !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username + const hasBackendLegacyAcceptance = !!inviteCode && !!inviteCodeData?.success && !!legacyAcquisition + if (hasValidInvite || hasBackendLegacyAcceptance) { dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) // Save to cookie so PWA-install + later signup still see the invite. saveToCookie('inviteCode', inviteCode) } - if (campaign) { - // useZeroDev reads `campaignTag` post-signup and calls /badge/award. - saveToCookie('campaignTag', campaign) - } + // Explicit URL acquisition survives signup in the shared cookie. + // Backend legacy acquisition is processed by `/invites/accept`, whose + // typed claim result is consumed after registration. + if (hasUrlBadgeCampaigns) queuePendingBadgeCampaigns(urlBadgeCampaigns) const signupUrl = redirectUri ? `/setup?step=signup&redirect_uri=${encodeURIComponent(redirectUri)}` @@ -166,14 +238,34 @@ function InvitePageContent() { router.push(signupUrl) } - if (isAwardingBadge || !shouldShowContent) { + const handleLoginWithBadgeCampaign = () => { + // Existing-user login does not call `/invites/accept`, so queue both + // explicit and backend-resolved compatibility acquisition for the + // authenticated recovery flow. + if (hasAcquisitionBadgeCampaigns) { + queuePendingBadgeCampaigns(acquisitionBadgeCampaigns, 30) + // Return to this acquisition boundary after the passkey ceremony so + // the authenticated page can settle and present the badge before its + // normal-app fallback. + saveRedirectUrl() + } + void handleLoginClick() + } + + const isDeadBareLink = !inviteCode && !hasUrlBadgeCampaigns + useEffect(() => { + if (isDeadBareLink) router.replace('/') + }, [isDeadBareLink, router]) + + if (isClaimingBadgeCampaigns || !shouldShowContent || isDeadBareLink) { return } // Invalid invite code (only reachable when an invite code was supplied). - // Bare-claim campaigns (skip / event_alumni / touched_grass) carry no invite - // code, so they bypass this gate and never show the invalid-invite screen. - if (!isBareClaimCampaign && (isError || !inviteCodeData?.success)) { + // A badge-campaign-only link has no inviter to validate. When a code is present, + // it must validate independently; badge campaigns never make a bad inviter valid. + const hasValidInvite = !!inviteCode && !!inviteCodeData?.onboardingResolved && !!inviteCodeData.username + if (inviteCode && !hasUrlBadgeCampaigns && !legacyAcquisition && (isError || !hasValidInvite)) { return (
@@ -223,7 +309,7 @@ function InvitePageContent() { disabled={isLoggingIn} loading={isLoggingIn} variant="primary-soft" - onClick={handleLoginClick} + onClick={handleLoginWithBadgeCampaign} shadowSize="4" > {loginLabel} diff --git a/src/components/Invites/JoinWaitlistPage.test.tsx b/src/components/Invites/JoinWaitlistPage.test.tsx new file mode 100644 index 0000000000..80b4278cd5 --- /dev/null +++ b/src/components/Invites/JoinWaitlistPage.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import JoinWaitlistPage from './JoinWaitlistPage' + +const mockAcceptInvite = jest.fn() +const mockFetchUser = jest.fn() +const mockRemoveFromCookie = jest.fn() +const mockSetStep = jest.fn() +const mockSettleAcceptedInviteAcquisition = jest.fn() +const mockCapture = jest.fn() + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: { user: { userId: 'user-1', email: 'member@example.com' } }, + fetchUser: mockFetchUser, + isFetchingUser: false, + logoutUser: jest.fn(), + }), +})) + +jest.mock('@/services/invites', () => ({ + invitesApi: { + acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args), + validateInviteCode: jest.fn(), + getWaitlistQueuePosition: jest.fn(), + }, +})) +jest.mock('@/services/invite-acquisition', () => ({ + settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), +})) + +jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) })) +jest.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: { success: true, position: 7 }, isLoading: false }), +})) +jest.mock('@/redux/hooks', () => ({ + useSetupStore: () => ({ inviteType: 'PAYMENT_LINK', inviteCode: '' }), +})) +jest.mock('@/hooks/useNotifications', () => ({ + useNotifications: () => ({ + requestPermission: jest.fn(), + afterPermissionAttempt: jest.fn(), + isPermissionGranted: true, + }), +})) +jest.mock('@/app/actions/users', () => ({ updateUserById: jest.fn() })) +jest.mock('nuqs', () => ({ + useQueryState: () => ['jail', mockSetStep], + parseAsStringEnum: () => ({ withDefault: () => ({}) }), +})) +jest.mock('@/utils/general.utils', () => ({ + getFromCookie: () => null, + removeFromCookie: (...args: unknown[]) => mockRemoveFromCookie(...args), + toInviteCode: (value: string) => value.trim().toLowerCase(), +})) +jest.mock('@/utils/format.utils', () => ({ isValidEmail: () => true })) +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@/assets/mascot', () => ({ + PeanutWavingHello: { src: '/waving.svg' }, + PeanutPointing: { src: '/pointing.svg' }, +})) +jest.mock('./InvitesPageLayout', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) +jest.mock('../Global/ValidatedInput', () => ({ + __esModule: true, + default: ({ + onUpdate, + }: { + onUpdate: (value: { value: string; isValid: boolean; isChanging: boolean }) => void + }) => ( + + ), +})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +jest.mock('../Global/ErrorAlert', () => ({ + __esModule: true, + default: ({ description }: { description: string }) =>
{description}
, +})) +jest.mock('../Global/PeanutLoading', () => ({ __esModule: true, default: () =>
Loading
})) +jest.mock('@/components/0_Bruddle/BaseInput', () => ({ BaseInput: () => })) + +describe('JoinWaitlistPage invite onboarding boundary', () => { + beforeEach(() => { + jest.clearAllMocks() + sessionStorage.clear() + mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) + }) + + it.each([ + ['awarded', true], + ['already_owned', true], + ['inactive', false], + ['expired', false], + ['unknown', false], + ] as const)( + 'settles a terminal %s campaign-only response and refreshes only confirmed possession', + async (outcome, shouldRefresh) => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome }], + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Enter legacy code' })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + await waitFor(() => expect(mockAcceptInvite).toHaveBeenCalledWith('offramp', 'PAYMENT_LINK')) + expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith( + expect.objectContaining({ campaignTag: 'offramp' }), + [expect.objectContaining({ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome })] + ) + expect(sessionStorage.getItem('showNoMoreJailModal')).toBeNull() + expect(mockRemoveFromCookie).toHaveBeenCalledWith('inviteCode') + if (shouldRefresh) expect(mockFetchUser).toHaveBeenCalledTimes(1) + else expect(mockFetchUser).not.toHaveBeenCalled() + expect(screen.queryByText('Something went wrong. Please try again or contact support.')).toBeNull() + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) + } + ) +}) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index b1714b0b8b..2c82f59238 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -23,8 +23,11 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { INVITER_NOT_FOUND_ERROR } from '@/constants/invites.consts' import { getFromCookie, removeFromCookie, toInviteCode } from '@/utils/general.utils' import { USERNAME_MIN_LENGTH } from '@/constants/general.consts' +import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' +import { isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' type WaitlistStep = 'email' | 'notifications' | 'jail' +type InviteAcceptanceOutcome = 'onboarding_resolved' | 'campaign_only' | 'failed' const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep => isPermissionGranted ? 'jail' : 'notifications' @@ -160,8 +163,12 @@ const JoinWaitlistPage = () => { setIsValidating(true) try { const res = await invitesApi.validateInviteCode(code) - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: res.success, source: 'waitlist_page' }) - return res.success + const onboardingResolved = res.success && res.onboardingResolved + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: onboardingResolved, + source: 'waitlist_page', + }) + return onboardingResolved } catch (e) { posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: false, source: 'waitlist_page' }) throw e @@ -171,8 +178,11 @@ const JoinWaitlistPage = () => { } // Shared by the manual Next button and the automatic attempt below. - // Returns true on success (access flipped, user refetched). - const acceptInviteWithCode = async (code: string, source: 'waitlist_page' | 'waitlist_auto'): Promise => { + // Campaign-only compatibility can settle without granting onboarding. + const acceptInviteWithCode = async ( + code: string, + source: 'waitlist_page' | 'waitlist_auto' + ): Promise => { const res = await invitesApi.acceptInvite(code, inviteType) if (!res.success) { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { @@ -180,20 +190,40 @@ const JoinWaitlistPage = () => { error_message: 'API returned unsuccessful', source, }) - return false + return 'failed' + } + if (!res.onboardingResolved) { + if (!res.legacyAcquisition) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { + invite_code: code, + error_message: 'Campaign-only response omitted acquisition descriptor', + source, + }) + return 'failed' + } + + // The adapter has done all it can. Settle terminal claims and keep + // only retryable campaign state; never retry the non-invite code. + settleAcceptedInviteAcquisition(res.legacyAcquisition, res.claims) + removeFromCookie('inviteCode') + // Provenance is audit-only. Refresh every confirmed permanent award + // so any badge-owned access/reward projection is visible immediately; + // unavailable outcomes must not masquerade as a capability change. + if (res.claims.some(isConfirmedBadgeCampaignClaim)) await fetchUser() + return 'campaign_only' } posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { invite_code: code, source }) sessionStorage.setItem('showNoMoreJailModal', 'true') removeFromCookie('inviteCode') await fetchUser() - return true + return 'onboarding_resolved' } const handleAcceptInvite = async () => { setIsAccepting(true) try { - const success = await acceptInviteWithCode(inviteCode, 'waitlist_page') - if (!success) { + const outcome = await acceptInviteWithCode(inviteCode, 'waitlist_page') + if (outcome === 'failed') { setError('Something went wrong. Please try again or contact support.') } } catch { diff --git a/src/components/Invites/badge-campaign-context.test.ts b/src/components/Invites/badge-campaign-context.test.ts new file mode 100644 index 0000000000..1338db9c3a --- /dev/null +++ b/src/components/Invites/badge-campaign-context.test.ts @@ -0,0 +1,254 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH, + MAX_RAW_BADGE_CAMPAIGN_LENGTH, + MAX_BADGE_CAMPAIGNS, + LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, + LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, + badgeCampaignForLegacyWire, + badgeCampaignsFromSearchParams, + clearPendingBadgeCampaigns, + getPendingBadgeCampaigns, + parsePendingBadgeCampaigns, + queuePendingBadgeCampaigns, + savePendingBadgeCampaigns, +} from './badge-campaign-context' +import { getFromCookie } from '@/utils/general.utils' +import { removeFromCookie, saveToCookie } from '@/utils/general.utils' + +function filesUnder(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesUnder(path) : [path] + }) +} + +describe('badge campaign identity transport', () => { + it('preserves repeated canonical badge campaign identities and their first-seen spelling', () => { + const params = new URLSearchParams( + 'badge_campaign=%20nita%20&badge_campaign=NITA&badge_campaign=Creator%2FSummer' + ) + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita', 'Creator/Summer']) + }) + + it('preserves published legacy campaign and campaignTag spellings', () => { + const params = new URLSearchParams('campaignTag=NITA&campaign=nita&campaign=second') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['NITA', 'second']) + }) + + it('enforces the backend tag count and length bounds', () => { + const params = new URLSearchParams() + for (let index = 0; index < MAX_BADGE_CAMPAIGNS + 3; index += 1) { + params.append('badge_campaign', `tag-${index}`) + } + params.append('badge_campaign', 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)) + + const badgeCampaigns = badgeCampaignsFromSearchParams(params) + expect(badgeCampaigns).toHaveLength(MAX_BADGE_CAMPAIGNS) + expect(badgeCampaigns).toEqual(Array.from({ length: MAX_BADGE_CAMPAIGNS }, (_, index) => `tag-${index}`)) + }) + + it('drops an overlong identity even when the count limit is not reached', () => { + const params = new URLSearchParams() + params.append('badge_campaign', 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)) + params.append('badge_campaign', ' valid ') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['valid']) + }) + + it('gives canonical badge campaigns precedence over legacy and analytics parameters', () => { + const params = new URLSearchParams( + 'utm_campaign=summer-analytics&campaign=legacy&campaignTag=older&badge_campaign=nita&badge_campaign=second' + ) + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita', 'second']) + }) + + it.each([ + `badge_campaign=&campaign=legacy&utm_campaign=offramp`, + `badge_campaign=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}&campaign=legacy&utm_campaign=offramp`, + ])('does not let a lower-priority transport override a rejected canonical campaign: %s', (query) => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams(query))).toEqual([]) + }) + + it('gives published legacy explicit parameters precedence over UTM analytics', () => { + const params = new URLSearchParams('utm_campaign=offramp&campaignTag=legacy-tag&campaign=second') + + expect(badgeCampaignsFromSearchParams(params)).toEqual(['legacy-tag', 'second']) + }) + + it.each([ + `campaign=&utm_campaign=offramp`, + `campaignTag=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}&utm_campaign=offramp`, + ])('does not let UTM override a rejected legacy campaign: %s', (query) => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams(query))).toEqual([]) + }) + + it('source-qualifies arbitrary UTM values without deciding whether they award', () => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=Summer-Analytics'))).toEqual([ + 'utm:Summer-Analytics', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=arbitrum'))).toEqual(['arbitrum']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=arbitrum'))).toEqual(['utm:arbitrum']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=offramp'))).toEqual(['offramp']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=offramp'))).toEqual(['utm:offramp']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=utm:offramp'))).toEqual([ + 'utm:offramp', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=utm:offramp'))).toEqual([ + 'utm:utm:offramp', + ]) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=IRL_NOMADS'))).toEqual(['IRL_NOMADS']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('badge_campaign=irl_nomads'))).toEqual(['irl_nomads']) + expect(badgeCampaignsFromSearchParams(new URLSearchParams('utm_campaign=irl-nomads'))).toEqual([ + 'utm:irl-nomads', + ]) + }) + + it('source-qualifies every published content UTM without a frontend allowlist', () => { + const publishedValues = new Set() + for (const file of filesUnder(join(process.cwd(), 'src/content'))) { + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(/utm_campaign=([^&"'\s<>)]*)/g)) { + if (match[1]) publishedValues.add(match[1]) + } + } + + // Guard the corpus scan itself with the known collision that motivated + // source qualification, then apply the generic transport invariant to + // every current and future literal value discovered above. + expect(publishedValues.has('arbitrum')).toBe(true) + expect(publishedValues.size).toBeGreaterThan(0) + for (const rawValue of publishedValues) { + expect(badgeCampaignsFromSearchParams(new URLSearchParams({ utm_campaign: rawValue }))).toEqual([ + `utm:${rawValue}`, + ]) + } + }) + + it('accepts a 64-character raw UTM value within the 68-character qualified transport bound', () => { + const maximumRaw = 'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH) + + const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(`utm_campaign=${maximumRaw}`)) + + expect(badgeCampaigns).toEqual([`utm:${maximumRaw}`]) + expect(badgeCampaigns[0]).toHaveLength(MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH) + expect( + badgeCampaignsFromSearchParams( + new URLSearchParams(`utm_campaign=${'x'.repeat(MAX_RAW_BADGE_CAMPAIGN_LENGTH + 1)}`) + ) + ).toEqual([]) + }) + + it('never infers a campaign from code-only creator attribution', () => { + expect(badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio'))).toEqual([]) + }) + + it('keeps inviter attribution and campaign acquisition independent', () => { + const params = new URLSearchParams('code=juanacervio&utm_campaign=summer-analytics&badge_campaign=nita') + + expect(params.get('code')).toBe('juanacervio') + expect(params.get('utm_campaign')).toBe('summer-analytics') + expect(badgeCampaignsFromSearchParams(params)).toEqual(['nita']) + }) + + it('adapts canonical-first URL intent to published singular campaignTag wires', () => { + expect( + badgeCampaignForLegacyWire( + new URLSearchParams( + 'utm_campaign=analytics&campaign=legacy&badge_campaign=canonical-first&badge_campaign=canonical-second' + ) + ) + ).toBe('canonical-first') + expect(badgeCampaignForLegacyWire(new URLSearchParams('campaignTag=published-legacy'))).toBe('published-legacy') + expect(badgeCampaignForLegacyWire(new URLSearchParams('utm_campaign=historic-alias'))).toBe( + 'utm:historic-alias' + ) + expect(badgeCampaignForLegacyWire(new URLSearchParams('code=creator'))).toBeUndefined() + }) + + it('round-trips array cookies losslessly and reads a legacy scalar cookie', () => { + expect(parsePendingBadgeCampaigns(['Creator/Summer', 'Tag,With,Commas'])).toEqual([ + 'Creator/Summer', + 'Tag,With,Commas', + ]) + expect(parsePendingBadgeCampaigns('legacy-tag')).toEqual(['legacy-tag']) + }) + + it('keeps the legacy key scalar while v2 stores the lossless queue', () => { + savePendingBadgeCampaigns(['single']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('single') + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['single']) + expect(getPendingBadgeCampaigns()).toEqual(['single']) + + savePendingBadgeCampaigns(['first', 'second']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('second') + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['first', 'second']) + expect(getPendingBadgeCampaigns()).toEqual(['first', 'second']) + clearPendingBadgeCampaigns() + }) + + it('migrates the previous array-shaped legacy cookie into v2 and scalarizes the old key', () => { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, ['legacy-first', 'legacy-newest']) + + expect(getPendingBadgeCampaigns()).toEqual(['legacy-first', 'legacy-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual(['legacy-first', 'legacy-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBe('legacy-newest') + + clearPendingBadgeCampaigns() + }) + + it('merges a newer old-bundle scalar without letting old clearing erase v2', () => { + savePendingBadgeCampaigns(['v2-first', 'v2-second']) + + // An older open bundle knows only campaignTag and writes a new intent. + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, 'old-bundle-newest') + expect(getPendingBadgeCampaigns()).toEqual(['v2-first', 'v2-second', 'old-bundle-newest']) + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toEqual([ + 'v2-first', + 'v2-second', + 'old-bundle-newest', + ]) + + // Its terminal cleanup removes only the scalar it understands. + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + expect(getPendingBadgeCampaigns()).toEqual(['v2-first', 'v2-second', 'old-bundle-newest']) + + clearPendingBadgeCampaigns() + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE)).toBeNull() + expect(getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE)).toBeNull() + }) + + it('keeps a newly clicked campaign when a full retry queue must evict an old entry', () => { + const oldCampaigns = Array.from({ length: MAX_BADGE_CAMPAIGNS }, (_, index) => `old-${index}`) + savePendingBadgeCampaigns(oldCampaigns) + + expect(queuePendingBadgeCampaigns(['new-live-campaign'])).toEqual([ + ...oldCampaigns.slice(1), + 'new-live-campaign', + ]) + expect(getPendingBadgeCampaigns()).toEqual([...oldCampaigns.slice(1), 'new-live-campaign']) + + clearPendingBadgeCampaigns() + }) + + it('clears bearer acquisition intent before an explicit account switch', () => { + savePendingBadgeCampaigns(['first-account-retry'], 30) + expect(getPendingBadgeCampaigns()).toEqual(['first-account-retry']) + + clearPendingBadgeCampaigns() + + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('treats a lost campaign query as no acquisition intent, never as attribution', () => { + const before = badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio&badge_campaign=nita')) + const after = badgeCampaignsFromSearchParams(new URLSearchParams('code=juanacervio')) + + expect(before).toEqual(['nita']) + expect(after).toEqual([]) + }) +}) diff --git a/src/components/Invites/badge-campaign-context.ts b/src/components/Invites/badge-campaign-context.ts new file mode 100644 index 0000000000..09b2f14670 --- /dev/null +++ b/src/components/Invites/badge-campaign-context.ts @@ -0,0 +1,218 @@ +import { + getFromCookie, + getFromLocalStorage, + removeFromCookie, + saveToCookie, + saveToLocalStorage, +} from '@/utils/general.utils' + +/** + * Badge campaigns are opaque backend-owned identities. The UI may transport them, + * but it must never translate them into badge codes or derive them from an + * inviter. In particular, `?code=juanacervio` carries no NITA campaign unless + * `?badge_campaign=nita` is also present. + */ +/** Published storage key retained for old bundles. Do not rename the string value. */ +export const LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE = 'campaignTag' +/** Published lossless queue key retained across rolling deploys. */ +export const LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE = 'campaignTagsV2' +/** Persisted account-boundary epoch; the string value is compatibility state. */ +export const PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY = 'campaignIntentEpoch' +export const BADGE_CAMPAIGN_QUERY_PARAM = 'badge_campaign' +export const MAX_BADGE_CAMPAIGNS = 20 +export const LEGACY_UTM_BADGE_CAMPAIGN_PREFIX = 'utm:' +/** Maximum raw length for explicit and UTM values before source qualification. */ +export const MAX_RAW_BADGE_CAMPAIGN_LENGTH = 64 +/** `utm:` plus the maximum 64-character raw UTM value. */ +export const MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH = + MAX_RAW_BADGE_CAMPAIGN_LENGTH + LEGACY_UTM_BADGE_CAMPAIGN_PREFIX.length + +// Explicit logout invalidates every settlement that began under the prior +// account. The durable epoch is shared by tabs; the process counter preserves +// the same guarantee if localStorage is unavailable in a restricted browser. +let processBadgeCampaignIntentGeneration = 0 + +function getDurableBadgeCampaignIntentEpoch(): number { + const value = getFromLocalStorage(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY) + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0 +} + +export type BadgeCampaignSearchParams = Pick + +export function sanitizeBadgeCampaignIdentities( + values: readonly unknown[], + maxLength = MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH +): string[] { + const badgeCampaigns: string[] = [] + const seen = new Set() + + for (const value of values) { + if (typeof value !== 'string') continue + const badgeCampaign = value.trim() + const normalized = badgeCampaign.toLowerCase() + if (badgeCampaign.length === 0 || badgeCampaign.length > maxLength || seen.has(normalized)) { + continue + } + seen.add(normalized) + badgeCampaigns.push(badgeCampaign) + if (badgeCampaigns.length === MAX_BADGE_CAMPAIGNS) break + } + + return badgeCampaigns +} + +/** + * Canonical badge campaign parameters win as a group over published legacy + * spellings, which in turn win over analytics-shaped UTM parameters. Presence + * establishes precedence even if every value in that group is malformed: a + * lower-priority parameter must never replace a rejected acquisition intent. + * + * Repeated values remain repeated identities (apart from case-insensitive + * duplicate removal), and casing/punctuation are preserved for the backend. + * `code` is deliberately not accepted by this function. + */ +function badgeCampaignsFromSearchParamsWithExplicitBound( + searchParams: BadgeCampaignSearchParams, + explicitMaxLength: number +): string[] { + const canonicalValues = searchParams.getAll(BADGE_CAMPAIGN_QUERY_PARAM) + if (canonicalValues.length > 0) { + return sanitizeBadgeCampaignIdentities(canonicalValues, explicitMaxLength) + } + + const legacyExplicitValues = [...searchParams.entries()] + .filter(([key]) => key === 'campaign' || key === 'campaignTag') + .map(([, value]) => value) + if (legacyExplicitValues.length > 0) { + return sanitizeBadgeCampaignIdentities(legacyExplicitValues, explicitMaxLength) + } + + // Source-qualify analytics-shaped input without resolving it. This prevents + // an ordinary SEO value that happens to equal a badge code from becoming an + // explicit acquisition. Only aliases explicitly marked `utm` in the backend + // badge catalog can resolve these identities to an award. + const utmValues = sanitizeBadgeCampaignIdentities( + searchParams.getAll('utm_campaign'), + MAX_RAW_BADGE_CAMPAIGN_LENGTH + ) + return sanitizeBadgeCampaignIdentities(utmValues.map((value) => `${LEGACY_UTM_BADGE_CAMPAIGN_PREFIX}${value}`)) +} + +/** Parse raw public URL values. Canonical slugs and legacy raw aliases are at most 64 characters. */ +export function badgeCampaignsFromSearchParams(searchParams: BadgeCampaignSearchParams): string[] { + return badgeCampaignsFromSearchParamsWithExplicitBound(searchParams, MAX_RAW_BADGE_CAMPAIGN_LENGTH) +} + +/** + * Deferred install payloads carry already-qualified queue identities, including + * `utm:` plus a 64-character raw value. Keep the same precedence rules without + * truncating that 68-character internal wire representation. + */ +export function badgeCampaignIdentitiesFromDeferredSearchParams(searchParams: BadgeCampaignSearchParams): string[] { + return badgeCampaignsFromSearchParamsWithExplicitBound(searchParams, MAX_BADGE_CAMPAIGN_IDENTITY_LENGTH) +} + +/** + * Published send-link claim endpoints still expose a singular `campaignTag` + * request field. Resolve their URL input through the shared namespace rules and + * forward the first identity from the winning group. New typed badge-claim + * callers must use the complete array returned above instead. + */ +export function badgeCampaignForLegacyWire(searchParams: BadgeCampaignSearchParams): string | undefined { + return badgeCampaignsFromSearchParams(searchParams)[0] +} + +/** Accept both the new array cookie and the legacy single-string cookie. */ +export function parsePendingBadgeCampaigns(value: unknown): string[] { + if (Array.isArray(value)) return sanitizeBadgeCampaignIdentities(value) + return sanitizeBadgeCampaignIdentities([value]) +} + +function mergeBadgeCampaignQueues(existing: readonly string[], incoming: readonly string[]): string[] { + const merged = [...sanitizeBadgeCampaignIdentities(existing)] + for (const badgeCampaign of sanitizeBadgeCampaignIdentities(incoming)) { + const existingIndex = merged.findIndex((candidate) => candidate.toLowerCase() === badgeCampaign.toLowerCase()) + if (existingIndex >= 0) merged.splice(existingIndex, 1) + merged.push(badgeCampaign) + if (merged.length > MAX_BADGE_CAMPAIGNS) merged.shift() + } + return merged +} + +function sameBadgeCampaignQueue(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((badgeCampaign, index) => badgeCampaign.toLowerCase() === right[index]?.toLowerCase()) + ) +} + +export function getPendingBadgeCampaigns(): string[] { + const rawV2 = getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) + const rawLegacy = getFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + const v2 = parsePendingBadgeCampaigns(rawV2) + const legacy = parsePendingBadgeCampaigns(rawLegacy) + const merged = mergeBadgeCampaignQueues(v2, legacy) + + // Upgrade a legacy scalar/array or a newer value written by an old bundle + // into the lossless queue. The old key remains scalar-only from this point + // forward. A missing legacy scalar is deliberately not reconstructed: an + // old bundle may have cleared it, but that cannot erase the v2 queue. + if (legacy.length > 0 && (!sameBadgeCampaignQueue(v2, merged) || Array.isArray(rawLegacy))) { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, merged, 30) + if (Array.isArray(rawLegacy)) { + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, merged[merged.length - 1], 30) + } + } + + return merged +} + +export function getPendingBadgeCampaignIntentGeneration(): string { + return `${processBadgeCampaignIntentGeneration}:${getDurableBadgeCampaignIntentEpoch()}` +} + +export function savePendingBadgeCampaigns(badgeCampaigns: readonly string[], expiryDays?: number): void { + const pending = sanitizeBadgeCampaignIdentities(badgeCampaigns) + if (pending.length === 0) { + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) + return + } + // V2 owns the lossless queue. The unversioned key is a compatibility mirror + // for older bundles and must always remain scalar; prefer the newest intent. + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE, pending, expiryDays) + saveToCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE, pending[pending.length - 1], expiryDays) +} + +/** Add acquisition intent without dropping an earlier retryable campaign. */ +export function queuePendingBadgeCampaigns(badgeCampaigns: readonly string[], expiryDays?: number): string[] { + const incoming = sanitizeBadgeCampaignIdentities(badgeCampaigns) + if (incoming.length === 0) return getPendingBadgeCampaigns() + + // A newly clicked acquisition must not disappear behind a cookie already at + // the contract cap. Move incoming identities to the newest end, evicting + // only the oldest retryable entries when capacity is exhausted. + const incomingKeys = new Set(incoming.map((badgeCampaign) => badgeCampaign.toLowerCase())) + const existing = getPendingBadgeCampaigns().filter( + (badgeCampaign) => !incomingKeys.has(badgeCampaign.toLowerCase()) + ) + const existingCapacity = MAX_BADGE_CAMPAIGNS - incoming.length + const retainedExisting = existingCapacity > 0 ? existing.slice(-existingCapacity) : [] + const queued = [...retainedExisting, ...incoming] + savePendingBadgeCampaigns(queued, expiryDays) + return queued +} + +/** + * Badge campaign identities are bearer acquisition intents, not user-owned preferences. + * Clear them on an intentional account switch so the next account cannot + * inherit an award. Do not call this for passive auth expiry or network loss; + * those cases retain the original user's safe retry path. + */ +export function clearPendingBadgeCampaigns(): void { + processBadgeCampaignIntentGeneration += 1 + const durableEpoch = getDurableBadgeCampaignIntentEpoch() + saveToLocalStorage(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, Math.max(Date.now(), durableEpoch + 1)) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGN_COOKIE) + removeFromCookie(LEGACY_PENDING_BADGE_CAMPAIGNS_V2_COOKIE) +} diff --git a/src/components/Invites/campaign-maps.test.ts b/src/components/Invites/campaign-maps.test.ts deleted file mode 100644 index 7e74de50e0..0000000000 --- a/src/components/Invites/campaign-maps.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { BADGES } from '@/components/Badges/badge.utils' -import { - INVITE_CODE_TO_CAMPAIGN_MAP, - UTM_CAMPAIGN_TO_BADGE_MAP, - WAITLIST_SKIP_CAMPAIGNS, - BARE_VANITY_CAMPAIGNS, - OFFRAMP_BADGE_CODE, - classifyBareCampaign, - resolveCampaign, -} from './campaign-maps' - -// Regression guard. The /invite flow resolves an inbound invite code / utm_campaign -// to one of these badge codes, carries it through signup, and the UI renders -// BADGES[code] for the awarded badge. If a code here has no BADGES entry, getBadgeIcon -// falls back to the Peanutman logo and getBadgeDisplayName returns the raw backend -// name — a silent visual regression with no conflict, no type error, no runtime throw. -// -// This is exactly how TOKEN_NATION_SP_2026 + ETHFLORIPA_HUB fell out of dev (the -// May 29 event-badge hotfixes were written against the old parallel-map shape and -// their additions evaporated when merged across the May 23 single-BADGES refactor), -// and later out of main (regressed on release, fixed by 32699f171). This test fails -// the moment a campaign code points at a badge the FE can't render. -const badgeCodes = new Set(Object.keys(BADGES)) - -describe('campaign maps reference real BADGES codes', () => { - it.each(Object.entries(UTM_CAMPAIGN_TO_BADGE_MAP))('utm_campaign "%s" → "%s" exists in BADGES', (_utm, code) => { - expect(badgeCodes).toContain(code) - }) - - it.each(Object.entries(INVITE_CODE_TO_CAMPAIGN_MAP))( - 'invite code "%s" → "%s" exists in BADGES', - (_invite, code) => { - expect(badgeCodes).toContain(code) - } - ) -}) - -describe('classifyBareCampaign', () => { - // A bare campaign (no invite code) must be claimable, or /invite dead-ends at - // the "Invalid Invite Code" screen. This is the bug that left touched_grass - // unclaimable — it was never registered as a bare campaign. - it.each([...WAITLIST_SKIP_CAMPAIGNS, ...BARE_VANITY_CAMPAIGNS])( - 'campaign "%s" is bare-claimable with no invite code', - (campaign) => { - expect(classifyBareCampaign(campaign, undefined).isBareClaimCampaign).toBe(true) - } - ) - - it('classifies waitlist-skip vs vanity, case-insensitively', () => { - // event_alumni skips the card waitlist → skip copy - expect(classifyBareCampaign('EVENT_ALUMNI', undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: true, - }) - // touched_grass is a vanity badge → claimable but NOT a waitlist skip - expect(classifyBareCampaign('TOUCHED_GRASS', undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: false, - }) - }) - - // Regression: both country-launch cohorts ship BARE links with no inviter. - // Mapping them in UTM_CAMPAIGN_TO_BADGE_MAP alone is not enough — without a - // bare-campaign registration the link dead-ends on "Invalid Invite Code". - it.each(['naija', 'terere'])('country-launch campaign "%s" is a bare claimable skip', (c) => { - expect(classifyBareCampaign(c, undefined)).toEqual({ - isBareClaimCampaign: true, - isWaitlistSkip: true, - }) - expect(classifyBareCampaign(c.toUpperCase(), undefined).isBareClaimCampaign).toBe(true) - }) - - it('only waitlist-skip campaigns promise a card-waitlist skip', () => { - for (const c of BARE_VANITY_CAMPAIGNS) { - expect(classifyBareCampaign(c, undefined).isWaitlistSkip).toBe(false) - } - }) - - it('an invite code defers to the invite flow (not bare-claimable)', () => { - expect(classifyBareCampaign('TOUCHED_GRASS', 'somecode')).toEqual({ - isBareClaimCampaign: false, - isWaitlistSkip: false, - }) - }) - - it('an unrelated or missing campaign is not bare-claimable', () => { - expect(classifyBareCampaign(undefined, undefined).isBareClaimCampaign).toBe(false) - expect(classifyBareCampaign('FOUNDER_HOUSE', undefined).isBareClaimCampaign).toBe(false) - }) -}) - -describe('resolveCampaign', () => { - // The offramp migration regression this guards: ?campaign=offramp used to - // reach /badge/award as the raw string 'offramp' and 400 (the backend matches - // badge codes). The explicit param must resolve through the UTM map first. - it('resolves a lowercase human-facing tag in the explicit param (?campaign=offramp)', () => { - expect(resolveCampaign('offramp', undefined, undefined)).toBe(OFFRAMP_BADGE_CODE) - expect(resolveCampaign('OFFRAMP', undefined, undefined)).toBe(OFFRAMP_BADGE_CODE) - }) - - it('passes an unmapped explicit param through raw (?campaign=OFFRAMP_USER)', () => { - expect(resolveCampaign('OFFRAMP_USER', undefined, undefined)).toBe('OFFRAMP_USER') - expect(resolveCampaign('FOUNDER_HOUSE', undefined, undefined)).toBe('FOUNDER_HOUSE') - }) - - it('documents that ?campaign= behaves exactly like ?utm_campaign=', () => { - for (const [utmKey, badgeCode] of Object.entries(UTM_CAMPAIGN_TO_BADGE_MAP)) { - expect(resolveCampaign(utmKey, undefined, undefined)).toBe(badgeCode) - expect(resolveCampaign(undefined, undefined, utmKey)).toBe(badgeCode) - } - }) - - it('explicit param wins over invite code and utm_campaign', () => { - expect(resolveCampaign('offramp', 'alumni', 'touched-grass')).toBe(OFFRAMP_BADGE_CODE) - }) - - it('invite code wins over utm_campaign when there is no explicit param', () => { - expect(resolveCampaign(undefined, 'offramp', 'touched-grass')).toBe(OFFRAMP_BADGE_CODE) - }) - - it('falls back to utm_campaign, and to undefined when nothing resolves', () => { - expect(resolveCampaign(undefined, undefined, 'offramp')).toBe(OFFRAMP_BADGE_CODE) - expect(resolveCampaign(undefined, 'not-a-special-code', undefined)).toBeUndefined() - expect(resolveCampaign(null, undefined, undefined)).toBeUndefined() - }) - - // The exact URL shape handed to a paid creator: a personal invite code for - // the referral plus the campaign tag for the badge. The personal code is not - // in INVITE_CODE_TO_CAMPAIGN_MAP, so the tag has to carry the badge alone. - it('resolves a creator link that pairs a personal invite code with a campaign tag', () => { - expect(resolveCampaign('nita', 'somepersonalcode', undefined)).toBe('NITA') - }) -}) diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts deleted file mode 100644 index 8035c5b10c..0000000000 --- a/src/components/Invites/campaign-maps.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Inbound campaign-resolution maps for /invite. Pure data, kept out of the -// InvitesPage client component so they can be unit-tested in isolation: every -// value here is a badge code, and if it isn't present in BADGES the awarded -// badge silently renders the Peanutman fallback + raw backend name (the -// parallel-maps→single-record regression). campaign-maps.test.ts guards that. - -// offramp.xyz → Peanut migration badge. Single FE source of truth for the code — -// the add-money entry gate (AddMoneyMethodSelection) and both maps below key on -// it. Mirrors peanut-api-ts BADGE_CODES.OFFRAMP_USER. -export const OFFRAMP_BADGE_CODE = 'OFFRAMP_USER' - -// mapping of special invite codes to their campaign tags -// when these invite codes are used, the corresponding campaign tag is automatically applied -export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { - arbiverseinvitesyou: 'ARBIVERSE_DEVCONNECT_BA_2025', - squirrelinvitesyou: 'ARBIVERSE_DEVCONNECT_BA_2025', // temporary: maps to arbiverse until 12pm noon tomorrow - founderhaus: 'FOUNDER_HOUSE', - alumni: 'EVENT_ALUMNI', - touched_grass: 'TOUCHED_GRASS', - offramp: OFFRAMP_BADGE_CODE, - irl_nomads: 'IRL_NOMADS', - survivor: 'SUPPORT_SURVIVOR', - notsoshhh: 'NOT_SO_SHHHH', - festajunina: 'FESTA_JUNINA_2026', - cardalpha: 'CARD_ALPHA', - psyops: 'PSYOPS_DIVISION', - founding: 'FOUNDING_PIONEER', - manicero: 'MANICERO', -} - -// Map inbound `utm_campaign` values to the badge codes the backend whitelists. -// Lets marketing/event links use a single UTM-shaped URL — PostHog auto-captures -// utm_* on $pageview so the same string also flows into the analytics funnel. -// Backend whitelist lives in peanut-api-ts/src/routes/badge.ts + invite.ts; keep -// in sync when adding a new entry here. -export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { - 'token-nation-2026': 'TOKEN_NATION_SP_2026', - ethfloripa: 'ETHFLORIPA_HUB', - alumni: 'EVENT_ALUMNI', - 'touched-grass': 'TOUCHED_GRASS', - offramp: OFFRAMP_BADGE_CODE, - 'festa-junina': 'FESTA_JUNINA_2026', - 'card-alpha': 'CARD_ALPHA', - 'irl-nomads': 'IRL_NOMADS', - manicero: 'MANICERO', - // Creator collab. Nita's link is /invite?code=&campaign=nita, - // so the tag arrives in the explicit param — source 1 below maps it here. Her - // own invite code stays personal, so there is no INVITE_CODE_TO_CAMPAIGN_MAP entry. - nita: 'NITA', - // Nigeria launch cohort. Arrives as ?campaign=naija; there is no - // INVITE_CODE_TO_CAMPAIGN_MAP entry because no invite code `naija` exists. - naija: 'NAIJA', - // Paraguay launch cohort. Same shape as naija above. - terere: 'TERERE', -} - -// Resolve the effective campaign (a badge code, or a raw passthrough tag) from -// the three places it can arrive on /invite. Precedence: -// 1. explicit ?campaign= / ?campaignTag= — mapped through the UTM map first so -// a human-facing lowercase token (?campaign=offramp) resolves to its badge -// code instead of reaching /badge/award raw and 400ing; unmapped values -// pass through unchanged (?campaign=OFFRAMP_USER still works). -// NOTE: this deliberately makes ?campaign= behave exactly like -// ?utm_campaign= — users and partners can't be expected to know the -// difference between the two param spellings. -// 2. a mapped special invite code (?code=offramp). -// 3. ?utm_campaign= — last so an explicit ?campaign= wins on links carrying both. -export function resolveCampaign( - campaignParam: string | null | undefined, - inviteCode: string | null | undefined, - utmCampaignParam: string | null | undefined -): string | undefined { - return ( - (campaignParam && UTM_CAMPAIGN_TO_BADGE_MAP[campaignParam.toLowerCase()]) || - campaignParam || - (inviteCode ? INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode] : undefined) || - (utmCampaignParam ? UTM_CAMPAIGN_TO_BADGE_MAP[utmCampaignParam] : undefined) || - undefined - ) -} - -// Bare ?campaign= links (no invite code) that are claimable without an invite — -// the InvitesPage auto-claim effect fires for these even for an already-logged-in -// user, and they bypass the "invalid invite" gate. Two flavours: -// - Waitlist-skip: the badge is also in peanut-api-ts SKIP_BADGE_CODES, so -// claiming it skips the card waitlist. `/invite` shows skip-the-waitlist copy. -// `skip` itself is the original bypass — the backend /badge/award also flips -// hasAppAccess + cardFlowEarlyAccessAt for it. Keep this set in sync with -// peanut-api-ts SKIP_BADGE_CODES. -// - Vanity: a commemorative badge with NO card-waitlist skip. Claimable from a -// bare link, but `/invite` shows generic badge-claim copy (not "skip"). -export const SKIP_CAMPAIGN = 'skip' -// naija and terere are the country-launch cohorts. Both are distributed as BARE -// campaign links with no inviter, and both are in peanut-api-ts -// POSTLAUNCH_SKIP_BADGE_CODES — so they belong here, not in BARE_VANITY_CAMPAIGNS -// (that set is for badges with no card-waitlist skip, and would show the wrong -// copy while the backend granted a skip anyway). -export const WAITLIST_SKIP_CAMPAIGNS: ReadonlySet = new Set([SKIP_CAMPAIGN, 'event_alumni', 'naija', 'terere']) -export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([ - 'touched_grass', - 'card_alpha', - 'festa_junina_2026', - 'irl_nomads', - 'offramp_user', - 'manicero', -]) - -export type CampaignClassification = { - /** Claimable from a bare link with no invite code (auto-claim + gate bypass). */ - isBareClaimCampaign: boolean - /** Subset of the above whose copy promises a card-waitlist skip. */ - isWaitlistSkip: boolean -} - -// Classify a resolved campaign for a visitor carrying the given invite code (if -// any). A campaign is only "bare-claimable" when there is no invite code — with -// an invite code the normal invite-validation path owns the flow. Matching is -// case-insensitive (campaign codes arrive in any case from ?campaign= URLs). -export function classifyBareCampaign( - campaign: string | undefined, - inviteCode: string | undefined -): CampaignClassification { - const key = campaign?.toLowerCase() - const isBare = !inviteCode && !!key - const isWaitlistSkip = isBare && WAITLIST_SKIP_CAMPAIGNS.has(key!) - const isVanity = isBare && BARE_VANITY_CAMPAIGNS.has(key!) - return { isBareClaimCampaign: isWaitlistSkip || isVanity, isWaitlistSkip } -} diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 72b33dfdbe..c62a878e1c 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -38,6 +38,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null earnedAt?: string | Date }> diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 5c94a7e5f6..b8923fbd8e 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -55,7 +55,7 @@ const JoinWaitlist = () => { setError('') setisLoading(true) const res = await invitesApi.validateInviteCode(inviteCode) - const isValid = res.success + const isValid = res.success && res.onboardingResolved posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: isValid, source: 'setup', diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 652be925d0..20cdb28a05 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -20,12 +20,13 @@ import { resetCrispProxySessions } from '@/utils/crisp' import { disableDemoMode } from '@/utils/demo' import posthog from 'posthog-js' import { useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' import { clearStepUpToken } from '@/services/step-up' +import { claimAndSettlePendingBadgeCampaigns, isConfirmedBadgeCampaignClaim } from '@/services/badge-campaigns' +import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' interface AuthContextType { user: IUserProfile | null @@ -62,7 +63,6 @@ const AuthContext = createContext(undefined) * adding accounts and logging out. It also provides hooks for child components to access user data and auth-related functions. */ export const AuthProvider = ({ children }: { children: ReactNode }) => { - const router = useRouter() const dispatch = useAppDispatch() const toast = useToast() const queryClient = useQueryClient() @@ -118,6 +118,40 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } }, [user]) + // Returning-user and app-restart recovery. Invite attribution is handled + // elsewhere; this only resumes opaque campaign identities after auth. The + // claim service de-dupes concurrent registration/page attempts and retains + // only retryable tags. + useEffect(() => { + const userId = user?.user.userId + if (!userId) return + + const badgeCampaigns = getPendingBadgeCampaigns() + if (badgeCampaigns.length === 0) return + + let cancelled = false + void claimAndSettlePendingBadgeCampaigns(badgeCampaigns).then(async (batch) => { + if (cancelled) return + if (batch.claims.some(isConfirmedBadgeCampaignClaim)) { + try { + await fetchUser() + } catch (error) { + captureException(error, { tags: { error_type: 'campaign_profile_refresh_failed' } }) + } + } + if (batch.pending.length > 0) { + captureException(new Error('authenticated campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { userId, pendingCampaigns: batch.pending, claims: batch.claims }, + }) + } + }) + + return () => { + cancelled = true + } + }, [user?.user.userId, fetchUser]) + const legacy_fetchUser = useCallback(async () => { const { data: fetchedUser } = await fetchUser() return fetchedUser ?? null @@ -202,6 +236,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() + // Pending badge campaigns are bearer acquisition intents. An explicit + // logout is an intentional account switch, so never let the next + // account on this browser inherit the previous account's award path. + // Passive auth expiry does not run this cleanup and retains retries. + clearPendingBadgeCampaigns() + // A cached step-up proof outliving the session would let the next user // of this device skip verification on card and withdrawal screens. clearStepUpToken() diff --git a/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx new file mode 100644 index 0000000000..c5d88674f8 --- /dev/null +++ b/src/hooks/__tests__/post-auth-redirect-consumers.test.tsx @@ -0,0 +1,78 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' +import { useAccountSetup } from '../useAccountSetup' +import { useLogin } from '../useLogin' + +const mockRouterPush = jest.fn() +const mockHandleLogin = jest.fn() +const mockAddAccount = jest.fn() +const mockToastError = jest.fn() +let explicitRedirect: string | null = null + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), + useSearchParams: () => ({ + get: (key: string) => (key === 'redirect_uri' ? explicitRedirect : null), + }), +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ + user: { user: { userId: 'user-1' } }, + addAccount: mockAddAccount, + }), +})) + +jest.mock('../useZeroDev', () => ({ + useZeroDev: () => ({ handleLogin: mockHandleLogin, isLoggingIn: false }), +})) + +jest.mock('@/components/0_Bruddle/Toast', () => ({ + useToast: () => ({ error: mockToastError }), +})) + +jest.mock('@/redux/hooks', () => ({ + useSetupStore: () => ({ telegramHandle: '' }), +})) + +jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn() })) + +const FINANCIAL_REDIRECT = '/claim?step=claim&id=payment-1' +const CAMPAIGN_REDIRECT = '/add-money/crypto?network=EVM&source=offramp' + +describe('post-auth redirect consumers', () => { + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + explicitRedirect = FINANCIAL_REDIRECT + mockHandleLogin.mockResolvedValue(undefined) + }) + + it('account setup consumes a superseded campaign redirect when the explicit financial route wins', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + const { result } = renderHook(() => useAccountSetup()) + + act(() => expect(result.current.handleRedirect()).toBe(true)) + + expect(mockRouterPush).toHaveBeenCalledWith(FINANCIAL_REDIRECT) + expect(getRedirectUrl()).toBeNull() + }) + + it('login cannot resurrect a campaign redirect after an explicit financial route consumed it', async () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + const first = renderHook(() => useLogin()) + + await act(async () => first.result.current.handleLoginClick()) + await waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith(FINANCIAL_REDIRECT)) + expect(getRedirectUrl()).toBeNull() + first.unmount() + + mockRouterPush.mockClear() + explicitRedirect = null + const later = renderHook(() => useLogin()) + + await act(async () => later.result.current.handleLoginClick()) + await waitFor(() => expect(mockRouterPush).toHaveBeenCalledWith('/home')) + }) +}) diff --git a/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx new file mode 100644 index 0000000000..25ece71f86 --- /dev/null +++ b/src/hooks/__tests__/useZeroDev-invite-onboarding.test.tsx @@ -0,0 +1,178 @@ +import { act, renderHook } from '@testing-library/react' +import { useZeroDev } from '../useZeroDev' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' + +const mockDispatch = jest.fn() +const mockAcceptInvite = jest.fn() +const mockRemoveFromCookie = jest.fn() +const mockSaveToCookie = jest.fn() +const mockSaveToLocalStorage = jest.fn() +const mockSetWebAuthnKey = jest.fn() +const mockSettleAcceptedInviteAcquisition = jest.fn() +const mockCapture = jest.fn() +const mockCaptureException = jest.fn() +const mockToWebAuthnKey = jest.fn() +const mockClaimAndSettlePendingBadgeCampaigns = jest.fn() +const mockIsConfirmedBadgeCampaignClaim = jest.fn() +const mockIsUnavailableBadgeCampaignClaim = jest.fn() +const mockPersistRegistrationBadgeCampaignDestination = jest.fn() +const mockSettleShhhhhCampaignContinuation = jest.fn() +let mockPendingBadgeCampaigns: string[] = [] + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: null, logoutUser: jest.fn() }), +})) +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ + setWebAuthnKey: mockSetWebAuthnKey, + getClientForChain: jest.fn(), + ensureClientForChain: jest.fn(), + }), +})) +jest.mock('@/context/loadingStates.context', () => { + const React = jest.requireActual('react') + return { loadingStateContext: React.createContext({ setLoadingState: jest.fn() }) } +}) +jest.mock('@/redux/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useSetupStore: () => ({ inviteCode: 'founderhaus', inviteType: 'PAYMENT_LINK' }), + useZerodevStore: () => ({ + isKernelClientReady: true, + isRegistering: false, + isLoggingIn: false, + isSendingUserOp: false, + address: undefined, + }), +})) +jest.mock('@/redux/slices/zerodev-slice', () => ({ + zerodevActions: { + resetZeroDevState: () => ({ type: 'zerodev/reset' }), + setIsRegistering: (payload: boolean) => ({ type: 'zerodev/registering', payload }), + setIsLoggingIn: (payload: boolean) => ({ type: 'zerodev/logging-in', payload }), + setIsSendingUserOp: (payload: boolean) => ({ type: 'zerodev/sending', payload }), + setAddress: (payload: string) => ({ type: 'zerodev/address', payload }), + }, +})) +jest.mock('@/redux/slices/setup-slice', () => ({ + setupActions: { + setInviteCode: (payload: string) => ({ type: 'setup/invite-code', payload }), + }, +})) +jest.mock('@/utils/general.utils', () => ({ + getFromCookie: (key: string) => (key === 'inviteCode' ? 'founderhaus' : null), + removeFromCookie: (...args: unknown[]) => mockRemoveFromCookie(...args), + saveToCookie: (...args: unknown[]) => mockSaveToCookie(...args), + saveToLocalStorage: (...args: unknown[]) => mockSaveToLocalStorage(...args), +})) +jest.mock('@zerodev/passkey-validator', () => ({ + toWebAuthnKey: (...args: unknown[]) => mockToWebAuthnKey(...args), + WebAuthnMode: { Register: 'Register', Login: 'Login' }, +})) +jest.mock('@/services/invites', () => ({ + invitesApi: { acceptInvite: (...args: unknown[]) => mockAcceptInvite(...args) }, +})) +jest.mock('@/services/invite-acquisition', () => ({ + settleAcceptedInviteAcquisition: (...args: unknown[]) => mockSettleAcceptedInviteAcquisition(...args), +})) +jest.mock('@/services/registration-acquisition', () => ({ + persistRegistrationBadgeCampaignDestination: (...args: unknown[]) => + mockPersistRegistrationBadgeCampaignDestination(...args), +})) +jest.mock('@/app/shhhhh/shhhhh-acquisition', () => ({ + settleShhhhhCampaignContinuation: (...args: unknown[]) => mockSettleShhhhhCampaignContinuation(...args), +})) +jest.mock('@/components/Invites/badge-campaign-context', () => ({ + getPendingBadgeCampaigns: () => mockPendingBadgeCampaigns, +})) +jest.mock('@/services/badge-campaigns', () => ({ + claimAndSettlePendingBadgeCampaigns: (...args: unknown[]) => mockClaimAndSettlePendingBadgeCampaigns(...args), + isConfirmedBadgeCampaignClaim: (...args: unknown[]) => mockIsConfirmedBadgeCampaignClaim(...args), + isUnavailableBadgeCampaignClaim: (...args: unknown[]) => mockIsUnavailableBadgeCampaignClaim(...args), +})) +jest.mock('@/services/consent', () => ({ signupConsentDocuments: () => [] })) +jest.mock('@/utils/auth.utils', () => ({ clearAuthState: jest.fn() })) +jest.mock('@/utils/walletCredential.utils', () => ({ + isStaleKeyError: () => false, + createStaleSessionError: () => new Error('stale'), +})) +jest.mock('@/utils/webauthn.utils', () => ({ + capturePasskeySignFailure: jest.fn(), + classifyPasskeyError: () => ({ code: 'UNKNOWN', message: 'unknown' }), +})) +jest.mock('@sentry/nextjs', () => ({ captureException: (...args: unknown[]) => mockCaptureException(...args) })) +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'localhost' })) +jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) + +describe('useZeroDev registration invite boundary', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPendingBadgeCampaigns = [] + mockToWebAuthnKey.mockResolvedValue({ id: 'new-passkey' }) + mockSettleAcceptedInviteAcquisition.mockReturnValue({ destination: '/home', pending: [] }) + mockSettleShhhhhCampaignContinuation.mockReturnValue(undefined) + mockIsConfirmedBadgeCampaignClaim.mockImplementation( + (claim: { outcome?: string }) => claim.outcome === 'awarded' || claim.outcome === 'already_owned' + ) + mockIsUnavailableBadgeCampaignClaim.mockImplementation((claim: { outcome?: string }) => + ['inactive', 'expired', 'unknown'].includes(claim.outcome ?? '') + ) + }) + + it.each(['awarded', 'inactive'] as const)( + 'settles a terminal %s campaign-only response without retaining an invite retry or reporting failure', + async (outcome) => { + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'founderhaus', + fallback: 'normal_app', + destination: 'normal_app', + }, + claims: [{ badgeCampaign: 'founderhaus', outcome }], + }) + const { result } = renderHook(() => useZeroDev()) + + await act(async () => result.current.handleRegister('new-user')) + + expect(mockSettleAcceptedInviteAcquisition).toHaveBeenCalledWith( + expect.objectContaining({ campaignTag: 'founderhaus' }), + [expect.objectContaining({ badgeCampaign: 'founderhaus', outcome })] + ) + expect(mockRemoveFromCookie).toHaveBeenCalledWith('inviteCode') + expect(mockDispatch).toHaveBeenCalledWith({ type: 'setup/invite-code', payload: '' }) + expect(mockSaveToCookie).not.toHaveBeenCalledWith('inviteCode', expect.anything(), expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPTED, expect.anything()) + expect(mockCapture).not.toHaveBeenCalledWith(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, expect.anything()) + expect(mockCaptureException).not.toHaveBeenCalled() + expect(mockSetWebAuthnKey).toHaveBeenCalledWith({ id: 'new-passkey' }) + } + ) + + it('settles the signed-out Shhhhh continuation from the typed registration claim', async () => { + const claims = [{ badgeCampaign: 'skip', badgeCode: 'WAITLIST_SKIP', outcome: 'awarded' as const }] + mockPendingBadgeCampaigns = ['skip'] + mockAcceptInvite.mockResolvedValue({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + mockClaimAndSettlePendingBadgeCampaigns.mockResolvedValue({ + claims, + pending: [], + transport: 'canonical', + }) + mockSettleShhhhhCampaignContinuation.mockReturnValue('/card') + const { result } = renderHook(() => useZeroDev()) + + await act(async () => result.current.handleRegister('new-user')) + + expect(mockClaimAndSettlePendingBadgeCampaigns).toHaveBeenCalledWith(['skip']) + expect(mockSettleShhhhhCampaignContinuation).toHaveBeenCalledWith(claims) + expect(mockPersistRegistrationBadgeCampaignDestination).not.toHaveBeenCalled() + expect(mockSetWebAuthnKey).toHaveBeenCalledWith({ id: 'new-passkey' }) + }) +}) diff --git a/src/hooks/useAccountSetup.ts b/src/hooks/useAccountSetup.ts index abb471b21b..2efec44743 100644 --- a/src/hooks/useAccountSetup.ts +++ b/src/hooks/useAccountSetup.ts @@ -2,11 +2,11 @@ import { useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import * as Sentry from '@sentry/nextjs' import { useAuth } from '@/context/authContext' -import { WalletProviderType } from '@/interfaces' -import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' +import { WalletProviderType } from '@/interfaces/wallet.interfaces' import { clearAuthState } from '@/utils/auth.utils' import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts' import { useSetupStore } from '@/redux/hooks' +import { consumePostAuthRedirect } from '@/services/post-auth-redirect' /** * shared hook for finalizing account setup after test transaction succeeds @@ -22,31 +22,14 @@ export const useAccountSetup = () => { const [isProcessing, setIsProcessing] = useState(false) const handleRedirect = (): boolean => { - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') - console.log('[useAccountSetup] Redirecting to redirect_uri:', validRedirectUrl) - router.push(validRedirectUrl) - return true - } - - const localStorageRedirect = getRedirectUrl() - if (localStorageRedirect) { - const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(localStorageRedirect)) - if (matchedAction) { - console.log('[useAccountSetup] Matched post-signup action, redirecting to /home') - router.push('/home') - } else { - clearRedirectUrl() - const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home') - console.log('[useAccountSetup] Redirecting to localStorage redirect:', validRedirectUrl) - router.push(validRedirectUrl) - } - } else { - console.log('[useAccountSetup] No redirect found, going to /home') - router.push('/home') - } - return false + const redirect = consumePostAuthRedirect(searchParams.get('redirect_uri'), { + deferStoredRedirect: (destination) => + POST_SIGNUP_ACTIONS.some((action) => action.pathPattern.test(destination)), + }) + + console.log('[useAccountSetup] Resolved post-auth redirect:', redirect) + router.push(redirect.destination) + return redirect.source === 'explicit' } /** diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 095aea254c..5099804e4e 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -16,7 +16,7 @@ import { useGeoLocation } from './useGeoLocation' import { useCardInfo } from './useCardInfo' import { useActivationStatus } from './useActivationStatus' import { useTransactionHistory } from './useTransactionHistory' -import { STAR_STRAIGHT_ICON } from '@/assets' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useToast } from '@/components/0_Bruddle/Toast' @@ -96,11 +96,7 @@ export const useHomeCarouselCTAs = () => { const { setIsQRScannerOpen } = useModalsContext() const { countryCode: userCountryCode } = useGeoLocation() - const { - isEligible: isCardPioneerEligible, - hasCardAccess: hasCardAccessGranted, - isLoading: isCardPioneerLoading, - } = useCardInfo() + const { hasCardAccess: hasCardAccessGranted } = useCardInfo() const { isActivated } = useActivationStatus() // Completion signals — used to hide educational CTAs from users who've already @@ -326,7 +322,6 @@ export const useHomeCarouselCTAs = () => { setCarouselCTAs(_carouselCTAs.filter((cta) => !dismissedRef.current.has(cta.id))) }, [ - user?.user?.userId, isPermissionGranted, isPermissionDenied, isPushOptedIn, @@ -340,9 +335,7 @@ export const useHomeCarouselCTAs = () => { deviceType, isPwa, userCountryCode, - isCardPioneerEligible, hasCardAccessGranted, - isCardPioneerLoading, isActivated, hasMadeQrPayment, hasSentInvites, diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx index 475694e062..4a4771a012 100644 --- a/src/hooks/useLogin.tsx +++ b/src/hooks/useLogin.tsx @@ -3,8 +3,8 @@ import { useAuth } from '@/context/authContext' import { useZeroDev } from './useZeroDev' import { captureMessage } from '@sentry/nextjs' import { useEffect, useState } from 'react' -import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' import { useRouter, useSearchParams } from 'next/navigation' +import { consumePostAuthRedirect } from '@/services/post-auth-redirect' // how long we wait for the user object after a successful passkey ceremony const POST_LOGIN_USER_TIMEOUT_MS = 15_000 @@ -40,19 +40,8 @@ export const useLogin = () => { useEffect(() => { // run only if login button is clicked to prevent un-intentional redirects if (isloginClicked && user) { - // redirect based on query params or saved redirect url - const localStorageRedirect = getRedirectUrl() - const redirect_uri = searchParams.get('redirect_uri') - if (redirect_uri) { - const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home') - router.push(validRedirectUrl) - } else if (localStorageRedirect) { - clearRedirectUrl() - const validRedirectUrl = getValidRedirectUrl(String(localStorageRedirect), '/home') - router.push(validRedirectUrl) - } else { - router.push('/home') - } + const redirect = consumePostAuthRedirect(searchParams.get('redirect_uri')) + router.push(redirect.destination) setIsloginClicked(false) setLoginResolved(false) } diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index ff58fd5628..93e74e78a0 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -5,8 +5,9 @@ import { loadingStateContext } from '@/context/loadingStates.context' import { useAuth } from '@/context/authContext' import { useKernelClient } from '@/context/kernelClient.context' import { useAppDispatch, useSetupStore, useZerodevStore } from '@/redux/hooks' +import { setupActions } from '@/redux/slices/setup-slice' import { zerodevActions } from '@/redux/slices/zerodev-slice' -import { getFromCookie, removeFromCookie, saveToCookie } from '@/utils/general.utils' +import { getFromCookie, removeFromCookie, saveToCookie, saveToLocalStorage } from '@/utils/general.utils' import { clearAuthState } from '@/utils/auth.utils' import { isStaleKeyError, createStaleSessionError } from '@/utils/walletCredential.utils' import { capturePasskeySignFailure, classifyPasskeyError } from '@/utils/webauthn.utils' @@ -15,6 +16,15 @@ import { useCallback, useContext } from 'react' import type { TransactionReceipt, Hex, Hash } from 'viem' import { captureException } from '@sentry/nextjs' import { invitesApi } from '@/services/invites' +import { + claimAndSettlePendingBadgeCampaigns, + isConfirmedBadgeCampaignClaim, + isUnavailableBadgeCampaignClaim, +} from '@/services/badge-campaigns' +import { settleAcceptedInviteAcquisition } from '@/services/invite-acquisition' +import { persistRegistrationBadgeCampaignDestination } from '@/services/registration-acquisition' +import { getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' +import { settleShhhhhCampaignContinuation } from '@/app/shhhhh/shhhhh-acquisition' import { signupConsentDocuments } from '@/services/consent' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -85,7 +95,7 @@ export const useZeroDev = () => { // invite code can also be store in cookies, so we need to check both const userInviteCode = inviteCode || inviteCodeFromCookie - const campaignTag = getFromCookie('campaignTag') + const badgeCampaigns = getPendingBadgeCampaigns() if (userInviteCode?.trim().length > 0) { /* @@ -97,26 +107,62 @@ export const useZeroDev = () => { * The cookie is only cleared on confirmed success. */ const keepInviteCodeForRetry = () => saveToCookie('inviteCode', userInviteCode, 30) + const clearAcceptedInviteCode = () => { + removeFromCookie('inviteCode') + dispatch(setupActions.setInviteCode('')) + } try { - const result = await invitesApi.acceptInvite(userInviteCode, inviteType, campaignTag) + const result = await invitesApi.acceptInvite(userInviteCode, inviteType) + let campaignOnlyProcessed = false if (result.success) { + if (result.legacyAcquisition) { + const acceptedBadgeCampaigns = [result.legacyAcquisition.campaignTag] + const { destination, pending } = settleAcceptedInviteAcquisition( + result.legacyAcquisition, + result.claims + ) + + // Keep an already-published migration continuation + // through setup only after the matching claim confirms. + if (destination !== '/home') saveToLocalStorage('redirect', destination) + if (pending.some((tag) => tag.toLowerCase() === acceptedBadgeCampaigns[0].toLowerCase())) { + captureException(new Error('accept-time legacy acquisition retained for retry'), { + tags: { error_type: 'invite_accept_campaign_retryable' }, + extra: { + inviteCode: userInviteCode, + pendingCampaigns: pending, + claims: result.claims, + }, + }) + } + if (!result.onboardingResolved) { + // A NONE adapter is not an invite retry. Its + // terminal claim is done; any retryable state is + // now carried solely by the versioned campaign queue. + campaignOnlyProcessed = true + clearAcceptedInviteCode() + } + } + } + + if (result.success && result.onboardingResolved) { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { invite_code: userInviteCode, invite_type: inviteType, - campaign_tag: campaignTag, + campaign_tags: badgeCampaigns, }) - if (inviteCodeFromCookie) { - removeFromCookie('inviteCode') - } - if (campaignTag) { - removeFromCookie('campaignTag') - } + clearAcceptedInviteCode() + } else if (campaignOnlyProcessed) { + // Deliberately no onboarding-failed analytics/Sentry: + // campaign-only compatibility resolved as designed. } else { posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, { invite_code: userInviteCode, - error_message: 'API returned unsuccessful', + error_message: result.success + ? 'Invite did not resolve onboarding' + : 'API returned unsuccessful', }) - captureException(new Error('register-time invite accept returned unsuccessful'), { + captureException(new Error('register-time invite onboarding unresolved'), { tags: { error_type: 'invite_accept_failed' }, extra: { inviteCode: userInviteCode, result }, }) @@ -135,18 +181,44 @@ export const useZeroDev = () => { keepInviteCodeForRetry() console.error('Error accepting invite', e) } - } else if (campaignTag) { - // No invite code but a campaign tag — only InvitesPage's skip-path - // CTA reaches here today (it sets the cookie without an inviteCode). - // The BE whitelists which campaigns are claimable, so passing other - // values through is safe — anything not on the whitelist 400s. - try { - await invitesApi.awardBadge(campaignTag) - posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: campaignTag }) - } catch (e) { - console.error('Error awarding campaign badge', e) - } finally { - removeFromCookie('campaignTag') + } + + // Campaign acquisition is independent from invite attribution. It + // runs after authentication whether or not an invite was present, + // and per-tag settlement keeps only transport/configuration retries. + // Re-read after `/invites/accept`: a confirmed legacy adapter may + // have settled the same tag, while a malformed/missing result may + // have queued it for an immediate canonical retry. + const pendingBadgeCampaigns = getPendingBadgeCampaigns() + if (pendingBadgeCampaigns.length > 0) { + const batch = await claimAndSettlePendingBadgeCampaigns(pendingBadgeCampaigns) + const confirmed = batch.claims.filter(isConfirmedBadgeCampaignClaim) + const unavailable = batch.claims.filter(isUnavailableBadgeCampaignClaim) + + // Explicit/UTM badge campaigns do not pass through `/invites/accept`. + // Shhhhh owns one compatibility continuation: only a confirmed + // Skip Pass replaces its safe /home marker with /card. Every + // other entrypoint uses backend-owned acquisition navigation. + const shhhhhDestination = settleShhhhhCampaignContinuation(batch.claims) + if (shhhhhDestination === undefined) persistRegistrationBadgeCampaignDestination(batch.claims) + + if (confirmed.length > 0) { + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { + campaign_tags: confirmed.map((claim) => claim.badgeCampaign), + badge_codes: confirmed.map((claim) => claim.badgeCode).filter(Boolean), + }) + } + if (unavailable.length > 0) { + console.warn( + 'Campaign unavailable during registration', + unavailable.map(({ badgeCampaign, outcome }) => ({ badgeCampaign, outcome })) + ) + } + if (batch.pending.length > 0) { + captureException(new Error('register-time campaign claim retained for retry'), { + tags: { error_type: 'campaign_claim_retryable' }, + extra: { pendingCampaigns: batch.pending, claims: batch.claims }, + }) } } @@ -158,7 +230,8 @@ export const useZeroDev = () => { } const err = e as Error console.error('[useZeroDev] registration failed:', err.name, err.message, err, { - shimInstalled: (globalThis as any).__capgoPasskeyShimInstalled, + shimInstalled: (globalThis as typeof globalThis & { __capgoPasskeyShimInstalled?: boolean }) + .__capgoPasskeyShimInstalled, }) dispatch(zerodevActions.setIsRegistering(false)) throw e diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 403e5dfb43..89b894565b 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -209,6 +209,7 @@ export interface User { code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null color: string | null earnedAt: string | Date diff --git a/src/services/__tests__/badge-campaigns.test.ts b/src/services/__tests__/badge-campaigns.test.ts new file mode 100644 index 0000000000..63a30e505c --- /dev/null +++ b/src/services/__tests__/badge-campaigns.test.ts @@ -0,0 +1,515 @@ +import { + claimAndSettlePendingBadgeCampaigns, + claimBadgeCampaigns, + destinationForConfirmedBadgeCampaignAcquisition, + pendingBadgeCampaignsAfterClaims, +} from '../badge-campaigns' +import { + PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, + clearPendingBadgeCampaigns, + getPendingBadgeCampaigns, + queuePendingBadgeCampaigns, + savePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' +import { serverFetch } from '@/utils/api-fetch' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +function claim(badgeCampaign: string, outcome: string, badgeCode?: string) { + return { + badgeCampaign, + outcome, + ...(badgeCode ? { badgeCode } : {}), + } +} + +function legacyClaim(campaignTag: string, outcome: string, badgeCode?: string) { + return { + campaignTag, + outcome, + ...(badgeCode ? { badgeCode } : {}), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('badge badge campaign claims contract', () => { + beforeEach(() => { + mockServerFetch.mockReset() + clearPendingBadgeCampaigns() + }) + + afterAll(() => clearPendingBadgeCampaigns()) + + it('trims, case-insensitively dedupes, and forwards opaque identities in one canonical request', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [claim('NITA', 'awarded', 'NITA'), claim('Creator/Summer', 'already_owned')], + }) + ) + + const result = await claimBadgeCampaigns([' NITA ', 'nita', 'Creator/Summer']) + + expect(result.transport).toBe('canonical') + expect(result.claims.map(({ badgeCampaign }) => badgeCampaign)).toEqual(['NITA', 'Creator/Summer']) + expect(mockServerFetch).toHaveBeenCalledTimes(1) + expect(mockServerFetch).toHaveBeenCalledWith('/badge/claims', { + method: 'POST', + body: JSON.stringify({ badgeCampaigns: ['NITA', 'Creator/Summer'] }), + }) + expect(mockServerFetch.mock.calls[0][1]?.body).not.toContain('code') + }) + + it('normalizes a rolling-deploy legacy response echo at the runtime boundary', async () => { + mockServerFetch.mockResolvedValue( + response(200, { claims: [legacyClaim('published-legacy', 'already_owned', 'LEGACY_BADGE')] }) + ) + + const result = await claimBadgeCampaigns(['published-legacy']) + + expect(result.claims).toEqual([ + { + badgeCampaign: 'published-legacy', + badgeCode: 'LEGACY_BADGE', + outcome: 'already_owned', + }, + ]) + }) + + it('settles source-qualified UTM identities only from typed backend outcomes', async () => { + const qualifiedIdentities = [ + 'utm:token-nation-2026', + 'utm:touched-grass', + 'utm:festa-junina', + 'utm:card-alpha', + 'utm:irl-nomads', + ] + mockServerFetch.mockResolvedValue( + response(200, { + claims: qualifiedIdentities.map((badgeCampaign) => claim(badgeCampaign, 'awarded')), + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(qualifiedIdentities) + + expect(JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body))).toEqual({ + badgeCampaigns: qualifiedIdentities, + }) + expect(result.claims.map(({ badgeCampaign }) => badgeCampaign)).toEqual(qualifiedIdentities) + expect(result.pending).toEqual([]) + }) + + it('accepts a catalog badge whose optional artwork is null', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'new-badge', + badgeCode: 'NEW_BADGE', + badge: { + code: 'NEW_BADGE', + name: 'New Badge', + description: null, + publicDescription: null, + iconUrl: null, + }, + outcome: 'awarded', + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['new-badge']) + + expect(result.claims[0]).toMatchObject({ outcome: 'awarded', badge: { iconUrl: null } }) + expect(result.pending).toEqual([]) + }) + + it('aligns backend echoes case-insensitively and retains only definition_missing in a mixed batch', async () => { + savePendingBadgeCampaigns(['NITA', 'Old', 'Mystery', 'Deploying']) + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + claim('nita', 'awarded', 'NITA'), + claim('old', 'expired'), + claim('MYSTERY', 'unknown'), + claim('deploying', 'definition_missing'), + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns() + + expect(result.pending).toEqual(['Deploying']) + expect(getPendingBadgeCampaigns()).toEqual(['Deploying']) + }) + + it.each(['awarded', 'already_owned', 'inactive', 'expired', 'unknown'])( + 'consumes the terminal %s outcome', + async (outcome) => { + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('campaign', outcome)] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['campaign']) + + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + } + ) + + it('retains a requested identity omitted from an otherwise valid response', async () => { + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('one', 'awarded')] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['one', 'two']) + + expect(result.claims[1]).toMatchObject({ badgeCampaign: 'two', outcome: 'retryable_error' }) + expect(result.pending).toEqual(['two']) + expect(getPendingBadgeCampaigns()).toEqual(['two']) + }) + + it.each([ + {}, + { claims: null }, + { + claims: [ + { + badgeCampaign: 123, + campaignTag: 'one', + outcome: 'awarded', + }, + ], + }, + { + claims: [ + { + badgeCampaign: 'one', + outcome: 'awarded', + badgeCode: 123, + }, + ], + }, + { + claims: [ + { + badgeCampaign: 'one', + outcome: 'awarded', + badge: { code: 'ONE', name: 'One', description: null, iconUrl: 123 }, + }, + ], + }, + ])('retains the identity for a malformed canonical 200 body %#', async (body) => { + mockServerFetch.mockResolvedValue(response(200, body)) + + const result = await claimAndSettlePendingBadgeCampaigns(['one']) + + expect(result.claims[0]).toMatchObject({ badgeCampaign: 'one', outcome: 'retryable_error' }) + expect(result.pending).toEqual(['one']) + }) + + it('uses a present canonical response identity instead of a conflicting legacy echo', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [{ badgeCampaign: 'one', campaignTag: 'other', outcome: 'awarded' }], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['one']) + + expect(result.claims).toEqual([{ badgeCampaign: 'one', outcome: 'awarded' }]) + expect(result.pending).toEqual([]) + }) + + it('settles valid entries while retaining a malformed entry from the same 200 response', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + claim('valid', 'awarded'), + { + badgeCampaign: 'partial', + outcome: 'awarded', + badgeCode: 123, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['valid', 'partial']) + + expect(result.claims.map(({ outcome }) => outcome)).toEqual(['awarded', 'retryable_error']) + expect(result.pending).toEqual(['partial']) + }) + + it('does not drop a different pending campaign while settling a subset', async () => { + savePendingBadgeCampaigns(['Claimed-Now', 'Earlier-Retry']) + mockServerFetch.mockResolvedValue(response(200, { claims: [claim('claimed-now', 'awarded')] })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Claimed-Now']) + + expect(result.pending).toEqual(['Earlier-Retry']) + expect(getPendingBadgeCampaigns()).toEqual(['Earlier-Retry']) + }) + + it.each(['older-first', 'newer-first'] as const)( + 'merge-safely settles concurrent cookie batches when %s completes', + async (completionOrder) => { + const olderResponse = deferred() + const newerResponse = deferred() + mockServerFetch.mockImplementation((_path, options) => { + const badgeCampaigns = JSON.parse(String(options?.body)).badgeCampaigns as string[] + return badgeCampaigns[0] === 'Older' ? olderResponse.promise : newerResponse.promise + }) + + savePendingBadgeCampaigns(['Older']) + const olderClaim = claimAndSettlePendingBadgeCampaigns(['Older']) + queuePendingBadgeCampaigns(['Newer']) + const newerClaim = claimAndSettlePendingBadgeCampaigns(['Newer']) + + if (completionOrder === 'older-first') { + olderResponse.resolve(response(200, { claims: [claim('Older', 'awarded')] })) + await olderClaim + expect(getPendingBadgeCampaigns()).toEqual(['Newer']) + newerResponse.resolve(response(200, { claims: [claim('Newer', 'awarded')] })) + await newerClaim + } else { + newerResponse.resolve(response(200, { claims: [claim('Newer', 'awarded')] })) + await newerClaim + expect(getPendingBadgeCampaigns()).toEqual(['Older']) + olderResponse.resolve(response(200, { claims: [claim('Older', 'awarded')] })) + await olderClaim + } + + expect(getPendingBadgeCampaigns()).toEqual([]) + } + ) + + it.each([401, 408, 425, 429, 500, 503])('retains badge campaigns after retryable HTTP %s', async (status) => { + mockServerFetch.mockResolvedValue(response(status, { error: 'retry later' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Retry-Me']) + + expect(result.claims).toEqual([ + expect.objectContaining({ badgeCampaign: 'Retry-Me', outcome: 'retryable_error', httpStatus: status }), + ]) + expect(result.pending).toEqual(['Retry-Me']) + }) + + it('does not resurrect retryable intent when explicit logout wins an in-flight claim race', async () => { + const pendingResponse = deferred() + mockServerFetch.mockReturnValue(pendingResponse.promise) + savePendingBadgeCampaigns(['First-Account']) + + const inFlightClaim = claimAndSettlePendingBadgeCampaigns() + clearPendingBadgeCampaigns() + pendingResponse.resolve(response(503, { error: 'retry later' })) + + const result = await inFlightClaim + expect(result.claims[0]).toMatchObject({ badgeCampaign: 'First-Account', outcome: 'retryable_error' }) + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('does not resurrect retryable intent after another tab advances the logout epoch', async () => { + const pendingResponse = deferred() + mockServerFetch.mockReturnValue(pendingResponse.promise) + savePendingBadgeCampaigns(['First-Account']) + + const inFlightClaim = claimAndSettlePendingBadgeCampaigns() + const currentEpoch = JSON.parse( + localStorage.getItem(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY) ?? '0' + ) as number + localStorage.setItem(PENDING_BADGE_CAMPAIGN_INTENT_EPOCH_STORAGE_KEY, JSON.stringify(currentEpoch + 1)) + savePendingBadgeCampaigns([]) + pendingResponse.resolve(response(503, { error: 'retry later' })) + + const result = await inFlightClaim + expect(result.pending).toEqual([]) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('never reuses an in-flight campaign response across an explicit account switch', async () => { + const firstAccountResponse = deferred() + const secondAccountResponse = deferred() + mockServerFetch + .mockReturnValueOnce(firstAccountResponse.promise) + .mockReturnValueOnce(secondAccountResponse.promise) + savePendingBadgeCampaigns(['Shared-Campaign']) + + const firstAccountClaim = claimAndSettlePendingBadgeCampaigns() + clearPendingBadgeCampaigns() + queuePendingBadgeCampaigns(['Shared-Campaign']) + const secondAccountClaim = claimAndSettlePendingBadgeCampaigns() + + expect(mockServerFetch).toHaveBeenCalledTimes(2) + firstAccountResponse.resolve( + response(200, { claims: [claim('Shared-Campaign', 'awarded', 'FIRST_ACCOUNT_BADGE')] }) + ) + const staleResult = await firstAccountClaim + expect(staleResult.pending).toEqual(['Shared-Campaign']) + expect(getPendingBadgeCampaigns()).toEqual(['Shared-Campaign']) + + secondAccountResponse.resolve(response(503, { error: 'retry later' })) + const currentResult = await secondAccountClaim + expect(currentResult.claims[0]).toMatchObject({ outcome: 'retryable_error', httpStatus: 503 }) + expect(currentResult.pending).toEqual(['Shared-Campaign']) + expect(getPendingBadgeCampaigns()).toEqual(['Shared-Campaign']) + }) + + it('retains badge campaigns after a network failure', async () => { + mockServerFetch.mockRejectedValue(new Error('offline')) + + const result = await claimAndSettlePendingBadgeCampaigns(['Retry-Me']) + + expect(result.claims[0].outcome).toBe('retryable_error') + expect(result.pending).toEqual(['Retry-Me']) + }) + + it.each([400, 403, 422])('consumes canonical terminal schema HTTP %s without legacy fallback', async (status) => { + mockServerFetch.mockResolvedValue(response(status, { error: 'invalid campaign input' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Bad']) + + expect(result.claims[0]).toMatchObject({ outcome: 'unknown', httpStatus: status }) + expect(result.pending).toEqual([]) + expect(mockServerFetch).toHaveBeenCalledTimes(1) + }) + + it.each([404, 405])('uses the compatibility endpoint only when canonical returns %s', async (status) => { + mockServerFetch + .mockResolvedValueOnce(response(status, { error: 'not deployed' })) + .mockResolvedValueOnce(response(200, { claim: legacyClaim('Legacy', 'awarded', 'LEGACY_BADGE') })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.transport).toBe('legacy') + expect(result.claims[0]).toMatchObject({ outcome: 'awarded', badgeCode: 'LEGACY_BADGE' }) + expect(result.pending).toEqual([]) + expect(mockServerFetch).toHaveBeenNthCalledWith(2, '/badge/award', { + method: 'POST', + body: JSON.stringify({ campaignTag: 'Legacy' }), + }) + }) + + it('retains an unconfirmed legacy 200 instead of reporting false success', async () => { + mockServerFetch + .mockResolvedValueOnce(response(404, null)) + .mockResolvedValueOnce(response(200, { message: 'Badge awarded successfully' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.claims[0].outcome).toBe('legacy_response_unconfirmed') + expect(result.pending).toEqual(['Legacy']) + }) + + it.each([ + [400, 'unknown', false], + [404, 'retryable_error', true], + [405, 'retryable_error', true], + [408, 'retryable_error', true], + [425, 'retryable_error', true], + [500, 'retryable_error', true], + ] as const)('settles legacy HTTP %s as %s', async (legacyStatus, outcome, remainsPending) => { + mockServerFetch + .mockResolvedValueOnce(response(404, null)) + .mockResolvedValueOnce(response(legacyStatus, { error: 'legacy failure' })) + + const result = await claimAndSettlePendingBadgeCampaigns(['Legacy']) + + expect(result.claims[0].outcome).toBe(outcome) + expect(result.pending).toEqual(remainsPending ? ['Legacy'] : []) + }) + + it('computes pending identities without depending on backend echo casing', () => { + expect( + pendingBadgeCampaignsAfterClaims( + ['First', 'Second'], + [ + { badgeCampaign: 'first', outcome: 'already_owned' }, + { badgeCampaign: 'SECOND', outcome: 'definition_missing' }, + ] + ) + ).toEqual(['Second']) + }) + + it('keeps validated acquisition navigation while dropping unknown policy and reward fields', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'public-link', + badgeCode: 'PERMANENT_BADGE', + outcome: 'awarded', + capabilities: [{ key: 'card.flow.bypass', lifecycle: { kind: 'one_shot' } }], + acquisition: { fallback: 'normal_app', destination: 'offramp_migration' }, + reward: { amount: 1000, currency: 'USD' }, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['public-link']) + + expect(result.claims).toEqual([ + { + badgeCampaign: 'public-link', + badgeCode: 'PERMANENT_BADGE', + outcome: 'awarded', + acquisition: { fallback: 'normal_app', destination: 'offramp_migration' }, + }, + ]) + expect(result.pending).toEqual([]) + }) + + it('routes only a confirmed, validated acquisition destination', () => { + const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const } + + expect( + destinationForConfirmedBadgeCampaignAcquisition([ + { badgeCampaign: 'opaque', outcome: 'already_owned', acquisition }, + ]) + ).toBe('/add-money/crypto?network=EVM&source=offramp') + expect( + destinationForConfirmedBadgeCampaignAcquisition([ + { badgeCampaign: 'opaque', outcome: 'expired', acquisition }, + ]) + ).toBe('/home') + expect( + destinationForConfirmedBadgeCampaignAcquisition([{ badgeCampaign: 'offramp', outcome: 'awarded' }]) + ).toBe('/home') + }) + + it('drops a malformed destination while retaining the permanent award outcome', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + claims: [ + { + badgeCampaign: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + acquisition: { fallback: 'normal_app', destination: 'https://attacker.example' }, + }, + ], + }) + ) + + const result = await claimAndSettlePendingBadgeCampaigns(['offramp']) + + expect(result.claims).toEqual([{ badgeCampaign: 'offramp', badgeCode: 'OFFRAMP_USER', outcome: 'awarded' }]) + expect(destinationForConfirmedBadgeCampaignAcquisition(result.claims)).toBe('/home') + expect(result.pending).toEqual([]) + }) +}) diff --git a/src/services/__tests__/invite-response.test.ts b/src/services/__tests__/invite-response.test.ts new file mode 100644 index 0000000000..95d3c4d918 --- /dev/null +++ b/src/services/__tests__/invite-response.test.ts @@ -0,0 +1,48 @@ +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from '../invite-response' + +describe('invite response rollout helpers', () => { + it('uses the legacy success signal only when both discriminators are absent', () => { + expect(resolveInviteResolutionFlags({ message: 'legacy success' }, true)).toEqual({ + attributionResolved: true, + onboardingResolved: true, + }) + expect(resolveInviteResolutionFlags({ attributionResolved: true }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + }) + + it('lets either explicit false discriminator win over a legacy success signal', () => { + expect(resolveInviteResolutionFlags({ attributionResolved: false, onboardingResolved: true }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + expect(resolveInviteResolutionFlags({ attributionResolved: true, onboardingResolved: false }, true)).toEqual({ + attributionResolved: false, + onboardingResolved: false, + }) + }) + + it('recognizes only a fully typed campaign-only response', () => { + const typed = { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + } + + expect(isTypedCampaignOnlyInviteResponse(typed)).toBe(true) + expect(isTypedCampaignOnlyInviteResponse({ ...typed, onboardingResolved: true })).toBe(false) + expect(isTypedCampaignOnlyInviteResponse({ ...typed, legacyAcquisition: undefined })).toBe(false) + expect( + isTypedCampaignOnlyInviteResponse({ + ...typed, + legacyAcquisition: { ...typed.legacyAcquisition, destination: 'future_product_flow' }, + }) + ).toBe(false) + }) +}) diff --git a/src/services/__tests__/invites-attribution.test.ts b/src/services/__tests__/invites-attribution.test.ts new file mode 100644 index 0000000000..0e780c044e --- /dev/null +++ b/src/services/__tests__/invites-attribution.test.ts @@ -0,0 +1,217 @@ +import { invitesApi } from '../invites' +import { EInviteType } from '../services.types' +import { serverFetch } from '@/utils/api-fetch' +import { validateInviteCode } from '@/app/actions/invites' +import { destinationForInviteAcquisition, settleAcceptedInviteAcquisition } from '../invite-acquisition' +import { clearPendingBadgeCampaigns, getPendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' + +jest.mock('@/utils/api-fetch', () => ({ serverFetch: jest.fn() })) +jest.mock('@/app/actions/invites', () => ({ validateInviteCode: jest.fn() })) + +const mockServerFetch = serverFetch as jest.MockedFunction +const mockValidateInviteCode = validateInviteCode as jest.MockedFunction + +function response(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response +} + +describe('invite attribution contract', () => { + beforeEach(() => { + jest.clearAllMocks() + clearPendingBadgeCampaigns() + }) + + afterAll(() => clearPendingBadgeCampaigns()) + + it('normalizes and submits inviter attribution without campaign acquisition fields', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + ) + + await invitesApi.acceptInvite(' @Juanacervio ', EInviteType.PAYMENT_LINK) + + expect(mockServerFetch).toHaveBeenCalledWith('/invites/accept', { + method: 'POST', + body: JSON.stringify({ inviteCode: 'juanacervio', type: EInviteType.PAYMENT_LINK }), + }) + const body = JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body)) + expect(body).not.toHaveProperty('campaignTag') + expect(body).not.toHaveProperty('campaignTags') + }) + + it('forwards a published send-link campaign tag as a field separate from inviter attribution', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: true, + onboardingResolved: true, + claims: [{ campaignTag: 'devconnect_ba_2025', outcome: 'awarded' }], + }) + ) + + await invitesApi.acceptInvite('alice', EInviteType.PAYMENT_LINK, 'devconnect_ba_2025') + + expect(JSON.parse(String(mockServerFetch.mock.calls[0][1]?.body))).toEqual({ + inviteCode: 'alice', + type: EInviteType.PAYMENT_LINK, + campaignTag: 'devconnect_ba_2025', + }) + }) + + it('consumes the typed legacy acquisition and matching accept-time claim', async () => { + mockServerFetch.mockResolvedValue( + response(409, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [ + { + campaignTag: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'already_owned', + capabilities: [], + }, + ], + }) + ) + + const result = await invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK) + + expect(result).toMatchObject({ + success: true, + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [{ badgeCampaign: 'offramp', outcome: 'already_owned' }], + }) + expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({ + destination: '/add-money/crypto?network=EVM&source=offramp', + pending: [], + }) + expect(getPendingBadgeCampaigns()).toEqual([]) + }) + + it('keeps an untyped HTTP 409 as a transport failure', async () => { + mockServerFetch.mockResolvedValue(response(409, { error: 'Invite code is not valid' })) + + await expect(invitesApi.acceptInvite('not-an-invite', EInviteType.PAYMENT_LINK)).resolves.toEqual({ + success: false, + attributionResolved: false, + onboardingResolved: false, + claims: [], + }) + }) + + it('supports a pre-discriminator HTTP 200 accept response', async () => { + mockServerFetch.mockResolvedValue(response(200, { message: 'Invite accepted', claims: [] })) + + await expect(invitesApi.acceptInvite('legacy-invite', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: true, + attributionResolved: true, + onboardingResolved: true, + claims: [], + }) + }) + + it('falls back normally when an accept-time compatibility claim is missing', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Invite accepted', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [], + }) + ) + + const result = await invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK) + + expect(result.claims).toEqual([{ badgeCampaign: 'offramp', outcome: 'retryable_error' }]) + expect(destinationForInviteAcquisition(result.legacyAcquisition!, result.claims)).toBe('/home') + expect(settleAcceptedInviteAcquisition(result.legacyAcquisition!, result.claims)).toEqual({ + destination: '/home', + pending: ['offramp'], + }) + expect(getPendingBadgeCampaigns()).toEqual(['offramp']) + }) + + it('preserves the typed legacy campaign identity and destination from invite validation', async () => { + mockValidateInviteCode.mockResolvedValue({ + data: { + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }, + }) + + await expect(invitesApi.validateInviteCode('offramp')).resolves.toEqual({ + success: true, + attributionResolved: false, + onboardingResolved: false, + username: 'peanut', + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + }) + }) + + it('lets explicit false discriminators override a successful HTTP 200', async () => { + mockServerFetch.mockResolvedValue( + response(200, { + message: 'Campaign processed without invite attribution', + attributionResolved: false, + onboardingResolved: false, + legacyAcquisition: { + campaignTag: 'offramp', + fallback: 'normal_app', + destination: 'offramp_migration', + }, + claims: [ + { + campaignTag: 'offramp', + badgeCode: 'OFFRAMP_USER', + outcome: 'awarded', + capabilities: [], + }, + ], + }) + ) + + await expect(invitesApi.acceptInvite('offramp', EInviteType.PAYMENT_LINK)).resolves.toMatchObject({ + success: true, + attributionResolved: false, + onboardingResolved: false, + claims: [{ badgeCampaign: 'offramp', outcome: 'awarded' }], + }) + }) +}) diff --git a/src/services/__tests__/post-auth-redirect.test.ts b/src/services/__tests__/post-auth-redirect.test.ts new file mode 100644 index 0000000000..68b7ea5d44 --- /dev/null +++ b/src/services/__tests__/post-auth-redirect.test.ts @@ -0,0 +1,71 @@ +import { consumePostAuthRedirect } from '../post-auth-redirect' +import { getRedirectUrl, saveToLocalStorage } from '@/utils/general.utils' + +const FINANCIAL_REDIRECT = '/claim?step=claim&id=payment-1' +const CAMPAIGN_REDIRECT = '/add-money/crypto?network=EVM&source=offramp' + +describe('post-auth redirect consumption', () => { + beforeEach(() => localStorage.clear()) + + it('discards a lower-priority campaign destination when an explicit financial continuation wins', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect(FINANCIAL_REDIRECT)).toEqual({ + destination: FINANCIAL_REDIRECT, + source: 'explicit', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + + // A later login cannot resurrect the superseded campaign journey. + expect(consumePostAuthRedirect(null)).toEqual({ + destination: '/home', + source: 'fallback', + deferred: false, + }) + }) + + it('also consumes stored state when a malformed explicit redirect falls back safely', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect('https://attacker.example/claim')).toEqual({ + destination: '/home', + source: 'explicit', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + }) + + it('consumes a confirmed published campaign destination exactly once', () => { + saveToLocalStorage('redirect', CAMPAIGN_REDIRECT) + + expect(consumePostAuthRedirect(null)).toEqual({ + destination: CAMPAIGN_REDIRECT, + source: 'stored', + deferred: false, + }) + expect(getRedirectUrl()).toBeNull() + }) + + it('can deliberately retain a safe stored continuation for a post-signup action', () => { + saveToLocalStorage('redirect', FINANCIAL_REDIRECT) + + expect( + consumePostAuthRedirect(null, { + deferStoredRedirect: (destination) => destination.includes('/claim'), + }) + ).toEqual({ destination: '/home', source: 'stored', deferred: true }) + expect(getRedirectUrl()).toBe(FINANCIAL_REDIRECT) + }) + + it('never defers an unsafe stored URL merely because its text matches the predicate', () => { + saveToLocalStorage('redirect', 'https://attacker.example/claim') + + expect( + consumePostAuthRedirect(null, { + deferStoredRedirect: (destination) => destination.includes('/claim'), + }) + ).toEqual({ destination: '/home', source: 'stored', deferred: false }) + expect(getRedirectUrl()).toBeNull() + }) +}) diff --git a/src/services/__tests__/registration-acquisition.test.ts b/src/services/__tests__/registration-acquisition.test.ts new file mode 100644 index 0000000000..7a3cabc287 --- /dev/null +++ b/src/services/__tests__/registration-acquisition.test.ts @@ -0,0 +1,41 @@ +import { saveToLocalStorage } from '@/utils/general.utils' +import { persistRegistrationBadgeCampaignDestination } from '../registration-acquisition' + +jest.mock('@/utils/general.utils', () => ({ saveToLocalStorage: jest.fn() })) + +const mockSaveToLocalStorage = saveToLocalStorage as jest.MockedFunction +const acquisition = { fallback: 'normal_app' as const, destination: 'offramp_migration' as const } + +describe('new-registration campaign navigation', () => { + beforeEach(() => jest.clearAllMocks()) + + it.each(['awarded', 'already_owned'] as const)( + 'persists a bespoke destination after a confirmed %s canonical claim', + (outcome) => { + expect( + persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }]) + ).toBe('/add-money/crypto?network=EVM&source=offramp') + expect(mockSaveToLocalStorage).toHaveBeenCalledWith( + 'redirect', + '/add-money/crypto?network=EVM&source=offramp' + ) + } + ) + + it.each(['inactive', 'expired', 'unknown', 'definition_missing', 'retryable_error'] as const)( + 'keeps the normal app destination after %s', + (outcome) => { + expect( + persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome, acquisition }]) + ).toBe('/home') + expect(mockSaveToLocalStorage).not.toHaveBeenCalled() + } + ) + + it('does not invent a destination when a confirmed claim omits acquisition metadata', () => { + expect(persistRegistrationBadgeCampaignDestination([{ badgeCampaign: 'offramp', outcome: 'awarded' }])).toBe( + '/home' + ) + expect(mockSaveToLocalStorage).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/acquisition-navigation.ts b/src/services/acquisition-navigation.ts new file mode 100644 index 0000000000..12f4b782e8 --- /dev/null +++ b/src/services/acquisition-navigation.ts @@ -0,0 +1,30 @@ +export type AcquisitionDestination = 'offramp_migration' | 'normal_app' + +export type AcquisitionNavigation = { + fallback: 'normal_app' + destination: AcquisitionDestination +} + +export const OFFRAMP_MIGRATION_ROUTE = '/add-money/crypto?network=EVM&source=offramp' + +const DESTINATION_ROUTES: Readonly> = { + offramp_migration: OFFRAMP_MIGRATION_ROUTE, + normal_app: '/home', +} + +export function parseAcquisitionNavigation(value: unknown): AcquisitionNavigation | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Partial + if ( + candidate.fallback !== 'normal_app' || + (candidate.destination !== 'offramp_migration' && candidate.destination !== 'normal_app') + ) { + return undefined + } + return { fallback: candidate.fallback, destination: candidate.destination } +} + +/** Translate a backend-owned destination enum into a same-origin application path. */ +export function acquisitionDestinationRoute(destination: AcquisitionDestination): string { + return DESTINATION_ROUTES[destination] +} diff --git a/src/services/badge-campaigns.ts b/src/services/badge-campaigns.ts new file mode 100644 index 0000000000..bf8fd1d97d --- /dev/null +++ b/src/services/badge-campaigns.ts @@ -0,0 +1,370 @@ +import { + getPendingBadgeCampaigns, + getPendingBadgeCampaignIntentGeneration, + sanitizeBadgeCampaignIdentities, + savePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' +import { serverFetch } from '@/utils/api-fetch' +import { acquisitionDestinationRoute, parseAcquisitionNavigation } from './acquisition-navigation' +import type { paths } from '@/types/api.generated' + +type GeneratedBadgeCampaignClaimRequest = paths['/badge/claims']['post']['requestBody']['content']['application/json'] +type GeneratedBadgeCampaignClaimBatch = paths['/badge/claims']['post']['responses'][200]['content']['application/json'] +type GeneratedBadgeCampaignClaim = GeneratedBadgeCampaignClaimBatch['claims'][number] +type GeneratedBadgePresentation = NonNullable + +export type BadgeCampaignClaimOutcome = + | GeneratedBadgeCampaignClaim['outcome'] + | 'retryable_error' + | 'legacy_response_unconfirmed' + +type CompatibleBadgePresentation = Omit & { + description: string | null + publicDescription?: string | null + iconUrl: string | null +} + +export type BadgeCampaignClaim = Omit & { + badge?: CompatibleBadgePresentation + outcome: BadgeCampaignClaimOutcome + httpStatus?: number +} + +export type BadgeCampaignClaimBatch = { + claims: BadgeCampaignClaim[] + transport: 'canonical' | 'legacy' +} + +type BackendBadgeCampaignClaim = Omit & { + outcome: GeneratedBadgeCampaignClaim['outcome'] +} + +const BACKEND_OUTCOMES = new Set([ + 'awarded', + 'already_owned', + 'inactive', + 'expired', + 'unknown', + 'definition_missing', +]) + +/** + * Outcomes that are fully resolved and should not loop on the next app open. + * `definition_missing` is deliberately retryable: it commonly means the API + * code deployed before its boot-seeded definition. Transport failures and an + * old endpoint's unconfirmed 200 also remain pending. + */ +const CONSUMED_OUTCOMES = new Set([ + 'awarded', + 'already_owned', + 'inactive', + 'expired', + 'unknown', +]) + +function uniqueBadgeCampaigns(badgeCampaigns: readonly string[]): string[] { + return sanitizeBadgeCampaignIdentities(badgeCampaigns) +} + +function retryableClaims(badgeCampaigns: readonly string[], httpStatus?: number): BadgeCampaignClaim[] { + return badgeCampaigns.map((badgeCampaign) => ({ + badgeCampaign, + outcome: 'retryable_error', + httpStatus, + })) +} + +function terminalUnknownClaims(badgeCampaigns: readonly string[], httpStatus: number): BadgeCampaignClaim[] { + return badgeCampaigns.map((badgeCampaign) => ({ + badgeCampaign, + outcome: 'unknown', + httpStatus, + })) +} + +function isRetryableHttpStatus(status: number): boolean { + return ( + status === 401 || + status === 404 || + status === 405 || + status === 408 || + status === 425 || + status === 429 || + status >= 500 + ) +} + +type BadgePresentation = NonNullable + +function parseBadgePresentation(value: unknown): BadgePresentation | undefined { + if (!value || typeof value !== 'object') return undefined + const badge = value as { + code?: unknown + name?: unknown + description?: unknown + publicDescription?: unknown + iconUrl?: unknown + } + if ( + typeof badge.code !== 'string' || + typeof badge.name !== 'string' || + (typeof badge.description !== 'string' && badge.description !== null) || + (badge.publicDescription !== undefined && + typeof badge.publicDescription !== 'string' && + badge.publicDescription !== null) || + (typeof badge.iconUrl !== 'string' && badge.iconUrl !== null) + ) { + return undefined + } + + return { + code: badge.code, + name: badge.name, + description: badge.description, + ...(badge.publicDescription !== undefined ? { publicDescription: badge.publicDescription } : {}), + iconUrl: badge.iconUrl, + } +} + +/** + * Project the public response onto acquisition state the client understands. + * Badge provenance is audit metadata, not a trust tier: confirmed public awards + * retain the badge's existing product semantics. Unknown policy/reward fields + * are ignored, while the small destination enum is validated before routing. + */ +function parseBackendBadgeCampaignClaim(value: unknown): BackendBadgeCampaignClaim | undefined { + if (!value || typeof value !== 'object') return undefined + const claim = value as { + badgeCampaign?: unknown + campaignTag?: unknown + badgeCode?: unknown + badge?: unknown + outcome?: unknown + acquisition?: unknown + } + // During rolling deploys, accept the published legacy echo only when the + // canonical field is absent. Presence of a malformed canonical value is a + // malformed response, never permission to reinterpret a second field. + const hasCanonicalBadgeCampaign = Object.prototype.hasOwnProperty.call(claim, 'badgeCampaign') + const badgeCampaign = hasCanonicalBadgeCampaign ? claim.badgeCampaign : claim.campaignTag + const badge = claim.badge === undefined ? undefined : parseBadgePresentation(claim.badge) + const acquisition = claim.acquisition === undefined ? undefined : parseAcquisitionNavigation(claim.acquisition) + if ( + typeof badgeCampaign !== 'string' || + badgeCampaign.length === 0 || + typeof claim.outcome !== 'string' || + !BACKEND_OUTCOMES.has(claim.outcome as BackendBadgeCampaignClaim['outcome']) || + (claim.badgeCode !== undefined && typeof claim.badgeCode !== 'string') || + (claim.badge !== undefined && !badge) + ) { + return undefined + } + + return { + badgeCampaign, + outcome: claim.outcome as BackendBadgeCampaignClaim['outcome'], + ...(typeof claim.badgeCode === 'string' ? { badgeCode: claim.badgeCode } : {}), + ...(badge ? { badge } : {}), + ...(acquisition ? { acquisition } : {}), + } +} + +async function responseJson(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} + +function alignClaims( + requested: readonly string[], + received: readonly BackendBadgeCampaignClaim[] +): BadgeCampaignClaim[] { + return requested.map((badgeCampaign) => { + const claim = received.find( + (candidate) => candidate.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase() + ) + return ( + claim ?? { + badgeCampaign, + outcome: 'retryable_error', + } + ) + }) +} + +/** + * Runtime boundary shared by canonical acquisition and compatibility responses + * such as `/invites/accept`. Generated types catch compile-time drift; this + * validator prevents a partial rolling-deploy response from becoming a false + * success in the browser. + */ +export function badgeCampaignClaimsFromPayload(payload: unknown, requested: readonly string[]): BadgeCampaignClaim[] { + const received = + payload && typeof payload === 'object' && Array.isArray((payload as { claims?: unknown }).claims) + ? (payload as { claims: unknown[] }).claims.flatMap((claim) => { + const parsed = parseBackendBadgeCampaignClaim(claim) + return parsed ? [parsed] : [] + }) + : [] + return alignClaims(uniqueBadgeCampaigns(requested), received) +} + +async function claimThroughLegacyEndpoint(badgeCampaigns: readonly string[]): Promise { + const claims: BadgeCampaignClaim[] = [] + + for (const badgeCampaign of badgeCampaigns) { + try { + const response = await serverFetch('/badge/award', { + method: 'POST', + // Published compatibility field; do not rename. + body: JSON.stringify({ campaignTag: badgeCampaign }), + }) + const body = await responseJson(response) + const structured = + body && typeof body === 'object' + ? ((body as { claim?: unknown; claims?: unknown }).claim ?? + (Array.isArray((body as { claims?: unknown }).claims) + ? (body as { claims: unknown[] }).claims[0] + : undefined)) + : undefined + + const parsedClaim = parseBackendBadgeCampaignClaim(structured) + if (response.ok && parsedClaim?.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase()) { + claims.push(parsedClaim) + } else if (response.ok) { + // The old endpoint returned 200 even when awardBadge no-op'd. + // Preserve the campaign for the canonical endpoint to verify. + claims.push({ badgeCampaign, outcome: 'legacy_response_unconfirmed' }) + } else if (isRetryableHttpStatus(response.status)) { + // The historical endpoint used 400 for a non-claimable tag. + // Its 404/405 therefore means the compatibility route itself + // is absent during a rolling deploy, not a terminal outcome. + claims.push(...retryableClaims([badgeCampaign], response.status)) + } else { + // Legacy 400 meant "not claimable" but exposed no typed reason. + // Consume it as terminal unknown rather than looping forever. + claims.push(...terminalUnknownClaims([badgeCampaign], response.status)) + } + } catch { + claims.push(...retryableClaims([badgeCampaign])) + } + } + + return { claims, transport: 'legacy' } +} + +async function requestBadgeCampaignClaims(badgeCampaigns: readonly string[]): Promise { + try { + const response = await serverFetch('/badge/claims', { + method: 'POST', + body: JSON.stringify({ badgeCampaigns: [...badgeCampaigns] } satisfies GeneratedBadgeCampaignClaimRequest), + }) + + // Compatibility is deliberately narrow: only an API version that does + // not expose the canonical route may use the legacy endpoint. + if (response.status === 404 || response.status === 405) { + return claimThroughLegacyEndpoint(badgeCampaigns) + } + + if (!response.ok) { + return { + claims: isRetryableHttpStatus(response.status) + ? retryableClaims(badgeCampaigns, response.status) + : terminalUnknownClaims(badgeCampaigns, response.status), + transport: 'canonical', + } + } + + const body = await responseJson(response) + return { + claims: badgeCampaignClaimsFromPayload(body, badgeCampaigns), + transport: 'canonical', + } + } catch { + return { claims: retryableClaims(badgeCampaigns), transport: 'canonical' } + } +} + +const inFlight = new Map>() + +/** The sole frontend badge-acquisition call. Badge campaign identities stay opaque. */ +export function claimBadgeCampaigns(rawBadgeCampaigns: readonly string[]): Promise { + const badgeCampaigns = uniqueBadgeCampaigns(rawBadgeCampaigns) + if (badgeCampaigns.length === 0) return Promise.resolve({ claims: [], transport: 'canonical' }) + + // Deduplicate only within one account-intent generation. Reusing an old + // account's authenticated response after explicit logout could otherwise + // consume the next account's identical pending campaign without awarding it. + const key = JSON.stringify([getPendingBadgeCampaignIntentGeneration(), badgeCampaigns]) + const existing = inFlight.get(key) + if (existing) return existing + + const request = requestBadgeCampaignClaims(badgeCampaigns).finally(() => inFlight.delete(key)) + inFlight.set(key, request) + return request +} + +export function pendingBadgeCampaignsAfterClaims( + requested: readonly string[], + claims: readonly BadgeCampaignClaim[] +): string[] { + return uniqueBadgeCampaigns(requested).filter((badgeCampaign) => { + const claim = claims.find((candidate) => candidate.badgeCampaign.toLowerCase() === badgeCampaign.toLowerCase()) + return !claim || !CONSUMED_OUTCOMES.has(claim.outcome) + }) +} + +export function settlePendingBadgeCampaigns( + requested: readonly string[], + claims: readonly BadgeCampaignClaim[], + expectedIntentGeneration = getPendingBadgeCampaignIntentGeneration() +): string[] { + // Explicit logout is an account-boundary write and must win over a stale + // response. Passive auth expiry never advances this generation, so ordinary + // offline/configuration retries remain durable. + if (expectedIntentGeneration !== getPendingBadgeCampaignIntentGeneration()) return getPendingBadgeCampaigns() + + const requestedKeys = new Set(uniqueBadgeCampaigns(requested).map((badgeCampaign) => badgeCampaign.toLowerCase())) + const untouched = getPendingBadgeCampaigns().filter( + (badgeCampaign) => !requestedKeys.has(badgeCampaign.toLowerCase()) + ) + const pending = uniqueBadgeCampaigns([...untouched, ...pendingBadgeCampaignsAfterClaims(requested, claims)]) + savePendingBadgeCampaigns(pending, pending.length > 0 ? 30 : undefined) + return pending +} + +export async function claimAndSettlePendingBadgeCampaigns( + requested: readonly string[] = getPendingBadgeCampaigns() +): Promise { + const intentGeneration = getPendingBadgeCampaignIntentGeneration() + const batch = await claimBadgeCampaigns(requested) + return { ...batch, pending: settlePendingBadgeCampaigns(requested, batch.claims, intentGeneration) } +} + +export function isConfirmedBadgeCampaignClaim(claim: BadgeCampaignClaim): boolean { + return claim.outcome === 'awarded' || claim.outcome === 'already_owned' +} + +/** + * Badge campaign navigation comes only from a confirmed canonical claim. Missing, + * malformed, unavailable, and retryable claims use the declared normal-app + * fallback. Multiple different bespoke destinations fail closed as well. + */ +export function destinationForConfirmedBadgeCampaignAcquisition(claims: readonly BadgeCampaignClaim[]): string { + const nonDefaultDestinations = new Set( + claims.flatMap((claim) => { + if (!isConfirmedBadgeCampaignClaim(claim) || !claim.acquisition) return [] + const { destination, fallback } = claim.acquisition + return destination === fallback ? [] : [destination] + }) + ) + + if (nonDefaultDestinations.size !== 1) return acquisitionDestinationRoute('normal_app') + return acquisitionDestinationRoute([...nonDefaultDestinations][0]) +} + +export function isUnavailableBadgeCampaignClaim(claim: BadgeCampaignClaim): boolean { + return claim.outcome === 'inactive' || claim.outcome === 'expired' || claim.outcome === 'unknown' +} diff --git a/src/services/invite-acquisition.ts b/src/services/invite-acquisition.ts new file mode 100644 index 0000000000..7783572a74 --- /dev/null +++ b/src/services/invite-acquisition.ts @@ -0,0 +1,54 @@ +import { isConfirmedBadgeCampaignClaim, settlePendingBadgeCampaigns, type BadgeCampaignClaim } from './badge-campaigns' +import { + acquisitionDestinationRoute, + parseAcquisitionNavigation, + type AcquisitionNavigation, +} from './acquisition-navigation' + +export type LegacyInviteAcquisition = AcquisitionNavigation & { + campaignTag: string +} + +/** + * Compatibility adapter for already-published legacy invite links. The backend + * owns the opaque campaign identity and destination enum; this client never + * infers either from the invite code or from badge provenance. + */ +export function parseLegacyInviteAcquisition(value: unknown): LegacyInviteAcquisition | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = value as { campaignTag?: unknown } + const navigation = parseAcquisitionNavigation(value) + if (typeof candidate.campaignTag !== 'string' || candidate.campaignTag.trim().length === 0 || !navigation) + return undefined + return { + campaignTag: candidate.campaignTag.trim(), + ...navigation, + } +} + +/** + * A bespoke compatibility destination is usable only after the matching badge + * claim is confirmed. Every other outcome follows the descriptor's safe fallback. + */ +export function destinationForInviteAcquisition( + acquisition: LegacyInviteAcquisition, + claims: readonly BadgeCampaignClaim[] +): string { + const matchingClaim = claims.find( + (claim) => claim.badgeCampaign.toLowerCase() === acquisition.campaignTag.toLowerCase() + ) + return acquisitionDestinationRoute( + matchingClaim && isConfirmedBadgeCampaignClaim(matchingClaim) ? acquisition.destination : acquisition.fallback + ) +} + +/** Consume the claim batch returned by signed-out registration's invite accept. */ +export function settleAcceptedInviteAcquisition( + acquisition: LegacyInviteAcquisition, + claims: readonly BadgeCampaignClaim[] +): { destination: string; pending: string[] } { + return { + destination: destinationForInviteAcquisition(acquisition, claims), + pending: settlePendingBadgeCampaigns([acquisition.campaignTag], claims), + } +} diff --git a/src/services/invite-response.ts b/src/services/invite-response.ts new file mode 100644 index 0000000000..b154ef579c --- /dev/null +++ b/src/services/invite-response.ts @@ -0,0 +1,58 @@ +import { parseLegacyInviteAcquisition } from './invite-acquisition' + +export type InviteResolutionFlags = { + attributionResolved: boolean + onboardingResolved: boolean +} + +function hasLegacyAcquisitionDescriptor(value: unknown): boolean { + return !!parseLegacyInviteAcquisition(value) +} + +/** + * Resolve rollout discriminators without letting partial or explicit-false + * responses become access. A pre-discriminator 200 may use its historical + * success signal only when both new fields are completely absent. + */ +export function resolveInviteResolutionFlags(payload: unknown, legacyNoFlagsResolved: boolean): InviteResolutionFlags { + if (!payload || typeof payload !== 'object') { + return { attributionResolved: false, onboardingResolved: false } + } + + const body = payload as { attributionResolved?: unknown; onboardingResolved?: unknown } + const hasAttribution = Object.hasOwn(body, 'attributionResolved') + const hasOnboarding = Object.hasOwn(body, 'onboardingResolved') + + if (!hasAttribution && !hasOnboarding) { + return { + attributionResolved: legacyNoFlagsResolved, + onboardingResolved: legacyNoFlagsResolved, + } + } + + // Either explicit false wins over every legacy hint. A partial rollout + // shape (only one discriminator) also fails closed. + if (body.attributionResolved === false || body.onboardingResolved === false || !hasAttribution || !hasOnboarding) { + return { attributionResolved: false, onboardingResolved: false } + } + + const resolved = body.attributionResolved === true && body.onboardingResolved === true + return { attributionResolved: resolved, onboardingResolved: resolved } +} + +/** New APIs use a typed 409 so old clients safely stop at non-2xx. */ +export function isTypedCampaignOnlyInviteResponse(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false + const body = payload as { + message?: unknown + attributionResolved?: unknown + onboardingResolved?: unknown + legacyAcquisition?: unknown + } + return ( + typeof body.message === 'string' && + body.attributionResolved === false && + body.onboardingResolved === false && + hasLegacyAcquisitionDescriptor(body.legacyAcquisition) + ) +} diff --git a/src/services/invites.ts b/src/services/invites.ts index ba11a41ef7..0357b70de5 100644 --- a/src/services/invites.ts +++ b/src/services/invites.ts @@ -4,27 +4,63 @@ import { toInviteCode } from '@/utils/general.utils' import { isCapacitor } from '@/utils/capacitor' import { enableDemoMode, isDemoInviteCode } from '@/utils/demo' import { EInviteType, type PointsInvitesResponse } from './services.types' +import { badgeCampaignClaimsFromPayload, type BadgeCampaignClaim } from './badge-campaigns' +import { parseLegacyInviteAcquisition, type LegacyInviteAcquisition } from './invite-acquisition' +import { isTypedCampaignOnlyInviteResponse, resolveInviteResolutionFlags } from './invite-response' + +export type AcceptInviteResult = { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + claims: BadgeCampaignClaim[] + legacyAcquisition?: LegacyInviteAcquisition +} + +export type ValidateInviteResult = { + success: boolean + attributionResolved: boolean + onboardingResolved: boolean + username: string + legacyAcquisition?: LegacyInviteAcquisition +} export const invitesApi = { - acceptInvite: async ( - inviteCode: string, - type: EInviteType, - campaignTag?: string - ): Promise<{ success: boolean }> => { + acceptInvite: async (inviteCode: string, type: EInviteType, campaignTag?: string): Promise => { try { const response = await serverFetch('/invites/accept', { method: 'POST', // Normalize here so hand-typed input (`@alice `, ` Alice`) works no // matter which screen collected it. Legacy ALICEINVITESYOU610 codes // pass through unchanged in meaning — the BE uppercases before parsing. + // Inviter attribution and campaign acquisition remain separate + // fields. Published send links still carry one opaque campaign + // tag through this compatibility call. body: JSON.stringify({ inviteCode: toInviteCode(inviteCode), type, campaignTag }), }) - if (!response.ok) { - return { success: false } + const body: unknown = await response.json() + const typedCampaignOnly = + response.status === 409 && + isTypedCampaignOnlyInviteResponse(body) && + !!body && + typeof body === 'object' && + Array.isArray((body as { claims?: unknown }).claims) + if (!response.ok && !typedCampaignOnly) { + return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } + } + const legacyAcquisition = parseLegacyInviteAcquisition( + body && typeof body === 'object' + ? (body as { legacyAcquisition?: unknown }).legacyAcquisition + : undefined + ) + const resolution = resolveInviteResolutionFlags(body, response.ok) + return { + success: true, + ...resolution, + claims: legacyAcquisition ? badgeCampaignClaimsFromPayload(body, [legacyAcquisition.campaignTag]) : [], + ...(legacyAcquisition ? { legacyAcquisition } : {}), } - return { success: true } } catch { - return { success: false } + return { success: false, attributionResolved: false, onboardingResolved: false, claims: [] } } }, @@ -44,7 +80,7 @@ export const invitesApi = { } }, - validateInviteCode: async (inviteCode: string): Promise<{ success: boolean; username: string }> => { + validateInviteCode: async (inviteCode: string): Promise => { try { const res = await validateInviteCode(toInviteCode(inviteCode)) // demo code enables demo mode. @@ -53,11 +89,14 @@ export const invitesApi = { } return { success: res.data?.success || false, + attributionResolved: res.data?.attributionResolved === true, + onboardingResolved: res.data?.onboardingResolved === true, username: res.data?.username || '', + legacyAcquisition: res.data?.legacyAcquisition, } } catch (e) { console.error('Error validating invite code:', e) - return { success: false, username: '' } + return { success: false, attributionResolved: false, onboardingResolved: false, username: '' } } }, @@ -78,20 +117,4 @@ export const invitesApi = { return { success: false, position: 0 } } }, - - awardBadge: async (campaignTag: string): Promise<{ success: boolean }> => { - try { - const response = await serverFetch('/badge/award', { - method: 'POST', - body: JSON.stringify({ campaignTag }), - }) - if (!response.ok) { - return { success: false } - } - return { success: true } - } catch (e) { - console.error('Error awarding badge:', e) - return { success: false } - } - }, } diff --git a/src/services/post-auth-redirect.ts b/src/services/post-auth-redirect.ts new file mode 100644 index 0000000000..a389cae05e --- /dev/null +++ b/src/services/post-auth-redirect.ts @@ -0,0 +1,59 @@ +import { clearRedirectUrl, getRedirectUrl, getValidRedirectUrl } from '@/utils/general.utils' + +export type PostAuthRedirectDecision = { + destination: string + source: 'explicit' | 'stored' | 'fallback' + deferred: boolean +} + +type PostAuthRedirectOptions = { + fallbackRoute?: string + /** + * Keep a safe stored intent for a later post-signup action (for example, + * showing the bank-claim continuation after identity verification). + */ + deferStoredRedirect?: (destination: string) => boolean +} + +/** + * Select and consume post-auth navigation in one place. + * + * An explicit `redirect_uri` always outranks generic stored state. Selecting + * it also discards that lower-priority state, including when the explicit URL + * is malformed and resolves to the safe fallback; otherwise an acquisition + * destination can unexpectedly resurrect during a later login. + * + * Stored state is one-shot by default. A caller may deliberately defer a safe + * stored destination for a later post-signup action, in which case it remains + * available and this function routes to the fallback for now. + */ +export function consumePostAuthRedirect( + explicitRedirectUri: string | null, + options: PostAuthRedirectOptions = {} +): PostAuthRedirectDecision { + const fallbackRoute = options.fallbackRoute ?? '/home' + + if (explicitRedirectUri !== null) { + clearRedirectUrl() + return { + destination: getValidRedirectUrl(explicitRedirectUri, fallbackRoute), + source: 'explicit', + deferred: false, + } + } + + const storedValue = getRedirectUrl() + if (typeof storedValue === 'string' && storedValue.length > 0) { + const destination = getValidRedirectUrl(storedValue, fallbackRoute) + if (destination !== fallbackRoute && options.deferStoredRedirect?.(destination)) { + return { destination: fallbackRoute, source: 'stored', deferred: true } + } + + clearRedirectUrl() + return { destination, source: 'stored', deferred: false } + } + + // Corrupt or blank generic state is no more reusable than an unsafe URL. + if (storedValue !== null && storedValue !== undefined) clearRedirectUrl() + return { destination: fallbackRoute, source: 'fallback', deferred: false } +} diff --git a/src/services/registration-acquisition.ts b/src/services/registration-acquisition.ts new file mode 100644 index 0000000000..6174f57ced --- /dev/null +++ b/src/services/registration-acquisition.ts @@ -0,0 +1,13 @@ +import { saveToLocalStorage } from '@/utils/general.utils' +import { destinationForConfirmedBadgeCampaignAcquisition, type BadgeCampaignClaim } from './badge-campaigns' + +/** + * Registration settles queued URL badge campaigns after authentication. Preserve a + * bespoke destination for the remaining setup flow only when the canonical + * claim is confirmed; every other outcome leaves normal navigation untouched. + */ +export function persistRegistrationBadgeCampaignDestination(claims: readonly BadgeCampaignClaim[]): string { + const destination = destinationForConfirmedBadgeCampaignAcquisition(claims) + if (destination !== '/home') saveToLocalStorage('redirect', destination) + return destination +} diff --git a/src/services/users.ts b/src/services/users.ts index eab141d0ba..b2b50b31be 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -37,6 +37,7 @@ export type ApiUser = { code: string name: string description: string | null + publicDescription?: string | null iconUrl: string | null color?: string | null earnedAt?: string diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index eedd903884..b4b104c5e9 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -226,7 +226,7 @@ export interface paths { patch?: never; trace?: never; }; - "/ens/{ensName}": { + "/ens/reverse/{address}": { parameters: { query?: never; header?: never; @@ -237,6 +237,43 @@ export interface paths { parameters: { query?: never; header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ens/{ensName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + chainId?: number; + }; + header?: never; path: { ensName: string; }; @@ -402,7 +439,7 @@ export interface paths { }; nextAction?: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -413,7 +450,7 @@ export interface paths { }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1115,7 +1152,7 @@ export interface paths { }; nextAction?: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1126,7 +1163,7 @@ export interface paths { }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1187,9 +1224,10 @@ export interface paths { }; content: { "application/json": { - sumsubAccessToken: string; - levelName: string; + sumsubAccessToken?: string; + levelName?: string; externalActionId?: string; + verificationUrl?: string; }; }; }; @@ -1222,7 +1260,12 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + tosLink: string; + endorsement: string; + }; + }; }; }; }; @@ -1273,6 +1316,130 @@ export interface paths { patch?: never; trace?: never; }; + "/users/consent/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + documents: { + slug: string; + currentVersion: string; + acceptedVersion: string | null; + acceptedAt: string | null; + needsAcceptance: boolean; + }[]; + needsReConsent: boolean; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/consent/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + documents: { + slug: string; + version: string; + hash?: string; + }[]; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + recorded: number; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/validate-bank-account-number": { parameters: { query?: never; @@ -1523,6 +1690,11 @@ export interface paths { username: string; cred: unknown; rpID?: string; + acceptedLegal?: { + slug: string; + version: string; + hash?: string; + }[]; }; }; }; @@ -4314,7 +4486,110 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: true; + /** @enum {boolean} */ + onboardingResolved: true; + attributionKind: "PERSONAL" | "SYSTEM" | "LEGACY_UNRESOLVED"; + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: false; + /** @enum {boolean} */ + onboardingResolved: false; + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + error: "badge_definitions_unavailable"; + message: string; + /** @enum {boolean} */ + retryable: true; + retryAfterSeconds: number; + }; + }; }; }; }; @@ -4386,7 +4661,55 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: true; + /** @enum {boolean} */ + onboardingResolved: true; + attributionKind: "PERSONAL" | "SYSTEM" | "LEGACY_UNRESOLVED"; + username: string; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + /** @enum {boolean} */ + attributionResolved: false; + /** @enum {boolean} */ + onboardingResolved: false; + legacyAcquisition?: { + campaignTag: string; + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; }; }; }; @@ -4842,6 +5165,86 @@ export interface paths { patch?: never; trace?: never; }; + "/badge/claims": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + badgeCampaigns: string[]; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + claims: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }[]; + }; + }; + }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + error: "badge_definitions_unavailable"; + message: string; + /** @enum {boolean} */ + retryable: true; + retryAfterSeconds: number; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/badge/award": { parameters: { query?: never; @@ -4863,6 +5266,7 @@ export interface paths { requestBody: { content: { "application/json": { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ campaignTag: string; }; }; @@ -4873,7 +5277,47 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + message: string; + claim: { + /** @description Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug. */ + badgeCampaign: string; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + badgeCode?: string; + badge?: { + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; + name: string; + description: string; + publicDescription: string; + iconUrl: string; + }; + outcome: "awarded" | "already_owned" | "inactive" | "expired" | "unknown" | "definition_missing"; + acquisition?: { + /** @enum {string} */ + fallback: "normal_app"; + destination: "offramp_migration" | "normal_app"; + }; + }; + }; + }; + }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + error: "badge_definitions_unavailable"; + message: string; + /** @enum {boolean} */ + retryable: true; + retryAfterSeconds: number; + }; + }; }; }; }; @@ -6288,6 +6732,11 @@ export interface paths { termsAccepted?: boolean; serializedApproval?: string; confirmedResidenceCountry?: string; + acceptedDocuments?: { + slug: string; + version: string; + hash?: string; + }[]; }; }; }; @@ -8839,7 +9288,8 @@ 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" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO"; + /** @description Stable badge code. Clients must tolerate codes added after SDK generation. */ + code: string; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 0dd8008360..b7c4970ae4 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -150,9 +150,38 @@ } } }, + "/ens/reverse/{address}": { + "get": { + "parameters": [ + { + "schema": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "in": "path", + "name": "address", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/ens/{ensName}": { "get": { "parameters": [ + { + "schema": { + "minimum": 1, + "type": "integer" + }, + "in": "query", + "name": "chainId", + "required": false + }, { "schema": { "type": "string" @@ -573,6 +602,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -638,6 +671,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1607,6 +1644,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1672,6 +1713,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1781,9 +1826,11 @@ }, "externalActionId": { "type": "string" + }, + "verificationUrl": { + "type": "string" } - }, - "required": ["sumsubAccessToken", "levelName"] + } } } } @@ -1795,7 +1842,23 @@ "get": { "responses": { "200": { - "description": "Default Response" + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tosLink": { + "type": "string" + }, + "endorsement": { + "type": "string" + } + }, + "required": ["tosLink", "endorsement"] + } + } + } } } } @@ -1819,6 +1882,175 @@ } } }, + "/users/consent/status": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "currentVersion": { + "type": "string" + }, + "acceptedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "acceptedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "needsAcceptance": { + "type": "boolean" + } + }, + "required": [ + "slug", + "currentVersion", + "acceptedVersion", + "acceptedAt", + "needsAcceptance" + ] + } + }, + "needsReConsent": { + "type": "boolean" + } + }, + "required": ["documents", "needsReConsent"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, + "/users/consent/accept": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } + } + }, + "required": ["documents"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recorded": { + "type": "number" + } + }, + "required": ["recorded"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/validate-bank-account-number": { "post": { "requestBody": { @@ -1967,6 +2199,25 @@ "cred": {}, "rpID": { "type": "string" + }, + "acceptedLegal": { + "maxItems": 4, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } } }, "required": ["userId", "username", "cred"] @@ -6308,6 +6559,9 @@ "type": "object", "properties": { "inviteCode": { + "minLength": 1, + "maxLength": 255, + "pattern": ".*\\S.*", "type": "string" }, "type": { @@ -6323,6 +6577,9 @@ ] }, "campaignTag": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", "type": "string" } }, @@ -6344,44 +6601,551 @@ ], "responses": { "200": { - "description": "Default Response" - } - } - } - }, - "/invites": { - "get": { - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/invites/validate": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "inviteCode": { - "type": "string" - } - }, - "required": ["inviteCode"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response" - } - } - } + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [true] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [true] + }, + "attributionKind": { + "anyOf": [ + { + "type": "string", + "enum": ["PERSONAL"] + }, + { + "type": "string", + "enum": ["SYSTEM"] + }, + { + "type": "string", + "enum": ["LEGACY_UNRESOLVED"] + } + ] + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": ["awarded"] + }, + { + "type": "string", + "enum": ["already_owned"] + }, + { + "type": "string", + "enum": ["inactive"] + }, + { + "type": "string", + "enum": ["expired"] + }, + { + "type": "string", + "enum": ["unknown"] + }, + { + "type": "string", + "enum": ["definition_missing"] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["fallback", "destination"] + } + }, + "required": ["badgeCampaign", "outcome"] + } + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["campaignTag", "fallback", "destination"] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved", + "attributionKind", + "claims" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [false] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [false] + }, + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": ["awarded"] + }, + { + "type": "string", + "enum": ["already_owned"] + }, + { + "type": "string", + "enum": ["inactive"] + }, + { + "type": "string", + "enum": ["expired"] + }, + { + "type": "string", + "enum": ["unknown"] + }, + { + "type": "string", + "enum": ["definition_missing"] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["fallback", "destination"] + } + }, + "required": ["badgeCampaign", "outcome"] + } + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["campaignTag", "fallback", "destination"] + } + }, + "required": ["message", "attributionResolved", "onboardingResolved", "claims"] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["badge_definitions_unavailable"] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean", + "enum": [true] + }, + "retryAfterSeconds": { + "type": "number" + } + }, + "required": ["error", "message", "retryable", "retryAfterSeconds"] + } + } + } + } + } + } + }, + "/invites": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/invites/validate": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "inviteCode": { + "minLength": 1, + "maxLength": 255, + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": ["inviteCode"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [true] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [true] + }, + "attributionKind": { + "anyOf": [ + { + "type": "string", + "enum": ["PERSONAL"] + }, + { + "type": "string", + "enum": ["SYSTEM"] + }, + { + "type": "string", + "enum": ["LEGACY_UNRESOLVED"] + } + ] + }, + "username": { + "type": "string" + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["campaignTag", "fallback", "destination"] + } + }, + "required": [ + "message", + "attributionResolved", + "onboardingResolved", + "attributionKind", + "username" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "attributionResolved": { + "type": "boolean", + "enum": [false] + }, + "onboardingResolved": { + "type": "boolean", + "enum": [false] + }, + "legacyAcquisition": { + "type": "object", + "properties": { + "campaignTag": { + "type": "string" + }, + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["campaignTag", "fallback", "destination"] + } + }, + "required": ["message", "attributionResolved", "onboardingResolved"] + } + } + } + } + } + } }, "/invites/waitlist-position": { "get": { @@ -6761,6 +7525,191 @@ } } }, + "/badge/claims": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "badgeCampaigns": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + } + } + }, + "required": ["badgeCampaigns"] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": ["awarded"] + }, + { + "type": "string", + "enum": ["already_owned"] + }, + { + "type": "string", + "enum": ["inactive"] + }, + { + "type": "string", + "enum": ["expired"] + }, + { + "type": "string", + "enum": ["unknown"] + }, + { + "type": "string", + "enum": ["definition_missing"] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["fallback", "destination"] + } + }, + "required": ["badgeCampaign", "outcome"] + } + } + }, + "required": ["claims"] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["badge_definitions_unavailable"] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean", + "enum": [true] + }, + "retryAfterSeconds": { + "type": "number" + } + }, + "required": ["error", "message", "retryable", "retryAfterSeconds"] + } + } + } + } + } + } + }, "/badge/award": { "post": { "requestBody": { @@ -6770,6 +7719,10 @@ "type": "object", "properties": { "campaignTag": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", "type": "string" } }, @@ -6791,7 +7744,148 @@ ], "responses": { "200": { - "description": "Default Response" + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "claim": { + "type": "object", + "properties": { + "badgeCampaign": { + "minLength": 1, + "maxLength": 68, + "pattern": ".*\\S.*", + "description": "Opaque badge-acquisition campaign value. New integrations use the badge_campaign slug.", + "type": "string" + }, + "badgeCode": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "badge": { + "type": "object", + "properties": { + "code": { + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "code", + "name", + "description", + "publicDescription", + "iconUrl" + ] + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": ["awarded"] + }, + { + "type": "string", + "enum": ["already_owned"] + }, + { + "type": "string", + "enum": ["inactive"] + }, + { + "type": "string", + "enum": ["expired"] + }, + { + "type": "string", + "enum": ["unknown"] + }, + { + "type": "string", + "enum": ["definition_missing"] + } + ] + }, + "acquisition": { + "type": "object", + "properties": { + "fallback": { + "type": "string", + "enum": ["normal_app"] + }, + "destination": { + "anyOf": [ + { + "type": "string", + "enum": ["offramp_migration"] + }, + { + "type": "string", + "enum": ["normal_app"] + } + ] + } + }, + "required": ["fallback", "destination"] + } + }, + "required": ["badgeCampaign", "outcome"] + } + }, + "required": ["message", "claim"] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": ["badge_definitions_unavailable"] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean", + "enum": [true] + }, + "retryAfterSeconds": { + "type": "number" + } + }, + "required": ["error", "message", "retryable", "retryAfterSeconds"] + } + } + } } } } @@ -8190,6 +9284,25 @@ "minLength": 2, "maxLength": 2, "type": "string" + }, + "acceptedDocuments": { + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } } } } @@ -11778,104 +12891,11 @@ "type": "string" }, "code": { - "anyOf": [ - { - "type": "string", - "enum": ["BETA_TESTER"] - }, - { - "type": "string", - "enum": ["DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["PRODUCT_HUNT"] - }, - { - "type": "string", - "enum": ["OG_2025_10_12"] - }, - { - "type": "string", - "enum": ["SEEDLING_DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["ARBIVERSE_DEVCONNECT_BA_2025"] - }, - { - "type": "string", - "enum": ["CARD_PIONEER"] - }, - { - "type": "string", - "enum": ["FOUNDER_HOUSE"] - }, - { - "type": "string", - "enum": ["BUG_WHISPERER"] - }, - { - "type": "string", - "enum": ["SHHHHH"] - }, - { - "type": "string", - "enum": ["NOT_SO_SHHHH"] - }, - { - "type": "string", - "enum": ["CARD_FIRST_SWIPE"] - }, - { - "type": "string", - "enum": ["CARD_SPENT_1K"] - }, - { - "type": "string", - "enum": ["CARD_ALPHA"] - }, - { - "type": "string", - "enum": ["TOKEN_NATION_SP_2026"] - }, - { - "type": "string", - "enum": ["ETHFLORIPA_HUB"] - }, - { - "type": "string", - "enum": ["IRL_NOMADS"] - }, - { - "type": "string", - "enum": ["EVENT_ALUMNI"] - }, - { - "type": "string", - "enum": ["TOUCHED_GRASS"] - }, - { - "type": "string", - "enum": ["OFFRAMP_USER"] - }, - { - "type": "string", - "enum": ["PSYOPS_DIVISION"] - }, - { - "type": "string", - "enum": ["WAITLIST_SKIP"] - }, - { - "type": "string", - "enum": ["FESTA_JUNINA_2026"] - }, - { - "type": "string", - "enum": ["MANICERO"] - } - ] + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Z][A-Z0-9_]{0,49}$", + "description": "Stable badge code. Clients must tolerate codes added after SDK generation.", + "type": "string" }, "revoke": { "type": "boolean" diff --git a/src/utils/__tests__/deferred-link.test.ts b/src/utils/__tests__/deferred-link.test.ts index 3120334670..9172ed2473 100644 --- a/src/utils/__tests__/deferred-link.test.ts +++ b/src/utils/__tests__/deferred-link.test.ts @@ -81,11 +81,51 @@ describe('buildDeferredPayload / parseDeferredPayload round-trip', () => { expect(parseDeferredPayload(payload)).toEqual({ lang: 'es-419', invite: 'abc123', - campaign: 'offramp', + badgeCampaigns: ['offramp'], dest: '/claim/XYZ?t=1', }) }) + it('round-trips repeated badge campaign identities without comma encoding or case loss', () => { + saveToCookie('campaignTag', ['Creator/Summer', 'Tag,With,Commas']) + + const payload = buildDeferredPayload('/home') + const payloadParams = new URLSearchParams(payload) + expect(payloadParams.getAll('badge_campaign')).toEqual(['Creator/Summer', 'Tag,With,Commas']) + expect(payloadParams.has('campaign')).toBe(false) + expect(parseDeferredPayload(payload)).toEqual({ + badgeCampaigns: ['Creator/Summer', 'Tag,With,Commas'], + dest: '/home', + }) + }) + + it('accepts old deferred campaign payloads while preferring the canonical namespace', () => { + expect(parseDeferredPayload('pnutdl=1&campaign=legacy-first&campaign=legacy-second')).toEqual({ + badgeCampaigns: ['legacy-first', 'legacy-second'], + }) + expect( + parseDeferredPayload( + 'pnutdl=1&utm_campaign=analytics&campaign=legacy&badge_campaign=canonical-first&badge_campaign=canonical-second' + ) + ).toEqual({ badgeCampaigns: ['canonical-first', 'canonical-second'] }) + }) + + it('keeps a marked historical UTM source-qualified for backend allowlist resolution', () => { + expect(parseDeferredPayload('pnutdl=1&utm_campaign=token-nation-2026')).toEqual({ + badgeCampaigns: ['utm:token-nation-2026'], + }) + }) + + it('round-trips the maximum-length source-qualified UTM identity through install handoff', () => { + const qualifiedUtmIdentity = `utm:${'x'.repeat(64)}` + saveToCookie('campaignTag', qualifiedUtmIdentity) + + expect(parseDeferredPayload(buildDeferredPayload('/home'))).toEqual({ + badgeCampaigns: [qualifiedUtmIdentity], + dest: '/home', + }) + }) + it('defaults dest to the current path + query and skips absent fields', () => { window.history.replaceState({}, '', '/claim/ABC?x=2') const parsed = parseDeferredPayload(buildDeferredPayload()) @@ -131,16 +171,16 @@ describe('parseDeferredPayload rejection', () => { }) describe('applyDeferredPayload', () => { - it('writes a SESSION inviteCode cookie (matching InvitesPage — a durable one locks existing users out of login) and a 30-day campaignTag', () => { + it('writes a SESSION inviteCode cookie and a 30-day campaign list', () => { applyDeferredPayload({ invite: 'abc', campaign: 'off' }) expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'abc') expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'off', 30) }) - it('normalizes invite and campaign like the existing writers', () => { + it('normalizes invite separately while preserving campaign spelling after outer trim', () => { applyDeferredPayload({ invite: ' @Alice ', campaign: ' OFFRAMP ' }) expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'alice') - expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'offramp', 30) + expect(mockSaveToCookie).toHaveBeenCalledWith('campaignTag', 'OFFRAMP', 30) }) it('strips the marketing locale prefix from dest (native export has no /{locale} routes)', () => { @@ -179,7 +219,9 @@ describe('applyDeferredPayload', () => { describe('restoreDeferredContext', () => { it('restores cookies, locale and dest from the android referrer, once', async () => { mockIsAndroidNative.mockReturnValue(true) - getReferrer.mockResolvedValue({ referrer: 'pnutdl=1&lang=es-419&invite=abc&campaign=off&dest=%2Fclaim%2FXYZ' }) + getReferrer.mockResolvedValue({ + referrer: 'pnutdl=1&lang=es-419&invite=abc&badge_campaign=off&dest=%2Fclaim%2FXYZ', + }) await expect(restoreDeferredContext()).resolves.toEqual({ dest: '/claim/XYZ', locale: 'es-419' }) expect(document.cookie).toContain('inviteCode=') diff --git a/src/utils/deferred-link.ts b/src/utils/deferred-link.ts index f185049b3d..ac9ac09f77 100644 --- a/src/utils/deferred-link.ts +++ b/src/utils/deferred-link.ts @@ -1,4 +1,4 @@ -// deferred deep linking: carry context (locale, invite code, campaign tag, +// deferred deep linking: carry context (locale, invite code, badge campaign identities, // destination path) from mobile web through the app-store install into the // native app. android rides the Play Install Referrer; iOS rides a clipboard // hand-off written on the store-bounce tap and read once on first launch. @@ -9,6 +9,13 @@ import { isValidLocale } from '@/i18n/config' import { isAndroidNative, isIOSNative } from './capacitor' import { getFromCookie, saveToCookie, sanitizeRedirectURL, toInviteCode } from './general.utils' import { deepLinkToNativePath } from './native-routes' +import { + BADGE_CAMPAIGN_QUERY_PARAM, + badgeCampaignIdentitiesFromDeferredSearchParams, + getPendingBadgeCampaigns, + parsePendingBadgeCampaigns, + queuePendingBadgeCampaigns, +} from '@/components/Invites/badge-campaign-context' // marker param distinguishing our payload from Play's organic referrer // (utm_source=google-play&utm_medium=organic) @@ -51,6 +58,9 @@ function persistRestoredLocale(locale: AppLocale): void { export interface DeferredPayload { lang?: string invite?: string + /** Current lossless multi-badge-campaign shape. */ + badgeCampaigns?: string[] + /** Legacy single-campaign payload accepted during app upgrade. */ campaign?: string dest?: string } @@ -90,7 +100,7 @@ function stripLocalePrefix(path: string): string { /** * builds the payload querystring from the current web context: locale from the - * /{locale}/ path prefix, invite/campaign from their existing cookies, dest + * /{locale}/ path prefix, invite/badge campaign from their existing cookies, dest * from the argument (defaults to the current path + query, locale stripped). */ export function buildDeferredPayload(dest?: string): string { @@ -102,8 +112,9 @@ export function buildDeferredPayload(dest?: string): string { const invite = getFromCookie('inviteCode') if (typeof invite === 'string' && invite) params.set('invite', invite) - const campaign = getFromCookie('campaignTag') - if (typeof campaign === 'string' && campaign) params.set('campaign', campaign) + for (const badgeCampaign of getPendingBadgeCampaigns()) { + params.append(BADGE_CAMPAIGN_QUERY_PARAM, badgeCampaign) + } const destination = dest ?? stripLocalePrefix(window.location.pathname) + window.location.search if (destination && destination !== '/') params.set('dest', destination) @@ -151,7 +162,13 @@ export function parseDeferredPayload(raw: string): DeferredPayload | null { } if (params.get(MARKER) !== '1') return null const pick = (key: string) => params.get(key) || undefined - return { lang: pick('lang'), invite: pick('invite'), campaign: pick('campaign'), dest: pick('dest') } + const badgeCampaigns = badgeCampaignIdentitiesFromDeferredSearchParams(params) + return { + lang: pick('lang'), + invite: pick('invite'), + badgeCampaigns: badgeCampaigns.length > 0 ? badgeCampaigns : undefined, + dest: pick('dest'), + } } // --------------------------------------------------------------------------- @@ -264,12 +281,11 @@ export function applyDeferredPayload(payload: DeferredPayload): RestoredContext // install→open→signup funnel and self-heals on app restart. const invite = payload.invite ? toInviteCode(payload.invite) : '' if (invite) saveToCookie('inviteCode', invite) - // campaignTag doesn't gate the setup step (removed in the #2346 fix), so - // it can safely outlive the session for badge attribution. lowercased - // like InvitesPage's utm_campaign handling — badge matching is - // case-sensitive. - const campaign = payload.campaign?.trim().toLowerCase() - if (campaign) saveToCookie('campaignTag', campaign, 30) + // Badge campaigns do not gate the setup step, so they can safely outlive the + // session for a network/configuration retry. Preserve the first trimmed + // spelling; backend resolution is case-insensitive. + const badgeCampaigns = parsePendingBadgeCampaigns(payload.badgeCampaigns ?? payload.campaign) + if (badgeCampaigns.length > 0) queuePendingBadgeCampaigns(badgeCampaigns, 30) const locale = payload.lang ? resolveAppLocale(payload.lang) : null if (locale) persistRestoredLocale(locale)