diff --git a/docs/NATIVE-RELEASE.md b/docs/NATIVE-RELEASE.md index b83d9fac76..f66a932cd5 100644 --- a/docs/NATIVE-RELEASE.md +++ b/docs/NATIVE-RELEASE.md @@ -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 @@ -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` | `..-ios` / `-android` | | **App Staging OTA** — manual, `dev` source | `staging` | `..` | +### 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 diff --git a/public/email/app-store-badge.png b/public/email/app-store-badge.png new file mode 100644 index 0000000000..7563e34811 Binary files /dev/null and b/public/email/app-store-badge.png differ diff --git a/public/email/google-play-badge.png b/public/email/google-play-badge.png new file mode 100644 index 0000000000..bbd0c09f90 Binary files /dev/null and b/public/email/google-play-badge.png differ diff --git a/public/email/native-app-launch-hero-money.png b/public/email/native-app-launch-hero-money.png new file mode 100644 index 0000000000..29813efa21 Binary files /dev/null and b/public/email/native-app-launch-hero-money.png differ diff --git a/public/email/native-app-launch-hero.png b/public/email/native-app-launch-hero.png new file mode 100644 index 0000000000..18ec603fd7 Binary files /dev/null and b/public/email/native-app-launch-hero.png differ diff --git a/src/__tests__/proxy.test.ts b/src/__tests__/proxy.test.ts index c7ef4796e7..6cbef9c65e 100644 --- a/src/__tests__/proxy.test.ts +++ b/src/__tests__/proxy.test.ts @@ -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') diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 964ebe0bde..7904869b76 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -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' @@ -32,7 +31,6 @@ type LandingPageClientProps = { primaryCta: CTAButton } marqueeMessages: string[] - locale: Locale strings: LandingStrings contentHrefs: LandingContentHrefs // Server-rendered slots @@ -50,7 +48,6 @@ type LandingPageClientProps = { export function LandingPageClient({ heroConfig, marqueeMessages, - locale, strings, contentHrefs, problemSlot, @@ -113,7 +110,7 @@ export function LandingPageClient({ primaryCta={primaryCta} buttonVisible={!isFooterVisible} strings={strings} - locale={locale} + contentHrefs={contentHrefs} customCta={migrationOn ? : undefined} /> diff --git a/src/components/LandingPage/LandingPageContent.tsx b/src/components/LandingPage/LandingPageContent.tsx index b0dbec0f80..f3f2ba93e0 100644 --- a/src/components/LandingPage/LandingPageContent.tsx +++ b/src/components/LandingPage/LandingPageContent.tsx @@ -72,7 +72,6 @@ export function LandingPageContent({ locale }: { locale: Locale }) { } diff --git a/src/components/LandingPage/__tests__/LandingPageClient.scrollJack.test.tsx b/src/components/LandingPage/__tests__/LandingPageClient.scrollJack.test.tsx index f4eb3a87b4..0533e66835 100644 --- a/src/components/LandingPage/__tests__/LandingPageClient.scrollJack.test.tsx +++ b/src/components/LandingPage/__tests__/LandingPageClient.scrollJack.test.tsx @@ -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[] = [] @@ -83,9 +83,8 @@ const renderLanding = (sendInSecondsSlot: ReactNode =
} mantecaSlot={
} regulatedRailsSlot={
} diff --git a/src/components/LandingPage/__tests__/ctaScrollJackRemnants.test.tsx b/src/components/LandingPage/__tests__/ctaScrollJackRemnants.test.tsx index a465f29c21..55d2ade6c4 100644 --- a/src/components/LandingPage/__tests__/ctaScrollJackRemnants.test.tsx +++ b/src/components/LandingPage/__tests__/ctaScrollJackRemnants.test.tsx @@ -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', () => ({ @@ -19,7 +20,12 @@ const strings = landingStrings(getTranslations('en')) describe('Hero CTA', () => { it('renders the CTA without a scale custom property', () => { const { container } = render( - + ) const cta = container.querySelector('.cta-motion') as HTMLElement | null @@ -31,7 +37,12 @@ describe('Hero CTA', () => { it('keeps rendering a custom CTA without a scale custom property', () => { const { container } = render( - store pair} /> + store pair} + /> ) const cta = container.querySelector('.cta-motion') as HTMLElement | null diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index e004e5a8d4..524df71fe7 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -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' /** @@ -87,7 +87,7 @@ const heroClouds: CloudConfig[] = [ type HeroProps = { strings: LandingStrings - locale: Locale + contentHrefs: LandingContentHrefs primaryCta?: CTAButton secondaryCta?: CTAButton buttonVisible?: boolean @@ -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 ( @@ -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' }} > - - Buenos Aires + + {strings.heroCityNewYork} .{' '} - - São Paulo + + {strings.heroCityMadrid} .{' '} - - Floripa + + {strings.heroCityMexicoCity} . diff --git a/src/components/LandingPage/landingStrings.ts b/src/components/LandingPage/landingStrings.ts index e22aae0ea9..550dd7a18f 100644 --- a/src/components/LandingPage/landingStrings.ts +++ b/src/components/LandingPage/landingStrings.ts @@ -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 @@ -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, diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx index 49a6ead12f..56f9fb96af 100644 --- a/src/components/Migration/DownloadQR.tsx +++ b/src/components/Migration/DownloadQR.tsx @@ -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() @@ -27,7 +31,7 @@ export default function DownloadQR({ surface, handoff }: { surface: MigrationSur return (
- + {t('qr.scanHint')}
diff --git a/src/components/Migration/__tests__/DownloadQR.test.tsx b/src/components/Migration/__tests__/DownloadQR.test.tsx index e3d32ba002..16884cba70 100644 --- a/src/components/Migration/__tests__/DownloadQR.test.tsx +++ b/src/components/Migration/__tests__/DownloadQR.test.tsx @@ -19,7 +19,7 @@ jest.mock('../StoreBadges', () => ({ it('keeps generic downloads bare even when ambient context exists', () => { render() - 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', '') }) @@ -27,9 +27,9 @@ it('puts an explicit campaign handoff in both the QR and store fallbacks', () => const { rerender } = render() 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() - expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/app`) + expect(screen.getByTestId('qr')).toHaveAttribute('href', `${window.location.origin}/home?app_entry=1`) }) diff --git a/src/constants/migration.consts.ts b/src/constants/migration.consts.ts index 4c2f7a2757..bab3acc758 100644 --- a/src/constants/migration.consts.ts +++ b/src/constants/migration.consts.ts @@ -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 diff --git a/src/hooks/__tests__/useNativeAppLinks.test.tsx b/src/hooks/__tests__/useNativeAppLinks.test.tsx index ef1e72feb2..cddd7317a5 100644 --- a/src/hooks/__tests__/useNativeAppLinks.test.tsx +++ b/src/hooks/__tests__/useNativeAppLinks.test.tsx @@ -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' @@ -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 +const mockParseDeferredPayload = parseDeferredPayload as jest.MockedFunction +const mockApplyDeferredPayload = applyDeferredPayload as jest.MockedFunction beforeEach(() => { jest.clearAllMocks() @@ -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', () => { diff --git a/src/hooks/useNativeAppLinks.ts b/src/hooks/useNativeAppLinks.ts index 7c60330115..cdfde46e18 100644 --- a/src/hooks/useNativeAppLinks.ts +++ b/src/hooks/useNativeAppLinks.ts @@ -18,6 +18,8 @@ import { dispatchBackPress } from '@/utils/back-handler' import { stashInvite } from '@/utils/invite-stash' import { EInviteType } from '@/services/services.types' import { badgeCampaignsFromSearchParams, queuePendingBadgeCampaigns } from '@/components/Invites/badge-campaign-context' +import { APP_ENTRY_QUERY_PARAM } from '@/constants/migration.consts' +import { applyDeferredPayload, parseDeferredPayload } from '@/utils/deferred-link' /* * App-lifecycle + deep-link listeners (back button, appStateChange focus, @@ -97,7 +99,7 @@ export function useNativeAppLinks() { // Same URL twice in one boot = cold-start double delivery // (getLaunchUrl + the bridge's appUrlOpen replay) — one nav is right. if (url === lastDispatchedUrl && Date.now() - lastDispatchedAt < 3000) return true - const target = deepLinkToNativePath(url) + let target = deepLinkToNativePath(url) if (!target) { /* * A peanut.me path with no native stand-in (blog, help, legal, @@ -121,6 +123,17 @@ export function useNativeAppLinks() { captureLink(source, url, null, 'dropped') return false } + // App-download QRs use /home because that association already ships + // in the native binary. The web proxy turns the same URL into /app; + // native consumes the marker here, applies the handoff directly, + // and navigates to its sanitized destination (or plain /home). + try { + const parsed = new URL(url, 'https://peanut.me') + if (parsed.pathname === '/home' && parsed.searchParams.get(APP_ENTRY_QUERY_PARAM) === '1') { + const payload = parseDeferredPayload(parsed.search) + target = payload ? (applyDeferredPayload(payload).dest ?? '/home') : '/home' + } + } catch {} // same-origin guard: only ever navigate to an in-app relative path const safe = sanitizeRedirectURL(target) if (!safe) { diff --git a/src/i18n/en.json b/src/i18n/en.json index f01b68dcbb..6a3464719d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -167,6 +167,9 @@ "footerPeanutVs": "Peanut vs {name}", "landingHeroTapScan": "TAP. SCAN. ANYWHERE.", "landingHeroNoLocalId": "No local ID or bank required.", + "landingHeroCityNewYork": "New York", + "landingHeroCityMadrid": "Madrid", + "landingHeroCityMexicoCity": "Mexico City", "landingZeroFees": "ZERO TRANSFER FEES", "landingReallyZero": "REALLY, WE MEAN ZERO", "landingNoHiddenFees": "NO HIDDEN FEES", diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json index ad4d5f2b28..00a31f9eb0 100644 --- a/src/i18n/es-419.json +++ b/src/i18n/es-419.json @@ -167,6 +167,9 @@ "footerPeanutVs": "Peanut vs. {name}", "landingHeroTapScan": "TOCA. ESCANEA. DONDE SEA.", "landingHeroNoLocalId": "Sin documento local ni banco.", + "landingHeroCityNewYork": "Nueva York", + "landingHeroCityMadrid": "Madrid", + "landingHeroCityMexicoCity": "Ciudad de México", "landingZeroFees": "CERO COMISIONES POR TRANSFERENCIA", "landingReallyZero": "EN SERIO, CERO", "landingNoHiddenFees": "SIN COMISIONES DE ENVÍO", diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json index ec3b146285..d0cf7bcd8a 100644 --- a/src/i18n/es-ar.json +++ b/src/i18n/es-ar.json @@ -167,6 +167,9 @@ "footerPeanutVs": "Peanut vs. {name}", "landingHeroTapScan": "TOCÁ. ESCANEÁ. DONDE SEA.", "landingHeroNoLocalId": "Sin documento local ni banco.", + "landingHeroCityNewYork": "Nueva York", + "landingHeroCityMadrid": "Madrid", + "landingHeroCityMexicoCity": "Ciudad de México", "landingZeroFees": "CERO COMISIONES POR TRANSFERENCIA", "landingReallyZero": "EN SERIO, CERO", "landingNoHiddenFees": "SIN COMISIONES DE ENVÍO", diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json index a426f3184b..8e67482a7e 100644 --- a/src/i18n/pt-br.json +++ b/src/i18n/pt-br.json @@ -167,6 +167,9 @@ "footerPeanutVs": "Peanut vs. {name}", "landingHeroTapScan": "TOQUE. ESCANEIE. EM QUALQUER LUGAR.", "landingHeroNoLocalId": "Funciona com qualquer passaporte.", + "landingHeroCityNewYork": "Nova York", + "landingHeroCityMadrid": "Madri", + "landingHeroCityMexicoCity": "Cidade do México", "landingZeroFees": "ZERO TAXAS DE TRANSFERÊNCIA", "landingReallyZero": "É SÉRIO, ZERO", "landingNoHiddenFees": "SEM TAXAS DE ENVIO", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index e612e36987..79e65c8979 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -126,6 +126,9 @@ export interface Translations { // Landing page — hero landingHeroTapScan: string landingHeroNoLocalId: string + landingHeroCityNewYork: string + landingHeroCityMadrid: string + landingHeroCityMexicoCity: string // Landing page — zero-fees section landingZeroFees: string diff --git a/src/proxy.ts b/src/proxy.ts index d5f17fd1ec..87d1ee920e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import maintenanceConfig from '@/config/underMaintenance.config' import { shouldBlockDevRoute } from '@/constants/dev-tools.consts' +import { APP_ENTRY_QUERY_PARAM } from '@/constants/migration.consts' import { LOCALE_COOKIE, toAppLocale, toMarketingLocale, withCountry } from '@/i18n/localeBridge' import { DEFAULT_LOCALE, type Locale } from '@/i18n/types' @@ -35,6 +36,17 @@ export function proxy(request: NextRequest) { } } + // QR downloads enter through /home because that path is already claimed by + // every released native shell. A browser has no App Link interception, so + // route it to the web-only smart-store page while preserving the deferred + // handoff. Remove the routing marker so /app can never loop back here. + if (pathname === '/home' && request.nextUrl.searchParams.get(APP_ENTRY_QUERY_PARAM) === '1') { + const target = request.nextUrl.clone() + target.pathname = '/app' + target.searchParams.delete(APP_ENTRY_QUERY_PARAM) + return NextResponse.redirect(target, 307) + } + const url = request.nextUrl.clone() const promoList: { [key: string]: string } = JSON.parse(process.env.PROMO_LIST ?? '{}') diff --git a/src/utils/__tests__/app-links.test.ts b/src/utils/__tests__/app-links.test.ts index c3700375ba..f5c3382b86 100644 --- a/src/utils/__tests__/app-links.test.ts +++ b/src/utils/__tests__/app-links.test.ts @@ -4,9 +4,9 @@ * claimed on one platform only opens the app on that platform, and the bug * looks like "deep links are flaky on Android"), so parity is pinned here. * - * /app gets its own case: it is the smart download link every QR encodes, and - * an installed user who scans one must land in the app — that is the whole - * point of claiming it (TASK-21788). + * /app gets its own negative case: it is a web-only smart-store route and must + * not expand the native surface. Download QRs enter through the already-shipped + * /home association and use an app_entry query marker (TASK-21788). */ import { readFileSync } from 'fs' import { join } from 'path' @@ -23,33 +23,17 @@ const androidPaths = [...manifest.matchAll(/android:path="([^"]+)"/g)].map((m) = const androidPrefixes = [...manifest.matchAll(/android:pathPrefix="([^"]+)"/g)].map((m) => m[1]) describe('App Links', () => { - /* - * SKIPPED, NOT ABANDONED — restore both with the next native release. - * - * The `/app` claim never reached a binary: v1.6.0 predates d91ea7cee, so its - * manifest and AASA carry no `/app` at all, and an intent filter cannot be - * shipped over the air. What the two lines did reach was - * check-native-ota-surface, which compares this tree's native fingerprint - * against the binary an OTA targets — so they blocked every bundle while - * delivering nothing, with production stuck on JS older than the binary - * running it. - * - * Both platforms were reverted together, deliberately: dropping only the - * Android half would manufacture exactly the drift the parity case below - * exists to catch, and that case stays live. Restoring these two is part of - * cutting the next native release — see docs/NATIVE-RELEASE.md. - */ - it.skip('claims /app and /app/* for every iOS appID', () => { + it('keeps the web-only /app route out of every iOS appID', () => { expect(details.length).toBeGreaterThan(0) for (const detail of details) { - expect(detail.paths).toContain('/app') - expect(detail.paths).toContain('/app/*') + expect(detail.paths).not.toContain('/app') + expect(detail.paths).not.toContain('/app/*') } }) - it.skip('claims /app on Android with the same exact-plus-prefix shape', () => { - expect(androidPaths).toContain('/app') - expect(androidPrefixes).toContain('/app/') + it('keeps the web-only /app route out of Android', () => { + expect(androidPaths).not.toContain('/app') + expect(androidPrefixes).not.toContain('/app/') }) it('lists the same paths for every iOS appID', () => { diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 7dc55ff2c0..c1d67469e9 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -694,13 +694,11 @@ describe('native-routes', () => { }) /* - * The claim is `/app` + `/app/*`, but only `/app` is a real page — on - * the web app and in the static export alike. The wildcard therefore has - * to collapse rather than pass through, or an installed user opening - * peanut.me/app/anything gets the SPA's missing-route → home bounce, - * which reads as a dropped tap. + * /app is no longer OS-associated, but old links and app-authored URLs + * can still reach the mapper. Preserve that compatibility without + * navigating to nonexistent /app/* pages. */ - it('collapses the /app wildcard onto the one real page', () => { + it('collapses legacy /app paths onto the one real page', () => { expect(deepLinkToNativePath('https://peanut.me/app')).toBe('/app') expect(deepLinkToNativePath('https://peanut.me/app/anything')).toBe('/app') expect(deepLinkToNativePath('https://peanut.me/app/x?pnutdl=1&dest=%2Fsend')).toBe( @@ -949,3 +947,48 @@ describe('redactNativePath (deep-link telemetry)', () => { expect(redactNativePath('')).toBe('') }) }) + +// Preserve profile-build links and legacy app-link routing together. +describe('deepLinkToNativePath — flagged /dev carve-out beside the /app collapse', () => { + const ORIGINAL_FLAG = process.env.NEXT_PUBLIC_LOTTIE_PROFILE_ENABLED + + afterEach(() => { + if (ORIGINAL_FLAG === undefined) delete process.env.NEXT_PUBLIC_LOTTIE_PROFILE_ENABLED + else process.env.NEXT_PUBLIC_LOTTIE_PROFILE_ENABLED = ORIGINAL_FLAG + jest.resetModules() + }) + + // NEXT_PUBLIC_* is read once at module load, so the module must be required + // again with the env var already set — and the capacitor mock re-armed on + // the fresh registry copy. + function loadWithFlag(enabled: boolean) { + if (enabled) process.env.NEXT_PUBLIC_LOTTIE_PROFILE_ENABLED = 'true' + else delete process.env.NEXT_PUBLIC_LOTTIE_PROFILE_ENABLED + let mod!: typeof import('../native-routes') + jest.isolateModules(() => { + ;(require('@/utils/capacitor').isCapacitor as jest.Mock).mockReturnValue(true) + mod = require('../native-routes') + }) + return mod + } + + it('lets a profile build open the one /dev route it ships', () => { + const mod = loadWithFlag(true) + expect(mod.deepLinkToNativePath('https://peanut.me/dev/lottie-profile')).toBe('/dev/lottie-profile') + expect(mod.isNativeExportPath('/dev/lottie-profile')).toBe(true) + }) + + it('keeps /dev links out of a normal build', () => { + const mod = loadWithFlag(false) + expect(mod.deepLinkToNativePath('https://peanut.me/dev/lottie-profile')).toBeNull() + expect(mod.isNativeExportPath('/dev/lottie-profile')).toBe(false) + }) + + // /app is web-only now: download QRs enter through /home?app_entry=1. The + // wildcard pages do not exist in the export, so they collapse onto /app. + it('still collapses /app/* onto /app with the query intact', () => { + const mod = loadWithFlag(true) + expect(mod.deepLinkToNativePath('https://peanut.me/app/ios?src=qr')).toBe('/app?src=qr') + expect(mod.deepLinkToNativePath('https://peanut.me/app')).toBe('/app') + }) +}) diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 0006e6dd57..9c86c09418 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -137,8 +137,9 @@ function mapDeepLinkPath(parsed: URL): string | null { return appendParams(path, extraParams) } - // OS associations also claim /app/*, but only /app exists in the native export. - // Preserve the query so /app can apply the deferred payload and open its destination. + // /app is deliberately not OS-associated: download QRs use the already- + // shipped /home claim. Keep mapping direct/app-authored and legacy /app + // URLs for compatibility, collapsing the nonexistent wildcard pages. if (segments[0] === 'app') { return appendParams('/app', extraParams) } @@ -269,7 +270,7 @@ function mapDeepLinkPath(parsed: URL): string | null { */ export const NATIVE_EXPORT_ROOTS: ReadonlySet = new Set([ 'add-money', - // QR scans can open this page inside the app, where it applies any deferred payload. + // Kept for direct/app-authored and legacy links; new QR scans enter via /home. 'app', 'badges', 'card',