Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/next-config-invite-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<Record<string, unknown>>> {
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<Array<Record<string, unknown>>> } | 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,
})
})
})
13 changes: 7 additions & 6 deletions src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
}
Expand Down
56 changes: 56 additions & 0 deletions src/app/invite/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | string[] | undefined>) =>
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')
})
})
13 changes: 11 additions & 2 deletions src/app/invite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/components/Badges/__tests__/BadgeDetailModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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')
})
})
15 changes: 15 additions & 0 deletions src/components/Invites/InvitesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<InvitesPage />)

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 = {
Expand Down
5 changes: 3 additions & 2 deletions src/components/Invites/InvitesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 '/'.
Expand Down
3 changes: 2 additions & 1 deletion src/components/Invites/badge-campaign-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion src/components/Profile/components/PublicProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ 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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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')
})

Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/constants/analytics.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<u>, credits the
* which link shape converts: 'invite_code' (/invite?invited_by=<u>, or the
* legacy ?code=<u> alias, credits the
* inviter at signup), 'profile' (peanut.me/<u>, credits via the guest-profile
* door), or 'none' (share carried no link, e.g. anti-dox card shares).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=<inviter>
// below that routes to /invite?invited_by=<inviter>
if (interceptGuestCta({ invite: inviterUsername ? toInviteCode(inviterUsername) : undefined })) return
const redirectUri = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useNativeAppLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
Expand Down Expand Up @@ -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 {}
Expand Down
4 changes: 2 additions & 2 deletions src/utils/__tests__/general.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

Expand Down
27 changes: 27 additions & 0 deletions src/utils/__tests__/invite-code.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 1 addition & 1 deletion src/utils/__tests__/invite-flow-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
6 changes: 6 additions & 0 deletions src/utils/__tests__/native-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand Down
Loading
Loading