Skip to content

feat(landing): app-store funnel — S-full hero, hoisted QR modal, Get-the-app fold, footer block (TASK-21788) - #3010

Closed
0xkkonrad wants to merge 2 commits into
devfrom
feat/pwa-sunset-landing-page
Closed

0xkkonrad wants to merge 2 commits into
devfrom
feat/pwa-sunset-landing-page

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Turns the landing page into the app-store funnel for the pwa-sunset window (TASK-21788, PR 2 of 4 — the page). Every download CTA on the page now ends at a store or at a QR that a phone can scan; nothing routes to /setup or /send while the flag is on. With the flag off the page is unchanged: the flag is false until mount, so SSR and the first client render are today's page byte for byte.

Depends on PR 1 (mechanism) for /app's payload branching and the app-links files. The two were built in parallel from the same brief; where PR 1's component API does not exist on dev yet, this PR added its own equivalents rather than editing PR 1's files — see Divergences.

What changed

  • Hero (fold 1). The CTA slot becomes a device-specific lockup. Desktop: the S-full lockup — a 160px-module QR encoding /app with the deferred payload and s=landing_hero, the scan hint, the white App Store / Google Play pair, and the content-system subtext; horizontal from lg up, stacked below. Phone: one full-width white "Download now" with the detected store's mark, an href that starts at /app and upgrades to the store deep link on mount, an "Other store" text link to /app, and the same subtext. No two-button row exists on a phone, so nothing can overflow at 320px.
  • Hero geometry. From md up and flag-on only (the compactArtwork prop), the artwork clamp moves from calc(100svh-23rem) to calc(100svh-29.5rem) and the mascot's from calc(100svh-28rem) to calc(100svh-34.5rem). The desktop lockup is ~164px where the single button was ~76px, and that 88px is what the clamp buys back so the marquee stays in the fold at 1440x900 and 1366x768. Below md both keep today's values: the phone CTA barely changes height, so the laptop clamp there only cut hero artwork (up to ~100px on a 320x568 phone) for nothing.
  • Mascot. A ResizeObserver on #hero now re-runs position(). resize alone missed every height change that leaves the window alone — the CTA swapping in on mount, a late webfont, an image decoding — each of which left the mascot floating off the headline.
  • One hoisted QR modal. AppModalProvider wraps the whole page inside LandingPageClient, above the server-rendered slots, and owns a single ScanToDownloadModal. Its interceptAppCta(surface, handoff) is the contract every re-pointed CTA calls: desktop opens the modal tagged with the caller's surface and carrying the hand-off, phones bounce to their store with the same hand-off, flag-off returns false and the CTA keeps its old behaviour.
  • Fold 4, zero fees. "Send Money" calls the intercept with landing_rates and dest=/send before the signed-in branch.
  • Fold 5, global cash. The SIGN UP inside the countries illustration is now a small 'use client' child; with the flag on its href is /app and the click opens the modal (landing_countries).
  • Fold 10, send in seconds → Get the app. A client child swaps the whole body on the flag: "GET THE APP." over the existing tagline, then the scanner capture in a 2px/28px-radius outline with a 6px hard shadow, a 192px QR with the hint, and the store pair stacked with the subtext. Phone gets the title, the outline and one store button. The capture is store-listing-v2/capture/shots-out/en/01-qr-scan.png downscaled to 360px wide and palette-optimised — 12.7 KB. The 3-up is md:gap-8 lg:gap-14 so the row fits the 736px content box at exactly 768px instead of silently shrinking the QR below its specified 192px.
  • Fold 12, footer. A new FooterGetTheApp block on top of the footer chrome, opt-in via showGetTheApp and passed only by the landing page: heading, hint, the store pair in white-on-black with the border kept and the shadow dropped (the LocaleSwitcher's treatment), and a 192px QR on desktop; one download button on a phone. It renders null with the flag off. The SEO footer's Learn More column gains two crawlable store-listing links from MIGRATION_CUTOVER_DATE on — every other download CTA is a script-driven bounce a crawler cannot follow, and this footer is a server component on every marketing page, so a date is the only gate it can read.
  • Code splitting. HeroAppLockup, PhoneAppCta and GetTheAppFold load through next/dynamic with ssr: false. None can render before mount, so nothing is lost, and AppQrCode → QRCodeWrapper → react-qr-code plus the scanner capture leave the landing page's main chunk for every visitor, flag off included.
  • Analytics. MIGRATION_SURFACES gains the six keys from the brief. MIGRATION_QR_SHOWN fires once per QR, at 50% visibility, with {surface, hasContext} — so the three QRs on the page are counted where they are seen, not where they mount.
  • Dormant sub-flag. LANDING_HERO_QR_FLAG = 'landing-hero-qr' is exported and documented; nothing reads it.
  • Copy. All copy pass pending: migration.otherStore and migration.getTheApp in the app catalog (en / es-419 / pt-BR, marketing subsets regenerated), plus five URL-locale keys in the marketing catalog — migrationGetTheApp, migrationQrTitle, migrationScanHint, migrationDownloadNow, migrationOtherStore in en / es-419 / es-ar / pt-br. The landing folds read the URL-locale ones so /pt-br stays Portuguese on a Spanish phone.

Review round 1 — what changed

Fix-then-ship. Every blocker and major addressed, plus all six minors.

  • blocker, e2e flag. isPwaSunsetOn honoured its localStorage override only under IS_DEV, so against next start (which is what the spec and playwright.regression.config.ts run) every "flag on" case rendered the flag-OFF page and passed vacuously. The override is now live on every non-production domain (IS_DEV || BASE_URL !== 'https://peanut.me') — the boundary isFeatureFlagEnabled's nonProdBypass already draws — and each flag-on case asserts its lockup is visible, so a silently-inert override fails loudly. Both directions pinned in migration.utils.test.ts.
  • blocker, desktop never exercised. useDeviceType branches on the user agent, not the viewport, and the shared project is a Pixel 7 — so the 1440/1366 cases rendered PhoneAppCta, never the QR lockup the re-tuned constants exist for. The laptop and 768 cases now run under a desktop UA via per-describe test.use, and assert the QR lockup before measuring the marquee.
  • major, dropped hand-off. AppModalProvider kept only the surface on desktop, so dest=/send was phone-only and the modal had no way to accept one. It now keeps {surface, handoff} and threads it into ScanToDownloadModal, which carries it to both the QR payload and the store anchors.
  • major, scroll lock. Removing #sticky-button-target on the flag-on branch could strand document.body.style.overflow = 'hidden' with no event able to clear it (a visitor already at fold 10 when the flag resolves — scroll restoration on reload or back-navigation). The scroll effect now bails and cleans up when the flag is on.
  • major, phone artwork. The compactArtwork clamp was applied at every width but tuned for the desktop lockup. It is md:-scoped now, matching its mascot twin.
  • major, flag-off store links. The SEO footer's two store links shipped on every marketing page with the flag off. Gated on the cutover date; the flag-off SSR HTML carries zero store hrefs again.
  • minors. URL-locale copy for the fold and footer headings; md:gap-8 lg:gap-14 on the 3-up plus a 768x1024 gate case; next/dynamic for the three lockups; showGetTheApp scoping the footer block to the landing page; the hero Log In link kept and re-pointed at /app instead of removed; AppStorePair back to the locked max-w-[26rem].

How verified

  • pnpm typecheck → 6 errors, all pre-existing TS2307 module-resolution failures in four files this branch never touches (src/utils/web-vitals-shim.ts, its test, src/utils/app-review.ts, src/utils/native-settings.ts), caused by the worktree's symlinked node_modules. Nothing from the diff appears in the output.
  • pnpm test -- src/components/LandingPage src/components/Migration src/i18n src/utils/__tests__/migration.utils.test.ts → 31 suites, 321 tests, all passing. New this round: the two isPwaSunsetOn production-build cases, the modal hand-off assertion in AppModalProvider.test.tsx, and the store-listing pair in Footer.test.tsx re-cast as before/after the cutover date.
  • pnpm lint --quiet <touched files> → clean. prettier --check over the same set → clean.
  • SSR check against next dev: curl of / and /pt-br with the flag off contains zero apps.apple.com / play.google.com hrefs — the flag-off page no longer advertises the listings.
  • Layout gate: e2e/flows/landing-migration.spec.ts — 4 locales x {1440x900, 1366x768, 768x1024, 390x844} x {flag off, flag on}, desktop UA on the first three, asserting the right lockup rendered (and that neither exists with the flag off), no horizontal overflow anywhere, and the marquee above the fold at both laptop sizes; plus one check that no /setup or /send href survives with the flag on. Screenshots go to LANDING_SHOTS_OUT. Not executed in this session — see divergence 17: this box could not keep next dev alive with a browser attached (V8 OOM mid-stream, pages never hydrated), so no flag-on capture from here would be honest evidence. CI runs it against a production build, where the override now works.

Divergences

  1. PR 1's API is not on dev, so this PR added its own components and touched none of PR 1's files. AppQrCode (bare framed QR, 160/192px, payload + surface in the url, visibility-gated impression) and AppStorePair (row/column/footer) do what the brief specifies for DownloadQR's new props and StorePair. Reconciling the two branches means collapsing them.
  2. MIGRATION_SURFACES gains all six keys verbatim, including the two only PR 1 and PR 4 read, so both branches produce an identical hunk instead of two partial lists that conflict.
  3. The re-tuned hero constants are gated on the flag (and now on md). Applying them unconditionally changes the flag-off hero wherever the clamp binds (1366x768 does), which breaks "renders identically to today when the flag is off". Rule (a) wins.
  4. The hero keeps its content-system headline. The artboards print "TAP. SCAN. ANYWHERE."; the h2 is strings.heroTapScan and the locked table changes only the CTA slot, so it is untouched.
  5. The two footer store links are untranslated English constants, in a column that already renders authored English across every locale by design (RESOURCE_NAME_OVERRIDES documents the rule in place), and the store names are untranslated constants already.
  6. StickyMobileCTA is left to PR 1, which lists the WEB → both-stores case in its own mechanism section.
  7. No unit test for the rates-widget re-point. NoFees renders ExchangeRateWidget (router, useSearchParams, a rate fetch); the one changed line is covered by the Playwright no-/send assertion, and the intercept it calls is unit-tested directly.
  8. The screenshot gate takes its output path from LANDING_SHOTS_OUT rather than hard-coding a scratchpad path into a repo file.
  9. The pwa-sunset localStorage override now covers every non-production domain, not just dev — the only way to turn the flag on in a build with no PostHog key, which is what the layout gate runs against. peanut.me is unchanged.
  10. The SEO footer's store links ride the cutover date, not the flag, because a client-only flag in a server component either loses the crawlable HTML or keeps the flag-off exposure. Pre-cutover crawlability is the trade.
  11. ScanToDownloadModal grew a handoff prop, and storeAnchorHref / onStoreAnchorClick gained an optional handoff argument. With a hand-off the modal renders PR 2's hand-off-aware QR and pair; without one it renders DownloadQR verbatim, so the home banner, guest flow and dev registry are untouched. Both helper changes are additive and neither PR 1 nor PR 4 rewrites those functions.
  12. The migration download copy is duplicated into the marketing (URL-locale) catalog. next-intl resolves the DEVICE locale, which put a Spanish H1 on a Portuguese fold. Teaching the provider the route locale is an IntlCore change far outside this PR. es-AR is voseo, consistent with its siblings.
  13. The footer download block is opt-in. FooterChrome is shared by /careers, /m/[slug], /shhhhh and the marketing layout; the locked structure scopes the block to fold 12, so showGetTheApp is passed only by LandingPageContent.
  14. The hero Log In link is kept and re-pointed at /app rather than removed — it is the only re-entry path a returning logged-out visitor has, and removing it also changed the CTA column's height on mount.
  15. Runtime re-verification was not possible in this session. next dev plus a browser is OOM-killed on this box (a second session holds another dev server); pages never hydrated, so every flag-on capture would have been a disguised flag-off page. Screenshots taken in that state were deleted rather than filed. Static gates and the SSR check above are what this round is verified by.

Full text with the reasoning for each: run/DIVERGENCES-page.md (round 2 entries are numbered 11–17 there).

Flag-off impact

None. Every change is inside the pwa-sunset branch, and the one exception from round 1 — the SEO footer's store-listing links, which rendered on every marketing page in every locale regardless of the flag — is now gated on MIGRATION_CUTOVER_DATE, verified by curling the SSR HTML for / and /pt-br. AppModalProvider wraps the tree and returns false from its intercept while the flag is off; FooterGetTheApp returns null and is only mounted on the landing page at all; the hero geometry, the fold-10 body and the countries CTA keep their exact current markup in the flag-off path, which SendInSecondsBody.test.tsx, CountriesSignUpCta.test.tsx, FooterGetTheApp.test.tsx and Footer.test.tsx each assert.

@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 Error Error Sep 7, 2026 9:56am 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: 85733cda-994b-4bad-80a4-78c7c088ac9d

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: 7251.68 → 7290.46 (+38.78)
Findings: +19 net (+59 new, -40 resolved)

🆕 New findings (59)

  • critical complexity — src/components/LandingPage/LandingPageClient.tsx — CC 51, MI 57.44, SLOC 207
  • high hotspot — src/components/LandingPage/hero.tsx — 48 commits, +476/-314 lines since 6 months ago
  • high complexity — src/components/LandingPage/hero.tsx — CC 40, MI 61.27, SLOC 97
  • high complexity — src/utils/migration.utils.ts — CC 36, MI 63.11, SLOC 90
  • high complexity — src/components/LandingPage/SEOFooter.tsx — CC 30, MI 66.88, SLOC 116
  • medium high-mdd — src/components/LandingPage/hero.tsx:166 — Hero: MDD 96.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/LandingPageClient.tsx:62 — LandingPageClient: MDD 80.5 (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-mdd — src/components/LandingPage/noFees.tsx:19 — NoFees: MDD 35.5 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/sendInSeconds.tsx:40 — SendInSeconds: MDD 36.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/landingStrings.ts:66 — landingStrings: MDD 29.2 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/SEOFooter.tsx:131 — SEOFooter: MDD 29.2 (uses across many lines from declarations)
  • medium method-complexity — src/components/LandingPage/LandingPageClient.tsx:159 — CC 17 SLOC 39
  • medium complexity — src/components/Migration/AppQrCode.tsx — CC 15, MI 65.81, SLOC 51
  • medium complexity — src/components/LandingPage/noFees.tsx — CC 10, MI 56.99, SLOC 50
  • medium complexity — src/components/LandingPage/sendInSeconds.tsx — CC 3, MI 55.74, SLOC 53
  • medium complexity — src/components/LandingPage/landingStrings.ts — CC 2, MI 51.17, SLOC 57
  • medium react-direct-dom — src/components/LandingPage/hero.tsx:68 — direct DOM: document.getElementById
  • medium react-direct-dom — src/components/LandingPage/LandingPageClient.tsx:161 — direct DOM: document.getElementById
  • medium react-effect-derives-state — src/components/LandingPage/PhoneAppCta.tsx:41 — small useEffect that only sets state from deps

…and 39 more.

✅ Resolved (40)

  • src/components/LandingPage/LandingPageClient.tsx — CC 51, MI 56.24, SLOC 205
  • src/components/LandingPage/hero.tsx — 46 commits, +401/-282 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/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/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/components/LandingPage/LandingPageClient.tsx:52 — LandingPageClient: DLT 30 (calls 30 distinct functions — high context load)
  • src/components/LandingPage/SEOFooter.tsx:109 — SEOFooter: MDD 28.9 (uses across many lines from declarations)
  • src/components/LandingPage/SEOFooter.tsx — CC 27, MI 67.52, SLOC 100
  • src/components/LandingPage/landingStrings.ts:49 — landingStrings: MDD 26.0 (uses across many lines from declarations)
  • src/components/LandingPage/LandingPageClient.tsx:153 — CC 17 SLOC 39
  • src/components/LandingPage/noFees.tsx — CC 9, MI 58.36, SLOC 44
  • src/components/LandingPage/sendInSeconds.tsx — CC 3, MI 55.92, SLOC 52
  • src/components/LandingPage/landingStrings.ts — CC 2, MI 52.52, SLOC 51
  • src/components/LandingPage/LandingPageClient.tsx:155 — direct DOM: document.getElementById
  • src/components/LandingPage/yourMoney.tsx:57 — Use next/link
  • src/constants/migration.consts.ts — CC 1, MI 54.12, SLOC 26

…and 20 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/LandingPage/FooterGetTheApp.tsx 0.0 5.4 +5.4
src/components/Migration/AppQrCode.tsx 0.0 4.9 +4.9
src/components/LandingPage/PhoneAppCta.tsx 0.0 4.7 +4.7
src/components/Migration/AppStorePair.tsx 0.0 4.2 +4.2
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.7 +3.7
src/components/LandingPage/GetTheAppFold.tsx 0.0 3.0 +3.0
src/components/LandingPage/HeroAppLockup.tsx 0.0 2.4 +2.4
src/components/LandingPage/hero.tsx 10.8 12.1 +1.3
src/components/LandingPage/landingStrings.ts 7.7 8.7 +1.0
src/components/LandingPage/noFees.tsx 7.8 8.5 +0.7
src/components/LandingPage/sendInSeconds.tsx 7.4 6.8 -0.5
src/components/LandingPage/yourMoney.tsx 5.7 4.8 -1.0

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 6109 ran, 0 failed, 0 skipped, 1.6m

📊 Coverage (unit)

metric %
statements 74.7%
branches 60.4%
functions 68.8%
lines 75.6%
⏱ 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_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 › 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 › 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 › 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 › 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 › a refused idempotency key tells the user to scan again, not to contact support
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
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
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@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 — changes requested

The exact head breaks production and native builds and leaves several migration funnel paths incorrect; exact-head unit, typecheck, format, and eslint checks are green, but required CI is red.

Findings

  • BLOCKING · src/components/LandingPage/sendInSeconds.tsx:10 · Server-only content import breaks both builds
    The client-safe LandingPage barrel re-exports this module and is imported by client surfaces such as ShhhhhLandingPage. Adding getLandingContent pulls src/lib/content.ts (and Node's fs module) into that client/native graph. At this SHA both the ds-shots production build and required native-export fail with Can't resolve 'fs'; the base SHA checks are green. Keep this module client-safe by passing the subtext from a server-only caller, or stop exporting a server-bound module through the client barrel.

  • MAJOR · src/components/Migration/AppModalProvider.tsx:39 · Desktop rates handoff loses the /send destination
    NoFees calls interceptAppCta(..., { dest: '/send' }), but the WEB branch stores only the surface. The modal then renders the existing DownloadQR, whose URL is plain /app, so a desktop visitor who scans after clicking the rates CTA installs/opens the app without the requested /send destination. Store the handoff with the modal state and render a payload-aware QR, then assert the desktop modal URL contains the destination.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:57 · SEO footer publishes an unavailable App Store listing — also flagged by claude-opus
    These listing links render in every flag state, including today's flag-off landing page. STORE_URL.ios currently returns HTTP 404 and this exact SHA declares IOS_APP_STORE_LISTING_LIVE = false, so merging adds a crawlable broken download link before the migration is enabled. Gate the iOS listing on its published-status constant (and only expose other listings once verified live), or add these anchors when the listings launch.

  • MAJOR · e2e/flows/landing-migration.spec.ts:39 · Production E2E cannot turn the migration flag on
    The regression config builds and runs next start, so NODE_ENV is production, while isPwaSunsetOn honors this localStorage override only when IS_DEV is true. Setting pwa-sunset here therefore cannot select the flag-on branch: the matrix either repeats the live flag-off UI or depends on remote PostHog state, and the final no-/setup assertion becomes nondeterministic. Use a production-valid deterministic test override and assert the flag-on marker before checking layout.

  • MAJOR · src/components/Migration/AppStorePair.tsx:54 · New CTA styles fail the required DS ratchet
    The exact-head ds-lint check is red while the base is green: AppStorePair introduces the legacy border-n-1 token and a hover-only file; lg:gap-7 and px-7 add two off-scale spacing uses; the phone frame adds two arbitrary radii; and CountriesSignUpCta, AppStorePair, and PhoneAppCta add hover styles without active states. Replace them with allowed semantic color/spacing/radius tokens and paired active states rather than raising the debt baseline.

  • MAJOR · src/components/LandingPage/PhoneAppCta.tsx:58 · Other store sends phones back to the same store
    On a phone, /app immediately redirects to the store detected from that same device. An iOS visitor therefore sees an App Store primary button and clicking Other store opens /app, which redirects to the App Store again; Android has the symmetric failure. Link to the opposite listing, or add an explicit picker/no-auto-redirect mode to /app and cover both platforms.

  • MINOR · src/components/Migration/AppQrCode.tsx:70 · QR impressions fire below the stated 50% threshold
    IntersectionObserver delivers an initial entry even when the target is below its configured threshold, and isIntersecting becomes true for any non-zero intersection. If a QR starts only 10% visible, this condition still fires the one-shot impression despite the claimed 50% gate. Require entry.intersectionRatio >= 0.5 and test entries below and above the threshold.

Checked clean

  • Pinned worktree HEAD, merge base, trusted author, base ref, and base SHA all match the supplied values; the detached worktree is clean.
  • Exact-head CI and failure logs inspected: format, typecheck, eslint, unit, analyze, human-authors, and bot-approval are green; ds-lint, native-export, ds-shots/build, deploy preview, and aggregate CI are red. The corresponding base checks are green.
  • Flag-off rendering branches, device selection, store-anchor/openStore behavior, modal ownership, QR payload construction, CTA route substitutions, footer links, and localization changes were traced through surrounding code.
  • New focused unit coverage was inspected and the exact-head full unit check is green.
  • Direct store endpoints were checked; the iOS 404 matches the repository's unpublished-listing guard.
  • Diff whitespace validation is clean. No issue comments or review comments were fetched or read.

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: a9d551321342 · Context: repo, ci, web · Took 22m

import { getTranslations } from '@/i18n'
import { DEFAULT_LOCALE, type Locale } from '@/i18n/types'
import { landingStrings } from './landingStrings'
import { getLandingContent } from '@/lib/landingContent'

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.

BLOCKING: Server-only content import breaks both builds

The client-safe LandingPage barrel re-exports this module and is imported by client surfaces such as ShhhhhLandingPage. Adding getLandingContent pulls src/lib/content.ts (and Node's fs module) into that client/native graph. At this SHA both the ds-shots production build and required native-export fail with Can't resolve 'fs'; the base SHA checks are green. Keep this module client-safe by passing the subtext from a server-only caller, or stop exporting a server-bound module through the client barrel.

(nextSurface, handoff) => {
if (!migrationOn || isCapacitor()) return false
if (deviceType === DeviceType.WEB) {
setSurface(nextSurface)

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: Desktop rates handoff loses the /send destination

NoFees calls interceptAppCta(..., { dest: '/send' }), but the WEB branch stores only the surface. The modal then renders the existing DownloadQR, whose URL is plain /app, so a desktop visitor who scans after clicking the rates CTA installs/opens the app without the requested /send destination. Store the handoff with the modal state and render a payload-aware QR, then assert the desktop modal URL contains the destination.

* column of English siblings, and the store names themselves are constants.
*/
const STORE_LISTINGS = [
{ slug: 'app-store', href: STORE_URL.ios, name: 'Peanut on the App Store' },

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: SEO footer publishes an unavailable App Store listing — also flagged by claude-opus

These listing links render in every flag state, including today's flag-off landing page. STORE_URL.ios currently returns HTTP 404 and this exact SHA declares IOS_APP_STORE_LISTING_LIVE = false, so merging adds a crawlable broken download link before the migration is enabled. Gate the iOS listing on its published-status constant (and only expose other listings once verified live), or add these anchors when the listings launch.

await page.addInitScript(
([on]) => {
try {
if (on) localStorage.setItem('pwa-sunset', 'true')

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: Production E2E cannot turn the migration flag on

The regression config builds and runs next start, so NODE_ENV is production, while isPwaSunsetOn honors this localStorage override only when IS_DEV is true. Setting pwa-sunset here therefore cannot select the flag-on branch: the matrix either repeats the live flag-off UI or depends on remote PostHog state, and the final no-/setup assertion becomes nondeterministic. Use a production-valid deterministic test override and assert the flag-on marker before checking layout.

icon={store === 'ios' ? 'apple-logo' : 'google-play'}
className={twMerge(
'w-full bg-white px-6 text-button-m hover:bg-white/90 sm:w-52 md:text-button-l',
isFooter && 'border-n-1 shadow-none hover:shadow-none'

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: New CTA styles fail the required DS ratchet

The exact-head ds-lint check is red while the base is green: AppStorePair introduces the legacy border-n-1 token and a hover-only file; lg:gap-7 and px-7 add two off-scale spacing uses; the phone frame adds two arbitrary radii; and CountriesSignUpCta, AppStorePair, and PhoneAppCta add hover styles without active states. Replace them with allowed semantic color/spacing/radius tokens and paired active states rather than raising the debt baseline.

</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: Other store sends phones back to the same store

On a phone, /app immediately redirects to the store detected from that same device. An iOS visitor therefore sees an App Store primary button and clicking Other store opens /app, which redirects to the App Store again; Android has the symmetric failure. Link to the opposite listing, or add an explicit picker/no-auto-redirect mode to /app and cover both platforms.

}
const observer = new IntersectionObserver(
(entries) => {
if (shown.current || !entries.some((entry) => entry.isIntersecting)) return

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: QR impressions fire below the stated 50% threshold

IntersectionObserver delivers an initial entry even when the target is below its configured threshold, and isIntersecting becomes true for any non-zero intersection. If a QR starts only 10% visible, this condition still fires the one-shot impression despite the claimed 50% gate. Require entry.intersectionRatio >= 0.5 and test entries below and above the threshold.

@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 — changes requested

P2 and P4 are fixed, but the exact head still breaks both builds and the required DS ratchet; the unavailable iOS footer link, misleading Other store CTA, QR threshold bug, and a static cutover-timing edge also remain.

Findings

  • BLOCKING · src/components/LandingPage/sendInSeconds.tsx:10 · Server-only content import breaks both builds
    sendInSeconds.tsx now imports the filesystem-backed landing-content reader, while LandingPage/index.ts still re-exports this component from the barrel consumed by the client-only merchant and /shhhhh pages. Exact-head CI confirms the resulting fs import trace through that barrel: both the regular production build in ds-shots and native-export fail to compile. Keep this server component out of the client-safe barrel (and import client-safe siblings directly), or pass the already-read subtext into it without adding the server reader to that client graph.

  • MAJOR · src/components/LandingPage/SEOFooter.tsx:64 · SEO footer publishes an unavailable App Store listing — also flagged by claude-opus
    The date gate delays publication, but at the cutover it still returns both listings unconditionally. IOS_APP_STORE_LISTING_LIVE remains false and its source comment documents that this exact App Store URL 404s, so a cutover with the repository's current availability state gives every marketing page and crawler a dead iOS link. Filter the iOS entry behind the listing-live signal (or only add it once the listing is verifiably public); the cutover date is not an availability check.

  • MAJOR · src/components/Migration/AppStorePair.tsx:57 · New CTA styles still fail the required DS ratchet
    Exact-head ds-lint is red: legacyColorClasses rises 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→47. This line contributes the new legacy border-n-1 and an unpaired hover state; the other new CTA files add px-7/gap-7, arbitrary 24px/28px radii, and more hover-only files. Replace the new debt with the current semantic border, spacing/radius tokens and paired active states across the touched CTAs instead of raising the baseline.

  • MAJOR · src/components/LandingPage/PhoneAppCta.tsx:63 · Other store routes phones back to the detected store
    On iOS the primary button already targets the App Store, but the Other store link goes to /app; that route detects iOS and immediately location.replaces to the App Store again. Android behaves symmetrically, so the alternative CTA cannot reach the other listing (and it also drops the handoff created by the direct store CTA). Point this link at the explicit opposite STORE_URL and preserve the normal click tracking/handoff behavior, or make /app accept an explicit store override.

  • MINOR · src/components/Migration/AppQrCode.tsx:74 · QR impressions still fire below 50% visibility
    An IntersectionObserver sends an initial callback even when an element is only slightly visible. With a 0.1 intersection ratio, isIntersecting is true, so this guard fires the impression despite the configured 0.5 threshold. Require entry.intersectionRatio >= 0.5 (and add below/at-threshold cases) before capturing.

  • MINOR · src/components/LandingPage/SEOFooter.tsx:68 · Cutover date is frozen into prerendered footer HTML
    This Date.now() runs while the statically generated homepage and localized marketing pages are built, not when a visitor requests them. A production deployment built before 2026-12-31 therefore keeps omitting the store links after cutover until another deploy/regeneration, contrary to the stated from-cutover behavior. Make the release state an explicit build/deploy input with a scheduled cutover deploy, or give these routes a bounded revalidation/runtime source that can actually cross the date.

Checked clean

  • Verified the detached worktree head, trusted author, PR number, base ref, base SHA, and merge base exactly match the supplied review target.
  • Rechecked P2: AppModalProvider now stores surface and handoff together and threads dest=/send into the desktop modal QR/store paths; its focused unit assertion covers the handoff.
  • Rechecked P4: non-production production builds now honor the localStorage flag override, and the Playwright cases positively assert that the expected phone or desktop lockup rendered.
  • Traced the landing CTA paths through AppModalProvider, /app smart-store routing, deferred payload helpers, store anchors, and the listing-live constant.
  • Reviewed the flag-off branches, scroll-lock cleanup, locale-specific copy plumbing, hero/device rendering, footer scoping, QR analytics, tests, and design-system drift for correctness, security, adversarial failures, and slop.
  • Exact-head unit, typecheck, eslint, format, provenance, analysis, and reporting checks passed; required CI is red because ds-lint, the regular production build, native export, visual build, aggregate CI, and preview deployment fail at this head.

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: e1f80504900c · Context: repo, ci · Took 24m

import { getTranslations } from '@/i18n'
import { DEFAULT_LOCALE, type Locale } from '@/i18n/types'
import { landingStrings } from './landingStrings'
import { getLandingContent } from '@/lib/landingContent'

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.

BLOCKING: Server-only content import breaks both builds

sendInSeconds.tsx now imports the filesystem-backed landing-content reader, while LandingPage/index.ts still re-exports this component from the barrel consumed by the client-only merchant and /shhhhh pages. Exact-head CI confirms the resulting fs import trace through that barrel: both the regular production build in ds-shots and native-export fail to compile. Keep this server component out of the client-safe barrel (and import client-safe siblings directly), or pass the already-read subtext into it without adding the server reader to that client graph.

* when the flag is off". A date is the one gate the server can answer.
*/
const STORE_LISTINGS = [
{ slug: 'app-store', href: STORE_URL.ios, name: 'Peanut on the App Store' },

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: SEO footer publishes an unavailable App Store listing — also flagged by claude-opus

The date gate delays publication, but at the cutover it still returns both listings unconditionally. IOS_APP_STORE_LISTING_LIVE remains false and its source comment documents that this exact App Store URL 404s, so a cutover with the repository's current availability state gives every marketing page and crawler a dead iOS link. Filter the iOS entry behind the listing-live signal (or only add it once the listing is verifiably public); the cutover date is not an availability check.

icon={store === 'ios' ? 'apple-logo' : 'google-play'}
className={twMerge(
'w-full bg-white px-6 text-button-m hover:bg-white/90 sm:w-52 md:text-button-l',
isFooter && 'border-n-1 shadow-none hover:shadow-none'

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: New CTA styles still fail the required DS ratchet

Exact-head ds-lint is red: legacyColorClasses rises 0→1, offScaleSpacing 188→190, offScaleRadius 37→39, and hoverNoActiveFiles 44→47. This line contributes the new legacy border-n-1 and an unpaired hover state; the other new CTA files add px-7/gap-7, arbitrary 24px/28px radii, and more hover-only files. Replace the new debt with the current semantic border, spacing/radius tokens and paired active states across the touched CTAs instead of raising the baseline.

</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: Other store routes phones back to the detected store

On iOS the primary button already targets the App Store, but the Other store link goes to /app; that route detects iOS and immediately location.replaces to the App Store again. Android behaves symmetrically, so the alternative CTA cannot reach the other listing (and it also drops the handoff created by the direct store CTA). Point this link at the explicit opposite STORE_URL and preserve the normal click tracking/handoff behavior, or make /app accept an explicit store override.

}
const observer = new IntersectionObserver(
(entries) => {
if (shown.current || !entries.some((entry) => entry.isIntersecting)) return

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: QR impressions still fire below 50% visibility

An IntersectionObserver sends an initial callback even when an element is only slightly visible. With a 0.1 intersection ratio, isIntersecting is true, so this guard fires the impression despite the configured 0.5 threshold. Require entry.intersectionRatio >= 0.5 (and add below/at-threshold cases) before capturing.

{ slug: 'google-play', href: STORE_URL.android, name: 'Peanut on Google Play' },
]

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.

MINOR: Cutover date is frozen into prerendered footer HTML

This Date.now() runs while the statically generated homepage and localized marketing pages are built, not when a visitor requests them. A production deployment built before 2026-12-31 therefore keeps omitting the store links after cutover until another deploy/regeneration, contrary to the stated from-cutover behavior. Make the release state an explicit build/deploy input with a scheduled cutover deploy, or give these routes a bounded revalidation/runtime source that can actually cross the date.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Superseded by #3018 (all four folded into one PR per Konrad); branch kept until that merges.

@0xkkonrad 0xkkonrad closed this Sep 7, 2026

This branch had an error being deployed

1 failed deployment
Preview — e1f80504 Deployed Sep 7, 2026 by vercel[bot]
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