diff --git a/next.config.js b/next.config.js index 552433f7c8..29c556800c 100644 --- a/next.config.js +++ b/next.config.js @@ -348,13 +348,14 @@ let nextConfig = { }, async redirects() { // Campaign/invite links on the root domain (peanut.me/?campaign=skip, - // peanut.me/?code=alice) hand off to /invite, which owns the claim flow. + // peanut.me/?invited_by=alice, legacy peanut.me/?code=alice) hand off to + // /invite, which owns the claim flow. // Query params carry over automatically. Deliberately NOT keyed on // utm_campaign — that would hijack ordinary marketing links to the // landing page; utm_campaign only resolves to a badge once on /invite. // Active in development too (unlike the locale redirects) so the flow is // locally testable. - const campaignRedirects = ['campaign', 'campaignTag', 'code'].map((key) => ({ + const campaignRedirects = ['campaign', 'campaignTag', 'invited_by', 'code'].map((key) => ({ source: '/', has: [{ type: 'query', key }], destination: '/invite', diff --git a/src/__tests__/next-config-invite-redirects.test.ts b/src/__tests__/next-config-invite-redirects.test.ts new file mode 100644 index 0000000000..c68d15348a --- /dev/null +++ b/src/__tests__/next-config-invite-redirects.test.ts @@ -0,0 +1,33 @@ +// Root-domain invite links (peanut.me/?invited_by=alice) must hand off to +// /invite like the legacy peanut.me/?code=alice shape does — the landing page +// ignores the param, so a missed key silently drops attribution. +// +// Loaded under NODE_ENV=development: that branch exports the plain config +// (no Serwist/Sentry wrappers) and redirects() returns exactly the campaign +// hand-offs, which is all this test is about. + +async function campaignRedirects(): Promise>> { + const previousEnv = process.env.NODE_ENV + // NODE_ENV is typed read-only by Next; Object.assign sidesteps the type, not the runtime. + Object.assign(process.env, { NODE_ENV: 'development' }) + try { + let config: { redirects(): Promise>> } | undefined + jest.isolateModules(() => { + config = require('../../next.config.js') + }) + return await config!.redirects() + } finally { + Object.assign(process.env, { NODE_ENV: previousEnv }) + } +} + +describe('root-domain invite redirects', () => { + it.each(['invited_by', 'code', 'campaign', 'campaignTag'])('hands peanut.me/?%s= off to /invite', async (key) => { + await expect(campaignRedirects()).resolves.toContainEqual({ + source: '/', + has: [{ type: 'query', key }], + destination: '/invite', + permanent: false, + }) + }) +}) diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 192ba47611..5742ae3ff8 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -13,6 +13,7 @@ import { isLikelyWebview, isDeviceOsSupported } from '@/components/Setup/Setup.u import { isCapacitor } from '@/utils/capacitor' import { isPwaSunsetOn } from '@/utils/migration.utils' import { getFromCookie, saveToCookie, toInviteCode } from '@/utils/general.utils' +import { inviteCodeFromParams } from '@/utils/invite-code.utils' import { useSearchParams } from 'next/navigation' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAuth } from '@/context/authContext' @@ -114,13 +115,13 @@ function SetupPageContent() { // route a returning user past Landing (the only screen with Log In) onto // Signup, unable to log back in (regression from PR #2346). /* - * ?code= arrives from an /invite deep link (native maps - * peanut.me/invite?code=X here — see native-routes.ts). Persist it - * as the same session cookie the web InvitesPage and the - * deferred-install hand-off write, so it survives the multi-step - * signup and reaches registration. + * ?invited_by= (or its legacy alias ?code=) arrives from an /invite + * deep link (native maps peanut.me/invite?invited_by=X here — see + * native-routes.ts). Persist it as the same session cookie the web + * InvitesPage and the deferred-install hand-off write, so it + * survives the multi-step signup and reaches registration. */ - const codeFromUrl = searchParams.get('code') + const codeFromUrl = inviteCodeFromParams(searchParams) if (codeFromUrl && toInviteCode(codeFromUrl)) { saveToCookie('inviteCode', toInviteCode(codeFromUrl)) } diff --git a/src/app/invite/__tests__/page.test.tsx b/src/app/invite/__tests__/page.test.tsx new file mode 100644 index 0000000000..e531bf46b9 --- /dev/null +++ b/src/app/invite/__tests__/page.test.tsx @@ -0,0 +1,56 @@ +// generateMetadata is the one SERVER reader of the inviter param: the unfurl +// (X / WhatsApp / Telegram preview) is built here, so it must accept the same +// params as the client — `invited_by` for new links, legacy alias `code`. +import { generateMetadata } from '../page' + +jest.mock('@/components/Invites/InvitesPage', () => () => null) +jest.mock('@/lib/hosting/get-origin', () => ({ + __esModule: true, + default: jest.fn(async () => 'https://peanut.me'), +})) +jest.mock('@/utils/og.utils', () => ({ + buildOgImageUrl: jest.fn( + ({ username }: { username: string }) => `https://peanut.me/api/og?username=${username}&isInvite=true` + ), +})) +const mockValidateInviteCode = jest.fn() +jest.mock('@/app/actions/invites', () => ({ + validateInviteCode: (...args: unknown[]) => mockValidateInviteCode(...args), +})) + +const metadataFor = (searchParams: Record) => + generateMetadata({ params: Promise.resolve({}), searchParams: Promise.resolve(searchParams) }) + +beforeEach(() => { + mockValidateInviteCode.mockReset() + mockValidateInviteCode.mockResolvedValue({ + data: { success: true, onboardingResolved: true, username: 'alice' }, + }) +}) + +describe('/invite generateMetadata', () => { + it.each([ + ['invited_by', { invited_by: 'alice' }], + ['legacy code', { code: 'alice' }], + ])('personalises the unfurl for %s links', async (_, searchParams) => { + const metadata = await metadataFor(searchParams) + + expect(mockValidateInviteCode).toHaveBeenCalledWith('alice') + expect(metadata.title).toBe('alice invited you to join Peanut') + expect(metadata.openGraph?.images).toEqual([ + expect.objectContaining({ url: 'https://peanut.me/api/og?username=alice&isInvite=true' }), + ]) + }) + + it('lets legacy code win when both are present', async () => { + await metadataFor({ code: 'offramp', invited_by: 'alice' }) + expect(mockValidateInviteCode).toHaveBeenCalledWith('offramp') + }) + + it('renders the generic page when no inviter param is present', async () => { + const metadata = await metadataFor({ redirect_uri: '/home' }) + + expect(mockValidateInviteCode).not.toHaveBeenCalled() + expect(metadata.title).toBe('Invites | Peanut') + }) +}) diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 3ce5c716c1..b38f69b504 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -4,10 +4,11 @@ import { type Metadata } from 'next' import { validateInviteCode } from '../actions/invites' import { BASE_URL } from '@/constants/general.consts' import { buildOgImageUrl } from '@/utils/og.utils' +import { inviteCodeFromParams } from '@/utils/invite-code.utils' export const dynamic = 'force-dynamic' -async function getInviteCodeData(inviteCode: string) { +async function getInviteCodeData(inviteCode: string | null) { if (!inviteCode) return null const response = await validateInviteCode(inviteCode) @@ -30,7 +31,15 @@ export async function generateMetadata({ const resolvedSearchParams = await searchParams const siteUrl: string = (await getOrigin()) || BASE_URL - const inviteCode = resolvedSearchParams.code as string + // Same reader as the client: new links carry ?invited_by=, every older + // shared link ?code=. The unfurl (X / WhatsApp / Telegram) is built here, + // so this reader must not lag the emitted shape. + const inviteCode = inviteCodeFromParams({ + get: (name) => { + const value = resolvedSearchParams[name] + return (Array.isArray(value) ? value[0] : value) ?? null + }, + }) const inviteCodeData = await getInviteCodeData(inviteCode) diff --git a/src/components/Badges/__tests__/BadgeDetailModal.test.tsx b/src/components/Badges/__tests__/BadgeDetailModal.test.tsx index 5f19b229c5..1e2923f519 100644 --- a/src/components/Badges/__tests__/BadgeDetailModal.test.tsx +++ b/src/components/Badges/__tests__/BadgeDetailModal.test.tsx @@ -74,7 +74,7 @@ describe('BadgeDetailModal', () => { await expect(shareProps.generateText()).resolves.toContain('Just put my Peanut card to work') // attributed share link, asserted origin-agnostically: shareableUrl resolves // to the jsdom origin here, not NEXT_PUBLIC_BASE_URL. - await expect(shareProps.generateText()).resolves.toContain('/invite?code=satoshi') + await expect(shareProps.generateText()).resolves.toContain('/invite?invited_by=satoshi') screen.getByRole('button', { name: en.badges.shareAchievement }).click() expect(onClose).toHaveBeenCalledTimes(1) @@ -86,7 +86,7 @@ describe('BadgeDetailModal', () => { expect(screen.getByRole('button', { name: ptBR.badges.shareAchievement })).toBeInTheDocument() const text = await mockShareButton.mock.calls[0][0].generateText() expect(text).toContain('Ganhei o selo First Swipe no Peanut!') - expect(text).toContain('/invite?code=satoshi') + expect(text).toContain('/invite?invited_by=satoshi') expect(text).not.toContain('Just put my Peanut card to work') }) }) diff --git a/src/components/Invites/InvitesPage.test.tsx b/src/components/Invites/InvitesPage.test.tsx index 9309eb64a0..ef0791be3a 100644 --- a/src/components/Invites/InvitesPage.test.tsx +++ b/src/components/Invites/InvitesPage.test.tsx @@ -353,6 +353,21 @@ describe('invite and badge campaign routing boundaries', () => { await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/profile/alice')) }) + it('accepts invited_by as an alias for the legacy code param', async () => { + mockSearch = 'invited_by=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 = { diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 3dfbe1225f..adb4c035c8 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -14,6 +14,7 @@ import { setupActions } from '@/redux/slices/setup-slice' import { useAuth } from '@/context/authContext' import { EInviteType } from '@/services/services.types' import { getValidRedirectUrl, saveRedirectUrl, saveToCookie } from '@/utils/general.utils' +import { inviteCodeFromParams } from '@/utils/invite-code.utils' import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff' import { useLogin } from '@/hooks/useLogin' import { useToast } from '@/components/0_Bruddle/Toast' @@ -42,7 +43,7 @@ function InvitePageContent() { const toast = useToast() const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end - const inviteCode = searchParams.get('code')?.toLowerCase().replace(/\?+$/, '') + const inviteCode = inviteCodeFromParams(searchParams)?.toLowerCase().replace(/\?+$/, '') const redirectUri = searchParams.get('redirect_uri') const safeRedirectUri = redirectUri ? getValidRedirectUrl(redirectUri, '') : '' const { user, isFetchingUser, fetchUser } = useAuth() @@ -244,7 +245,7 @@ function InvitePageContent() { // A bare link that resolves to nothing claimable — unknown ?campaign= value, // a stray tracking param swept up by the root-domain redirect, an empty - // ?code= — gets the landing page, not the invalid-invite error. That screen + // ?invited_by= / ?code= — gets the landing page, not the invalid-invite error. That screen // is reserved for links that actually carried an invite code. Safe from a // redirect loop: the root redirect only fires when the params are present, // and we replace with a bare '/'. diff --git a/src/components/Invites/badge-campaign-context.ts b/src/components/Invites/badge-campaign-context.ts index a0344ad304..27afaeddb2 100644 --- a/src/components/Invites/badge-campaign-context.ts +++ b/src/components/Invites/badge-campaign-context.ts @@ -9,7 +9,8 @@ import { /** * 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 + * inviter. In particular, `?invited_by=juanacervio` (or legacy `?code=`) + * carries no NITA campaign unless * `?badge_campaign=nita` is also present. */ /** Published storage key retained for old bundles. Do not rename the string value. */ diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 7790d9a9d9..3f4911a4e7 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -111,7 +111,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa }) if (intercepted) return // Unresolvable and mismatched codes still navigate — /invite owns the messaging. - router.push(`/invite?code=${code}`) + router.push(`/invite?invited_by=${code}`) } finally { setIsJoining(false) } diff --git a/src/components/Profile/components/__tests__/PublicProfile.test.tsx b/src/components/Profile/components/__tests__/PublicProfile.test.tsx index a696f2fa31..5fb8bfa2f6 100644 --- a/src/components/Profile/components/__tests__/PublicProfile.test.tsx +++ b/src/components/Profile/components/__tests__/PublicProfile.test.tsx @@ -112,7 +112,7 @@ describe('PublicProfile guest door', () => { fireEvent.click(screen.getByRole('button', { name: JOIN_CTA })) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/invite?code=satoshi')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/invite?invited_by=satoshi')) expect(mockValidateInviteCode).toHaveBeenCalledWith('satoshi') expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'satoshi') expect(posthog.capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.REFERRAL_CTA_CLICKED, { @@ -190,7 +190,7 @@ describe('PublicProfile guest door', () => { const joinButtons = screen.getAllByRole('button', { name: JOIN_CTA }) fireEvent.click(joinButtons[joinButtons.length - 1]) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/invite?code=satoshi')) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/invite?invited_by=satoshi')) expect(mockSaveToCookie).toHaveBeenCalledWith('inviteCode', 'satoshi') }) @@ -211,7 +211,7 @@ describe('PublicProfile guest door', () => { fireEvent.click(await screen.findByRole('button', { name: JOIN_CTA })) - await waitFor(() => expect(mockPush).toHaveBeenCalledWith(`/invite?code=${expectedCode}`)) + await waitFor(() => expect(mockPush).toHaveBeenCalledWith(`/invite?invited_by=${expectedCode}`)) expect(mockSaveToCookie).not.toHaveBeenCalled() expect(posthog.capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.REFERRAL_CTA_CLICKED, { source: REFERRAL_SOURCES.PUBLIC_PROFILE_GUEST, diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 48720c3a18..dbdf24194b 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -340,7 +340,8 @@ export const MODAL_TYPES = { * INVITE_LINK_SHARED events. * * Referral events also carry a `link_type` property so PostHog can compare - * which link shape converts: 'invite_code' (/invite?code=, credits the + * which link shape converts: 'invite_code' (/invite?invited_by=, or the + * legacy ?code= alias, credits the * inviter at signup), 'profile' (peanut.me/, credits via the guest-profile * door), or 'none' (share carried no link, e.g. anti-dox card shares). */ diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx index 3a53ffa9a2..b2238625d7 100644 --- a/src/features/payments/shared/components/SendWithPeanutCta.tsx +++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx @@ -70,7 +70,7 @@ export default function SendWithPeanutCta({ // migration window: web signups are closed — hand the guest to the // app stores instead (QR modal on desktop, store link on mobile). // the inviter rides the deferred hand-off, mirroring the web path - // below that routes to /invite?code= + // below that routes to /invite?invited_by= if (interceptGuestCta({ invite: inviterUsername ? toInviteCode(inviterUsername) : undefined })) return const redirectUri = encodeURIComponent( window.location.pathname + window.location.search + window.location.hash diff --git a/src/hooks/useNativeAppLinks.ts b/src/hooks/useNativeAppLinks.ts index 4985b6d8ef..01c01ee601 100644 --- a/src/hooks/useNativeAppLinks.ts +++ b/src/hooks/useNativeAppLinks.ts @@ -7,7 +7,7 @@ import { captureMessage } from '@/utils/sentry-lazy' import { isCapacitor } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL, saveToCookie } from '@/utils/cookie-url.utils' -import { toInviteCode } from '@/utils/invite-code.utils' +import { inviteCodeFromParams, toInviteCode } from '@/utils/invite-code.utils' import { getOneSignalAdapter } from '@/services/onesignal' /* @@ -59,7 +59,7 @@ export function useNativeAppLinks() { try { const parsed = new URL(url, 'https://peanut.me') if (parsed.pathname.split('/').filter(Boolean)[0] === 'invite') { - const code = toInviteCode(parsed.searchParams.get('code') ?? '') + const code = toInviteCode(inviteCodeFromParams(parsed.searchParams) ?? '') if (code) saveToCookie('inviteCode', code) } } catch {} diff --git a/src/utils/__tests__/general.utils.test.ts b/src/utils/__tests__/general.utils.test.ts index bb405bf8f5..933df5c960 100644 --- a/src/utils/__tests__/general.utils.test.ts +++ b/src/utils/__tests__/general.utils.test.ts @@ -488,13 +488,13 @@ describe('General Utilities', () => { it('emits a username-only invite code (no INVITESYOU, no suffix)', () => { const { inviteCode, inviteLink } = generateInviteCodeLink('alice') expect(inviteCode).toBe('alice') - expect(inviteLink).toBe('https://peanut.example.org/invite?code=alice') + expect(inviteLink).toBe('https://peanut.example.org/invite?invited_by=alice') }) it('lowercases mixed-case usernames', () => { const { inviteCode, inviteLink } = generateInviteCodeLink('Alice') expect(inviteCode).toBe('alice') - expect(inviteLink).toBe('https://peanut.example.org/invite?code=alice') + expect(inviteLink).toBe('https://peanut.example.org/invite?invited_by=alice') }) }) diff --git a/src/utils/__tests__/invite-code.utils.test.ts b/src/utils/__tests__/invite-code.utils.test.ts new file mode 100644 index 0000000000..fcef0ab058 --- /dev/null +++ b/src/utils/__tests__/invite-code.utils.test.ts @@ -0,0 +1,27 @@ +// inviteCodeFromParams — the one reader for the inviter param. New links emit +// `invited_by`; every previously shared link carries `code`, which stays +// supported forever as an alias. + +import { inviteCodeFromParams } from '@/utils/invite-code.utils' + +describe('inviteCodeFromParams', () => { + it('reads the invited_by param new links emit', () => { + expect(inviteCodeFromParams(new URLSearchParams('invited_by=alice'))).toBe('alice') + }) + + it('reads the legacy code alias', () => { + expect(inviteCodeFromParams(new URLSearchParams('code=alice'))).toBe('alice') + }) + + it('lets legacy code win when both are present — an existing link keeps its behavior', () => { + expect(inviteCodeFromParams(new URLSearchParams('code=offramp&invited_by=alice'))).toBe('offramp') + }) + + it('falls through an empty legacy code to invited_by', () => { + expect(inviteCodeFromParams(new URLSearchParams('code=&invited_by=alice'))).toBe('alice') + }) + + it('returns null when neither is present', () => { + expect(inviteCodeFromParams(new URLSearchParams('redirect_uri=%2Fhome'))).toBeNull() + }) +}) diff --git a/src/utils/__tests__/invite-flow-url.test.ts b/src/utils/__tests__/invite-flow-url.test.ts index 0bd7180c47..3123394c32 100644 --- a/src/utils/__tests__/invite-flow-url.test.ts +++ b/src/utils/__tests__/invite-flow-url.test.ts @@ -17,7 +17,7 @@ beforeEach(() => { describe('inviteFlowUrl', () => { it('web routes to the invite landing page and writes no cookie', () => { - expect(inviteFlowUrl('alice', '%2Fclaim%2FX')).toBe('/invite?code=alice&redirect_uri=%2Fclaim%2FX') + expect(inviteFlowUrl('alice', '%2Fclaim%2FX')).toBe('/invite?invited_by=alice&redirect_uri=%2Fclaim%2FX') expect(getFromCookie('inviteCode')).toBeFalsy() }) diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2bafedc779..b33ae14e60 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -339,6 +339,12 @@ describe('native-routes', () => { // The invite landing page is stripped from the native export — an // /invite App Link must land on signup with the code riding along. it('maps an invite link onto the signup flow, code preserved', () => { + expect(deepLinkToNativePath('https://peanut.me/invite?invited_by=alice')).toBe( + '/setup?step=signup&invited_by=alice' + ) + }) + + it('maps a legacy ?code= invite link the same way — old shared links keep working', () => { expect(deepLinkToNativePath('https://peanut.me/invite?code=alice')).toBe( '/setup?step=signup&code=alice' ) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 7608c3d76e..4d36d63325 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -791,10 +791,12 @@ export function slugify(text: string): string { * Canonical invite-code shape: a bare, lowercased username (e.g. `alice`). * * Single source of truth — use this anywhere an invite code is built for - * `/invite?code=…` or `acceptInvite`. The legacy `ALICEINVITESYOU610` / - * `ALICEINVITESYOU` shapes are no longer emitted, but stay fully supported on - * the backend (peanut-api-ts `extractUsernameFromInvite` uppercases the input - * and matches the old suffixes), so existing shared links keep working. + * `/invite?invited_by=…` or `acceptInvite`. Links now emit `invited_by`; the + * `code` param stays supported as a read-side alias (inviteCodeFromParams), + * and the legacy `ALICEINVITESYOU610` / `ALICEINVITESYOU` shapes are no longer + * emitted but stay fully supported on the backend (peanut-api-ts + * `extractUsernameFromInvite` uppercases the input and matches the old + * suffixes), so existing shared links keep working. * * Also tolerates hand-typed input ("Who invited you?" asks for a username, so * people paste `@alice ` or ` Alice`): trims whitespace and strips a leading @. @@ -810,14 +812,14 @@ export { jsonStringify, jsonParse, saveToCookie, getFromCookie, sanitizeRedirect * cookie on native, never call it during render. */ export const inviteFlowUrl = (inviteCode: string, redirectUri: string): string => { - if (!isCapacitor()) return `/invite?code=${inviteCode}&redirect_uri=${redirectUri}` + if (!isCapacitor()) return `/invite?invited_by=${inviteCode}&redirect_uri=${redirectUri}` saveToCookie('inviteCode', inviteCode) return `/setup?step=signup&redirect_uri=${redirectUri}` } export const generateInviteCodeLink = (username: string) => { const inviteCode = toInviteCode(username) - const inviteLink = shareableUrl(`/invite?code=${inviteCode}`) + const inviteLink = shareableUrl(`/invite?invited_by=${inviteCode}`) return { inviteLink, inviteCode } } diff --git a/src/utils/invite-code.utils.ts b/src/utils/invite-code.utils.ts index 18b67d95cc..19f5cc745c 100644 --- a/src/utils/invite-code.utils.ts +++ b/src/utils/invite-code.utils.ts @@ -10,3 +10,14 @@ * landing page. */ export const toInviteCode = (username: string): string => username.trim().replace(/^@/, '').toLowerCase() + +/** + * Reads the inviter from a link's query params. New links emit `invited_by` + * (it names what the value is — the inviter's username); `code` is the alias + * every previously shared link carries and stays supported forever. When both + * are present the legacy `code` wins, so a pre-existing link keeps its exact + * behavior even with the new param appended to it. An empty `code=` falls + * through to `invited_by` instead of masking it. + */ +export const inviteCodeFromParams = (params: { get(name: string): string | null }): string | null => + params.get('code') || params.get('invited_by') || null diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 0c74dd0a77..2488298b7a 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -128,9 +128,10 @@ function mapDeepLinkPath(parsed: URL): string | null { return rewriteMethodPath(path, extraParams || undefined) } /* - * `/invite?code=X` — the invite landing page is stripped from the native - * export (scripts/native-build.js), so route to the signup flow with the - * params riding along; the setup page persists ?code= as the session + * `/invite?invited_by=X` (legacy alias `?code=X`) — the invite landing + * page is stripped from the native export (scripts/native-build.js), so + * route to the signup flow with the params riding along; the setup page + * persists the inviter param as the session * inviteCode cookie (same mechanism as the deferred-install hand-off, and * openDeepLink writes the same cookie as a belt-and-suspenders), and a * logged-in session on /setup bounces itself home.