Skip to content
Merged
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
41 changes: 27 additions & 14 deletions docs/NATIVE-RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,21 +197,19 @@ and it is fine for it to lag behind what ships.

### Cutting a release

> **Owed to the next native release: the `/app` App Links claim.**
> `d91ea7cee` added `/app` + `/app/*` to `public/.well-known/apple-app-site-association`
> and `/app` + `/app/` to `android/app/src/main/AndroidManifest.xml`. `v1.6.0` predates
> that commit, so no shipped binary or AASA ever carried the claim — but the manifest half
> moved the native fingerprint, and `check-native-ota-surface` compares that fingerprint
> against the binary an OTA targets. The two lines therefore blocked every production
> bundle while delivering nothing, with the fleet stuck on JS older than its own binary.
> Both platforms were reverted together so the iOS↔Android parity case in
> `src/utils/__tests__/app-links.test.ts` stays enforced; only the two `/app`-presence
> cases are `it.skip`.
> **Download QRs use the shipped `/home` App Links claim.**
> `d91ea7cee` once added `/app` + `/app/*` to the AASA file and the Android manifest.
> `v1.6.0` predates that commit, so no shipped binary ever carried the claim, but the
> manifest half moved the native fingerprint that `check-native-ota-surface` compares
> against the binary an OTA targets — blocking every production bundle while delivering
> nothing. Both platforms were reverted together, and restoring them was queued for the
> next release.
>
> **Restore all three in the PR that cuts the next native release** — the AASA entries, the
> manifest entries, and the two skipped cases. Never restore them on `dev` alone: an intent
> filter cannot ship over the air, so on their own they block OTA again for no user-visible
> gain. `native-routes.ts` still maps `/app/*` → `/app`, so nothing else needs touching.
> That plan is superseded. Download QRs now enter through the already-shipped `/home`
> association plus an `app_entry` marker, so an installed app opens them with no new
> intent filter and no store release. `/app` stays web-only, and
> `src/utils/__tests__/app-links.test.ts` pins its absence on both platforms with live
> cases. See §9 "App-download QR links do not expand the native surface".

The native workflows are manual and accept `dev`, `main`, and `release/android-kyc`.
Production OTA is different: every update to `main` runs **App Release OTA**, and that
Expand Down Expand Up @@ -397,6 +395,21 @@ update to `main`. It has no manual dispatch trigger and refuses every other ref.
| **App Release OTA** — automatic push to `main` | `ios-mobile-release` and `android-mobile-release` | `<major>.<build>.<ota+1>-ios` / `-android` |
| **App Staging OTA** — manual, `dev` source | `staging` | `<major>.<build>.<commit count>` |

### App-download QR links do not expand the native surface

Generated download QRs use `https://peanut.me/home?app_entry=1`, not `/app`.
`/home` is already present in the Android intent filter and every iOS AASA entry
shipped with v1.6. In a browser, `src/proxy.ts` removes `app_entry` and redirects
to the `/app` smart-store page while preserving the deferred-link payload. In an
installed app, `useNativeAppLinks` consumes the marker, applies that payload, and
opens its sanitized destination or `/home`.

Keep `/app` out of `AndroidManifest.xml` and the AASA file. Adding it there is a
native-surface change and requires a coordinated store release; server-side App
Link configuration cannot make an Android binary claim paths outside its
manifest. Existing `/app` links remain valid web smart links, while newly
generated QRs get installed-app opening without a native rebuild.

For a production OTA:

1. Merge the desired app changes into `dev`, record its exact commit, and wait for its
Expand Down
Binary file added public/email/app-store-badge.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/email/google-play-badge.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/email/native-app-launch-hero-money.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/email/native-app-launch-hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ function runProxy(path: string) {
return proxy(new NextRequest(`https://peanut.me${path}`))
}

describe('app-entry gateway', () => {
it('sends a browser QR scan to the smart-store page and preserves its handoff', () => {
const response = runProxy('/home?app_entry=1&pnutdl=1&badgeCampaign=door&dest=%2Fcard')

expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('https://peanut.me/app?pnutdl=1&badgeCampaign=door&dest=%2Fcard')
})

it('does not redirect ordinary /home navigation', () => {
const response = runProxy('/home?tab=activity')

expect(response.status).toBe(200)
expect(response.headers.get('location')).toBeNull()
})
})

