+ // overflow-x-clip: decorative absolutely-positioned elements (clouds,
+ // stars) extend past the right edge and made the whole page scroll
+ // horizontally on mobile. clip (not hidden) so no scroll container is
+ // created and sticky/fixed children keep working.
+
{children}
diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx
index 3e8a87181d..99b3206a4b 100644
--- a/src/components/LandingPage/StickyMobileCTA.tsx
+++ b/src/components/LandingPage/StickyMobileCTA.tsx
@@ -4,11 +4,20 @@ import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import { Button } from '@/components/0_Bruddle/Button'
+import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { useTranslations } from 'next-intl'
+import { trackStoreClick } from '@/utils/migration.utils'
export function StickyMobileCTA() {
const [visible, setVisible] = useState(false)
const rafId = useRef(0)
const lastVisible = useRef(false)
+ const migrationOn = useMigrationFlag()
+ const tMigration = useTranslations('migration')
+ const { deviceType } = useDeviceType()
+ const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios'
useEffect(() => {
const check = () => {
@@ -43,11 +52,32 @@ export function StickyMobileCTA() {
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 border-t-2 border-n-1 bg-white px-4 py-3 md:hidden"
>
-
-
- SIGN UP NOW
-
-
+ {migrationOn ? (
+ // this bar is md:hidden so the visitor is on a phone —
+ // deep-link their store during the migration window
+
trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO)}
+ >
+
+ {tMigration('downloadNow')}
+
+
+ ) : (
+
+
+ SIGN UP NOW
+
+
+ )}
)}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 30e9e11d85..75531217a6 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -8,6 +8,7 @@ import Image from 'next/image'
import { useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/0_Bruddle/Button'
import { CloudsCss } from './CloudsCss'
+import { type CTAButton } from '@/components/LandingPage/landing.types'
/**
* Peanut mascot that positions itself so only 6% of its height (the feet)
@@ -72,18 +73,13 @@ function PeanutMascot() {
)
}
-type CTAButton = {
- label: string
- href: string
- isExternal?: boolean
- subtext?: string
-}
-
type HeroProps = {
primaryCta?: CTAButton
secondaryCta?: CTAButton
buttonVisible?: boolean
buttonScale?: number
+ /** replaces the primary button entirely (store-button pair on desktop during the migration window) */
+ customCta?: React.ReactNode
}
const getInitialAnimation = (variant: 'primary' | 'secondary') => ({
@@ -113,7 +109,7 @@ const transitionConfig = { type: 'spring', damping: 15 } as const
const getButtonContainerClasses = (variant: 'primary' | 'secondary') =>
`relative z-20 mt-8 md:mt-12 flex flex-col items-center justify-center ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}`
-export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1 }: HeroProps) {
+export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1, customCta }: HeroProps) {
const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => {
return (
{cta.label}
@@ -142,6 +140,17 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1
)
}
+ const renderCustomCta = () => (
+
+ {customCta}
+
+ )
+
return (
No local ID or bank required.
- {primaryCta && renderCTAButton(primaryCta, 'primary')}
+ {primaryCta ? renderCTAButton(primaryCta, 'primary') : customCta ? renderCustomCta() : null}
{secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
) => void
+}
diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx
new file mode 100644
index 0000000000..da82b4ceb5
--- /dev/null
+++ b/src/components/Migration/DownloadQR.tsx
@@ -0,0 +1,33 @@
+'use client'
+import { useEffect } from 'react'
+import posthog from 'posthog-js'
+import { useTranslations } from 'next-intl'
+import QRCodeWrapper from '@/components/Global/QRCodeWrapper'
+import StoreBadges from '@/components/Migration/StoreBadges'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import { SELF_URL } from '@/constants/general.consts'
+import { type MigrationSurface } from '@/constants/migration.consts'
+
+// one smart QR instead of a per-store toggle: it encodes /app, which
+// redirects to the store of whichever phone scans it.
+export default function DownloadQR({ surface }: { surface: MigrationSurface }) {
+ const t = useTranslations('migration')
+
+ useEffect(() => {
+ posthog.capture(ANALYTICS_EVENTS.MIGRATION_QR_SHOWN, { surface })
+ }, [surface])
+
+ // the serving origin, not SELF_URL: a preview's QR must point at the
+ // preview (SELF_URL would send scanners to prod) and a LAN-served dev
+ // build must encode the LAN address so a real phone can scan it
+ const origin = typeof window !== 'undefined' ? window.location.origin : SELF_URL
+
+ return (
+
+
+ {t('qr.scanHint')}
+ {/* desktop can install directly too (e.g. Google Play from the browser) */}
+
+
+ )
+}
diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx
new file mode 100644
index 0000000000..5bc04110f6
--- /dev/null
+++ b/src/components/Migration/MigrationDownloadModal.tsx
@@ -0,0 +1,115 @@
+'use client'
+import { useEffect, useState } from 'react'
+import posthog from 'posthog-js'
+import { useTranslations } from 'next-intl'
+import ActionModal from '@/components/Global/ActionModal'
+import DownloadQR from '@/components/Migration/DownloadQR'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
+import {
+ DOWNLOAD_PROMPT_SNOOZE_DAYS,
+ MIGRATION_SURFACES,
+ MIGRATION_URGENCY_THRESHOLD_DAYS,
+ STORE_NAME,
+} from '@/constants/migration.consts'
+import { getMigrationCutoverTime, openStore } from '@/utils/migration.utils'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { useUserStore } from '@/redux/hooks'
+import { isCapacitor } from '@/utils/capacitor'
+import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
+
+const SNOOZE_MS = DOWNLOAD_PROMPT_SNOOZE_DAYS * 24 * 60 * 60 * 1000
+
+/**
+ * Post-login "Peanut is becoming an app" prompt (TASK-20826), shown on the
+ * web app during the migration notice window (flag on, cutover not reached).
+ * Self-gating; reports visibility so home can suppress lower-priority modals.
+ */
+export default function MigrationDownloadModal({
+ onVisibilityChange,
+}: {
+ onVisibilityChange?: (visible: boolean) => void
+}) {
+ const t = useTranslations('migration')
+ const migrationOn = useMigrationFlag()
+ const { deviceType } = useDeviceType()
+ const { user } = useUserStore()
+ const [visible, setVisible] = useState(false)
+
+ const userId = user?.user.userId
+
+ useEffect(() => {
+ // sunset block owns post-cutover; every ineligible path clears state so
+ // an already-shown modal disappears if the flag flips off mid-session
+ if (!migrationOn || !userId || isCapacitor() || Date.now() >= getMigrationCutoverTime()) {
+ setVisible(false)
+ return
+ }
+ const snoozedAt = getUserPreferences(userId)?.migrationPromptSnoozedAt
+ if (snoozedAt && Date.now() - new Date(snoozedAt).getTime() < SNOOZE_MS) {
+ setVisible(false)
+ return
+ }
+ setVisible(true)
+ posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.MIGRATION_DOWNLOAD })
+ }, [migrationOn, userId])
+
+ useEffect(() => {
+ onVisibilityChange?.(visible)
+ }, [visible, onVisibilityChange])
+
+ const snooze = () => {
+ setVisible(false)
+ updateUserPreferences(userId, { migrationPromptSnoozedAt: new Date().toISOString() })
+ posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.MIGRATION_DOWNLOAD })
+ }
+
+ const daysLeft = Math.max(1, Math.ceil((getMigrationCutoverTime() - Date.now()) / (24 * 60 * 60 * 1000)))
+ const isDesktop = deviceType === DeviceType.WEB
+ const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios'
+
+ // two-phase copy: celebrate the app while the cutover is far, switch to
+ // friendly urgency (deadline in the copy) for the final stretch
+ const isUrgent = daysLeft <= MIGRATION_URGENCY_THRESHOLD_DAYS
+
+ const remindLaterCta = {
+ text: t(isUrgent ? 'downloadPrompt.remindLater' : 'downloadPrompt.maybeLater'),
+ variant: 'transparent' as const,
+ className: 'underline h-6 text-xs font-normal',
+ onClick: snooze,
+ }
+
+ return (
+ : undefined}
+ ctaClassName="md:flex-col gap-4"
+ ctas={
+ isDesktop
+ ? [remindLaterCta]
+ : [
+ {
+ text: STORE_NAME[store],
+ variant: 'purple',
+ shadowSize: '4',
+ icon: store === 'ios' ? ('apple-logo' as const) : ('google-play' as const),
+ onClick: () => {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, {
+ modal_type: MODAL_TYPES.MIGRATION_DOWNLOAD,
+ cta: 'store',
+ })
+ openStore(store, MIGRATION_SURFACES.DOWNLOAD_MODAL)
+ },
+ },
+ remindLaterCta,
+ ]
+ }
+ />
+ )
+}
diff --git a/src/components/Migration/MigrationHero.tsx b/src/components/Migration/MigrationHero.tsx
new file mode 100644
index 0000000000..cbbe055f0f
--- /dev/null
+++ b/src/components/Migration/MigrationHero.tsx
@@ -0,0 +1,35 @@
+import Image from 'next/image'
+import { twMerge } from 'tailwind-merge'
+import { PEANUTMAN_MOBILE } from '@/assets/mascot'
+import starImage from '@/assets/icons/star.png'
+
+const STARS = [
+ 'left-[8%] top-[18%] size-8',
+ 'right-[12%] top-[14%] size-9',
+ 'right-[14%] bottom-[16%] size-7',
+ 'left-[12%] bottom-[14%] size-7',
+] as const
+
+// periwinkle mascot hero shared by the sunset block and the /app smart link
+export default function MigrationHero({ className }: { className?: string }) {
+ return (
+
+ {STARS.map((pos) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Migration/ReviewPromptModal.tsx b/src/components/Migration/ReviewPromptModal.tsx
new file mode 100644
index 0000000000..764c0ba33a
--- /dev/null
+++ b/src/components/Migration/ReviewPromptModal.tsx
@@ -0,0 +1,90 @@
+'use client'
+import { useEffect, useState } from 'react'
+import posthog from 'posthog-js'
+import { useTranslations } from 'next-intl'
+import ActionModal from '@/components/Global/ActionModal'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
+import { REVIEW_URL } from '@/constants/migration.consts'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { useTransactionHistory } from '@/hooks/useTransactionHistory'
+import { useModalsContext } from '@/context/ModalsContext'
+import { useUserStore } from '@/redux/hooks'
+import { isCapacitor, openExternalUrl } from '@/utils/capacitor'
+import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
+
+/**
+ * "Loving Peanut so far?" pre-prompt (TASK-20598), native app only, asked once
+ * ever. Love it → store review page; Could be better → support drawer, so
+ * unhappy users never reach the store.
+ *
+ * ponytail: "good moment" V1 = user has at least one transaction and visits
+ * home (same {mode:'latest', limit:50} query key useHomeCarouselCTAs already
+ * fetches there, so the read is free). Wiring the exact success screens is the
+ * upgrade path. Store-page deep link for the rating;
+ * @capacitor-community/in-app-review for the native sheet if conversion
+ * matters.
+ */
+export default function ReviewPromptModal() {
+ const t = useTranslations('migration')
+ const migrationOn = useMigrationFlag()
+ const { deviceType } = useDeviceType()
+ const { user } = useUserStore()
+ const { openSupportWithMessage } = useModalsContext()
+ const { data: latestHistory } = useTransactionHistory({ mode: 'latest', limit: 50 })
+ const [visible, setVisible] = useState(false)
+
+ const userId = user?.user.userId
+ const hasTransacted = (latestHistory?.entries.length ?? 0) > 0
+
+ useEffect(() => {
+ if (!migrationOn || !userId || !isCapacitor() || !hasTransacted) return
+ if (getUserPreferences(userId)?.reviewPromptShownAt) return
+ setVisible(true)
+ posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.APP_REVIEW })
+ }, [migrationOn, userId, hasTransacted])
+
+ const close = (cta?: 'love' | 'meh') => {
+ setVisible(false)
+ // stamp on interaction, not on show — home's priority wrapper can
+ // unmount a just-shown modal, and a show-time stamp would burn the
+ // once-ever ask before the user ever saw it
+ updateUserPreferences(userId, { reviewPromptShownAt: new Date().toISOString() })
+ if (cta) {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.APP_REVIEW, cta })
+ } else {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.APP_REVIEW })
+ }
+ }
+
+ return (
+ close()}
+ icon="star"
+ title={t('review.title')}
+ description={t('review.description')}
+ ctas={[
+ {
+ text: t('review.loveIt'),
+ variant: 'purple',
+ shadowSize: '4',
+ onClick: () => {
+ close('love')
+ void openExternalUrl(REVIEW_URL[deviceType === DeviceType.ANDROID ? 'android' : 'ios'])
+ },
+ },
+ {
+ text: t('review.meh'),
+ variant: 'stroke',
+ shadowSize: '4',
+ onClick: () => {
+ close('meh')
+ // prefilled so the drawer opens ready for feedback
+ openSupportWithMessage(t('review.supportPrefill'))
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/src/components/Migration/ScanToDownloadModal.tsx b/src/components/Migration/ScanToDownloadModal.tsx
new file mode 100644
index 0000000000..1ca1fd01be
--- /dev/null
+++ b/src/components/Migration/ScanToDownloadModal.tsx
@@ -0,0 +1,28 @@
+'use client'
+import { useTranslations } from 'next-intl'
+import ActionModal from '@/components/Global/ActionModal'
+import DownloadQR from '@/components/Migration/DownloadQR'
+import type { MigrationSurface } from '@/constants/migration.consts'
+
+// desktop download surface: any download CTA on a laptop opens this QR
+// instead of a dead store link.
+export default function ScanToDownloadModal({
+ visible,
+ onClose,
+ surface,
+}: {
+ visible: boolean
+ onClose: () => void
+ surface: MigrationSurface
+}) {
+ const t = useTranslations('migration')
+ return (
+ }
+ />
+ )
+}
diff --git a/src/components/Migration/StoreBadges.tsx b/src/components/Migration/StoreBadges.tsx
new file mode 100644
index 0000000000..fc6189341a
--- /dev/null
+++ b/src/components/Migration/StoreBadges.tsx
@@ -0,0 +1,44 @@
+'use client'
+import { Button } from '@/components/0_Bruddle/Button'
+import { STORE_NAME, STORE_URL, type MigrationSurface } from '@/constants/migration.consts'
+import { trackStoreClick } from '@/utils/migration.utils'
+
+// store-button pair. compact: under download CTAs and QRs, same design
+// language as /app (purple App Store, stroke Google Play). hero: the landing
+// hero's desktop CTA row — two equal white buttons on the pink hero.
+export default function StoreBadges({
+ surface,
+ appearance = 'compact',
+}: {
+ surface: MigrationSurface
+ appearance?: 'compact' | 'hero'
+}) {
+ const isHero = appearance === 'hero'
+ return (
+
+ )
+}
diff --git a/src/components/Migration/StoreButtons.tsx b/src/components/Migration/StoreButtons.tsx
new file mode 100644
index 0000000000..e8f02cac9c
--- /dev/null
+++ b/src/components/Migration/StoreButtons.tsx
@@ -0,0 +1,24 @@
+'use client'
+import { Button } from '@/components/0_Bruddle/Button'
+import DownloadQR from '@/components/Migration/DownloadQR'
+import { STORE_NAME, type MigrationSurface, type StoreKind } from '@/constants/migration.consts'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { openStore } from '@/utils/migration.utils'
+
+// one primary CTA per device: the visitor's store on mobile, scan-to-download QR on desktop.
+export default function StoreButtons({ surface }: { surface: MigrationSurface }) {
+ const { deviceType } = useDeviceType()
+ if (deviceType === DeviceType.WEB) return
+ const store: StoreKind = deviceType === DeviceType.ANDROID ? 'android' : 'ios'
+ return (
+ openStore(store, surface)}
+ >
+ {STORE_NAME[store]}
+
+ )
+}
diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx
new file mode 100644
index 0000000000..65be01304a
--- /dev/null
+++ b/src/components/Migration/SunsetScreen.tsx
@@ -0,0 +1,54 @@
+'use client'
+import { useEffect } from 'react'
+import posthog from 'posthog-js'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/0_Bruddle/Button'
+import MigrationHero from '@/components/Migration/MigrationHero'
+import StoreButtons from '@/components/Migration/StoreButtons'
+import SupportDrawer from '@/components/Global/SupportDrawer'
+import { useModalsContext } from '@/context/ModalsContext'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import { MIGRATION_SURFACES } from '@/constants/migration.consts'
+
+/**
+ * Full-screen block once the website is switched off (TASK-20827) — rendered
+ * by the mobile-ui layout instead of the app. Download is the only way
+ * forward; the support link covers people who can't install (keep-web
+ * bypass is handed out there).
+ */
+export default function SunsetScreen() {
+ const t = useTranslations('migration')
+ const { setIsSupportModalOpen } = useModalsContext()
+
+ useEffect(() => {
+ posthog.capture(ANALYTICS_EVENTS.MIGRATION_SUNSET_VIEWED)
+ }, [])
+
+ return (
+ // mobile: 50/50 vertical split, copy at the top of the lower half and
+ // the CTA pinned to the bottom. desktop (md+): 50/50 row, hero left,
+ // content centered right.
+
+
+
+ {/* centered on desktop to match the centered store CTA below */}
+
+
{t('sunset.heading')}
+
{t('sunset.sub')}
+
+
+
+ setIsSupportModalOpen(true)}
+ >
+ {t('sunset.supportLink')}
+
+
+
+ {/* the layout's SupportDrawer never mounts when this screen replaces it */}
+
+
+ )
+}
diff --git a/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx
new file mode 100644
index 0000000000..b13e52bd17
--- /dev/null
+++ b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx
@@ -0,0 +1,126 @@
+/** @jest-environment jsdom */
+/**
+ * MigrationDownloadModal — the pwa-sunset "Peanut is becoming an app" prompt.
+ *
+ * Gating contract: flag ON + logged-in web user + before the cutover + snooze
+ * expired. Flag OFF (today's default) must render nothing — the key
+ * flag-off-regression check for the migration PR.
+ */
+import React from 'react'
+import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
+import { IntlWrapper } from '@/test-utils/intl'
+import { DOWNLOAD_PROMPT_SNOOZE_DAYS, MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts'
+
+const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper })
+
+// freeze "now" 30 days before the cutover so the notice-window cases don't
+// start failing once the real calendar passes MIGRATION_CUTOVER_DATE
+const DAY_MS = 24 * 60 * 60 * 1000
+const FROZEN_NOW = MIGRATION_CUTOVER_DATE.getTime() - 30 * DAY_MS
+
+let mockFlagOn = false
+jest.mock('@/hooks/useMigrationFlag', () => ({
+ useMigrationFlag: () => mockFlagOn,
+}))
+
+let mockIsCapacitor = false
+jest.mock('@/utils/capacitor', () => ({
+ isCapacitor: () => mockIsCapacitor,
+ openExternalUrl: jest.fn(),
+}))
+
+jest.mock('@/redux/hooks', () => ({
+ useUserStore: () => ({ user: { user: { userId: 'user-1' } } }),
+}))
+
+const mockGetPrefs = jest.fn()
+const mockUpdatePrefs = jest.fn()
+jest.mock('@/utils/general.utils', () => ({
+ getUserPreferences: (...args: unknown[]) => mockGetPrefs(...args),
+ updateUserPreferences: (...args: unknown[]) => mockUpdatePrefs(...args),
+}))
+
+jest.mock('posthog-js', () => ({ capture: jest.fn() }))
+
+jest.mock('@/components/Global/ActionModal', () => ({
+ __esModule: true,
+ default: (props: { visible: boolean; title?: string; ctas?: { text: string; onClick?: () => void }[] }) =>
+ props.visible ? (
+
+
{props.title}
+ {props.ctas?.map((c) => (
+
+ {c.text}
+
+ ))}
+
+ ) : null,
+}))
+
+import MigrationDownloadModal from '../MigrationDownloadModal'
+
+let nowSpy: jest.SpyInstance
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockFlagOn = false
+ mockIsCapacitor = false
+ mockGetPrefs.mockReturnValue(undefined)
+ nowSpy = jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW)
+})
+afterEach(() => {
+ nowSpy.mockRestore()
+})
+
+describe('MigrationDownloadModal', () => {
+ it('renders nothing while the pwa-sunset flag is off', () => {
+ render( )
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('shows for a logged-in web user during the notice window', () => {
+ mockFlagOn = true
+ render( )
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('stays hidden inside the native app', () => {
+ mockFlagOn = true
+ mockIsCapacitor = true
+ render( )
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('stays hidden while a recent snooze is active, reappears after it expires', () => {
+ mockFlagOn = true
+ mockGetPrefs.mockReturnValue({ migrationPromptSnoozedAt: new Date(FROZEN_NOW).toISOString() })
+ const { unmount } = render( )
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ unmount()
+
+ const justExpired = new Date(FROZEN_NOW - (DOWNLOAD_PROMPT_SNOOZE_DAYS + 1) * DAY_MS).toISOString()
+ mockGetPrefs.mockReturnValue({ migrationPromptSnoozedAt: justExpired })
+ render( )
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('stays hidden past the cutover (the sunset block owns that state)', () => {
+ mockFlagOn = true
+ nowSpy.mockReturnValue(MIGRATION_CUTOVER_DATE.getTime() + 1000)
+ render( )
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ })
+
+ it('remind-me-later snoozes and reports visibility', () => {
+ mockFlagOn = true
+ const onVisibilityChange = jest.fn()
+ render( )
+ expect(onVisibilityChange).toHaveBeenLastCalledWith(true)
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
+ expect(mockUpdatePrefs).toHaveBeenCalledWith('user-1', {
+ migrationPromptSnoozedAt: expect.any(String),
+ })
+ expect(onVisibilityChange).toHaveBeenLastCalledWith(false)
+ })
+})
diff --git a/src/components/Notifications/SetupNotificationsModal.tsx b/src/components/Notifications/SetupNotificationsModal.tsx
index d3539c2902..3a9432ee12 100644
--- a/src/components/Notifications/SetupNotificationsModal.tsx
+++ b/src/components/Notifications/SetupNotificationsModal.tsx
@@ -4,9 +4,13 @@ import ActionModal from '../Global/ActionModal'
import posthog from 'posthog-js'
import { useTranslations } from 'next-intl'
import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
export default function SetupNotificationsModal() {
const t = useTranslations('notifications')
+ // migration-era copy ("Get money alerts") only ships when the pwa-sunset
+ // flag is on — flag off keeps today's prompt byte-for-byte (TASK-20771)
+ const migrationOn = useMigrationFlag()
const {
showPermissionModal,
requestPermission,
@@ -46,8 +50,8 @@ export default function SetupNotificationsModal() {
visible={showPermissionModal}
onClose={handleCloseNotifsSetupModal}
modalPanelClassName="m-0 max-w-[90%]"
- title={t('setupTitle')}
- description={t('setupDescription')}
+ title={t(migrationOn ? 'migrationSetupTitle' : 'setupTitle')}
+ description={t(migrationOn ? 'migrationSetupDescription' : 'setupDescription')}
icon="bell"
ctaClassName="md:flex-col gap-4"
ctas={[
diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx
index 29e4077af7..6845dcab6a 100644
--- a/src/components/Setup/Views/Landing.tsx
+++ b/src/components/Setup/Views/Landing.tsx
@@ -12,9 +12,25 @@ import { useEffect } from 'react'
import { disableDemoMode } from '@/utils/demo'
import DocsLink from '@/components/Global/DocsLink'
import { useTranslations } from 'next-intl'
+import StoreButtons from '@/components/Migration/StoreButtons'
+import { MIGRATION_SURFACES } from '@/constants/migration.consts'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { useKeepWebBypass } from '@/hooks/useKeepWebBypass'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { isCapacitor } from '@/utils/capacitor'
const LandingStep = () => {
const t = useTranslations('setup')
+ const tMigration = useTranslations('migration')
+ const migrationOn = useMigrationFlag()
+ const hasKeepWebBypass = useKeepWebBypass()
+ const { deviceType } = useDeviceType()
+
+ // migration notice window on web (any device): NEW signups are closed —
+ // don't onboard users into a product that shuts in weeks; the app is the
+ // path. Existing users keep Log In until the cutover. Native app and
+ // keep-web bypass users see the normal card.
+ const blockSignup = migrationOn && !isCapacitor() && !hasKeepWebBypass
const { handleNext } = useSetupFlow()
const { handleLoginClick, isLoggingIn } = useLogin()
const toast = useToast()
@@ -44,16 +60,27 @@ const LandingStep = () => {
return (
- {
- posthog.capture(ANALYTICS_EVENTS.SIGNUP_CLICKED)
- handleNext()
- }}
- >
- {t('landing.signUp')}
-
+ {blockSignup ? (
+
+ {/* heading only above the desktop QR — a lone store button
+ explains itself */}
+ {deviceType === DeviceType.WEB && (
+
{tMigration('banner.title')}
+ )}
+
+
+ ) : (
+ {
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_CLICKED)
+ handleNext()
+ }}
+ >
+ {t('landing.signUp')}
+
+ )}
{headingTitle && (
{headingTitle}
)}
- {headingDescription && {headingDescription}
}
+ {headingDescription && (
+
+ {headingDescription}
+
+ )}
{/* main content area */}
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index aa8964b4aa..0c51ab3ebf 100644
--- a/src/constants/analytics.consts.ts
+++ b/src/constants/analytics.consts.ts
@@ -270,6 +270,20 @@ export const ANALYTICS_EVENTS = {
DELETE_ACCOUNT_INITIATED: 'delete_account_initiated',
DELETE_ACCOUNT_CONFIRMED: 'delete_account_confirmed',
DELETE_ACCOUNT_FAILED: 'delete_account_failed',
+
+ // ── PWA sunset / app migration ──
+ // Funnel: modal_shown(migration_download) → store_cta_clicked / qr_shown
+ // → install (first native-platform event per distinct id, PostHog-side).
+ // `surface` ∈ MIGRATION_SURFACES; store_cta_clicked also carries
+ // `store` ∈ 'ios' | 'android'. qr_shown fires once per QR display (the
+ // smart QR serves both stores, so it has no store dimension).
+ MIGRATION_SUNSET_VIEWED: 'migration_sunset_viewed',
+ // guest Join/Continue-with-Peanut CTA rendered to a logged-out web
+ // visitor during the window — the impression leg of the guest funnel
+ MIGRATION_GUEST_CTA_SHOWN: 'migration_guest_cta_shown',
+ MIGRATION_STORE_CTA_CLICKED: 'migration_store_cta_clicked',
+ MIGRATION_QR_SHOWN: 'migration_qr_shown',
+ MIGRATION_KEEP_WEB_USED: 'migration_keep_web_used',
} as const
/**
@@ -283,6 +297,8 @@ export const MODAL_TYPES = {
CARD_PIONEER: 'card_pioneer',
KYC_COMPLETED: 'kyc_completed',
INVITE: 'invite',
+ MIGRATION_DOWNLOAD: 'migration_download',
+ APP_REVIEW: 'app_review',
} as const
/**
diff --git a/src/constants/migration.consts.ts b/src/constants/migration.consts.ts
new file mode 100644
index 0000000000..a26836a3ea
--- /dev/null
+++ b/src/constants/migration.consts.ts
@@ -0,0 +1,66 @@
+/**
+ * PWA → native app migration (pwa-sunset).
+ *
+ * Everything here is dark until the `pwa-sunset` PostHog flag is flipped ON
+ * (no deploy needed). Flag ON starts the notice window: download prompts +
+ * store links appear and new signups skip the PWA-install steps. Once
+ * MIGRATION_CUTOVER_DATE passes (flag still ON), the web app is replaced by
+ * the full-screen sunset block (SunsetScreen).
+ */
+
+export const PWA_SUNSET_FLAG = 'pwa-sunset'
+
+// ponytail: cutover date is a constant; move to flag payload only if the date
+// needs to move without a deploy. placeholder — set the real date before flag-on.
+export const MIGRATION_CUTOVER_DATE = new Date('2026-12-31T00:00:00Z')
+
+// how long "Remind me later" snoozes the download prompt modal
+export const DOWNLOAD_PROMPT_SNOOZE_DAYS = 3
+
+// download prompt copy switches from celebratory to friendly-urgency once
+// the cutover is this close (Hugo's two-phase notice window)
+export const MIGRATION_URGENCY_THRESHOLD_DAYS = 14
+
+// how long "Not now" on the notifications pre-prompt snoozes before re-asking
+// (only during the migration window; flag off keeps closed-forever)
+export const NOTIF_PROMPT_SNOOZE_DAYS = 14
+
+// store review deep links ("Love it" on the review prompt). the ios
+// write-review action needs the real numeric app id — placeholder until launch.
+export const REVIEW_URL = {
+ ios: 'https://apps.apple.com/app/peanut?action=write-review',
+ android: 'https://play.google.com/store/apps/details?id=me.peanut.wallet',
+} as const
+
+// support escape hatch for users who can't install the app: support DMs
+// `/home?keep-web=
`; visiting it stores a 90-day cookie that bypasses
+// the sunset block.
+// ponytail: static shared token, FE-only; per-user tokens need a BE endpoint.
+export const KEEP_WEB_COOKIE = 'keep-web'
+export const KEEP_WEB_TOKEN = 'walnut-still-cracks'
+export const KEEP_WEB_COOKIE_DAYS = 90
+
+// placeholder store URLs — real App Store numeric id + Play listing must be
+// confirmed before flag-on (also needed for the review deep link).
+export const STORE_URL = {
+ ios: 'https://apps.apple.com/app/peanut',
+ android: 'https://play.google.com/store/apps/details?id=me.peanut.wallet',
+} as const
+
+export const STORE_NAME = {
+ ios: 'App Store',
+ android: 'Google Play',
+} as const
+
+/** `surface` property for migration analytics events. */
+export const MIGRATION_SURFACES = {
+ DOWNLOAD_MODAL: 'download_modal',
+ SUNSET_SCREEN: 'sunset_screen',
+ LANDING_HERO: 'landing_hero',
+ HOME_BANNER: 'home_banner',
+ SETUP: 'setup',
+ GUEST_FLOW: 'guest_flow',
+} as const
+
+export type MigrationSurface = (typeof MIGRATION_SURFACES)[keyof typeof MIGRATION_SURFACES]
+export type StoreKind = keyof typeof STORE_URL
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index a69edabd5c..4785ec75a5 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -48,6 +48,7 @@ export const DEDICATED_ROUTES = [
'fix-card-signature',
// Public pages (existing)
+ 'app', // smart store link (/app) — QR codes point here, redirects by device
'm', // merchant landing pages (/m/[slug]) — added on main; register so the catch-all never treats it as a recipient
'careers',
'jobs',
diff --git a/src/context/ModalsContext.tsx b/src/context/ModalsContext.tsx
index 3d544f3efa..c33a6e0f83 100644
--- a/src/context/ModalsContext.tsx
+++ b/src/context/ModalsContext.tsx
@@ -11,6 +11,10 @@ interface ModalsContextType {
isSignInModalOpen: boolean
setIsSignInModalOpen: (isOpen: boolean) => void
+ // Get-the-app scan-to-download modal (pwa-sunset, desktop surfaces)
+ isGetAppModalOpen: boolean
+ setIsGetAppModalOpen: (isOpen: boolean) => void
+
// Support Drawer
isSupportModalOpen: boolean
setIsSupportModalOpen: (isOpen: boolean) => void
@@ -38,6 +42,9 @@ export function ModalsProvider({ children }: { children: ReactNode }) {
// Guest Login/Sign In Modal
const [isSignInModalOpen, setIsSignInModalOpen] = useState(false)
+ // Get-the-app scan-to-download modal
+ const [isGetAppModalOpen, setIsGetAppModalOpen] = useState(false)
+
// Support Drawer
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
const [supportPrefilledMessage, setSupportPrefilledMessage] = useState('')
@@ -63,6 +70,10 @@ export function ModalsProvider({ children }: { children: ReactNode }) {
isSignInModalOpen,
setIsSignInModalOpen,
+ // Get-the-app scan-to-download modal
+ isGetAppModalOpen,
+ setIsGetAppModalOpen,
+
// Support Drawer
isSupportModalOpen,
setIsSupportModalOpen,
@@ -81,6 +92,7 @@ export function ModalsProvider({ children }: { children: ReactNode }) {
[
isIosPwaInstallModalOpen,
isSignInModalOpen,
+ isGetAppModalOpen,
isSupportModalOpen,
supportPrefilledMessage,
openSupportWithMessage,
diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx
index 88251f3892..320477b3e9 100644
--- a/src/features/payments/shared/components/SendWithPeanutCta.tsx
+++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx
@@ -21,6 +21,7 @@ import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useMemo } from 'react'
import { useTranslations } from 'next-intl'
+import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff'
interface SendWithPeanutCtaProps extends ButtonProps {
title?: string
@@ -56,6 +57,9 @@ export default function SendWithPeanutCta({
const isLoggedIn = !!user?.user?.userId
// assume logged in while fetching to prevent "Join Peanut" flash
const showAsLoggedIn = isFetchingUser || isLoggedIn
+ const { interceptGuestCta, storeHandoffModal } = useGuestStoreHandoff({
+ trackImpressionWhenGuest: requiresAuth && !isFetchingUser && !isLoggedIn,
+ })
const handleClick = (e: React.MouseEvent) => {
// don't act while auth is still resolving
@@ -63,6 +67,9 @@ export default function SendWithPeanutCta({
// if auth is required and user is not logged in, redirect to signup
if (requiresAuth && !isLoggedIn) {
+ // migration window: web signups are closed — hand the guest to the
+ // app stores instead (QR modal on desktop, store link on mobile)
+ if (interceptGuestCta()) return
const redirectUri = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash
)
@@ -110,31 +117,34 @@ export default function SendWithPeanutCta({
}, [])
return (
-
- {!showAsLoggedIn ? (
-
-
{t('cta.join')}
- {peanutLogo}
-
- ) : insufficientBalance ? (
-
-
{t('cta.addFundsTo')}
- {peanutLogo}
-
- ) : (
-
-
{title || t('cta.sendWith')}
- {peanutLogo}
-
- )}
-
+ <>
+ {storeHandoffModal}
+
+ {!showAsLoggedIn ? (
+
+
{t('cta.join')}
+ {peanutLogo}
+
+ ) : insufficientBalance ? (
+
+
{t('cta.addFundsTo')}
+ {peanutLogo}
+
+ ) : (
+
+
{title || t('cta.sendWith')}
+ {peanutLogo}
+
+ )}
+
+ >
)
}
diff --git a/src/hooks/useGuestStoreHandoff.tsx b/src/hooks/useGuestStoreHandoff.tsx
new file mode 100644
index 0000000000..5653d5dc68
--- /dev/null
+++ b/src/hooks/useGuestStoreHandoff.tsx
@@ -0,0 +1,55 @@
+'use client'
+import { useEffect, useRef, useState } from 'react'
+import posthog from 'posthog-js'
+import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import { MIGRATION_SURFACES } from '@/constants/migration.consts'
+import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
+import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { isCapacitor } from '@/utils/capacitor'
+import { openStore } from '@/utils/migration.utils'
+
+/**
+ * Guest-flow store handoff for the migration window (mockup §03/§08): when a
+ * logged-out web visitor taps "Join Peanut" / "Continue with Peanut" on a
+ * claim/request page, don't route them into a signup that's closed — desktop
+ * opens the scan-to-download QR modal, phones deep-link their store.
+ *
+ * Returns `interceptGuestCta` (call it first in the CTA handler; true = the
+ * click was handled here) and `storeHandoffModal` (render it next to the CTA).
+ * Native app guests keep the normal in-app flow.
+ */
+export function useGuestStoreHandoff({
+ trackImpressionWhenGuest = false,
+}: { trackImpressionWhenGuest?: boolean } = {}) {
+ const migrationOn = useMigrationFlag()
+ const { deviceType } = useDeviceType()
+ const [qrOpen, setQrOpen] = useState(false)
+
+ // guest-funnel impression (TASK-20939): fires once per mount when the CTA
+ // is actually shown to a logged-out web visitor during the window. The
+ // caller passes its settled guest state so we don't count the pre-auth
+ // flash where every visitor briefly looks logged-out.
+ const impressionFired = useRef(false)
+ useEffect(() => {
+ if (!trackImpressionWhenGuest || !migrationOn || isCapacitor() || impressionFired.current) return
+ impressionFired.current = true
+ posthog.capture(ANALYTICS_EVENTS.MIGRATION_GUEST_CTA_SHOWN, { surface: MIGRATION_SURFACES.GUEST_FLOW })
+ }, [trackImpressionWhenGuest, migrationOn])
+
+ const interceptGuestCta = (): boolean => {
+ if (!migrationOn || isCapacitor()) return false
+ if (deviceType === DeviceType.WEB) {
+ setQrOpen(true)
+ return true
+ }
+ openStore(deviceType === DeviceType.ANDROID ? 'android' : 'ios', MIGRATION_SURFACES.GUEST_FLOW)
+ return true
+ }
+
+ const storeHandoffModal = qrOpen ? (
+ setQrOpen(false)} surface={MIGRATION_SURFACES.GUEST_FLOW} />
+ ) : null
+
+ return { interceptGuestCta, storeHandoffModal }
+}
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index 8ebe0b36a3..0688721ffc 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -20,6 +20,10 @@ import { useTransactionHistory } from './useTransactionHistory'
import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
import underMaintenanceConfig from '@/config/underMaintenance.config'
import { useToast } from '@/components/0_Bruddle/Toast'
+import { PEANUTMAN_MOBILE } from '@/assets/mascot'
+import { MIGRATION_SURFACES } from '@/constants/migration.consts'
+import { useMigrationFlag } from './useMigrationFlag'
+import { openStore } from '@/utils/migration.utils'
// Days a dismissed CTA stays hidden before reappearing. Set above 1 so dismiss feels
// "sticky" but below 14 so we still nudge users about valuable actions they haven't
@@ -73,6 +77,8 @@ const getDismissedCTAs = (userId: string | undefined): Map => {
export const useHomeCarouselCTAs = () => {
const t = useTranslations('home.carousel')
+ const tMigration = useTranslations('migration')
+ const migrationOn = useMigrationFlag()
const [carouselCTAs, setCarouselCTAs] = useState([])
const { user } = useAuth()
const dismissedRef = useRef>(new Map())
@@ -94,7 +100,7 @@ export const useHomeCarouselCTAs = () => {
const isInFlight = rails.some((rail) => rail.status === 'pending' || rail.status === 'requires-info')
const { deviceType } = useDeviceType()
const isPwa = usePWAStatus()
- const { setIsIosPwaInstallModalOpen, openSupportWithMessage } = useModalsContext()
+ const { setIsIosPwaInstallModalOpen, openSupportWithMessage, setIsGetAppModalOpen } = useModalsContext()
const { setIsQRScannerOpen } = useModalsContext()
const { countryCode: userCountryCode } = useGeoLocation()
@@ -136,6 +142,27 @@ export const useHomeCarouselCTAs = () => {
const _carouselCTAs: CarouselCTA[] = []
const b = (chunks: React.ReactNode) => {chunks}
+ // pwa-sunset notice window: get-the-app nudge leads the carousel and
+ // supersedes the ios-pwa-install CTA below (TASK-20829). Mobile goes
+ // straight to the visitor's store; desktop opens the scan-to-download QR.
+ if (migrationOn && !isCapacitor()) {
+ _carouselCTAs.push({
+ id: 'app-install',
+ title: tMigration('banner.title'),
+ description: tMigration('banner.description'),
+ icon: 'mobile-install',
+ logo: PEANUTMAN_MOBILE,
+ iconSize: 16,
+ onClick: () => {
+ if (deviceType === DeviceType.WEB) {
+ setIsGetAppModalOpen(true)
+ } else {
+ openStore(deviceType === DeviceType.ANDROID ? 'android' : 'ios', MIGRATION_SURFACES.HOME_BANNER)
+ }
+ },
+ })
+ }
+
// Home CTAs gate on "user can do a bank deposit or a pay" — provider-blind.
// Rain (card) does NOT count; a card-only user must still see the verify CTA.
const hasKycApproval = bankRails().some((r) => r.status === 'enabled') || canDo('pay')
@@ -187,8 +214,21 @@ export const useHomeCarouselCTAs = () => {
// the user must reinstall — so route to the install modal. On native
// the OS prompt falls back to the Settings app (handled in requestPermission),
// so let it through instead of showing a PWA-install dead end.
+ // During the migration window the reinstall answer is the native
+ // app, not the retiring PWA.
if (isPermissionDenied && !isCapacitor()) {
- setIsIosPwaInstallModalOpen(true)
+ if (migrationOn) {
+ if (deviceType === DeviceType.WEB) {
+ setIsGetAppModalOpen(true)
+ } else {
+ openStore(
+ deviceType === DeviceType.ANDROID ? 'android' : 'ios',
+ MIGRATION_SURFACES.HOME_BANNER
+ )
+ }
+ } else {
+ setIsIosPwaInstallModalOpen(true)
+ }
return
}
const result = await requestPermission()
@@ -203,7 +243,7 @@ export const useHomeCarouselCTAs = () => {
})
}
- if (deviceType === DeviceType.IOS && !isPwa && !isCapacitor()) {
+ if (!migrationOn && deviceType === DeviceType.IOS && !isPwa && !isCapacitor()) {
_carouselCTAs.push({
id: 'ios-pwa-install',
title: t('iosPwa.title'),
@@ -320,6 +360,9 @@ export const useHomeCarouselCTAs = () => {
toast,
dismissCTA,
openSupportWithMessage,
+ migrationOn,
+ tMigration,
+ setIsGetAppModalOpen,
])
useEffect(() => {
diff --git a/src/hooks/useKeepWebBypass.ts b/src/hooks/useKeepWebBypass.ts
new file mode 100644
index 0000000000..f817c753b1
--- /dev/null
+++ b/src/hooks/useKeepWebBypass.ts
@@ -0,0 +1,27 @@
+'use client'
+import { useEffect, useState } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import { KEEP_WEB_COOKIE, KEEP_WEB_COOKIE_DAYS, KEEP_WEB_TOKEN } from '@/constants/migration.consts'
+import { getFromCookie, saveToCookie } from '@/utils/general.utils'
+
+/**
+ * Support escape hatch for the sunset block: `?keep-web=` (DM'd by
+ * support) persists a 90-day cookie that lets this browser keep using the web
+ * app. Shared by every layout that renders the sunset gate so the token works
+ * no matter which route the user lands on.
+ */
+export function useKeepWebBypass(): boolean {
+ const [hasBypass, setHasBypass] = useState(
+ () => typeof document !== 'undefined' && getFromCookie(KEEP_WEB_COOKIE) === KEEP_WEB_TOKEN
+ )
+ useEffect(() => {
+ const param = new URLSearchParams(window.location.search).get(KEEP_WEB_COOKIE)
+ if (param === KEEP_WEB_TOKEN) {
+ saveToCookie(KEEP_WEB_COOKIE, KEEP_WEB_TOKEN, KEEP_WEB_COOKIE_DAYS)
+ posthog.capture(ANALYTICS_EVENTS.MIGRATION_KEEP_WEB_USED)
+ setHasBypass(true)
+ }
+ }, [])
+ return hasBypass
+}
diff --git a/src/hooks/useMigrationFlag.ts b/src/hooks/useMigrationFlag.ts
new file mode 100644
index 0000000000..df2234ac27
--- /dev/null
+++ b/src/hooks/useMigrationFlag.ts
@@ -0,0 +1,37 @@
+'use client'
+import { useEffect, useState } from 'react'
+import { useFeatureFlags } from '@/hooks/useFeatureFlag'
+import { isPwaSunsetOn } from '@/utils/migration.utils'
+
+/**
+ * Is the PWA-sunset migration live? Fails closed (false) until PostHog flags
+ * load; re-renders when they do (see useFeatureFlags).
+ *
+ * Deliberately no `nonProdBypass`: it would force the sunset block on for all
+ * of staging/previews once the cutover date passes, bricking QA of the
+ * un-flagged state.
+ *
+ * Testing with the flag on:
+ * - PostHog UI (project 138913): add a release condition on `pwa-sunset`
+ * matching your `email` at 100%.
+ * - On a preview/prod build: run
+ * `posthog.featureFlags.overrideFeatureFlags({ flags: { 'pwa-sunset': true } })`
+ * in the console (persists for the session; posthog-js >=1.3xx requires the
+ * `flags` wrapper — a flat object is silently ignored). Clear with
+ * `overrideFeatureFlags(false)`.
+ * - Local dev (posthog never inits): `localStorage.setItem('pwa-sunset', 'true')`
+ * + reload; cutover via `localStorage.setItem('pwa-sunset-cutover', '2020-01-01')`.
+ * See isPwaSunsetOn / getMigrationCutoverTime.
+ */
+export function useMigrationFlag(): boolean {
+ // subscribe to posthog flag-load events so consumers re-render when flags
+ // arrive; the actual read goes through isPwaSunsetOn (dev override aware)
+ useFeatureFlags()
+ // hydration-safe: posthog serves CACHED flags synchronously for returning
+ // visitors, so a render-time read would disagree with the flag-off SSR
+ // HTML on prerendered surfaces (landing, setup) and hard-fail hydration.
+ // false until mounted keeps server and first client render identical.
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => setMounted(true), [])
+ return mounted && isPwaSunsetOn()
+}
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 16594670d2..bd7557d676 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -8,8 +8,12 @@ import { isDemoMode } from '@/utils/demo'
import { useUserStore } from '@/redux/hooks'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
+import { NOTIF_PROMPT_SNOOZE_DAYS } from '@/constants/migration.consts'
+import { isPwaSunsetOn } from '@/utils/migration.utils'
import { UTM_SOURCES, UTM_MEDIUMS } from '@/utils/utm.utils'
+const NOTIF_PROMPT_SNOOZE_MS = NOTIF_PROMPT_SNOOZE_DAYS * 24 * 60 * 60 * 1000
+
/*
* Notification state lives in a module-level store shared by every
* useNotifications() consumer. The hook used to keep per-instance useState and
@@ -118,7 +122,18 @@ async function evaluateVisibility() {
}
const userPreferences = getUserPreferences(currentExternalId ?? undefined)
- const modalClosed = userPreferences?.notifModalClosed ?? false
+ // migration window (TASK-20771): "Not now" snoozes instead of dismissing
+ // forever — the custom pre-prompt exists so we CAN re-ask later. Legacy
+ // `notifModalClosed: true` (no timestamp) converts to a snooze starting
+ // now, same trick as getDismissedCTAs. Flag off keeps the old
+ // closed-forever behavior.
+ let closedAt = userPreferences?.notifModalClosedAt
+ if (!closedAt && userPreferences?.notifModalClosed) {
+ closedAt = new Date().toISOString()
+ updateUserPreferences(currentExternalId ?? undefined, { notifModalClosedAt: closedAt })
+ }
+ const snoozeExpired = !!closedAt && Date.now() - new Date(closedAt).getTime() >= NOTIF_PROMPT_SNOOZE_MS
+ const modalClosed = !!closedAt && !(isPwaSunsetOn() && snoozeExpired)
// don't show modal if permission is denied (carousel cta will handle it)
if (state.permissionState === 'denied') {
@@ -266,14 +281,22 @@ async function requestPermission(): Promise {
// close modal when user dismisses it
function closePermissionModal() {
setState({ showPermissionModal: false })
- updateUserPreferences(currentExternalId ?? undefined, { notifModalClosed: true })
+ // legacy boolean kept so bundles predating notifModalClosedAt stay closed
+ updateUserPreferences(currentExternalId ?? undefined, {
+ notifModalClosed: true,
+ notifModalClosedAt: new Date().toISOString(),
+ })
posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.NOTIFICATIONS })
}
// update permission state after user interacts with permission prompt
async function afterPermissionAttempt() {
- // mark modal as closed to prevent it from showing again
- updateUserPreferences(currentExternalId ?? undefined, { notifModalClosed: true })
+ // mark modal as closed (permanent flag-off; 14-day snooze while the
+ // pwa-sunset flag is on — see evaluateVisibility)
+ updateUserPreferences(currentExternalId ?? undefined, {
+ notifModalClosed: true,
+ notifModalClosedAt: new Date().toISOString(),
+ })
await refreshPermissionState()
}
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 1f75e7bec1..73d91adf89 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -1838,6 +1838,8 @@
"iconAlt": "icon",
"setupTitle": "Turn on notifications?",
"setupDescription": "Enable notifications and get alerts for all wallet activity.",
+ "migrationSetupTitle": "Get money alerts",
+ "migrationSetupDescription": "We'll ping you the moment money lands or someone asks you to pay.",
"enable": "Enable notifications",
"requesting": "Requesting...",
"notNow": "Not now"
@@ -2705,5 +2707,40 @@
"promptFailed": "Unlock again to open the app.",
"unlock": "Unlock",
"logOut": "Log out"
+ },
+ "migration": {
+ "downloadPrompt": {
+ "earlyTitle": "The Peanut app is here!",
+ "earlyDescription": "Peanut now lives on the App Store and Google Play. Faster, smoother, and it pings you the moment money lands. Your account comes with you, nothing to set up.",
+ "maybeLater": "I'll download it later",
+ "title": "Peanut is going app-only",
+ "description": "In {days, plural, one {# day} other {# days}} Peanut moves fully to the app. Download it now and pick up right where you left off. Your account and money move with you automatically.",
+ "remindLater": "Remind me later"
+ },
+ "sunset": {
+ "heading": "Peanut is now app-only",
+ "sub": "Download the app to pick up right where you left off. Your account and money are already there waiting for you.",
+ "supportLink": "Having trouble downloading the app? Chat with our support"
+ },
+ "qr": {
+ "title": "Get the Peanut app",
+ "scanHint": "Scan with your phone camera to download."
+ },
+ "banner": {
+ "title": "The Peanut app is here!",
+ "description": "Faster, smoother, and it pings you when money lands."
+ },
+ "review": {
+ "title": "Loving Peanut so far?",
+ "description": "A quick rating helps other people find us.",
+ "loveIt": "Love it",
+ "meh": "Could be better",
+ "supportPrefill": "I have some feedback about the app, here's what could be better: "
+ },
+ "downloadNow": "Download now",
+ "smartLink": {
+ "redirecting": "Taking you to the store…",
+ "pickStore": "Global cash, local feel. Pick your store to download."
+ }
}
}
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 8d97233e65..1b6a34538a 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -1838,6 +1838,8 @@
"iconAlt": "ícono",
"setupTitle": "¿Activar las notificaciones?",
"setupDescription": "Activa las notificaciones y recibe alertas de toda la actividad de tu billetera.",
+ "migrationSetupTitle": "Recibe alertas de dinero",
+ "migrationSetupDescription": "Te avisamos al instante cuando llegue dinero o alguien te pida un pago.",
"enable": "Activar notificaciones",
"requesting": "Solicitando...",
"notNow": "Ahora no"
@@ -2705,5 +2707,40 @@
"promptFailed": "Desbloquea de nuevo para abrir la app.",
"unlock": "Desbloquear",
"logOut": "Cerrar sesión"
+ },
+ "migration": {
+ "downloadPrompt": {
+ "earlyTitle": "¡La app de Peanut ya está aquí!",
+ "earlyDescription": "Peanut ya vive en el App Store y Google Play. Más rápida, más fluida y te avisa al instante cuando llega tu dinero. Tu cuenta va contigo, sin configurar nada.",
+ "maybeLater": "La descargo más tarde",
+ "title": "Peanut será solo app",
+ "description": "En {days, plural, one {# día} other {# días}} Peanut se muda por completo a la app. Descárgala ahora y continúa justo donde quedaste. Tu cuenta y tu dinero se mudan contigo automáticamente.",
+ "remindLater": "Recordarme más tarde"
+ },
+ "sunset": {
+ "heading": "Peanut ahora es solo app",
+ "sub": "Descarga la app y continúa justo donde quedaste. Tu cuenta y tu dinero ya te están esperando ahí.",
+ "supportLink": "¿Problemas para descargar la app? Habla con nuestro soporte"
+ },
+ "qr": {
+ "title": "Descarga la app de Peanut",
+ "scanHint": "Escanea con la cámara de tu celular para descargar."
+ },
+ "banner": {
+ "title": "¡La app de Peanut ya está aquí!",
+ "description": "Más rápida, más fluida y te avisa cuando llega tu dinero."
+ },
+ "review": {
+ "title": "¿Te está gustando Peanut?",
+ "description": "Una calificación rápida ayuda a que otros nos encuentren.",
+ "loveIt": "Me encanta",
+ "meh": "Podría mejorar",
+ "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: "
+ },
+ "downloadNow": "Descargar ahora",
+ "smartLink": {
+ "redirecting": "Te llevamos a la tienda…",
+ "pickStore": "Elige tu tienda y descarga la app."
+ }
}
}
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index baa909ed1f..81fd01d966 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -1838,6 +1838,8 @@
"iconAlt": "ícone",
"setupTitle": "Ativar as notificações?",
"setupDescription": "Ative as notificações e receba alertas de toda a atividade da sua carteira.",
+ "migrationSetupTitle": "Receba alertas de dinheiro",
+ "migrationSetupDescription": "Avisamos na hora quando o dinheiro chegar ou alguém pedir um pagamento.",
"enable": "Ativar notificações",
"requesting": "Solicitando...",
"notNow": "Agora não"
@@ -2705,5 +2707,40 @@
"promptFailed": "Desbloqueie novamente para abrir o app.",
"unlock": "Desbloquear",
"logOut": "Sair"
+ },
+ "migration": {
+ "downloadPrompt": {
+ "earlyTitle": "O app do Peanut chegou!",
+ "earlyDescription": "O Peanut agora vive na App Store e no Google Play. Mais rápido, mais fluido e te avisa na hora que o dinheiro chega. Sua conta vai com você, sem configurar nada.",
+ "maybeLater": "Baixo depois",
+ "title": "O Peanut vai ser só app",
+ "description": "Em {days, plural, one {# dia} other {# dias}} o Peanut se muda por completo para o app. Baixe agora e continue de onde parou. Sua conta e seu dinheiro se mudam com você automaticamente.",
+ "remindLater": "Lembrar depois"
+ },
+ "sunset": {
+ "heading": "O Peanut agora é só app",
+ "sub": "Baixe o app e continue de onde parou. Sua conta e seu dinheiro já estão lá esperando por você.",
+ "supportLink": "Problemas para baixar o app? Fale com nosso suporte"
+ },
+ "qr": {
+ "title": "Baixe o app do Peanut",
+ "scanHint": "Escaneie com a câmera do seu celular para baixar."
+ },
+ "banner": {
+ "title": "O app do Peanut chegou!",
+ "description": "Mais rápido, mais fluido e te avisa quando o dinheiro chega."
+ },
+ "review": {
+ "title": "Está gostando do Peanut?",
+ "description": "Uma avaliação rápida ajuda outras pessoas a nos encontrar.",
+ "loveIt": "Adorei",
+ "meh": "Pode melhorar",
+ "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: "
+ },
+ "downloadNow": "Baixar agora",
+ "smartLink": {
+ "redirecting": "Levando você para a loja…",
+ "pickStore": "Escolha sua loja e baixe o app."
+ }
}
}
diff --git a/src/utils/__tests__/migration.utils.test.ts b/src/utils/__tests__/migration.utils.test.ts
new file mode 100644
index 0000000000..eb4d82af93
--- /dev/null
+++ b/src/utils/__tests__/migration.utils.test.ts
@@ -0,0 +1,112 @@
+/** @jest-environment jsdom */
+/**
+ * migration.utils — the pwa-sunset primitives.
+ *
+ * shouldShowSunsetBlock is the single predicate that can make the whole app
+ * inaccessible (three call sites: both layouts + implicitly /app's flag gate),
+ * so its matrix is pinned here. The dev-only localStorage overrides are what
+ * local e2e QA rides on — a silent break there blinds every future QA round.
+ */
+
+let mockFlagEnabled = false
+jest.mock('@/utils/featureFlag.utils', () => ({
+ isFeatureFlagEnabled: () => mockFlagEnabled,
+}))
+
+let mockIsCapacitor = false
+jest.mock('@/utils/capacitor', () => ({
+ isCapacitor: () => mockIsCapacitor,
+ openExternalUrl: jest.fn(),
+}))
+
+// the localStorage overrides are dev-only; force the dev branch in tests
+jest.mock('@/constants/general.consts', () => ({
+ ...jest.requireActual('@/constants/general.consts'),
+ IS_DEV: true,
+}))
+
+jest.mock('posthog-js', () => ({ capture: jest.fn() }))
+
+import { MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts'
+import { getMigrationCutoverTime, isPwaSunsetOn, shouldShowSunsetBlock } from '@/utils/migration.utils'
+
+const CUTOVER = MIGRATION_CUTOVER_DATE.getTime()
+const AFTER = CUTOVER + 1000
+const BEFORE = CUTOVER - 1000
+
+beforeEach(() => {
+ localStorage.clear()
+ mockFlagEnabled = false
+ mockIsCapacitor = false
+})
+
+describe('isPwaSunsetOn', () => {
+ it('fails closed by default', () => {
+ expect(isPwaSunsetOn()).toBe(false)
+ })
+
+ it('follows the posthog flag', () => {
+ mockFlagEnabled = true
+ expect(isPwaSunsetOn()).toBe(true)
+ })
+
+ it('dev localStorage override turns it on without posthog', () => {
+ localStorage.setItem('pwa-sunset', 'true')
+ expect(isPwaSunsetOn()).toBe(true)
+ })
+
+ it('ignores non-"true" override values', () => {
+ localStorage.setItem('pwa-sunset', 'false')
+ expect(isPwaSunsetOn()).toBe(false)
+ })
+})
+
+describe('getMigrationCutoverTime', () => {
+ it('returns the constant by default', () => {
+ expect(getMigrationCutoverTime()).toBe(CUTOVER)
+ })
+
+ it('dev localStorage override moves the cutover', () => {
+ localStorage.setItem('pwa-sunset-cutover', '2020-01-01')
+ expect(getMigrationCutoverTime()).toBe(new Date('2020-01-01').getTime())
+ })
+
+ it('garbage override falls back to the constant', () => {
+ localStorage.setItem('pwa-sunset-cutover', 'not-a-date')
+ expect(getMigrationCutoverTime()).toBe(CUTOVER)
+ })
+})
+
+describe('shouldShowSunsetBlock', () => {
+ const base = { migrationOn: true, hasKeepWebBypass: false, now: AFTER }
+
+ it('blocks past the cutover with the flag on', () => {
+ expect(shouldShowSunsetBlock(base)).toBe(true)
+ })
+
+ it('never blocks with the flag off', () => {
+ expect(shouldShowSunsetBlock({ ...base, migrationOn: false })).toBe(false)
+ })
+
+ it('never blocks before the cutover (notice window)', () => {
+ expect(shouldShowSunsetBlock({ ...base, now: BEFORE })).toBe(false)
+ })
+
+ it('public guest paths pass through', () => {
+ expect(shouldShowSunsetBlock({ ...base, isPublic: true })).toBe(false)
+ })
+
+ it('the native app is never blocked', () => {
+ mockIsCapacitor = true
+ expect(shouldShowSunsetBlock(base)).toBe(false)
+ })
+
+ it('the keep-web support bypass passes through', () => {
+ expect(shouldShowSunsetBlock({ ...base, hasKeepWebBypass: true })).toBe(false)
+ })
+
+ it('respects the dev cutover override', () => {
+ localStorage.setItem('pwa-sunset-cutover', '2020-01-01')
+ expect(shouldShowSunsetBlock({ ...base, now: BEFORE })).toBe(true)
+ })
+})
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index 55e3a2b1ea..b4c58e417d 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -493,6 +493,14 @@ export type UserPreferences = {
* Read by useHomeCarouselCTAs to apply a per-CTA cooldown before re-showing.
* Legacy shape was `string[]` (permanent dismissal); both are accepted on read. */
dismissedCarouselCTAs?: string[] | Record
+ /** ISO timestamp of the last "Remind me later" on the app-migration download prompt. */
+ migrationPromptSnoozedAt?: string
+ /** ISO timestamp the notifications pre-prompt was dismissed — replaces the
+ * legacy permanent `notifModalClosed` so we can re-ask after a cooldown
+ * during the migration window. */
+ notifModalClosedAt?: string
+ /** ISO timestamp the app-review prompt was shown (asked once, ever). */
+ reviewPromptShownAt?: string
}
export const updateUserPreferences = (
diff --git a/src/utils/migration.utils.ts b/src/utils/migration.utils.ts
new file mode 100644
index 0000000000..ccfe25242e
--- /dev/null
+++ b/src/utils/migration.utils.ts
@@ -0,0 +1,73 @@
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import { IS_DEV } from '@/constants/general.consts'
+import {
+ MIGRATION_CUTOVER_DATE,
+ PWA_SUNSET_FLAG,
+ STORE_URL,
+ type MigrationSurface,
+ type StoreKind,
+} from '@/constants/migration.consts'
+import { isFeatureFlagEnabled } from '@/utils/featureFlag.utils'
+import { isCapacitor, openExternalUrl } from '@/utils/capacitor'
+
+/**
+ * Flag read with a dev-only localStorage override. Local dev never inits
+ * posthog (instrumentation-client gates on NODE_ENV), so e2e QA flips the
+ * flag with `localStorage.setItem('pwa-sunset', 'true')` + reload instead.
+ * Inert outside dev builds.
+ */
+export function isPwaSunsetOn(): boolean {
+ if (IS_DEV && typeof localStorage !== 'undefined' && localStorage.getItem(PWA_SUNSET_FLAG) === 'true') {
+ return true
+ }
+ return isFeatureFlagEnabled(PWA_SUNSET_FLAG)
+}
+
+/**
+ * The one sunset-block predicate, shared by every layout that can replace the
+ * app with the download screen ((mobile-ui) and (setup)). Public paths are the
+ * caller's concern: guest claim/request links must keep working, so the
+ * mobile-ui layout passes `isPublic`.
+ */
+export function shouldShowSunsetBlock({
+ migrationOn,
+ hasKeepWebBypass,
+ isPublic = false,
+ now = Date.now(),
+}: {
+ migrationOn: boolean
+ hasKeepWebBypass: boolean
+ isPublic?: boolean
+ now?: number
+}): boolean {
+ return migrationOn && !isPublic && !isCapacitor() && !hasKeepWebBypass && now >= getMigrationCutoverTime()
+}
+
+/**
+ * Cutover timestamp with a dev-only localStorage override
+ * (`localStorage.setItem('pwa-sunset-cutover', '2020-01-01')` + reload) so the
+ * post-cutover sunset block can be QA'd locally without editing the constant.
+ */
+export function getMigrationCutoverTime(): number {
+ if (IS_DEV && typeof localStorage !== 'undefined') {
+ const iso = localStorage.getItem('pwa-sunset-cutover')
+ if (iso) {
+ const t = new Date(iso).getTime()
+ if (!Number.isNaN(t)) return t
+ }
+ }
+ return MIGRATION_CUTOVER_DATE.getTime()
+}
+
+/** track a store CTA click without navigating (for anchors that navigate themselves). */
+export function trackStoreClick(store: StoreKind, surface: MigrationSurface) {
+ posthog.capture(ANALYTICS_EVENTS.MIGRATION_STORE_CTA_CLICKED, { surface, store })
+}
+
+/** navigate to the app store, tracking which surface sent the user there. */
+export function openStore(store: StoreKind, surface: MigrationSurface) {
+ trackStoreClick(store, surface)
+ // fire-and-forget: native Browser plugin or window.open on web
+ void openExternalUrl(STORE_URL[store])
+}