feat(migration): pwa-sunset surfaces behind PostHog flag (native-app migration) - #2591
Conversation
…20912) everything in the app-migration ships dark behind the pwa-sunset PostHog flag so launch is a UI toggle, not a deploy. event names land first so every surface wires the same funnel (TASK-20939).
…ASK-20826/20827/20828) the notice window and the cutover are both runtime states of the pwa-sunset flag: flag on shows the post-login download prompt (3-day snooze), flag on + past MIGRATION_CUTOVER_DATE swaps the app for the full-screen sunset block. support hands ?keep-web=<token> to users who can't install; the 90-day cookie bypasses the block. components are extracted from the /dev/migration showcase (PR #2574) and localized because the product surface enforces jsx-no-literals.
…get-the-app banner (TASK-20600/20830/20829) flag-on stops onboarding new users into a product that dies in 30 days: setup drops the InstallPWA steps and offers the store instead, the landing hero CTA becomes Download now (store deep-link on mobile, scan-to-download QR on desktop), and the home carousel leads with a get-the-app nudge that supersedes the ios-pwa-install CTA. the (setup) route group gets its own post-cutover sunset gate since it doesn't share the mobile-ui layout.
…ge (TASK-20771/20598) the custom notifications pre-prompt exists so we can re-ask — during the migration window 'Not now' snoozes 14 days instead of dismissing forever (legacy notifModalClosed converts to a snooze on read). the review prompt asks once ever, native only, after the user has a transaction; happy path deep-links the store review page, unhappy path lands in the support drawer so bad reviews never reach the store.
flag off must render nothing — that's the whole PR's regression guarantee — plus capacitor/snooze/cutover suppression and the remind-me-later write.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds a feature-flagged PWA-to-native migration flow with download prompts, QR/store CTAs, sunset screens, modal prioritization, review prompts, analytics, preference persistence, localized copy, and post-cutover web gating. ChangesPWA sunset migration
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Code-analysis diffPainscore total: 6792.73 → 6885.96 (+93.23) 🆕 New findings (146)
…and 126 more. ✅ Resolved (107)
…and 87 more. 📈 Painscore deltas (top movers)
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/app/`(mobile-ui)/home/page.tsx:
- Around line 270-274: Update the BalanceWarningModal render in the home page to
gate its visible prop with !showMigrationModal, while preserving its existing
saved-state condition. This ensures the migration prompt takes priority even
before MigrationDownloadModal reports visibility.
In `@src/app/`(setup)/layout.tsx:
- Around line 92-98: Update the setup layout’s migration gate around
`SunsetScreen` to process the `keep-web` query token before evaluating cutover
access, reusing the mobile layout’s query-to-cookie handling or shared hook.
Base the post-cutover bypass decision on the resolved persisted token state so a
valid token allows direct `/setup?keep-web=<token>` access.
In `@src/components/Migration/__tests__/MigrationDownloadModal.test.tsx`:
- Around line 66-89: Update the notice-window tests around
MigrationDownloadModal to freeze the clock at a date before the migration
cutover, preventing the “shows for a logged-in web user” case from depending on
the current calendar. In the “recent snooze” test, derive the expired timestamp
from DOWNLOAD_PROMPT_SNOOZE_DAYS plus a small margin instead of hardcoding ten
days, and restore the mocked time after each test.
In `@src/components/Migration/MigrationDownloadModal.tsx`:
- Around line 41-48: Update the useEffect eligibility checks in
MigrationDownloadModal so setVisible(false) runs whenever migrationOn is false,
userId is missing, the app is running in Capacitor, the cutover date has passed,
or the prompt is still snoozed; retain the existing visibility and analytics
behavior for eligible users.
In `@src/constants/migration.consts.ts`:
- Around line 31-37: Remove the shared KEEP_WEB_TOKEN constant from the
client-facing migration constants and replace the keep-web bypass flow with
per-user, short-lived signed tokens issued and validated server-side. Update
consumers of KEEP_WEB_COOKIE and the `/home?keep-web` handling to use server
validation without embedding any reusable secret in the client bundle.
- Line 15: Before enabling the migration flag, replace the placeholder cutover
date in MIGRATION_CUTOVER_DATE with the approved launch date, and update the
associated store URL constants to valid production destinations. Ensure the iOS
review URL includes the required numeric App Store ID, then validate all
affected constants used by migration CTAs and “Love it.”
In `@src/hooks/useHomeCarouselCTAs.tsx`:
- Line 234: Update the denied-notification branch in the callback containing
setIsIosPwaInstallModalOpen so that, when migrationOn is true for a web PWA, it
uses the native download flow instead of opening the iOS PWA install modal. Keep
the existing modal behavior for non-migrated cases and preserve the dedicated
carousel CTA condition in the surrounding logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 09166ca6-c4e1-4b13-bc34-9f83c0affd5a
📒 Files selected for processing (25)
eslint.config.jssrc/app/(mobile-ui)/home/page.tsxsrc/app/(mobile-ui)/layout.tsxsrc/app/(setup)/layout.tsxsrc/components/LandingPage/LandingPageClient.tsxsrc/components/LandingPage/hero.tsxsrc/components/Migration/DownloadQR.tsxsrc/components/Migration/MigrationDownloadModal.tsxsrc/components/Migration/ReviewPromptModal.tsxsrc/components/Migration/ScanToDownloadModal.tsxsrc/components/Migration/StoreButtons.tsxsrc/components/Migration/SunsetScreen.tsxsrc/components/Migration/__tests__/MigrationDownloadModal.test.tsxsrc/components/Setup/Views/Landing.tsxsrc/constants/analytics.consts.tssrc/constants/migration.consts.tssrc/context/ModalsContext.tsxsrc/hooks/useHomeCarouselCTAs.tsxsrc/hooks/useMigrationFlag.tssrc/hooks/useNotifications.tssrc/i18n/app/messages/en.jsonsrc/i18n/app/messages/es-419.jsonsrc/i18n/app/messages/pt-BR.jsonsrc/utils/general.utils.tssrc/utils/migration.utils.ts
- keep-web param now honored on /setup too (shared useKeepWebBypass hook) - balance-warning modal render-gated behind the migration prompt (effect ordering race) - download modal clears state on every ineligible path (flag flip-off mid-session) - denied-notification fallback routes to the app download during the migration window instead of the retiring PWA install modal - gating tests frozen 30 days pre-cutover and snooze expiry derived from the constant (calendar-independent)
…et, prompt burn, flag-off copy leak - useMigrationFlag returns false until mounted: posthog serves cached flags synchronously, so render-time reads disagreed with flag-off SSR HTML on prerendered surfaces (landing/setup) and hard-failed hydration - setup step filter latches the flag at first run instead of reacting to the async flag load, which re-dispatched setSteps and yanked mid-flow users back to the landing step - review prompt stamps reviewPromptShownAt on interaction, not on show — a priority-wrapper unmount was burning the once-ever ask unseen - notifications pre-prompt copy is now flag-gated (migrationSetupTitle/ Description keys); flag off keeps today's copy byte-for-byte - post-signup manager unmounts while the migration prompt shows, with a stuck-visibility clear so lower-priority modals aren't suppressed
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/Migration/ReviewPromptModal.tsx (1)
39-44: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winClear the review modal when eligibility is lost.
If
migrationOn,userId, native status, or transaction eligibility changes after display, this early return leavesvisibletrue. MirrorMigrationDownloadModaland callsetVisible(false)on ineligible paths.Proposed fix
useEffect(() => { - if (!migrationOn || !userId || !isCapacitor() || !hasTransacted) return - if (getUserPreferences(userId)?.reviewPromptShownAt) return + if ( + !migrationOn || + !userId || + !isCapacitor() || + !hasTransacted || + getUserPreferences(userId)?.reviewPromptShownAt + ) { + setVisible(false) + return + } setVisible(true)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/Migration/ReviewPromptModal.tsx` around lines 39 - 44, Update the eligibility handling in the ReviewPromptModal useEffect so every ineligible path—migrationOn, userId, native status, or hasTransacted being false—calls setVisible(false) before returning. Preserve the existing preference check, display behavior, and analytics capture for eligible users.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/useKeepWebBypass.ts`:
- Around line 19-23: Replace the client-side KEEP_WEB_TOKEN comparison in the
useKeepWebBypass flow with server- or edge-side validation of a signed or opaque
bypass token. Have the validated endpoint set an HttpOnly, Secure bypass cookie,
and ensure protected-route enforcement checks that cookie before serving
content; remove reliance on the bundled KEEP_WEB_TOKEN and client-written
cookie.
---
Outside diff comments:
In `@src/components/Migration/ReviewPromptModal.tsx`:
- Around line 39-44: Update the eligibility handling in the ReviewPromptModal
useEffect so every ineligible path—migrationOn, userId, native status, or
hasTransacted being false—calls setVisible(false) before returning. Preserve the
existing preference check, display behavior, and analytics capture for eligible
users.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: da9dfb88-c779-4369-aacf-89b6fd90f84c
📒 Files selected for processing (14)
src/app/(mobile-ui)/home/page.tsxsrc/app/(mobile-ui)/layout.tsxsrc/app/(setup)/layout.tsxsrc/components/Migration/MigrationDownloadModal.tsxsrc/components/Migration/ReviewPromptModal.tsxsrc/components/Migration/__tests__/MigrationDownloadModal.test.tsxsrc/components/Notifications/SetupNotificationsModal.tsxsrc/hooks/useHomeCarouselCTAs.tsxsrc/hooks/useKeepWebBypass.tssrc/hooks/useMigrationFlag.tssrc/hooks/useNotifications.tssrc/i18n/app/messages/en.jsonsrc/i18n/app/messages/es-419.jsonsrc/i18n/app/messages/pt-BR.json
🚧 Files skipped from review as they are similar to previous changes (2)
- src/i18n/app/messages/en.json
- src/i18n/app/messages/pt-BR.json
…e, banner logo fix - sunset desktop copy centered to match the centered store CTA column - /app smart link rebuilt on the shared MigrationHero (sunset-screen visual language) with proper store buttons and a spinner while redirecting, replacing the bare centered-links fallback - get-the-app carousel card drops the yellow icon-container override so the mascot renders without a circle background - hero extracted to MigrationHero now that two screens share it
…p loads inside the CTA - setup landing during the window (web, no bypass): Sign up is replaced by the store block on every device — don't onboard users into a product that shuts in weeks. Log In and wallet recovery stay until cutover (the lockout Hugo flagged). ?step=signup / invite-code jumps land on the gated landing instead of the signup form, closing the claim/invite side door. Native app unaffected. - /app: the visitor's store button carries the loading state (disabled) while the redirect happens and settles to clickable buttons if the store never takes over — replaces the floating spinner.
… QR, no title-block gap, centered desktop copy the 'Get the Peanut app' label stays only on desktop where it explains the QR; a lone store button explains itself. the wrapper's fixed md:max-h-48 title block left a large gap above the QR — dropped and copy centered on desktop, scoped to the sunset landing so every legacy setup screen keeps its layout.
a preview's QR pointed scanners at prod and a LAN-served dev build at localhost — window.location.origin is correct everywhere.
…e underline pile - apple-logo + google-play brand marks added to the icon registry (monochrome currentColor, official silhouettes) - StoreBadges: the classic black badge pair (Download on the App Store / GET IT ON Google Play) replaces the two underlined 'Open X' links under the QR, so the modal has one text CTA instead of three - landing hero gets the badge pair under 'Download now' (standard native-app LP pattern); sticky mobile bar stays single-button, no room - every store CTA now carries its brand mark (sunset/setup buttons, the download modal, /app)
…ashes - 'The Peanut app is here!' with the bang; carousel banner reuses the modal's copy instead of 'moving to the app' - 'Maybe later' -> 'I'll download it later' - sunset support link -> 'Having trouble downloading the app? Chat with our support' - em-dashes stripped from all user-facing migration copy - unused qr.openIos/openAndroid keys dropped (StoreBadges carries the official English badge text)
…f store badges purple App Store + stroke Google Play with brand icons, matching the /app pair — the black two-line badges didn't belong to the design system.
…, white pair on desktop phones get a single Download now carrying their store's mark and deep-linking straight to it; desktop drops the redundant primary and shows two equal white store buttons (the pink-on-pink pair with a mismatched big button above read badly). the desktop QR modal trigger goes with it — the pair deep-links directly, QR still lives on setup and the download modal. sticky bar picks up the brand mark too.
…oth device CTA paths
…et gate extracted + tested - /app: mounted guard kills the React #418 on phones (redirect state was derived from useDeviceType at first render, which is WEB on the server); flag gate 404s the page until pwa-sunset resolves ON (waits for the posthog flag callback with a 4s timeout so first-time scanners aren't misjudged); copy moved to the migration i18n namespace in all three locales - migration.downloadNow is finally consumed: hero mobile CTA and the sticky bar translate via the app locale (LatAm-first funnel) - the sunset-block condition is one shouldShowSunsetBlock predicate shared by both layouts instead of two hand-rolled copies, and the matrix (flag/cutover/public/native/bypass + dev overrides) is pinned by unit tests
…to the app mockup §03/§08 behavior that was missing: during the migration window a logged-out web visitor tapping Join Peanut / Continue with Peanut on a claim or request page no longer routes into a signup that's closed — desktop opens the scan-to-download QR modal, phones deep-link their store. One useGuestStoreHandoff hook wired into both guest CTAs (SendWithPeanutCta + SendLinkActionList); native-app guests keep the normal in-app flow. New guest_flow analytics surface.
… X/overlay close suffices
…(TASK-20939) migration_guest_cta_shown fires once per mount when the Join/Continue CTA is actually shown to a settled logged-out web visitor during the window, so guest click-through-rate is computable. Caller passes its settled guest state to avoid counting the pre-auth flash.
Summary
The PWA→native migration surfaces, all dark behind the
pwa-sunsetPostHog flag — launch is a flag flip in the PostHog UI, not a deploy. Extracted from the/dev/migrationshowcase (#2574) and wired into the real flows.Flag OFF (default, today): no behavior change on any existing surface (pinned by test). For accuracy, the edits that DO ship flag-off: the setup step-filter conditional, the home modal-priority guards,
window.posthogexposure (official-snippet parity, enables QA/support console access),overflow-x-clipon the landing shell (fixes a pre-existing mobile horizontal-scroll bug), a one-timenotifModalClosedAtmigration write for users carrying the legacy dismissal boolean, two lazily-fetched modal chunks on /home (~5-10KB, matches the existing self-gating-modal pattern), and the/approute existing (it 404s until the flag resolves ON).Flag ON (notice window): post-login "Peanut is becoming an app" modal (3-day snooze, days-to-cutover in copy), get-the-app home-carousel banner (supersedes ios-pwa-install), landing hero CTA → "Download now" (store deep-link on mobile / scan-to-download QR on desktop), setup drops the InstallPWA steps and offers store links, notifications pre-prompt re-asks after 14 days instead of never.
Flag ON + past
MIGRATION_CUTOVER_DATE: full-screen sunset block on both the app and setup layouts;?keep-web=<token>(support-distributed) sets a 90-day bypass cookie.Native app only: review prompt, once ever, after the user has a transaction — "Love it" → store review page, "Could be better" → support drawer.
Tasks
pwa-sunsetflag constant +useMigrationFlag()hook (hydration-safe wrapper over the existinguseFeatureFlags); everything else in this PR gates on it.SunsetScreenreplaces the app (and /setup) onceMIGRATION_CUTOVER_DATEpasses with the flag on; V1 global-date constant as agreed.?keep-web=<token>persists a 90-day cookie via the shareduseKeepWebBypasshook and skips the sunset block on every gated layout; FE-only V1 with a static support-distributed token.pwa-install/android-initial-pwa-install/unsupported-browserand the post-setup iOS force-install screen while the flag is on (latched per setup session to avoid mid-flow resets).app-installhome-carousel CTA (supersedes the ios-pwa-install CTA), store on mobile / QR modal on desktop, with the carousel's existing dismiss-cooldown machinery.notifModalClosedconverts to a snooze on read).MODAL_SHOWN/DISMISSED/CTA_CLICKEDwith newmigration_download/app_reviewtypes plusmigration_sunset_viewed,migration_store_cta_clicked,migration_qr_shown,migration_keep_web_used, all carryingsurface/storeprops — instrumented inside each component.Risks / launch blockers (not merge blockers)
migration.consts.tsbefore flag-on (iOS?action=write-reviewneeds the numeric id too).MIGRATION_CUTOVER_DATEis a placeholder constant (2026-12-31) — set the real date before flag-on; moving it requires a deploy (accepted V1 trade-off, upgrade path = flag payload).pwa-sunsetflag must be created OFF in PostHog (project 138913) before merge — an undefined flag fails closed, so even that is safe.shouldShowSunsetBlockmatrix tests.Design notes / accepted trade-offs
useMigrationFlagreturnsfalseuntil mounted — posthog serves cached flags synchronously for returning visitors, so render-time reads would hard-fail hydration against flag-off SSR HTML on prerendered surfaces (review finding).The setup step filter latches the flag at its first run instead of reacting to the async flag load — a mid-load flip re-dispatched
setStepsand reset a mid-flow user to the landing step (review finding). First-ever visitors in the seconds before flags cache get the legacy flow; returning visitors are correct immediately.The review prompt stamps
reviewPromptShownAton interaction, not on show, so a priority-wrapper unmount can't burn the once-ever ask unseen.The notifications pre-prompt 14-day re-ask is bounded by the flag lifecycle: flag deleted post-launch (feature-gates doctrine) → behavior returns to closed-forever.
Post-cutover guest story (V1): a guest hitting a link → setup is blocked with the download screen; after install, re-tapping the original link opens it in-app via universal links (invite code + redirect intact). The polished no-re-tap story is the deferred deep link (feat(native): deferred deep linking through the store install (TASK-20772) #2560) — its
deferred-linkutils live onmainand aren't indevyet, so the handoff isn't wired. Follow-up after the main→dev back-merge: attach the payload inopenStore()(single chokepoint, upgrades every migration surface at once).Reviewer flag (follow-up, not this PR):
useNotifications.tsnow carries three dismissal representations (legacynotifModalClosedbool +notifModalClosedAttimestamp + flag-conditional snooze) in a historically fix-prone file — standing deep-dive candidate to consolidate once the migration flag is deleted.Cutover date = code constant, not flag payload (one async source, not two).
keep-web = one static shared FE token — courtesy escape hatch, not a security control; per-user tokens need a BE endpoint.
Review prompt "good moment" V1 = has-a-transaction + home visit (shares HomeHistory's query cache); wiring exact success screens is the upgrade path. Store-page deep link;
@capacitor-community/in-app-reviewif conversion matters.Landing hero override is English-only like the rest of that surface; the permanent label change goes through the content system post-cutover, deleting the override.
Legacy
notifModalClosed: trueconverts to a snooze-from-now on read (same trick asgetDismissedCTAs).The sunset gate reads
Date.now()at render with no timer: a session left open across the exact cutover moment stays usable until its next navigation/reload. Accepted — the block catches everyone within one interaction./appis flag-gated with an async-flag wait (posthog callback + 4s timeout) so first-time scanners aren't 404'd before their flags arrive; flag-off it rendersnotFound().QA
posthog.featureFlags.overrideFeatureFlags({ flags: { 'pwa-sunset': true } })(posthog-js 1.3xx needs theflagswrapper; a flat object is silently ignored): download modal on /home, carousel banner, "Download now" hero, setup shows store links and skips PWA steps; desktop CTAs open the QR modal.MIGRATION_CUTOVER_DATEin the past → sunset block on every route incl. /setup;?keep-web=walnut-still-cracksbypasses it and survives reload.surface/storeprops.Screenshots: ✅ embedded below (11 states, all flag-on surfaces). Flag-off renders pixel-identical (pinned by test).
Screenshots
Captured on the branch against the local sandbox (harness debug ribbons at the top of logged-in shots are local-dev chrome, not product UI). Assets live on the
pr-assets-2591orphan branch — delete it after merge.Download prompt (flag ON, notice window)
Sunset block (flag ON + past cutover)
New-user funnel (flag ON)
Home banner + smart link
Native nudges (native-only gates — captured by temporarily forcing visibility on the real components in a local run; the gating logic itself is unchanged and covered above)
Behavior on real devices (OS permission dialog, store review page) still needs the simulator/device QA pass (TASK-20831).
Guest claim/request flow under the cutover (public paths bypass the sunset gate — captured with flag ON + cutover past; this PR does not modify these pages. The "Join PEANUT" CTA's app-handoff redesign belongs to the deferred-deep-link work, #2560/TASK-20772)
During the migration window the guest CTA no longer routes into the (closed) web signup: desktop opens the QR modal above, phones deep-link straight to their store (
useGuestStoreHandoff, wired into both the pay/request and claim-link CTAs). Native-app guests keep the normal in-app flow.