describe('API cache policy', () => {
it('lets the exact exchange-rate route preserve its route-owned cache headers', () => {
const response = runProxy('/api/exchange-rate?from=PLN&to=EUR')
Expand Down
5 changes: 1 addition & 4 deletions src/components/LandingPage/LandingPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import dynamic from 'next/dynamic'
import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA'
import underMaintenanceConfig from '@/config/underMaintenance.config'
import type { LandingStrings } from './landingStrings'
import type { Locale } from '@/i18n/types'
import { AppModalProvider } from '@/components/Migration/AppModalProvider'
import { LandingDownloadCta } from './LandingDownloadCta'
import { type CTAButton } from '@/components/LandingPage/landing.types'
Expand All @@ -32,7 +31,6 @@ type LandingPageClientProps = {
primaryCta: CTAButton
}
marqueeMessages: string[]
locale: Locale
strings: LandingStrings
contentHrefs: LandingContentHrefs
// Server-rendered slots
Expand All @@ -50,7 +48,6 @@ type LandingPageClientProps = {
export function LandingPageClient({
heroConfig,
marqueeMessages,
locale,
strings,
contentHrefs,
problemSlot,
Expand Down Expand Up @@ -113,7 +110,7 @@ export function LandingPageClient({
primaryCta={primaryCta}
buttonVisible={!isFooterVisible}
strings={strings}
locale={locale}
contentHrefs={contentHrefs}
customCta={migrationOn ? <LandingDownloadCta subtext={heroConfig.primaryCta.subtext} /> : undefined}
/>
<Marquee {...marqueeProps} />
Expand Down
1 change: 0 additions & 1 deletion src/components/LandingPage/LandingPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export function LandingPageContent({ locale }: { locale: Locale }) {
<LandingPageClient
heroConfig={heroConfig}
marqueeMessages={marqueeMessages}
locale={locale}
strings={strings}
contentHrefs={contentHrefs}
problemSlot={<ProblemFold strings={strings} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { render, screen } from '@testing-library/react'
import { act, type ReactNode } from 'react'
import type { LandingStrings } from '../landingStrings'
import type { LandingContentHrefs } from '../landingContentHrefs'
import { EN_LANDING_CONTENT_HREFS } from '../landingContentHrefs'

const heroProps: Record<string, unknown>[] = []

Expand Down Expand Up @@ -83,9 +83,8 @@ const renderLanding = (sendInSecondsSlot: ReactNode = <section id="send-in-secon
<LandingPageClient
heroConfig={{ primaryCta: { label: 'Sign up', href: '/setup' } }}
marqueeMessages={['GLOBAL']}
locale="en"
strings={{} as LandingStrings}
contentHrefs={{} as LandingContentHrefs}
contentHrefs={EN_LANDING_CONTENT_HREFS}
problemSlot={<div />}
mantecaSlot={<div />}
regulatedRailsSlot={<div />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { render } from '@testing-library/react'
import { Hero } from '../hero'
import { SendInSeconds } from '../sendInSeconds'
import { landingStrings } from '../landingStrings'
import { EN_LANDING_CONTENT_HREFS } from '../landingContentHrefs'
import { getTranslations } from '@/i18n'

jest.mock('next/image', () => ({
Expand All @@ -19,7 +20,12 @@ const strings = landingStrings(getTranslations('en'))
describe('Hero CTA', () => {
it('renders the CTA without a scale custom property', () => {
const { container } = render(
<Hero strings={strings} locale="en" buttonVisible primaryCta={{ label: 'Sign up', href: '/setup' }} />
<Hero
strings={strings}
contentHrefs={EN_LANDING_CONTENT_HREFS}
buttonVisible
primaryCta={{ label: 'Sign up', href: '/setup' }}
/>
)

const cta = container.querySelector('.cta-motion') as HTMLElement | null
Expand All @@ -31,7 +37,12 @@ describe('Hero CTA', () => {

it('keeps rendering a custom CTA without a scale custom property', () => {
const { container } = render(
<Hero strings={strings} locale="en" buttonVisible customCta={<span>store pair</span>} />
<Hero
strings={strings}
contentHrefs={EN_LANDING_CONTENT_HREFS}
buttonVisible
customCta={<span>store pair</span>}
/>
)

const cta = container.querySelector('.cta-motion') as HTMLElement | null
Expand Down
18 changes: 9 additions & 9 deletions src/components/LandingPage/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Button } from '@/components/0_Bruddle/Button'
import { CloudsCss, type CloudConfig } from './CloudsCss'
import { AnimateOnView } from '@/components/Global/AnimateOnView'
import type { LandingStrings } from './landingStrings'
import type { Locale } from '@/i18n/types'
import type { LandingContentHrefs } from './landingContentHrefs'
import { type CTAButton } from '@/components/LandingPage/landing.types'

/**
Expand Down Expand Up @@ -87,7 +87,7 @@ const heroClouds: CloudConfig[] = [

type HeroProps = {
strings: LandingStrings
locale: Locale
contentHrefs: LandingContentHrefs
primaryCta?: CTAButton
secondaryCta?: CTAButton
buttonVisible?: boolean
Expand All @@ -113,7 +113,7 @@ const getCtaStyle = (variant: 'primary' | 'secondary', buttonVisible?: boolean):
const getButtonContainerClasses = (variant: 'primary' | 'secondary') =>
`relative z-20 mt-8 flex flex-col items-center justify-center ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}`

export function Hero({ primaryCta, secondaryCta, buttonVisible, customCta, strings, locale }: HeroProps) {
export function Hero({ primaryCta, secondaryCta, buttonVisible, customCta, strings, contentHrefs }: HeroProps) {
const migrationOn = useMigrationFlag()
const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => {
return (
Expand Down Expand Up @@ -195,16 +195,16 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, customCta, strin
className="mt-2 block text-center text-xl leading-tight text-foreground-primary md:mt-4 md:text-5xl"
style={{ fontWeight: 500, letterSpacing: '-0.5px' }}
>
<Link prefetch={false} href={`/${locale}/argentina`} className="hover:underline">
Buenos Aires
<Link prefetch={false} href={contentHrefs.unitedStates} className="hover:underline">
{strings.heroCityNewYork}
</Link>
.{' '}
<Link prefetch={false} href={`/${locale}/brazil`} className="hover:underline">
São Paulo
<Link prefetch={false} href={contentHrefs.spain} className="hover:underline">
{strings.heroCityMadrid}
</Link>
.{' '}
<Link prefetch={false} href={`/${locale}/brazil`} className="hover:underline">
Floripa
<Link prefetch={false} href={contentHrefs.mexico} className="hover:underline">
{strings.heroCityMexicoCity}
</Link>
.
</span>
Expand Down
6 changes: 6 additions & 0 deletions src/components/LandingPage/landingStrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export interface LandingStrings {
sendMoney: string
heroTapScan: string
heroNoLocalId: string
heroCityNewYork: string
heroCityMadrid: string
heroCityMexicoCity: string
zeroFees: string
reallyZero: string
noHiddenFees: string
Expand Down Expand Up @@ -56,6 +59,9 @@ export function landingStrings(i18n: Translations): LandingStrings {
sendMoney: i18n.sendMoney,
heroTapScan: i18n.landingHeroTapScan,
heroNoLocalId: i18n.landingHeroNoLocalId,
heroCityNewYork: i18n.landingHeroCityNewYork,
heroCityMadrid: i18n.landingHeroCityMadrid,
heroCityMexicoCity: i18n.landingHeroCityMexicoCity,
zeroFees: i18n.landingZeroFees,
reallyZero: i18n.landingReallyZero,
noHiddenFees: i18n.landingNoHiddenFees,
Expand Down
10 changes: 7 additions & 3 deletions src/components/Migration/DownloadQR.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import QRCodeWrapper from '@/components/Global/QRCodeWrapper'
import StoreBadges from '@/components/Migration/StoreBadges'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { SELF_URL } from '@/constants/general.consts'
import { type MigrationSurface } from '@/constants/migration.consts'
import { APP_ENTRY_QUERY_PARAM, type MigrationSurface } from '@/constants/migration.consts'
import { buildDeferredPayload } from '@/utils/deferred-link'
import type { StoreHandoff } from '@/utils/migration.utils'

/** Generic downloads encode bare /app; explicit guest handoffs also carry their campaign and destination. */
/**
* QR scans enter through /home, which is claimed by the already-released
* native shells. Browsers are redirected to /app by the web proxy; installed
* apps consume the marker and any explicit guest handoff directly.
*/
export default function DownloadQR({ surface, handoff }: { surface: MigrationSurface; handoff?: StoreHandoff }) {
const t = useTranslations('migration')
const [payload, setPayload] = useState<string>()
Expand All @@ -27,7 +31,7 @@ export default function DownloadQR({ surface, handoff }: { surface: MigrationSur

return (
<div className="flex w-full flex-col items-center gap-3 py-2">
<QRCodeWrapper url={`${origin}/app${payload ? `?${payload}` : ''}`} />
<QRCodeWrapper url={`${origin}/home?${APP_ENTRY_QUERY_PARAM}=1${payload ? `&${payload}` : ''}`} />
<span className="text-body-xs text-foreground-secondary">{t('qr.scanHint')}</span>
<StoreBadges surface={surface} payload={payload} />
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/components/Migration/__tests__/DownloadQR.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ jest.mock('../StoreBadges', () => ({

it('keeps generic downloads bare even when ambient context exists', () => {
render(<DownloadQR surface="landing_hero" />)
expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/app`)
expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/home?app_entry=1`)
expect(screen.getByTestId('stores')).toHaveAttribute('data-payload', '')
})

it('puts an explicit campaign handoff in both the QR and store fallbacks', () => {
const { rerender } = render(<DownloadQR surface="landing_door" handoff={{ dest: '/card' }} />)
expect(screen.getByTestId('qr')).toHaveAttribute(
'href',
`${window.location.origin}/app?pnutdl=1&badgeCampaign=door&dest=%2Fcard`
`${window.location.origin}/home?app_entry=1&pnutdl=1&badgeCampaign=door&dest=%2Fcard`
)
expect(screen.getByTestId('stores')).toHaveAttribute('data-payload', 'pnutdl=1&badgeCampaign=door&dest=%2Fcard')
rerender(<DownloadQR surface="landing_hero" />)
expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/app`)
expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/home?app_entry=1`)
})
12 changes: 10 additions & 2 deletions src/constants/migration.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@

export const PWA_SUNSET_FLAG = 'pwa-sunset'

/**
* Marks the already-native-associated `/home` route as an app-download entry
* point. On web, the proxy sends it to the `/app` smart-store page. In an
* installed app, useNativeAppLinks consumes the same URL and applies any
* deferred payload directly.
*/
export const APP_ENTRY_QUERY_PARAM = 'app_entry'

// ponytail: cutover date is a constant; move to flag payload only if the date
// needs to move without a deploy. placeholder — set the real date before flag-on.
export const MIGRATION_CUTOVER_DATE = new Date('2026-12-31T00:00:00Z')
// needs to move without a deploy.
export const MIGRATION_CUTOVER_DATE = new Date('2026-10-09T00:00:00Z')

// how long "Remind me later" snoozes the download prompt modal
export const DOWNLOAD_PROMPT_SNOOZE_DAYS = 3
Expand Down
33 changes: 32 additions & 1 deletion src/hooks/__tests__/useNativeAppLinks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// deferred-link.test.ts).
import { renderHook, waitFor } from '@testing-library/react'
import { useNativeAppLinks } from '../useNativeAppLinks'
import { restoreDeferredContext } from '@/utils/deferred-link'
import { applyDeferredPayload, parseDeferredPayload, restoreDeferredContext } from '@/utils/deferred-link'
import { markDeepLinkNavigated, resetDeepLinkStateForTests } from '@/utils/deep-link-state'
import { getOneSignalAdapter } from '@/services/onesignal'
import { BASE_URL } from '@/constants/general.consts'
Expand Down Expand Up @@ -61,9 +61,13 @@ jest.mock('@capacitor/app', () => ({

jest.mock('@/utils/deferred-link', () => ({
restoreDeferredContext: jest.fn(() => Promise.resolve(null)),
parseDeferredPayload: jest.fn(() => null),
applyDeferredPayload: jest.fn(() => ({ dest: null, locale: null })),
}))

const mockRestore = restoreDeferredContext as jest.MockedFunction<typeof restoreDeferredContext>
const mockParseDeferredPayload = parseDeferredPayload as jest.MockedFunction<typeof parseDeferredPayload>
const mockApplyDeferredPayload = applyDeferredPayload as jest.MockedFunction<typeof applyDeferredPayload>

beforeEach(() => {
jest.clearAllMocks()
Expand All @@ -73,6 +77,33 @@ beforeEach(() => {
resetDeepLinkStateForTests()
resetBackHandlersForTests()
sessionStorage.clear()
mockParseDeferredPayload.mockReturnValue(null)
mockApplyDeferredPayload.mockReturnValue({ dest: null, locale: null })
})

describe('app-entry gateway', () => {
it('applies a QR handoff and opens its destination without visiting /app', async () => {
launchUrl = 'https://peanut.me/home?app_entry=1&pnutdl=1&badgeCampaign=door&dest=%2Fcard'
const payload = { badgeCampaigns: ['door'], dest: '/card' }
mockParseDeferredPayload.mockReturnValue(payload)
mockApplyDeferredPayload.mockReturnValue({ dest: '/card', locale: null })

renderHook(() => useNativeAppLinks())

await waitFor(() => expect(push).toHaveBeenCalledWith('/card'))
expect(mockParseDeferredPayload).toHaveBeenCalledWith('?app_entry=1&pnutdl=1&badgeCampaign=door&dest=%2Fcard')
expect(mockApplyDeferredPayload).toHaveBeenCalledWith(payload)
expect(push).not.toHaveBeenCalledWith(expect.stringContaining('/app'))
})

it('opens ordinary home when a generic QR has no deferred payload', async () => {
launchUrl = 'https://peanut.me/home?app_entry=1'

renderHook(() => useNativeAppLinks())

await waitFor(() => expect(push).toHaveBeenCalledWith('/home'))
expect(mockApplyDeferredPayload).not.toHaveBeenCalled()
})
})

describe('useNativeAppLinks deferred restore wiring', () => {
Expand Down
Loading
Loading