Skip to content

feat(landing): app-store funnel — QR hand-off through /app, S-full hero, Get-the-app fold, footer block, /shhhhh hand-off, scroll-jack removal (TASK-21788) - #3018

Closed
0xkkonrad wants to merge 16 commits into
devfrom
feat/pwa-sunset-landing-funnel
Closed

feat(landing): app-store funnel — QR hand-off through /app, S-full hero, Get-the-app fold, footer block, /shhhhh hand-off, scroll-jack removal (TASK-21788)#3018
0xkkonrad wants to merge 16 commits into
devfrom
feat/pwa-sunset-landing-funnel

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

The whole peanut.me → app-store funnel for TASK-21788, as one PR. Behind the PostHog pwa-sunset flag: every web CTA on the landing page, the /shhhhh door and the sticky bar stops sending people into a signup that is closing, and starts handing them to the app — a QR on desktop, the right store deep link on a phone, with the deferred context (locale, invite code, queued badge campaign, destination) surviving the install on both platforms.

This supersedes the four branches it was built from. They were reviewed separately, merged here in order (mechanism → page → scroll-jack → shhhhh), and the two components the page branch had to duplicate — because the mechanism branch's API was not on dev yet — are collapsed into one implementation each.

Flag off, the page is what ships today.

Screenshots

Captured from the Vercel preview of head 2add057 (production build) at deviceScaleFactor 1, animations off. "Before" is the same build with the flag off — byte-identical to what ships today; "after" is pwa-sunset on.

Before (today / flag off) After (flag on)
Desktop hero — 1440×900
Desktop hero — 1440×900
Phone hero — 390×844, iPhone UA
Phone hero — 390×844, iPhone UA

The SIGN UP button becomes the S-full lockup (QR + scan hint + store pair, subtext kept); the phone gets one full-width DOWNLOAD NOW with the detected store's mark and an "Other store" link.

The whole page, flag on — hero, folds, get-the-app fold, footer (1440 wide, full page):

The 1366×768 laptop fold — the re-tuned artwork clamps keep the marquee inside the fold:

The hoisted QR modal, opened from the fold-4 rates "Send Money" CTA (dest=/send):

Fold 10, now the get-the-app fold — scanner capture in the phone outline, 224px QR, stacked store pair:

The new footer block (FooterGetTheApp) above SEOFooter:

The whole phone page, flag on (390 wide, full page):

The sticky Download bar, scrolled 500px on a phone:

Where a scan lands — /app?pnutdl=1&lang=pt-br&invite=ABC123&dest=%2Fsend&s=landing_hero on an iPhone (Android auto-redirects through the Play referrer instead):

The /shhhhh door, flag on — "TRY THE DOOR" hands off with dest=/card instead of routing to /setup:

The pt-BR hero — the lockup copy resolves against the URL locale, not the device:

What changed

Mechanism — the hand-off itself

  • DownloadQR takes a payload (a querystring the caller holds) or a handoff ({dest, invite}) it derives one from after mount, never both, so the code and the store buttons under it cannot carry different context. Encodes ${origin}/app?<payload>&s=<surface>; size picks the frame (160 / 192 / 224px); MIGRATION_QR_SHOWN fires once at 50% visibility with {surface, hasContext}.
  • /app (src/app/app/page.tsx, still 'use client' for the Capacitor export) branches on the payload: Android auto-redirects through playStoreUrlWithReferrer, iOS renders the two store buttons and writes the clipboard hand-off inside the tap, a bare /app behaves exactly as today, and the native app applies the payload and routes to dest. It also reads the s= tag back and reports it as qr_surface, so a scan is attributable to the QR that produced it.
  • StoreBadgesStorePair: real anchors over storeAnchorHref + onStoreAnchorClick, both of which take an optional hand-off. Appearances compact / hero / stacked / footer.
  • StickyMobileCTA: a desktop-mode phone (deviceType === WEB) gets both stores instead of a guess.
  • App Links: /app and /app/* added to every appID in apple-app-site-association and to the Android intent filter, app added to NATIVE_EXPORT_ROOTS, and /app/* collapsed to /app in the deep-link mapper.
  • Six new MIGRATION_SURFACES: smart_link, landing_app_fold, landing_footer, landing_rates, landing_countries, landing_door.

Hero and page

  • AppModalProvider in LandingPageClient wraps the server slots, with ONE hoisted ScanToDownloadModal that every re-pointed CTA opens with its own calling surface.
  • Hero, desktop: the S-full lockup — QR, scan hint, the white Bruddle store pair, the italic subtext — horizontal at ≥1024px, stacked below. Hero, phone: one full-width white download button with the detected store's mark, server-rendered href="/app" upgraded to the deep link on mount, plus an "Other store" link back to /app.
  • Artwork clamps re-tuned so the marquee stays inside the fold at 1440×900 and 1366×768: max-h-[calc(100svh-29.5rem)] for the hero artwork and calc(100svh-34.5rem) for the mascot — applied only with the flag on, so the flag-off hero keeps today's exact geometry. Mascot gained a ResizeObserver on #hero.
  • Fold 4 (rates) and fold 5 (countries, via a small 'use client' child) open the hoisted modal on desktop with dest=/send, and deep-link the store on a phone.
  • Dormant LANDING_HERO_QR_FLAG = 'landing-hero-qr' exported for a later split.

Fold 10 and the footer

  • Fold 10 becomes the get-the-app fold: "GET THE APP.", the existing tagline, then a 3-up of the scanner capture inside a drawn phone outline, a 224px QR frame, and the stacked store pair with the subtext. Phones get the title, the outline and one store button.
  • New footer block (FooterGetTheApp, opt-in via showGetTheApp so it stays on the landing page and off /careers, /m/[slug] and /shhhhh): heading, hint, the shadowless store pair on black, and a 224px QR. SEOFooter gains crawlable store-listing links in Learn More, gated on the cutover date rather than the client-only flag.

Scroll-jack

  • The send-in-seconds scroll freeze is gone: isScrollFrozen, virtualScrollY, the wheel/touch preventDefault handlers, document.body.style.overflow, handleScrollDelta, the buttonScale growth and its prop plumbing through hero.tsx, the #sticky-button-target anchor and the dead --cta-scale custom property. Fold 10 keeps its cta-motion / cta-enter entrance; id="send-in-seconds" is untouched.

/shhhhh

  • With the flag on, the door hands off instead of routing to /setup: desktop opens the modal with {dest: '/card'}, phones deep-link the store with the same context. useGuestStoreHandoff gained an optional surface so both halves of the door report landing_door. Covers all four door affordances and the campaign-tagged /setup?step=signup push, and the badge-campaign tag now rides the hand-off.

Dedupe done in this PR

  • src/components/Migration/AppQrCode.tsx, AppStorePair.tsx and AppQrCode.test.tsx are deleted. DownloadQR and StorePair are the single implementations; grep for either old name returns nothing.
  • Ported into the survivors: DownloadQR gains bare (frame only, for the three landing lockups, which place their own hint and pair) and className, and keeps the data-testid="app-qr-code" the e2e spec addresses; StorePair gains the footer appearance and className. AppStorePair's row/column/footer map to hero/stacked/footer — with the fold-10 stacked call site now behind a device guard, since stacked collapses to one store on a known platform and column did not (divergence 12).
  • ScanToDownloadModal's private HandoffDownloadQR wrapper is gone — one line, <DownloadQR surface handoff />. useGuestStoreHandoff passes the hand-off, not a pre-built payload.
  • New AMBIENT_HANDOFF (a frozen empty StoreHandoff) so the three landing QRs keep carrying locale and invite context with no destination of their own — without making every DownloadQR context-bearing.
  • One MIGRATION_SURFACES list, no duplicate i18n keys, no doubled analytics constants (that file is untouched), one isPwaSunsetOn localStorage override — the non-production-host one, with its tests.

How verified

Gate Command Result
Types pnpm exec tsc --noEmit Exactly the 6 known pre-existing TS2307 Cannot find module (web-vitals ×3, @capacitor/app-launcher, @capgo/capacitor-in-app-review, capacitor-native-settings), all in files this PR does not touch. Zero errors in the diff.
Unit pnpm test -- --runInBand src/components/Migration src/components/LandingPage src/app/app src/app/shhhhh src/hooks src/utils/__tests__ 2514 passed, 0 failed; 187 suites passed. 2 suites fail to LOAD on the same missing native-only packages (app-review.test.ts, native-settings.test.ts) — both untouched by this branch.
Lint pnpm lint --quiet <45 changed files> Clean.
Format prettier --check <changed files> Clean.

The worktree symlinks node_modules to the shared checkout, which is physically missing the four native-only packages above — the documented worktree artifact all four source branches recorded.

Re-run after the round-3 review fixes (1aff3601a), one heavy process at a time: pnpm typecheck → the same six pre-existing TS2307, none in the diff. pnpm test -- --runInBand src/components/Migration src/components/LandingPage src/i18n34 suites, 319 tests green, and src/hooks src/utils/__tests__/migration.utils.test.ts src/app108 suites, 1100 green / 3 skipped. eslint --quiet and prettier --check clean on all 19 changed files. New cases: the iPad store column, the desktop pair, the fold's heading level, and marketing-messages.test.ts re-passing against the regenerated subsets.

Tests updated because behaviour changed on purpose: SendInSecondsBody.test.tsx (the #sticky-button-target assertion flipped to toBeNull() — the page branch's new file had re-introduced the anchor the scroll-jack branch deleted), LandingPageClient.scrollJack.test.tsx (mocked the renamed StorePair), useGuestStoreHandoff.test.tsx (asserts the modal's handoff prop, not payload), and three lockup tests taught the new AMBIENT_HANDOFF export. AppQrCode.test.tsx's two unique cases were migrated into DownloadQR.test.tsx.

No browser pass in this session: one heavy process at a time is a hard limit here, and the page branch documented that a dev server plus an attached browser is OOM-killed on this box mid-run, producing captures that were silently flag-off pages. The layout claims are asserted by e2e/flows/landing-migration.spec.ts (4 locales × 1440/1366/390, flag off and on, no horizontal overflow, marquee inside the fold), which CI runs against a production build where the flag override works.

Divergences

Full merged log: run/DIVERGENCES-funnel.md. Each entry there carries its reasoning.

From this consolidation pass

  1. AppQrCode / AppStorePair deleted, DownloadQR / StorePair survive. The page branch's own divergence 1 asked for exactly this. Behaviour ported: bare, className, the app-qr-code testid, the footer appearance, the layout mapping above. Reason: one implementation per concern, and the survivors are the ones carrying the hand-off.
  2. QR frames shrink 4px at three call sites. AppQrCode's size was the module area, DownloadQR's is the frame width. Mapped onto the frames the mechanism branch's amended divergence 3 prescribes — hero 196→192px frame (160→156px modules), fold and footer 228→224 (192→188). Reason: adding 196 and 228 to FRAME_WIDTH would keep two size vocabularies alive for 4px.
  3. AMBIENT_HANDOFF added. AppQrCode always built a deferred payload; DownloadQR only does so with a hand-off, so a bare landing QR would have dropped a /pt-br visitor's lang and their invite cookie. Reason: fixing it by making every DownloadQR context-bearing would change the modal, the home banner and the guest flow, which are deliberately bare.
  4. DownloadQR's scan hint moved into a ScanHint child. Reason: bare renders no copy, and calling useTranslations unconditionally would drag a NextIntlClientProvider requirement into three lockups, one of which renders outside one.
  5. useGuestStoreHandoff passes a hand-off, not a pre-built payload. Reason: the shhhhh branch's divergence 5 says to drop its payload prop if the mechanism branch lands first. One derivation instead of two.
  6. #sticky-button-target removed from SendInSecondsBody.tsx. Reason: the page branch wrote that file against a dev that still had the scroll-jack, silently undoing the scroll-jack branch's deletion — a conflict no merge algorithm could see, and it would have failed that branch's own regression test.
  7. Conflict resolutions. Hero customCta → the page branch's HeroAppLockup/PhoneAppCta split (covers both devices). LandingPageClient scroll machinery → deleted wholesale, including the mechanism branch's migrationOn release valve, which existed only to unfreeze machinery that is now gone. hero.tsx → the page branch's props minus buttonScale.
  8. No runtime browser pass (see How verified).

From the consolidation review (round 3) — seven findings, all applied; two of the entries above were corrected in the log because the log itself was the thing at fault.

  1. The code-split the merge documented was void, and is now real. AppModalProvider static-imported ScanToDownloadModal (→ DownloadQRQRCodeWrapperreact-qr-code) and the provider wraps every landing fold; FooterGetTheApp static-imported DownloadQR and FooterChrome imports that block on every page carrying the footer. So the QR library shipped to 100% of landing traffic with the flag off and the ssr:false wrappers on the hero lockup bought nothing. Both are now dynamic(..., { ssr: false }); react-qr-code is reachable from the landing page only through the four lazy boundaries the LandingPageClient comment now names. StorePair stays static on purpose — StickyMobileCTA already puts it in the main chunk.
  2. DownloadQR's payload prop deleted. Divergence A4 chose handoff and migrated every production caller, but the prop and its discriminated union survived, alive only in two tests — two ways to feed one QR its context. DownloadQRProps is now a flat object with handoff?: StoreHandoff, and the two cases assert the derived querystring instead of a literal.
  3. migration.otherStore / migration.getTheApp deleted from the app catalogs. They were dead: every consumer reads strings.migration.* from the URL-locale catalog, and StickyMobileCTA reads only downloadNow. Removed from {en,es-419,pt-BR}.json and the marketing subsets regenerated with scripts/generate-marketing-messages.js. Divergence 14's claim that in-app surfaces still read them was wrong and is corrected in the log.
  4. Fold 10's store column is gated on the DEVICE, not the viewport. md:flex is 768px, which an iPad clears, while StorePair appearance="stacked" picks its stores from the UA-based useDeviceType() — so a tablet got one store button where the pre-dedupe column rendered both, next to a QR it cannot scan. GetTheAppFold now takes FooterGetTheApp's deviceType === DeviceType.WEB guard and falls through to PhoneAppCta. Divergence A1's claim that every column call site was desktop-only is corrected in the log.
  5. Fold 10's title is an h2, not a second h1. Pre-migration fold 10 had no heading element at all, so the flag-on <h1>GET THE APP.</h1> was a new second h1 under the hero's. Same classes, h2 tag, level pinned by a test.
  6. SendInSeconds takes subtext as a prop. It was calling getLandingContent(locale) a second time per page render — an uncached readFileSync + frontmatter parse — to reach a value LandingPageContent already holds.
  7. The SEOFooter store links are gated at BUILD time, not runtime. ⚠️ Operational note for the release: storeListings() reads Date.now() inside a statically prerendered server component, so the value freezes into the HTML at build. The links appear at the first deploy on or after MIGRATION_CUTOVER_DATE, not at the cutover instant — shipping them needs a deploy after that date (a redeploy of the same commit is enough). The date gate is kept (divergence 12's reasoning stands: the flag is client-only and these links exist to be crawlable); the comment above STORE_LISTINGS and the two test names now say so explicitly instead of reading as a runtime claim.

Carried from the four branches — mechanism: /app made a mappable native route; handoff threaded through the store helpers; size = frame width, +224; /app/* collapsed in the mapper; /app reads the s= tag. Page: hero clamps gated on the flag; content-system headline kept; footer store links untranslated and cutover-gated; no unit test for the rates re-point; spec output path from the environment; localStorage override widened to every non-production host; the modal's handoff prop and the two extended helpers; migration copy duplicated into the marketing catalog for URL-locale resolution; the footer block made opt-in; the hero Log In link re-pointed at /app. Scroll-jack: dead --cta-scale removed; the two wrapper elements removed; centring asserted numerically instead of by screenshot diff. Shhhhh: surface option on the hook instead of the door calling openStore; the campaign-tagged path intercepted too; the door's impression event gated on settled auth.

New copy is marked copy pass pending: the five migration* keys in the URL-locale catalog (en, es-419, es-ar voseo, pt-br). There is one copy of each string — the app-catalog duplicates added in round 1 were deleted in round 3 (divergence 11 above).

Flag-off impact

None on the landing page: every new surface is behind useMigrationFlag(), which is false until mount and false in SSR, and the re-tuned hero clamps are applied only when it is true. The SEOFooter store links are gated on MIGRATION_CUTOVER_DATE, not the flag, so they stay out of the pre-cutover HTML too — at build time, so they need a deploy on or after that date to appear, and from that build on they render for flag-off visitors on every page mounting SEOFooter (divergence 15).

Two changes ship unconditionally, both deliberate:

  • The scroll-jack is deleted for everyone. That is its own PR's whole purpose — the freeze ran with the flag off. Scrolling past fold 10 is now free; the CTA keeps its entrance animation and its centring (asserted at 0.00px offset across four route × width combinations).
  • App Links now claim /app and /app/* on both platforms, and /app is a mapped native route. A bare /app behaves exactly as today; the only behaviour change is that an installed user opening peanut.me/app/... lands in the app rather than the browser.

Supersedes #3009 #3010 #3011 #3012.

@notion-workspace

Copy link
Copy Markdown

Landing Page Changes

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 7, 2026 1:16pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: d6dbb437-bf34-44ea-a422-88ba6f80a00b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7252.27 → 7290.29 (+38.02)
Findings: +16 net (+97 new, -81 resolved)

🆕 New findings (97)

  • critical complexity — src/utils/native-routes.ts — CC 118, MI 53.61, SLOC 322
  • critical complexity — src/app/app/page.tsx — CC 62, MI 62.52, SLOC 143
  • critical complexity — src/utils/deferred-link.ts — CC 61, MI 58.07, SLOC 211
  • critical complexity — src/app/shhhhh/ShhhhhLandingPage.tsx — CC 55, MI 55.25, SLOC 326
  • high hotspot — src/components/LandingPage/hero.tsx — 50 commits, +486/-331 lines since 6 months ago
  • high method-complexity — src/utils/native-routes.ts:113 — mapDeepLinkPath CC 44 SLOC 131
  • high hotspot — src/app/shhhhh/ShhhhhLandingPage.tsx — 41 commits, +1211/-530 lines since 6 months ago
  • high complexity — src/components/LandingPage/hero.tsx — CC 39, MI 61.46, SLOC 96
  • high complexity — src/utils/migration.utils.ts — CC 37, MI 62.54, SLOC 93
  • high complexity — src/components/LandingPage/SEOFooter.tsx — CC 30, MI 66.88, SLOC 116
  • medium react-long-component — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage is 505 lines — split it
  • medium high-mdd — src/app/shhhhh/ShhhhhLandingPage.tsx:143 — ShhhhhLandingPage: MDD 197.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/hero.tsx:164 — Hero: MDD 99.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/LandingPageClient.tsx:68 — LandingPageClient: MDD 63.4 (uses across many lines from declarations)
  • medium high-mdd — src/utils/native-routes.ts:113 — mapDeepLinkPath: MDD 59.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/GetTheAppFold.tsx:28 — GetTheAppFold: MDD 48.5 (uses across many lines from declarations)
  • medium high-mdd — src/app/app/page.tsx:69 — SmartStoreRedirect: MDD 43.4 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/FooterChrome.tsx:58 — FooterChrome: MDD 42.7 (uses across many lines from declarations)
  • medium high-dlt — src/app/app/page.tsx:69 — SmartStoreRedirect: DLT 36 (calls 36 distinct functions — high context load)
  • medium high-mdd — src/components/LandingPage/noFees.tsx:19 — NoFees: MDD 35.5 (uses across many lines from declarations)

…and 77 more.

✅ Resolved (81)

  • src/utils/native-routes.ts — CC 117, MI 53.73, SLOC 319
  • src/utils/deferred-link.ts — CC 61, MI 58.08, SLOC 211
  • src/components/LandingPage/LandingPageClient.tsx — CC 51, MI 56.24, SLOC 205
  • src/app/shhhhh/ShhhhhLandingPage.tsx — CC 48, MI 55.82, SLOC 313
  • src/components/LandingPage/hero.tsx — 46 commits, +401/-282 lines since 6 months ago
  • src/utils/native-routes.ts:113 — mapDeepLinkPath CC 43 SLOC 128
  • src/app/app/page.tsx — CC 41, MI 64.42, SLOC 78
  • src/app/shhhhh/ShhhhhLandingPage.tsx — 39 commits, +1172/-523 lines since 6 months ago
  • src/utils/migration.utils.ts — CC 35, MI 63.55, SLOC 89
  • src/components/LandingPage/hero.tsx — CC 31, MI 61.88, SLOC 88
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage is 475 lines — split it
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage: MDD 185.4 (uses across many lines from declarations)
  • src/components/LandingPage/hero.tsx:134 — Hero: MDD 89.3 (uses across many lines from declarations)
  • src/components/LandingPage/LandingPageClient.tsx:52 — LandingPageClient: MDD 62.3 (uses across many lines from declarations)
  • src/components/LandingPage/sendInSeconds.tsx:41 — SendInSeconds: MDD 54.7 (uses across many lines from declarations)
  • src/utils/native-routes.ts:113 — mapDeepLinkPath: MDD 49.7 (uses across many lines from declarations)
  • src/components/LandingPage/FooterChrome.tsx:56 — FooterChrome: MDD 44.8 (uses across many lines from declarations)
  • src/components/LandingPage/noFees.tsx:17 — NoFees: MDD 33.2 (uses across many lines from declarations)
  • src/app/shhhhh/ShhhhhLandingPage.tsx:141 — ShhhhhLandingPage: DLT 32 (calls 32 distinct functions — high context load)
  • src/components/LandingPage/LandingPageClient.tsx:52 — LandingPageClient: DLT 30 (calls 30 distinct functions — high context load)

…and 61 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/LandingPage/GetTheAppFold.tsx 0.0 8.5 +8.5
src/components/Migration/StorePair.tsx 0.0 5.3 +5.3
src/components/LandingPage/PhoneAppCta.tsx 0.0 4.8 +4.8
src/components/LandingPage/FooterGetTheApp.tsx 0.0 4.8 +4.8
src/components/Migration/AppModalProvider.tsx 0.0 4.0 +4.0
src/components/LandingPage/SendInSecondsBody.tsx 0.0 3.9 +3.9
src/components/LandingPage/CountriesSignUpCta.tsx 0.0 3.8 +3.8
src/components/LandingPage/HeroAppLockup.tsx 0.0 2.5 +2.5
src/components/Migration/DownloadQR.tsx 4.2 6.1 +1.9
src/components/LandingPage/hero.tsx 10.8 12.2 +1.4
src/constants/migration.consts.ts 4.5 5.6 +1.0
src/components/LandingPage/landingStrings.ts 7.7 8.7 +1.0
src/components/LandingPage/noFees.tsx 7.8 8.5 +0.7
src/app/app/page.tsx 8.1 8.8 +0.6
src/app/shhhhh/ShhhhhLandingPage.tsx 12.2 12.7 +0.5
src/components/LandingPage/sendInSeconds.tsx 7.4 6.6 -0.7
src/components/LandingPage/yourMoney.tsx 5.7 4.8 -1.0
src/components/LandingPage/LandingPageClient.tsx 10.3 7.2 -3.1
src/components/Migration/StoreBadges.tsx 4.6 0.0 -4.6

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 6174 ran, 0 failed, 0 skipped, 2.0m

📊 Coverage (unit)

metric %
statements 74.9%
branches 60.9%
functions 68.8%
lines 75.8%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
3.0s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Scan that recovers on the retry lands on the payment screen, not an error
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 8 screens moved

10 of 68 shots changed · 58 identical · baseline 5c52afa → head 2add057

worst % screen widths
3.19% profile 320
1.67% avatar-picker 430
0.07% badges 320, 430
0.03% add-money-crypto 430
0.03% add-money 430
0.03% empty-accounts 320, 430
0.03% withdraw 430
0.02% reconsent 430

job summary · before/after/diff images — artifact

Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

P1 is fixed, but the Other store CTA still returns users to their detected store and the exact-head design-system ratchet still fails.

Findings

  • MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
    On iOS the primary CTA already targets the App Store, but this link opens bare /app; SmartStoreRedirect detects the same iOS device and immediately redirects to the App Store again. Android mirrors the same failure with Google Play, so the advertised alternate store is unreachable. Build this link from the opposite StoreKind and pair its explicit store href with the matching click/handoff handler.

  • MINOR · src/components/Migration/StorePair.tsx:85 · Restore the design-system ratchet
    The exact-head ds-lint check still fails: legacyColorClasses 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→46. This footer branch contributes the legacy border-n-1 and hover-only styling; the added PhoneAppCta, HeroAppLockup, CountriesSignUpCta, and GetTheAppFold classes account for the remaining off-scale/hover regressions. Replace them with semantic color and allowed spacing/radius tokens plus pressed states, or record an explicitly reviewed baseline increase if the deviations are intentional.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:74 · [claude-opus] Crawlable App Store link is gated on a placeholder date, not on the listing being live
    SEOFooter.tsx:74-80 adds Peanut on the App StoreSTORE_URL.ios and Peanut on Google Play as plain anchors, gated only on Date.now() >= MIGRATION_CUTOVER_DATE. This footer renders on every marketing page in all four locales (app/[locale]/(marketing)/layout.tsx, /careers, /m/[slug], /shhhhh), and the gate is deliberately independent of pwa-sunset, so the links appear for flag-off visitors too.

That makes it the only place in the repo where the iOS store URL becomes user- and crawler-visible without either of the two signals that encode whether the listing exists. migration.consts.ts:55 states the opposite: IOS_APP_STORE_LISTING_LIVE = false, with the comment "The iOS listing (App Store Connect app 6786373552) is not published yet: the store URL 404s" — which is exactly why Profile/index.tsx:47 suppresses the iOS store-update prompt. And MIGRATION_CUTOVER_DATE is itself marked "placeholder — set the real date before flag-on" (migration.consts.ts:13-15).

Failure scenario: the team does the documented next step and sets the real cutover date to a value at or before the next deploy, before the App Store listing is published (or before the flag is flipped). The next build ships a site-wide, crawlable link to a 404 App Store page in every locale's footer, and Google indexes "Peanut on the App Store" — while /home/chip/mono/product/app.md still declares format: pwa and "PWA means no app store approval delays", i.e. no app-store distribution at all.

The code is the wrong side here: the promise should follow the signals that already exist rather than a placeholder date. Fix: filter STORE_LISTINGS on IOS_APP_STORE_LISTING_LIVE for the iOS entry (Google Play is live and can ship on the date gate as-is), so the crawlable claim can never outrun the listing. product/app.md's format: and the "no app store approval delays" note then need updating as part of the actual launch, not as part of this PR.

Checked clean

  • P1 is fixed: SendInSeconds now receives subtext from server-owned LandingPageContent and no longer imports the filesystem-backed landing loader.
  • The smart-link handoff strips the analytics surface before forwarding payloads and sanitizes native destinations before routing.
  • The iOS AASA, Android intent filter, and native export route set agree on the new /app exact and wildcard paths.
  • The landing and /shhhhh migration paths remain flag-gated, and the /shhhhh handoff waits for settled authentication before redirecting a guest.
  • Exact-head format, eslint, typecheck, native-export, unit, analyze, and deploy-preview checks passed; ds-lint and its aggregate ci-success check failed, while advisory ds-shots was still in progress.
  • Focused local Jest execution was unavailable because this detached worktree has no node_modules; exact-head unit CI passed instead.

Security review: did not run — openrouter-http-402. This review is one reviewer short.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 1aff3601ae72 · Context: repo, product · Took 17m

</Button>
</a>
{showOtherStore && (
<Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Route Other store to the alternate listing

On iOS the primary CTA already targets the App Store, but this link opens bare /app; SmartStoreRedirect detects the same iOS device and immediately redirects to the App Store again. Android mirrors the same failure with Google Play, so the advertised alternate store is unreachable. Build this link from the opposite StoreKind and pair its explicit store href with the matching click/handoff handler.

Comment thread src/components/Migration/StorePair.tsx Outdated
isHero
? 'w-full bg-white px-6 py-3 text-button-m hover:bg-white/90 md:py-7 md:text-button-l'
: isFooter
? 'w-full border-n-1 bg-white px-6 text-button-m shadow-none hover:bg-white/90 hover:shadow-none md:text-button-l'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: Restore the design-system ratchet

The exact-head ds-lint check still fails: legacyColorClasses 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→46. This footer branch contributes the legacy border-n-1 and hover-only styling; the added PhoneAppCta, HeroAppLockup, CountriesSignUpCta, and GetTheAppFold classes account for the remaining off-scale/hover regressions. Replace them with semantic color and allowed spacing/radius tokens plus pressed states, or record an explicitly reviewed baseline increase if the deviations are intentional.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Runtime QA (single branch)

Head verified 1aff3601ae728cfb2dedd675a350f308bbb04cca, driven with Playwright against one
next dev --webpack -p 3055 in the worktree; flag on via localStorage['pwa-sunset']='true'.
27 cases, 58 screenshots. No API is running, so every page carries the "Trouble reaching Peanut"
toast and api.staging.peanut.me CORS errors — expected noise, not this branch.

# Item Result Evidence
1 Flag off = today's page PARTIAL Flag-off DOM at 1440/1366/390, all locales: 0 QRs, 0 phone-app-cta, 0 store hrefs, /setup ×2 + /send ×1 intact, no #sticky-button-target, body.style.overflow unset. Not pixel-diffed vs origin/dev (see below), and not identical by design — the scroll-jack deletion is un-gated.
2a Desktop hero lockup, 1440×900 + 1366×768 PASS QR (192px frame) inside #hero + hint + store pair + subtext, horizontal at both widths, all four locales.
2b No horizontal scrollbar PASS scrollWidth === clientWidth in all 27 captures, /pt-br and 390px-with-desktop-UA included.
2c QR encodes /app?…&s=<surface> PASS Module matrix read out of the rendered SVG and compared with an independently built matrix over all 8 masks: 0 of 1681 modules differ/app?pnutdl=1&s=landing_hero, …s=landing_app_fold, …s=landing_footer; on /pt-br …?pnutdl=1&lang=pt-br&s=landing_hero. A one-character-off URL differs by 110–825. Payload matches the Play href on the same page.
2d Marquee in the fold @ 1440×900 FAIL (regression) #hero.nextElementSibling top — flag off 878/900, flag on 936/900 (same on es-419, es-ar, pt-br). This is exactly what e2e/flows/landing-migration.spec.ts:128 asserts, so CI will fail it.
2e Marquee in the fold @ 1366×768 FAIL (pre-existing) Flag on 804/768, flag off 814/768 — below the fold on origin/dev too; the lockup is 10px better than today's stack. Not caused by this PR.
3a Phone: one button, right icon PASS iPhone → apple-logo + apps.apple.com/us/app/id6786373552; Android → google-play + …&referrer=pnutdl%3D1; "Other store" → /app; copy localizes (BAIXAR AGORA / Outra loja). No two-button row at any width.
3b href upgrades /app → store on mount PASS SSR markup ships /app; post-mount anchor is the store URL in every phone capture.
3c Sticky bar after 300px PASS At scrollY=400: flag on + known platform → 1 store anchor (DOWNLOAD NOW); flag off → the old 2-anchor SIGN UP NOW / Log in bar.
3d Desktop-mode Android → both stores PASS Desktop UA at 390×844: sticky bar has 2 anchors (App Store + Google Play), page renders the stacked desktop lockup, no overflow.
4a /app?pnutdl=1&lang=pt-br&invite=ABC123&dest=%2Fsend&s=landing_hero, Android PASS Redirects to play.google.com/…&referrer=pnutdl%3D1%26lang%3Dpt-br%26invite%3DABC123%26dest%3D%252Fsend; s= stripped, rest survives.
4b same, iPhone PASS No redirect; pink App Store button first, stroke Google Play second carrying the same referrer.
4c Bare /app PASS iPhone and Android show "Taking you to the store…" + the loading button (auto-redirect fires; the store nav can't complete offline). Desktop: both buttons, no redirect. Flag off /app renders the 404 page — deliberate per the notFound() comment, worth a line in the PR body since the brief says "today's redirect".
5 No /setup / /send href with the flag on PASS setupHrefs = [], sendHrefs = [] in all twelve flag-on landing captures (2 and 1 flag-off). Fold 4 "Send Money" → modal, Play href …&referrer=pnutdl%3D1%26dest%3D%252Fsend. Fold 5 countries SIGN UP → same modal. /shhhhh "TRY THE DOOR": desktop → modal with …dest%3D%252Fcard; iPhone → window.open(apps.apple.com/…); Android → window.open(play.google.com/…&referrer=pnutdl%3D1%26dest%3D%252Fcard). No /setup navigation anywhere.
6 AASA + AndroidManifest claim /app PASS AASA parses as JSON; all three appIDs list "/app" and "/app/*". Manifest has android:path="/app" + android:pathPrefix="/app/".
7 No React #418/#423 PASS Console captured on every case: no hydration warning, no minified React error, no page error from this branch.
8 pnpm test:e2e:regression NOT RUN Its webServer is npx next start, which needs a production build (pnpm build OOMs here) — it exits "Could not find a production build". E2E_BASE_URL is not read by playwright.regression.config.ts; it uses REGRESSION_PORT only. Best effort against the dev server (REGRESSION_PORT=3055 --workers=1): 6 passed / 27 failed, every failure net::ERR_CONNECTION_REFUSED after earlyoom killed the dev server (VmRSS 4.9 GB) two or three tests in. A narrowed run got 1440x900 › en, flag off green; en, flag on timed out on getByTestId('app-qr-code') after 10s — a next dev lazy-chunk artifact, since direct probes render that lockup every time.

The one thing to fix

2d. Flag on, the hero grows 878 → 936px at 1440×900 and pushes the marquee 36px out of the
fold. The compactArtwork clamps are working (~38px off the artwork), but the horizontal lockup
still adds ~96px net over the SIGN UP button it replaces. landing-migration.spec.ts asserts
marqueeTop < 900 for this viewport, so this blocks CI on a production build. 1366×768 misses
too but misses on origin/dev as well — separate, pre-existing.

Not tested

  • Real-device iOS clipboard hand-off (copyIOSHandoff needs a real Safari gesture + clipboard, and the app reading it back on first launch).
  • Vercel preview — everything here is next dev on localhost.
  • Universal-link open on a device: the AASA and Manifest entries were validated as files only, not as an actual app open on hardware.
  • Flag delivery through PostHog (only the localStorage override was exercised), including /app's onFeatureFlags + 4s timeout path.
  • Analytics actually landing in PostHog (MIGRATION_QR_SHOWN, MIGRATION_STORE_CLICK, DEFERRED_LINK_HANDOFF_CREATED) — only their DOM/navigation consequences were checked.
  • Pixel diff of the flag-off page against origin/dev (needs a second checkout + a second dev server; this box OOM-kills one).
  • Phone flag-off on /es-419 and /es-ar, and the 768×1024 viewport.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Hero fold fix (flag on)

Runtime QA caught a real regression on this branch: with the flag ON the marquee dropped below the fold at 1440x900, which the branch's own gate in e2e/flows/landing-migration.spec.ts asserts against. The flag-on artwork reserve was too small for the desktop lockup. Re-tuned, measured, fixed — flag-off classes untouched.

Change (src/components/LandingPage/hero.tsx, flag-on branch only)

constant before after
artwork cap (md:) calc(100svh-29.5rem) calc(100svh-33.5rem)
mascot cap (md:) min(40vh,calc(100svh-34.5rem)) min(40vh,calc(100svh-38.5rem))

The mascot keeps the same +5rem offset over the artwork cap it has today.

Arithmetic. The hero is h-fit at these widths, so its height is pt-4 + artwork + text column + xl:pb-4. The flag-on text column measures 476px in all four locales at both widths — 94px more than the flag-off 382px, which is what the QR lockup costs over the single button. With 32px of hero padding that is a fixed 508px (31.75rem) below the artwork. 33.5rem keeps 28px of slack: 1440x900 caps the artwork at 364px, 1366x768 at 232px.

Marquee top, px. All four locales (/, /es-419, /es-ar, /pt-br) gave the identical value in every cell.

viewport flag before after fold
1440x900 off 878 878 900
1440x900 on 936 (36 under) 872 900
1366x768 off 814 (46 under, pre-existing on dev) 814 768
1366x768 on 804 (36 under) 740 768

Mascot height, px. 152px at 1366x768 is the smallest it gets, above the 150px floor set for this change.

viewport flag before after
1440x900 off 320 320
1440x900 on 320 284
1366x768 off 307 307
1366x768 on 216 152

How verified. next dev --webpack -p 3055 in the worktree, Playwright reading document.getElementById('hero').nextElementSibling.getBoundingClientRect().top; flag on via localStorage['pwa-sunset']='true' + reload, waiting for the app-qr-code lockup. 2 viewports x 4 locales x flag off/on, before and after. pnpm test -- --runInBand src/components/LandingPage: 13 suites, 50 tests green. pnpm lint --quiet and pnpm prettier --check clean on the touched file.

Spec. No change needed — both target viewports now pass marqueeTop < viewport height, so no divergence was logged.

Flag-off impact. None. Only the two flag-on class strings changed; flag-off measures 878 and 814, the same as before.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

ds-lint ratchet fixed on 1a4bf7496 — the four metrics this PR pushed up are back at or under baseline, no --write-baseline.

metric baseline before after
legacyColorClasses 0 1 0
offScaleSpacing 188 190 187
offScaleRadius 37 39 37
hoverNoActiveFiles 44 46 43

Every other metric was already flat and stayed flat. node scripts/ds-lint-counts.mjs --check passes locally with no regressions.

Files touched (all already in this PR's diff):

  • src/components/Migration/StorePair.tsxborder-n-1border-border-button (both #000000; the token SlideToConfirm/BottomNav already use), hero md:py-7md:py-6, and an active:bg-white/90 next to each of the three hover:bg-white/90.
  • src/components/LandingPage/PhoneAppCta.tsxpx-7px-6, active:bg-white/90 added.
  • src/components/LandingPage/CountriesSignUpCta.tsxactive:bg-white/90 added.
  • src/components/LandingPage/HeroAppLockup.tsxlg:gap-7lg:gap-6.
  • src/components/LandingPage/GetTheAppFold.tsx — phone outline rounded-[24px] … md:rounded-[28px] → bare rounded.

Divergence

The phone outline in fold 10 was specified at a 28px corner radius. The DS radius scale is rounded (4px), rounded-sm (2px), none, round (999px), full — nothing between 4px and a stadium, and rounded-round on a 180×360 box is a capsule. It now carries the sanctioned 4px corner with its 2px black border, which is the same frame treatment as the DownloadQR next to it in the same 3-up row, so the two read as one unit. Logged in run/DIVERGENCES-funnel.md §D.

Nothing else moves visually: the border colour is byte-identical, the padding/gap changes are 4–8px on a full-width button and one desktop row gap, and the active: tints only fire on press (touch previously got no tint at all, which is the law the metric enforces). The hero md:py-7md:py-6 takes 8px off the store buttons, which moves the marquee further into the fold.

Verified: pnpm test -- --runInBand src/components/Migration src/components/LandingPage → 17 suites / 77 tests green; pnpm lint --quiet and prettier --check clean on the five files.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

P2 and P4 are fixed at this head, but the alternate-store CTA still loops to the detected store and the crawlable iOS listing is still controlled by the placeholder cutover date rather than listing availability.

Findings

  • MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
    On iOS the primary CTA resolves to the App Store, but Other store still opens /app; a bare /app detects iOS and immediately redirects to the App Store again. Android has the mirror failure, so the only alternate-store affordance loops back to the same listing. Compute the opposite store here and use its storeAnchorHref plus matching onStoreAnchorClick so the alternate listing and handoff tracking are explicit.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:80 · Gate the crawlable iOS link on listing availability
    The repository still marks the iOS listing unavailable, while this build-time gate publishes both store links solely when the placeholder cutover date passes. A deploy after that date can therefore emit a crawlable App Store link that still 404s; an earlier listing launch would remain uncrawlable until the date. Gate each listing on its actual release state (and redeploy when that state changes) instead of using the shared cutover calendar as a proxy.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:74 · [claude-opus] SEO footer publishes an App Store link the repo itself says is not live
    SEOFooter.tsx adds two crawlable store anchors gated on Date.now() >= MIGRATION_CUTOVER_DATE. Two problems with that gate, both product-truth:

  1. src/constants/migration.consts.ts:55 states IOS_APP_STORE_LISTING_LIVE = false with the comment "The iOS listing (App Store Connect app 6786373552) is not published yet: the store URL 404s". STORE_URL.ios is exactly the href this block emits as Peanut on the App Store. The rest of the codebase respects that constant (src/components/Profile/index.tsx:47 suppresses the iOS store-update prompt on it); this new block does not. A crawler following it gets a 404, and the link is in the site's link graph on every marketing page, in all four locales, for flag-off visitors too — the file's own doc comment calls that out as accepted divergence 12.

  2. The date it is gated on is a placeholder. MIGRATION_CUTOVER_DATE = new Date('2026-12-31T00:00:00Z') carries the comment "placeholder — set the real date before flag-on". So the condition that publishes a public, indexable promise that Peanut is downloadable from both stores is a value nobody has committed to, and it is read at build time, so it flips on whichever redeploy happens to land after it.

The product side agrees with the constant, not the code: /home/chip/mono/product/app.md (last_updated 2026-07-05) still records format: pwa and "PWA means no app store approval delays" — there is no documented store availability for Peanut at all.

Fix: gate the iOS entry on IOS_APP_STORE_LISTING_LIVE (and ideally both entries on listing-live constants) rather than on the placeholder date, so the crawlable link appears when the listing exists rather than when a build happens to run late enough. product/app.md also needs the native-app facts before the flag goes on, but that is a docs follow-up, not this PR's blocker.

Checked clean

  • Confirmed the detached HEAD and merge base exactly match the supplied head and base SHAs; git diff --check is clean.
  • P2 is fixed: StorePair now uses the design-system border token, valid spacing, and pressed-state treatment.
  • P4 is fixed: sendInSeconds no longer imports filesystem-backed landing content, and the server component passes the reused subtext into it.
  • Traced deferred payload creation, smart-link store routing, native /app mapping, and the landing/shhhhh handoff paths.
  • Exact-head analyze and review checks are successful; the focused local Jest command could not start because this detached worktree has no node_modules/Jest binary.

Security review: did not run — this change has no security, privacy or money surface, so it was not asked. This review is one reviewer short.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 1a4bf7496d4f · Context: repo · Took 15m

</Button>
</a>
{showOtherStore && (
<Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Route Other store to the alternate listing

On iOS the primary CTA resolves to the App Store, but Other store still opens /app; a bare /app detects iOS and immediately redirects to the App Store again. Android has the mirror failure, so the only alternate-store affordance loops back to the same listing. Compute the opposite store here and use its storeAnchorHref plus matching onStoreAnchorClick so the alternate listing and handoff tracking are explicit.

]

/** Evaluated once per prerender — see the note above: build time, not request time. */
const storeListings = () => (Date.now() >= MIGRATION_CUTOVER_DATE.getTime() ? STORE_LISTINGS : [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Gate the crawlable iOS link on listing availability

The repository still marks the iOS listing unavailable, while this build-time gate publishes both store links solely when the placeholder cutover date passes. A deploy after that date can therefore emit a crawlable App Store link that still 404s; an earlier listing launch would remain uncrawlable until the date. Gate each listing on its actual release state (and redeploy when that state changes) instead of using the shared cutover calendar as a proxy.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

ds-shots follow-up (head 2add057)

Fold gate at 1366×768. The four flag off failures were the spec asserting a guarantee dev never gave. Measured, all four locales: flag off = 814 px, flag on = 740 px, viewport 768. Same pair in CI (run 34123714386: Expected: < 768 / Received: 814) and against this branch's Vercel preview.

The spec now splits the two states at that height:

  • flag on — unchanged strict gate, marqueeTop < 768. This is the page the PR owns.
  • flag offmarqueeTop <= 814, dev's own value, through a new marqueeCeilingFlagOff field on the viewport table. A regression ceiling, not a fold promise: the flag-off page is what dev ships and this branch does not touch it.

1440×900 keeps the strict assertion in both states (flag off 878 < 900, flag on 872).

Result against the preview (e2e/flows/landing-migration.spec.ts, no local build): 33 passed, 0 failed in 23.5 s.

dev-showcase timeout — pre-existing, not caused by this PR. It was reported as 1 flaky, not as a failure: it timed out at 90 s on the first attempt and passed on retry. Evidence it is a budget problem that predates the branch:

  • the sweep takes 1.4–1.5 m against a 90 s timeout on branches this PR has nothing to do with — fix/toast-black-border (run 34124030504), fix/longpress-about-rows (34119539373), fix/bridge-history-pending-fx-fetch (34117824145) all pass at 1.4–1.5 m. It sits on the wall on every branch.
  • the timeout fires inside page.waitForTimeout(800) while walking 53 doc routes, not on a page that hangs or errors.
  • this PR touches no file under src/app/(mobile-ui)/dev, and nothing in the showcase references StoreBadges, StorePair, DownloadQR or any landing component — grep -rn StoreBadges src e2e returns nothing, and the /dev/ds audit data files hold those names only as prose, with no imports.

What did change is the load beside it: the flows suite went from 10 tests to 43 on 2 workers, so the sweep gets less CPU and crossed a line it was already touching. Raising that test's timeout (or splitting the sweep) belongs in its own PR against the spec, so this branch leaves it alone.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Vercel preview smoke (production build)

Target: https://peanut-wallet-git-feat-pwa-sunset-landing-funnel-squirrellabs.vercel.app — head 1a4bf7496, deploy dpl_FX5yKQov2p5AZwALkJhn9DBuLv54.
Method: headless Chromium (Playwright 1.58.2) against the deployed production build, one browser, sequential contexts; flag forced with localStorage.setItem('pwa-sunset','true') via an init script (branch's non-production override). Raw HTML checks with curl. QR decoded from the rendered pixels of the [data-testid="app-qr-code"] element (element screenshot at DPR 3, white quiet zone added, @paulmillr/qr decoder) — i.e. the same thing a phone camera sees, logo overlay included.
Artefacts (screenshots + JSON dumps) live in run/preview/ on the QA host; filenames below.

# Check Result Evidence
1a Flag off / @1440×900: no QR, no store hrefs, SIGN UP/setup PASS t1-flagoff.json: qr: null, storeHrefs: [], setupHrefs: [{"/setup","SIGN UP"}, {"/setup?step=login","Log in"}, {"/setup","SIGN UP"}] · flagoff-home-1440x900.png
1b Same @390×844 PASS identical dump at 390×844 · flagoff-home-390x844.png
1c Flag-off SSR document is unchanged (no funnel markup) PASS curl iPhone-UA / → 0 × phone-app-cta, 0 × app-qr-code, 0 × href="/app" (home-iphone-ssr.html)
2a Flag on: hero lockup visible (app-qr-code) on /, /pt-br, /es-ar PASS 3 QRs per page; hero QR rect @1440×900 top 628, 192×192 (/), top 628 (/pt-br, /es-ar); @1366×768 top 496, 192×192 · flagon-{root,pt-br,es-ar}-{1440x900,1366x768}.png
2b Marquee top < 900 @1440×900 PASS .rfm-marquee-container top = 874 on all three locales (t2-flagon.json)
2c Marquee top < 768 @1366×768 PASS top = 742 on all three locales
2d No horizontal overflow PASS scrollWidth == innerWidth on all 6 locale×viewport combos (overflow: "none")
2e No /setup or /send href on any CTA, flag on PASS badHrefs: [] on all 6 combos. Remaining /send-money-to/*, /help/send-euros-* hits are SEO-footer content links, not CTAs
2f Hero QR decodes to /app?…s=landing_hero PASS /…/app?pnutdl=1&s=landing_hero · /pt-br…/app?pnutdl=1&lang=pt-br&s=landing_hero · /es-ar…/app?pnutdl=1&lang=es-ar&s=landing_hero (decoded from qr-root.png, qr-pt-br.png, qr-es-ar.png; path-level decode of the same SVG agrees)
2g Desktop store pair hrefs PASS https://apps.apple.com/us/app/id6786373552 and https://play.google.com/store/apps/details?id=me.peanut.wallet&referrer=pnutdl%3D1%26lang%3Dpt-br (locale carried into referrer)
3a iPhone UA @390×844: ONE full-width store button, App Store URL after mount PASS [data-testid="phone-app-cta"] → single anchor https://apps.apple.com/us/app/id6786373552, text DOWNLOAD NOW, width 358 / 390 (w-full) · phone-iphone-top.png
3b That href is /app before mount and upgrades on mount PASS MutationObserver log: [{t:863, href:"/app"}, {t:1163, href:"https://apps.apple.com/us/app/id6786373552"}] (t6-firsthref.json). Note: the server document contains no funnel markup at all (see 1c) — SSR always renders the flag-off page, so the /app fallback is the client's first flag-on render, not raw HTML
3c "Other store" link → /app PASS {"href":"/app","text":"Other store","w":72} inside phone-app-cta
3d Sticky bar after 400px scroll PASS before scroll: no fixed CTA container; after scrollTo(0,400): div.pointer-events-none.fixed.right-0.bottom-0.left-0.z-50… top 774, h 70, w 390, link apps.apple.com/us/app/id6786373552 · phone-iphone-scroll400.png
3e Android UA → Play URL with referrer= PASS hero + sticky href https://play.google.com/store/apps/details?id=me.peanut.wallet&referrer=pnutdl%3D1 · phone-android-{top,scroll400}.png
3f Desktop-mode Android UA (no Android/Mobile tokens, 412px) → BOTH store buttons in the sticky bar PASS sticky links = [apps.apple.com/…id6786373552, play.google.com/…&referrer=pnutdl%3D1], btns ["App Store","Google Play"]. Hero also falls back to the full desktop lockup (QR @192px + both stores) rather than guessing · phone-android-desktop-mode-scroll400.png
4a /app?pnutdl=1&lang=pt-br&invite=ABC123&dest=%2Fsend&s=landing_hero + Android UA → Play with referrer= PASS intercepted (aborted, not followed): https://play.google.com/store/apps/details?id=me.peanut.wallet&referrer=pnutdl%3D1%26lang%3Dpt-br%26invite%3DABC123%26dest%3D%252Fsend — full payload round-trips, s correctly dropped
4b Same URL + iPhone UA → no redirect, App Store button present PASS location.href unchanged; body text Get the Peanut app · Global cash, local feel. Pick your store to download.; anchors App Storeapps.apple.com/…id6786373552, Google Play → Play URL with the same referrer= · app-payload-iphone.png
4c Bare /app + iPhone UA → apps.apple.com/us/app/id6786373552 PASS intercepted https://apps.apple.com/us/app/id6786373552
4d Bare /app + Android UA → Play, no referrer PASS (bonus) intercepted https://play.google.com/store/apps/details?id=me.peanut.wallet
5a /shhhhh flag on, desktop click TRY THE DOOR → modal with QR PASS QR count 0 → 1; dialog text Get the Peanut app · Scan with your phone camera to download. · App Store · Google Play · Close; modal QR decodes to …/app?pnutdl=1&dest=%2Fcard&s=landing_door · shhhhh-desktop-modal.png, shhhhh-modal-qr.png
5b /shhhhh flag on, iPhone UA → store URL PASS click on TRY THE DOOR produced an intercepted navigation to https://apps.apple.com/us/app/id6786373552; page itself stayed on /shhhhh
5c No /setup href left on /shhhhh with the flag on PASS setupLinksBefore: []
6 Hydration: no React #418/#423/#425 or Hydration console errors PASS console + pageerror captured on every run above (/ flag off ×2 viewports, / /pt-br /es-ar flag on ×2 viewports, 3 phone UAs, 4 /app variants, /shhhhh ×2): filter #418|#423|#425|Hydration|hydratempty everywhere. Only console noise is the Vercel preview toolbar (Framing 'https://vercel.live/' violates … report-only CSP + one 403) — preview-only, not from this branch
7 /.well-known/apple-app-site-association lists /app PASS HTTP 200, application/json, parses; all three appIDs (4K73GMPZP8.com.squirrellabs.peanut-app, web.app.peanut, PW388G893L.me.peanut.wallet) contain both /app and /app/* (aasa.json)

Notes (non-blocking)

  • Marquee at 1440×900 sits at y=874 — inside the 900px fold by 26px, so only its top sliver is visible. It clears the fold comfortably at 1366×768 (742 of 768). If the intent was "a readable strip of marquee in the fold", 1440×900 is at the edge.
  • Fold-10 / lower single-store CTA on a desktop-mode phone UA defaults to the App Store with no "Other store" escape link (phone-app-cta at document y≈8804 for the 412px desktop-mode Android context). The hero and the sticky bar both offer the full pair in that case, so the user is never trapped — just noting the one place that still guesses.
  • /app's SSR document contains no store markup (it's a 'use client' page for the Capacitor export), so both the store buttons and the Android auto-redirect are client-side only. Expected per the brief; recorded so nobody reads item 3b as a regression.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

Two previously raised defects remain: the Other store link returns Android visitors to Google Play, and a post-cutover build can publish the unavailable iOS listing. The design-system finding is fixed, and exact-head required CI is green.

Findings

  • MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Route Other store to the alternate listing
    This remains reproducible (and is the same defect recorded as P1). On an Android phone the primary CTA already targets Google Play, but Other store links to /app; that route detects Android and redirects to Google Play again, so the advertised alternate store is unreachable. Compute the opposite store here and use its real store href and click handler instead of routing through the device-detecting smart link.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:80 · Gate the App Store link on listing availability
    This remains present (the same defect recorded as P4 and P5). A build at or after the cutover returns both listings unconditionally, while IOS_APP_STORE_LISTING_LIVE is still false and documents that the iOS URL is unavailable. Redeploying this same commit after the date therefore publishes a crawlable 404 across marketing footers. Filter the iOS entry on the availability constant, or make the cutover gate require it, and cover the unavailable post-cutover case.

  • MAJOR · src/app/app/page.tsx:96 · [moonshotai/kimi-k3] Attacker-controlled dest param drives in-app routing on /app (open redirect risk)
    src/app/app/page.tsx reads window.location.search, and when the payload marker is present calls applyDeferredPayload(parsed).dest and passes it straight to router.replace(dest ?? '/home'). Anyone can craft peanut.me/app?pnutdl=1&dest= and get a victim to open it — the Android manifest and apple-app-site-association now claim /app and /app/, so the link opens directly inside the native app where router.replace executes with no visible validation in this file. If parseDeferredPayload/applyDeferredPayload (in src/utils/deferred-link.ts, not fully visible in this diff) do not strictly allowlist internal app routes, a dest of the form //evil.com or an absolute URL would let an attacker bounce a victim from a trusted peanut.me link to a phishing site that appears to come from the app, and a dest pointing at privileged in-app routes could pair with the invite/campaign auto-apply. Fix: validate dest against a strict allowlist (e.g. ^/[a-z0-9-/]$ plus a known-destinations set) inside parseDeferredPayload and fall back to /home on anything else; add a test that a hostile dest is rejected.

Checked clean

  • Confirmed the detached worktree head, merge base, trusted author, and dev target exactly match the supplied values.
  • P2 is fixed: the footer store button now uses the design-system border token and interaction-state classes, and exact-head ds-lint passes.
  • P1 duplicates the still-present P3 Other store defect; P4 and P5 duplicate the still-present P6 listing-availability defect, so each cluster is reported once.
  • Traced QR payload construction, Android install-referrer delivery, iOS clipboard delivery, native /app application, App Links route mapping, landing CTA interception, and /shhhhh handoff without retaining another defect.
  • Exact-head ci-success, unit, typecheck, native-export, eslint, format, and ds-lint completed successfully; advisory ds-shots was still in progress.
  • A local focused Jest run could not start because the detached worktree has no node_modules/Jest binary; no dependencies were installed into the read-only review worktree.

Security review by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.

Third opinion by claude-opus: 0 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 2add0577c3f7 · Context: repo · Took 17m

</Button>
</a>
{showOtherStore && (
<Link prefetch={false} href="/app" className="block text-center text-body-s text-n-1 underline">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Route Other store to the alternate listing

This remains reproducible (and is the same defect recorded as P1). On an Android phone the primary CTA already targets Google Play, but Other store links to /app; that route detects Android and redirects to Google Play again, so the advertised alternate store is unreachable. Compute the opposite store here and use its real store href and click handler instead of routing through the device-detecting smart link.

]

/** Evaluated once per prerender — see the note above: build time, not request time. */
const storeListings = () => (Date.now() >= MIGRATION_CUTOVER_DATE.getTime() ? STORE_LISTINGS : [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: Gate the App Store link on listing availability

This remains present (the same defect recorded as P4 and P5). A build at or after the cutover returns both listings unconditionally, while IOS_APP_STORE_LISTING_LIVE is still false and documents that the iOS URL is unavailable. Redeploying this same commit after the date therefore publishes a crawlable 404 across marketing footers. Filter the iOS entry on the availability constant, or make the cutover gate require it, and cover the unavailable post-cutover case.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Superseded by #3027, which carries the selected additions from this PR while retaining #3024’s single Download now action and shared desktop QR modal.

Included: remaining landing CTA routing, mobile store fallbacks, plain footer store links, /shhhhh campaign handoff, native /app routing, iOS login handoff, and scroll-freeze removal. The inline hero QR, artwork shrink, large Get-the-app section and footer QR block are intentionally omitted.

#3027 is verified and ready for human review. Real-device native validation and the launch-content update remain required before enabling the migration flag.

@0xkkonrad 0xkkonrad closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant