From 1dbcdfb6ea2a89cac38cae3bc1a31ef03dc9b7d1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:51:48 +0530 Subject: [PATCH 01/33] feat(migration): pwa-sunset flag scaffold + analytics taxonomy (TASK-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). --- src/constants/analytics.consts.ts | 11 +++++++ src/constants/migration.consts.ts | 50 +++++++++++++++++++++++++++++++ src/hooks/useMigrationFlag.ts | 23 ++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/constants/migration.consts.ts create mode 100644 src/hooks/useMigrationFlag.ts diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index aa8964b4a..89ff24ce7 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -270,6 +270,15 @@ 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` ∈ 'ios' | 'android'. + MIGRATION_SUNSET_VIEWED: 'migration_sunset_viewed', + 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 +292,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 000000000..e7f401efd --- /dev/null +++ b/src/constants/migration.consts.ts @@ -0,0 +1,50 @@ +/** + * 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 + +// 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', +} as const + +export type MigrationSurface = (typeof MIGRATION_SURFACES)[keyof typeof MIGRATION_SURFACES] +export type StoreKind = keyof typeof STORE_URL diff --git a/src/hooks/useMigrationFlag.ts b/src/hooks/useMigrationFlag.ts new file mode 100644 index 000000000..b1f714796 --- /dev/null +++ b/src/hooks/useMigrationFlag.ts @@ -0,0 +1,23 @@ +'use client' +import { useFeatureFlags } from '@/hooks/useFeatureFlag' +import { PWA_SUNSET_FLAG } from '@/constants/migration.consts' + +/** + * 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%. + * - Locally / on a preview: run + * `posthog.featureFlags.overrideFeatureFlags({ 'pwa-sunset': true })` + * in the console (persists for the session). + */ +export function useMigrationFlag(): boolean { + const isEnabled = useFeatureFlags() + return isEnabled(PWA_SUNSET_FLAG) +} From 0f327c6308eae45c5f3834eaf0b81d03ce80302d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:57:21 +0530 Subject: [PATCH 02/33] feat(migration): download prompt, sunset block and keep-web bypass (TASK-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= 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. --- eslint.config.js | 2 +- src/app/(mobile-ui)/home/page.tsx | 29 +++++- src/app/(mobile-ui)/layout.tsx | 40 ++++++++ src/components/Migration/DownloadQR.tsx | 36 +++++++ .../Migration/MigrationDownloadModal.tsx | 98 +++++++++++++++++++ .../Migration/ScanToDownloadModal.tsx | 30 ++++++ src/components/Migration/StoreButtons.tsx | 18 ++++ src/components/Migration/SunsetScreen.tsx | 70 +++++++++++++ src/i18n/app/messages/en.json | 29 ++++++ src/i18n/app/messages/es-419.json | 29 ++++++ src/i18n/app/messages/pt-BR.json | 29 ++++++ src/utils/general.utils.ts | 2 + src/utils/migration.utils.ts | 11 +++ 13 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 src/components/Migration/DownloadQR.tsx create mode 100644 src/components/Migration/MigrationDownloadModal.tsx create mode 100644 src/components/Migration/ScanToDownloadModal.tsx create mode 100644 src/components/Migration/StoreButtons.tsx create mode 100644 src/components/Migration/SunsetScreen.tsx create mode 100644 src/utils/migration.utils.ts diff --git a/eslint.config.js b/eslint.config.js index 43394020d..a6117a8c1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -220,7 +220,7 @@ module.exports = [ files: [ 'src/app/(mobile-ui)/**/*.tsx', 'src/app/(setup)/**/*.tsx', - 'src/components/{Home,Send,Request,Profile,Setup,Settings,Card,AddMoney,AddWithdraw,Withdraw,Claim,Payment,Points,Badges,Notifications,Invites,TransactionDetails,Kyc,IdentityVerification,ExchangeRate,Common,ForceIOSPWAInstall,User}/**/*.tsx', + 'src/components/{Home,Send,Request,Profile,Setup,Settings,Card,AddMoney,AddWithdraw,Withdraw,Claim,Payment,Points,Badges,Notifications,Invites,TransactionDetails,Kyc,IdentityVerification,ExchangeRate,Common,ForceIOSPWAInstall,User,Migration}/**/*.tsx', 'src/components/Global/**/*.tsx', 'src/features/**/*.tsx', ], diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 78d48a144..5e07381ab 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -48,6 +48,7 @@ const NoMoreJailModal = lazy(() => import('@/components/Global/NoMoreJailModal') const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal')) const WelcomeUnlockModal = lazy(() => import('@/components/Home/WelcomeUnlockModal')) const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal')) +const MigrationDownloadModal = lazy(() => import('@/components/Migration/MigrationDownloadModal')) const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds @@ -79,6 +80,9 @@ export default function Home() { const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) const [showKycModal, setShowKycModal] = useState(false) + // migration download prompt outranks every other home modal (self-gating, + // only during the pwa-sunset notice window) + const [showMigrationModal, setShowMigrationModal] = useState(false) // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key @@ -157,14 +161,23 @@ export default function Home() { if ( balanceInUsd > BALANCE_WARNING_THRESHOLD && !hasSeenBalanceWarning && - !showPermissionModal && // highest priority + !showMigrationModal && // highest priority + !showPermissionModal && !showKycModal && !isPostSignupActionModalVisible ) { setShowBalanceWarningModal(true) } } - }, [balance, isFetchingBalance, showPermissionModal, showKycModal, isPostSignupActionModalVisible, user]) + }, [ + balance, + isFetchingBalance, + showMigrationModal, + showPermissionModal, + showKycModal, + isPostSignupActionModalVisible, + user, + ]) if (isLoading) { return @@ -241,20 +254,26 @@ export default function Home() { /> - {showPermissionModal && !showBalanceWarningModal && ( + {showPermissionModal && !showBalanceWarningModal && !showMigrationModal && ( )} + + + + + + {/* Add Money Prompt Modal */} {/* TODO @dev Disabling this, re-enable after properly fixing */} {/* setShowAddMoneyPromptModal(false)} /> */} {/* these modals manage their own state internally */} - {!showBalanceWarningModal && ( + {!showBalanceWarningModal && !showMigrationModal && ( <> @@ -273,7 +292,7 @@ export default function Home() { { // close the modal immediately for better ux setShowKycModal(false) diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 1ddebfa49..6545f8a04 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -32,6 +32,17 @@ import { useNativePlugins } from '@/hooks/useNativePlugins' import '@/hooks/useSafeBack' import { isCapacitor } from '@/utils/capacitor' import { isDemoMode, enableDemoMode } from '@/utils/demo' +import posthog from 'posthog-js' +import SunsetScreen from '@/components/Migration/SunsetScreen' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { + KEEP_WEB_COOKIE, + KEEP_WEB_COOKIE_DAYS, + KEEP_WEB_TOKEN, + MIGRATION_CUTOVER_DATE, +} from '@/constants/migration.consts' +import { getFromCookie, saveToCookie } from '@/utils/general.utils' const Layout = ({ children }: { children: React.ReactNode }) => { useNativePlugins() @@ -51,6 +62,21 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const alignStart = isHome || isHistory || isSupport const router = useRouter() const { showIosPwaInstallScreen } = useSetupStore() + const migrationOn = useMigrationFlag() + + // support escape hatch for the sunset block: `?keep-web=` (DM'd by + // support) persists a cookie that lets this browser keep using the web app. + const [hasKeepWebBypass, setHasKeepWebBypass] = 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) + setHasKeepWebBypass(true) + } + }, []) // detect online/offline status for full-page offline screen const { isOnline, isInitialized } = useNetworkStatus() @@ -152,6 +178,20 @@ const Layout = ({ children }: { children: React.ReactNode }) => { } } + // PWA sunset: past the cutover the web app is switched off — download the + // native app is the only way forward (keep-web cookie bypasses, public + // guest links keep working). Must precede the PWA-install and waitlist + // screens: the web is gone either way. + if ( + migrationOn && + !isPublicPath && + !isCapacitor() && + !hasKeepWebBypass && + Date.now() >= MIGRATION_CUTOVER_DATE.getTime() + ) { + return + } + // After setup flow is completed, show ios pwa install screen if not in pwa if (!isPublicPath && showIosPwaInstallScreen) { return diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx new file mode 100644 index 000000000..f73af3c17 --- /dev/null +++ b/src/components/Migration/DownloadQR.tsx @@ -0,0 +1,36 @@ +'use client' +import { useEffect, useState } from 'react' +import posthog from 'posthog-js' +import { useTranslations } from 'next-intl' +import QRCodeWrapper from '@/components/Global/QRCodeWrapper' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { STORE_NAME, STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' + +// scan-to-download with a store toggle. on desktop we can't know the visitor's +// phone OS, so we show both store QRs behind a toggle instead of guessing. +export default function DownloadQR({ surface }: { surface: MigrationSurface }) { + const t = useTranslations('migration') + const [store, setStore] = useState('ios') + + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.MIGRATION_QR_SHOWN, { surface, store }) + }, [surface, store]) + + return ( +
+
+ {(['ios', 'android'] as const).map((s) => ( + + ))} +
+ + {t('qr.scanHint')} +
+ ) +} diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx new file mode 100644 index 000000000..a970d1a65 --- /dev/null +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -0,0 +1,98 @@ +'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 { openStore } from '@/utils/migration.utils' +import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { + DOWNLOAD_PROMPT_SNOOZE_DAYS, + MIGRATION_CUTOVER_DATE, + MIGRATION_SURFACES, + STORE_NAME, +} from '@/constants/migration.consts' +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(() => { + if (!migrationOn || !userId || isCapacitor()) return + if (Date.now() >= MIGRATION_CUTOVER_DATE.getTime()) return // sunset block owns post-cutover + const snoozedAt = getUserPreferences(userId)?.migrationPromptSnoozedAt + if (snoozedAt && Date.now() - new Date(snoozedAt).getTime() < SNOOZE_MS) 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((MIGRATION_CUTOVER_DATE.getTime() - Date.now()) / (24 * 60 * 60 * 1000))) + const isDesktop = deviceType === DeviceType.WEB + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + + return ( + : undefined} + ctas={ + isDesktop + ? [] + : [ + { + text: STORE_NAME[store], + variant: 'purple', + shadowSize: '4', + icon: 'mobile-install', + onClick: () => { + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { + modal_type: MODAL_TYPES.MIGRATION_DOWNLOAD, + cta: 'store', + }) + openStore(store, MIGRATION_SURFACES.DOWNLOAD_MODAL) + }, + }, + ] + } + footer={ + + } + /> + ) +} diff --git a/src/components/Migration/ScanToDownloadModal.tsx b/src/components/Migration/ScanToDownloadModal.tsx new file mode 100644 index 000000000..06e5c230f --- /dev/null +++ b/src/components/Migration/ScanToDownloadModal.tsx @@ -0,0 +1,30 @@ +'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 ( + } + ctas={[{ text: t('qr.done'), variant: 'purple', shadowSize: '4', onClick: onClose }]} + /> + ) +} diff --git a/src/components/Migration/StoreButtons.tsx b/src/components/Migration/StoreButtons.tsx new file mode 100644 index 000000000..62f934e5d --- /dev/null +++ b/src/components/Migration/StoreButtons.tsx @@ -0,0 +1,18 @@ +'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 ( + + ) +} diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx new file mode 100644 index 000000000..13f383220 --- /dev/null +++ b/src/components/Migration/SunsetScreen.tsx @@ -0,0 +1,70 @@ +'use client' +import { useEffect } from 'react' +import Image from 'next/image' +import posthog from 'posthog-js' +import { useTranslations } from 'next-intl' +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' +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'] as const + +/** + * 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 ( +
+
+
+ {STARS.map((pos) => ( + + ))} + Peanut +
+
+

{t('sunset.heading')}

+

{t('sunset.sub')}

+
+ + +
+
+
+ {/* the layout's SupportDrawer never mounts when this screen replaces it */} + +
+ ) +} diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 1f75e7bec..b96cdc543 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2705,5 +2705,34 @@ "promptFailed": "Unlock again to open the app.", "unlock": "Unlock", "logOut": "Log out" + }, + "migration": { + "downloadPrompt": { + "title": "Peanut is becoming an app", + "description": "Peanut is moving to the App Store and Google Play. In {days, plural, one {# day} other {# days}} it will only work in the app — download it now to keep using your account.", + "remindLater": "Remind me later" + }, + "sunset": { + "heading": "Peanut lives on your phone now", + "sub": "The website has closed. Download the app to get back into your account — your money is safe.", + "supportLink": "Can't download the app? Contact support" + }, + "qr": { + "title": "Scan to download Peanut", + "description": "Pick your phone's store, then scan the code with your camera.", + "scanHint": "Scan with your phone camera", + "done": "Done" + }, + "banner": { + "title": "Get the Peanut app", + "description": "Faster payments and alerts 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" + }, + "downloadNow": "Download now" } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 8d97233e6..2f52a1ad1 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2705,5 +2705,34 @@ "promptFailed": "Desbloquea de nuevo para abrir la app.", "unlock": "Desbloquear", "logOut": "Cerrar sesión" + }, + "migration": { + "downloadPrompt": { + "title": "Peanut se convierte en una app", + "description": "Peanut se muda al App Store y Google Play. En {days, plural, one {# día} other {# días}} solo funcionará en la app — descárgala ahora para seguir usando tu cuenta.", + "remindLater": "Recordarme más tarde" + }, + "sunset": { + "heading": "Peanut ahora vive en tu teléfono", + "sub": "El sitio web cerró. Descarga la app para volver a tu cuenta — tu dinero está seguro.", + "supportLink": "¿No puedes descargar la app? Contacta a soporte" + }, + "qr": { + "title": "Escanea para descargar Peanut", + "description": "Elige la tienda de tu teléfono y escanea el código con tu cámara.", + "scanHint": "Escanea con la cámara de tu teléfono", + "done": "Listo" + }, + "banner": { + "title": "Descarga la app de Peanut", + "description": "Pagos más rápidos y alertas 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" + }, + "downloadNow": "Descargar ahora" } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index baa909ed1..2a4aab38e 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2705,5 +2705,34 @@ "promptFailed": "Desbloqueie novamente para abrir o app.", "unlock": "Desbloquear", "logOut": "Sair" + }, + "migration": { + "downloadPrompt": { + "title": "O Peanut está virando um app", + "description": "O Peanut está migrando para a App Store e o Google Play. Em {days, plural, one {# dia} other {# dias}} ele só funcionará no app — baixe agora para continuar usando sua conta.", + "remindLater": "Lembrar depois" + }, + "sunset": { + "heading": "O Peanut agora mora no seu celular", + "sub": "O site foi encerrado. Baixe o app para voltar à sua conta — seu dinheiro está seguro.", + "supportLink": "Não consegue baixar o app? Fale com o suporte" + }, + "qr": { + "title": "Escaneie para baixar o Peanut", + "description": "Escolha a loja do seu celular e escaneie o código com a câmera.", + "scanHint": "Escaneie com a câmera do seu celular", + "done": "Pronto" + }, + "banner": { + "title": "Baixe o app do Peanut", + "description": "Pagamentos mais rápidos e alertas 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" + }, + "downloadNow": "Baixar agora" } } diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 55e3a2b1e..c080ef1d5 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -493,6 +493,8 @@ 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 } export const updateUserPreferences = ( diff --git a/src/utils/migration.utils.ts b/src/utils/migration.utils.ts new file mode 100644 index 000000000..cac6c77d8 --- /dev/null +++ b/src/utils/migration.utils.ts @@ -0,0 +1,11 @@ +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' +import { openExternalUrl } from '@/utils/capacitor' + +/** navigate to the app store, tracking which surface sent the user there. */ +export function openStore(store: StoreKind, surface: MigrationSurface) { + posthog.capture(ANALYTICS_EVENTS.MIGRATION_STORE_CTA_CLICKED, { surface, store }) + // fire-and-forget: native Browser plugin or window.open on web + void openExternalUrl(STORE_URL[store]) +} From 560837d7e2a70148ccaf53817fb2ce87d698a5a1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:02:04 +0530 Subject: [PATCH 03/33] feat(migration): store links on landing + setup, retire PWA install, 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. --- src/app/(mobile-ui)/home/page.tsx | 17 +++++++ src/app/(setup)/layout.tsx | 31 ++++++++++++- .../LandingPage/LandingPageClient.tsx | 44 ++++++++++++++++++- src/components/LandingPage/hero.tsx | 2 + src/components/Setup/Views/Landing.tsx | 13 ++++++ src/context/ModalsContext.tsx | 12 +++++ src/hooks/useHomeCarouselCTAs.tsx | 35 ++++++++++++++- src/utils/migration.utils.ts | 7 ++- 8 files changed, 155 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 5e07381ab..9edf53c35 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -38,6 +38,8 @@ import ActivationCTAs from '@/components/Home/ActivationCTAs' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' +import { useModalsContext } from '@/context/ModalsContext' // Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size // Components are only loaded when user triggers them @@ -49,6 +51,7 @@ const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal')) const WelcomeUnlockModal = lazy(() => import('@/components/Home/WelcomeUnlockModal')) const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal')) const MigrationDownloadModal = lazy(() => import('@/components/Migration/MigrationDownloadModal')) +const ScanToDownloadModal = lazy(() => import('@/components/Migration/ScanToDownloadModal')) const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500') const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds @@ -57,6 +60,7 @@ export default function Home() { const t = useTranslations('home') const tNav = useTranslations('navigation') const { showPermissionModal } = useNotifications() + const { isGetAppModalOpen, setIsGetAppModalOpen } = useModalsContext() const { balance, isFetchingBalance, spendableBalance, isFetchingSpendableBalance } = useWallet() const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() const { resetWithdrawFlow } = useWithdrawFlow() @@ -267,6 +271,19 @@ export default function Home() {
+ + {/* desktop target of the get-the-app carousel CTA */} + {isGetAppModalOpen && ( + + + setIsGetAppModalOpen(false)} + surface={MIGRATION_SURFACES.HOME_BANNER} + /> + + + )} {/* Add Money Prompt Modal */} {/* TODO @dev Disabling this, re-enable after properly fixing */} diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index ad3538abd..b6b8645d0 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -11,12 +11,17 @@ import { Banner } from '@/components/Global/Banner' import SupportDrawer from '@/components/Global/SupportDrawer' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { usePullToRefresh } from '@/hooks/usePullToRefresh' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import SunsetScreen from '@/components/Migration/SunsetScreen' +import { KEEP_WEB_COOKIE, KEEP_WEB_TOKEN, MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts' import { isCapacitor } from '@/utils/capacitor' +import { getFromCookie } from '@/utils/general.utils' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { const dispatch = useAppDispatch() const isPWA = usePWAStatus() const { deviceType } = useDeviceType() + const migrationOn = useMigrationFlag() /* * Bottom-inset fill color. Periwinkle is for Android 15 edge-to-edge (matches @@ -54,6 +59,15 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { useEffect(() => { // filter steps and set them in redux state const filteredSteps = setupSteps.filter((step) => { + // pwa-sunset notice window: stop onboarding new users into the PWA — + // the InstallPWA screens go away, store links show on the landing + // step instead (TASK-20830 / TASK-20600) + if ( + migrationOn && + ['pwa-install', 'android-initial-pwa-install', 'unsupported-browser'].includes(step.screenId) + ) { + return false + } // Filter out pwa-install if already in PWA if (step.screenId === 'pwa-install' && isPWA) return false @@ -62,15 +76,28 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { dispatch(setupActions.setSteps(filteredSteps)) // if ios and not in pwa, show ios pwa install screen after setup flow is completed - if (deviceType === DeviceType.IOS && !isPWA) { + // (retired during the migration window — the app download replaces the PWA) + if (!migrationOn && deviceType === DeviceType.IOS && !isPWA) { dispatch(setupActions.setShowIosPwaInstallScreen(true)) } else { dispatch(setupActions.setShowIosPwaInstallScreen(false)) } - }, [isPWA, deviceType, dispatch]) + }, [isPWA, deviceType, dispatch, migrationOn]) usePullToRefresh() + // pwa-sunset: past the cutover the web signup is switched off too — same + // block as the mobile-ui layout (this route group has its own layout, so + // it needs its own gate). keep-web cookie bypasses. + if ( + migrationOn && + !isCapacitor() && + Date.now() >= MIGRATION_CUTOVER_DATE.getTime() && + getFromCookie(KEEP_WEB_COOKIE) !== KEEP_WEB_TOKEN + ) { + return + } + return ( <> {/* Status-bar safe zone + feedback ribbon. diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index bb05397b3..c1949c305 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -8,12 +8,18 @@ import { SUPPORTED_RAILS_FAQ_ID } from '@/constants/faq.consts' import TweetCarousel from '@/components/LandingPage/TweetCarousel' import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA' import underMaintenanceConfig from '@/config/underMaintenance.config' +import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal' +import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { trackStoreClick } from '@/utils/migration.utils' type CTAButton = { label: string href: string isExternal?: boolean subtext?: string + onClick?: (e: React.MouseEvent) => void } type FAQQuestion = { @@ -53,6 +59,35 @@ export function LandingPageClient({ footerSlot, }: LandingPageClientProps) { const { isFooterVisible } = useFooterVisibility() + const migrationOn = useMigrationFlag() + const { deviceType } = useDeviceType() + const [qrModalOpen, setQrModalOpen] = useState(false) + + // pwa-sunset: the hero CTA becomes "Download now" — the visitor's store on + // mobile, the scan-to-download QR on desktop. English-only like the rest of + // this surface; the permanent label change goes through the content system + // post-cutover, at which point this override is deleted (TASK-20600). + const primaryCta = useMemo((): CTAButton => { + if (!migrationOn) return heroConfig.primaryCta + if (deviceType === DeviceType.WEB) { + return { + label: 'Download now', + href: '#', + onClick: (e) => { + e.preventDefault() + setQrModalOpen(true) + }, + } + } + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + return { + label: 'Download now', + href: STORE_URL[store], + isExternal: true, + // the anchor navigates; only track here + onClick: () => trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO), + } + }, [migrationOn, deviceType, heroConfig.primaryCta]) // Memoized: this component re-renders per scroll frame during the button // animation — don't rebuild the FAQ array + rich answer element each time. @@ -199,7 +234,14 @@ export function LandingPageClient({ return ( <> - + + {qrModalOpen && ( + setQrModalOpen(false)} + surface={MIGRATION_SURFACES.LANDING_HERO} + /> + )} {mantecaSlot} diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index 30e9e11d8..d709d9ff1 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -77,6 +77,7 @@ type CTAButton = { href: string isExternal?: boolean subtext?: string + onClick?: (e: React.MouseEvent) => void } type HeroProps = { @@ -127,6 +128,7 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1 href={cta.href} target={cta.isExternal ? '_blank' : undefined} rel={cta.isExternal ? 'noopener noreferrer' : undefined} + onClick={cta.onClick} > + ))} diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx index e2e3230cc..6265b8b07 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -68,6 +68,13 @@ export default function MigrationDownloadModal({ const isDesktop = deviceType === DeviceType.WEB const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + const remindLaterCta = { + text: t('downloadPrompt.remindLater'), + variant: 'transparent' as const, + className: 'underline h-6', + onClick: snooze, + } + return ( : undefined} + ctaClassName="md:flex-col gap-4" ctas={ isDesktop - ? [] + ? [remindLaterCta] : [ { text: STORE_NAME[store], @@ -93,13 +101,9 @@ export default function MigrationDownloadModal({ openStore(store, MIGRATION_SURFACES.DOWNLOAD_MODAL) }, }, + remindLaterCta, ] } - footer={ - - } /> ) } diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx index 13f383220..eefa41896 100644 --- a/src/components/Migration/SunsetScreen.tsx +++ b/src/components/Migration/SunsetScreen.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react' import Image from 'next/image' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' +import { Button } from '@/components/0_Bruddle/Button' import StoreButtons from '@/components/Migration/StoreButtons' import SupportDrawer from '@/components/Global/SupportDrawer' import { useModalsContext } from '@/context/ModalsContext' @@ -54,12 +55,13 @@ export default function SunsetScreen() {

{t('sunset.sub')}

- +
diff --git a/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx index bd980d64c..b13e52bd1 100644 --- a/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx +++ b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx @@ -44,11 +44,15 @@ jest.mock('posthog-js', () => ({ capture: jest.fn() })) jest.mock('@/components/Global/ActionModal', () => ({ __esModule: true, - default: (props: { visible: boolean; title?: string; footer?: React.ReactNode }) => + default: (props: { visible: boolean; title?: string; ctas?: { text: string; onClick?: () => void }[] }) => props.visible ? (

{props.title}

- {props.footer} + {props.ctas?.map((c) => ( + + ))}
) : null, })) From 4955bd261fc2232aa0457f99db7b9306eb8feef7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:01:34 +0530 Subject: [PATCH 09/33] refactor(landing): dedupe CTAButton into landing.types.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review flag: the type was defined in both hero.tsx and LandingPageClient.tsx and this PR had grown both copies with onClick — one shared type file per the separate-types rule. also corrects the review-prompt comment's cache attribution (useHomeCarouselCTAs' limit-50 key, not HomeHistory's limit-5). --- src/components/LandingPage/LandingPageClient.tsx | 9 +-------- src/components/LandingPage/hero.tsx | 9 +-------- src/components/LandingPage/landing.types.ts | 9 +++++++++ src/components/Migration/ReviewPromptModal.tsx | 9 +++++---- 4 files changed, 16 insertions(+), 20 deletions(-) create mode 100644 src/components/LandingPage/landing.types.ts diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index c1949c305..039f33ee0 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -9,19 +9,12 @@ import TweetCarousel from '@/components/LandingPage/TweetCarousel' import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA' import underMaintenanceConfig from '@/config/underMaintenance.config' import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal' +import { type CTAButton } from '@/components/LandingPage/landing.types' import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useMigrationFlag } from '@/hooks/useMigrationFlag' import { trackStoreClick } from '@/utils/migration.utils' -type CTAButton = { - label: string - href: string - isExternal?: boolean - subtext?: string - onClick?: (e: React.MouseEvent) => void -} - type FAQQuestion = { id: string question: string diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index d709d9ff1..88f973cf8 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,14 +73,6 @@ function PeanutMascot() { ) } -type CTAButton = { - label: string - href: string - isExternal?: boolean - subtext?: string - onClick?: (e: React.MouseEvent) => void -} - type HeroProps = { primaryCta?: CTAButton secondaryCta?: CTAButton diff --git a/src/components/LandingPage/landing.types.ts b/src/components/LandingPage/landing.types.ts new file mode 100644 index 000000000..a6358c6ab --- /dev/null +++ b/src/components/LandingPage/landing.types.ts @@ -0,0 +1,9 @@ +// shared shape of the landing hero CTA buttons (hero.tsx renders them, +// LandingPageClient builds them from the content system / migration override) +export type CTAButton = { + label: string + href: string + isExternal?: boolean + subtext?: string + onClick?: (e: React.MouseEvent) => void +} diff --git a/src/components/Migration/ReviewPromptModal.tsx b/src/components/Migration/ReviewPromptModal.tsx index f1b1f8079..cf43c0db9 100644 --- a/src/components/Migration/ReviewPromptModal.tsx +++ b/src/components/Migration/ReviewPromptModal.tsx @@ -19,10 +19,11 @@ import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils * unhappy users never reach the store. * * ponytail: "good moment" V1 = user has at least one transaction and visits - * home (shares the useTransactionHistory cache with HomeHistory, 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. + * 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') From a99b6e8a6b0927bed6d240f18b999b5cb8f9e1cf Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:38:31 +0530 Subject: [PATCH 10/33] chore(analytics): expose window.posthog like the official snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the npm bundle doesn't attach the instance, which made console feature-flag overrides (pwa-sunset preview QA) impossible — the localStorage persistence hack gets clobbered on posthog boot. --- instrumentation-client.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 53dec5d25..ad1ed9580 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -37,6 +37,11 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') { disable_session_recording: isNativeBuild && !nativeReplayEnabled(), }) + // expose the instance like the official snippet does — console access for + // QA (feature-flag overrides, e.g. pwa-sunset preview testing) and support + // debugging; the npm bundle doesn't attach it by itself + ;(window as Window & { posthog?: typeof posthog }).posthog = posthog + // The web build inits Sentry via sentry.client.config.ts (injected by // withSentryConfig) with tunnelRoute '/monitoring'. The Capacitor static // export runs neither withSentryConfig nor a server for that tunnel, so From 4671ebee9c996c1bc448a80502437cb11f642232 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:48:38 +0530 Subject: [PATCH 11/33] docs(migration): correct the flag-override console recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posthog-js 1.3xx overrideFeatureFlags requires the { flags: {...} } wrapper — the flat-object shape from older versions is silently ignored (it only fires callbacks without registering the override), which is exactly how preview QA got stuck. --- src/hooks/useMigrationFlag.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useMigrationFlag.ts b/src/hooks/useMigrationFlag.ts index a1c60cffd..bce699d47 100644 --- a/src/hooks/useMigrationFlag.ts +++ b/src/hooks/useMigrationFlag.ts @@ -15,8 +15,10 @@ import { PWA_SUNSET_FLAG } from '@/constants/migration.consts' * - PostHog UI (project 138913): add a release condition on `pwa-sunset` * matching your `email` at 100%. * - Locally / on a preview: run - * `posthog.featureFlags.overrideFeatureFlags({ 'pwa-sunset': true })` - * in the console (persists for the session). + * `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)`. */ export function useMigrationFlag(): boolean { const isEnabled = useFeatureFlags() From 54df03c7bdcd36469667eec7d83a081d5d745699 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:10:03 +0530 Subject: [PATCH 12/33] =?UTF-8?q?fix(migration):=20preview=20QA=20round=20?= =?UTF-8?q?1=20=E2=80=94=20sticky=20CTA,=20desktop=20setup=20gate,=20dev?= =?UTF-8?q?=20overrides,=20landing=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StickyMobileCTA flips to DOWNLOAD NOW -> store deep-link during the migration window (it was still pushing /setup signup) - desktop /setup during the window is download-only (QR, no signup/login) unless the keep-web support bypass is present; mobile signup stays open with the store block; bypass users aren't nagged with store CTAs - pwa-sunset flag + cutover date get dev-only localStorage overrides (isPwaSunsetOn / getMigrationCutoverTime) because local dev never inits posthog — enables full local e2e of the flow - landing horizontal scroll on mobile (pre-existing prod bug): clip right-overflowing decorative elements at the shell --- src/app/(mobile-ui)/layout.tsx | 4 +- src/app/(setup)/layout.tsx | 7 ++-- .../LandingPage/LandingPageShell.tsx | 6 ++- .../LandingPage/StickyMobileCTA.tsx | 33 +++++++++++++--- .../Migration/MigrationDownloadModal.tsx | 13 ++----- src/components/Setup/Views/Landing.tsx | 28 +++++++++++-- src/hooks/useMigrationFlag.ts | 13 +++++-- src/hooks/useNotifications.ts | 6 +-- src/utils/migration.utils.ts | 39 ++++++++++++++++++- 9 files changed, 117 insertions(+), 32 deletions(-) diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index d2b41b5ef..08865b261 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -35,7 +35,7 @@ import { isDemoMode, enableDemoMode } from '@/utils/demo' import SunsetScreen from '@/components/Migration/SunsetScreen' import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' import { useMigrationFlag } from '@/hooks/useMigrationFlag' -import { MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts' +import { getMigrationCutoverTime } from '@/utils/migration.utils' const Layout = ({ children }: { children: React.ReactNode }) => { useNativePlugins() @@ -167,7 +167,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => { !isPublicPath && !isCapacitor() && !hasKeepWebBypass && - Date.now() >= MIGRATION_CUTOVER_DATE.getTime() + Date.now() >= getMigrationCutoverTime() ) { return } diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index e7b95dd32..f01ab658b 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -14,8 +14,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh' import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' import { useMigrationFlag } from '@/hooks/useMigrationFlag' import SunsetScreen from '@/components/Migration/SunsetScreen' -import { MIGRATION_CUTOVER_DATE, PWA_SUNSET_FLAG } from '@/constants/migration.consts' -import { isFeatureFlagEnabled } from '@/utils/featureFlag.utils' +import { getMigrationCutoverTime, isPwaSunsetOn } from '@/utils/migration.utils' import { isCapacitor } from '@/utils/capacitor' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { @@ -68,7 +67,7 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { useEffect(() => { if (migrationOnAtEntry.current === null) { - migrationOnAtEntry.current = isFeatureFlagEnabled(PWA_SUNSET_FLAG) + migrationOnAtEntry.current = isPwaSunsetOn() } const migrationSteps = migrationOnAtEntry.current @@ -104,7 +103,7 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { // pwa-sunset: past the cutover the web signup is switched off too — same // block as the mobile-ui layout (this route group has its own layout, so // it needs its own gate). keep-web cookie/param bypasses. - if (migrationOn && !isCapacitor() && !hasKeepWebBypass && Date.now() >= MIGRATION_CUTOVER_DATE.getTime()) { + if (migrationOn && !isCapacitor() && !hasKeepWebBypass && Date.now() >= getMigrationCutoverTime()) { return } diff --git a/src/components/LandingPage/LandingPageShell.tsx b/src/components/LandingPage/LandingPageShell.tsx index 94a848860..9fe52d9c7 100644 --- a/src/components/LandingPage/LandingPageShell.tsx +++ b/src/components/LandingPage/LandingPageShell.tsx @@ -3,7 +3,11 @@ import { FooterVisibilityObserver } from '@/components/Global/FooterVisibilityOb export function LandingPageShell({ children }: { children: ReactNode }) { return ( -
+ // 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 3e8a87181..751182b71 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -4,11 +4,18 @@ 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 { 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 { deviceType } = useDeviceType() + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' useEffect(() => { const check = () => { @@ -43,11 +50,27 @@ 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" > - - - + {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)} + > + + + ) : ( + + + + )} )} diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx index 6265b8b07..a26ae9cdf 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -4,14 +4,9 @@ import posthog from 'posthog-js' import { useTranslations } from 'next-intl' import ActionModal from '@/components/Global/ActionModal' import DownloadQR from '@/components/Migration/DownloadQR' -import { openStore } from '@/utils/migration.utils' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' -import { - DOWNLOAD_PROMPT_SNOOZE_DAYS, - MIGRATION_CUTOVER_DATE, - MIGRATION_SURFACES, - STORE_NAME, -} from '@/constants/migration.consts' +import { DOWNLOAD_PROMPT_SNOOZE_DAYS, MIGRATION_SURFACES, 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' @@ -41,7 +36,7 @@ export default function MigrationDownloadModal({ 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() >= MIGRATION_CUTOVER_DATE.getTime()) { + if (!migrationOn || !userId || isCapacitor() || Date.now() >= getMigrationCutoverTime()) { setVisible(false) return } @@ -64,7 +59,7 @@ export default function MigrationDownloadModal({ posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.MIGRATION_DOWNLOAD }) } - const daysLeft = Math.max(1, Math.ceil((MIGRATION_CUTOVER_DATE.getTime() - Date.now()) / (24 * 60 * 60 * 1000))) + 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' diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index c009bceef..92d385700 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -12,14 +12,24 @@ import { useEffect } from 'react' import { disableDemoMode } from '@/utils/demo' import DocsLink from '@/components/Global/DocsLink' import { useTranslations } from 'next-intl' +import DownloadQR from '@/components/Migration/DownloadQR' 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' const LandingStep = () => { const t = useTranslations('setup') const tMigration = useTranslations('migration') const migrationOn = useMigrationFlag() + const { deviceType } = useDeviceType() + const hasKeepWebBypass = useKeepWebBypass() + + // migration window, desktop, no support bypass: the app is the product — + // web signup/login is closed, download is the only path. the keep-web + // link support hands out restores the normal auth screen. + const downloadOnly = migrationOn && deviceType === DeviceType.WEB && !hasKeepWebBypass const { handleNext } = useSetupFlow() const { handleLoginClick, isLoggingIn } = useLogin() const toast = useToast() @@ -46,6 +56,17 @@ const LandingStep = () => { } } + if (downloadOnly) { + return ( + + +

{tMigration('banner.title')}

+ +
+
+ ) + } + return ( @@ -77,9 +98,10 @@ const LandingStep = () => { {t('landing.recoverWallet')}
- {/* pwa-sunset notice window: the app replaces the PWA — offer the - store up front (TASK-20600) */} - {migrationOn && ( + {/* pwa-sunset notice window on mobile: signup stays open, but the + store is offered up front (TASK-20600). bypass users came to + keep using the web — don't push the app at them. */} + {migrationOn && deviceType !== DeviceType.WEB && !hasKeepWebBypass && (

{tMigration('banner.title')}

diff --git a/src/hooks/useMigrationFlag.ts b/src/hooks/useMigrationFlag.ts index bce699d47..df2234ac2 100644 --- a/src/hooks/useMigrationFlag.ts +++ b/src/hooks/useMigrationFlag.ts @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' import { useFeatureFlags } from '@/hooks/useFeatureFlag' -import { PWA_SUNSET_FLAG } from '@/constants/migration.consts' +import { isPwaSunsetOn } from '@/utils/migration.utils' /** * Is the PWA-sunset migration live? Fails closed (false) until PostHog flags @@ -14,19 +14,24 @@ import { PWA_SUNSET_FLAG } from '@/constants/migration.consts' * Testing with the flag on: * - PostHog UI (project 138913): add a release condition on `pwa-sunset` * matching your `email` at 100%. - * - Locally / on a preview: run + * - 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 { - const isEnabled = useFeatureFlags() + // 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 && isEnabled(PWA_SUNSET_FLAG) + return mounted && isPwaSunsetOn() } diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 17505eee8..bd7557d67 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -8,8 +8,8 @@ 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, PWA_SUNSET_FLAG } from '@/constants/migration.consts' -import { isFeatureFlagEnabled } from '@/utils/featureFlag.utils' +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 @@ -133,7 +133,7 @@ async function evaluateVisibility() { updateUserPreferences(currentExternalId ?? undefined, { notifModalClosedAt: closedAt }) } const snoozeExpired = !!closedAt && Date.now() - new Date(closedAt).getTime() >= NOTIF_PROMPT_SNOOZE_MS - const modalClosed = !!closedAt && !(isFeatureFlagEnabled(PWA_SUNSET_FLAG) && snoozeExpired) + const modalClosed = !!closedAt && !(isPwaSunsetOn() && snoozeExpired) // don't show modal if permission is denied (carousel cta will handle it) if (state.permissionState === 'denied') { diff --git a/src/utils/migration.utils.ts b/src/utils/migration.utils.ts index 4c729c769..1cf3ada2a 100644 --- a/src/utils/migration.utils.ts +++ b/src/utils/migration.utils.ts @@ -1,8 +1,45 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.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 { 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) +} + +/** + * 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 }) From 00df0971a098cb231fca8731152986bebb27feff Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:21:06 +0530 Subject: [PATCH 13/33] fix(migration): sunset screen is full-bleed, not a bordered card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the mockup rendered it as a showcase card inside /dev/migration; the real screen fills the viewport — full-width mascot hero, content column capped at max-w-md, safe-area padding on the hero. --- src/components/Migration/SunsetScreen.tsx | 69 ++++++++++++----------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx index eefa41896..9c5df133e 100644 --- a/src/components/Migration/SunsetScreen.tsx +++ b/src/components/Migration/SunsetScreen.tsx @@ -29,42 +29,43 @@ export default function SunsetScreen() { }, []) return ( -
-
-
- {STARS.map((pos) => ( - - ))} +
+
+ {STARS.map((pos) => ( Peanut -
-
-

{t('sunset.heading')}

-

{t('sunset.sub')}

-
- - -
-
-
+ ))} + Peanut +
+
+

{t('sunset.heading')}

+

{t('sunset.sub')}

+
+ + +
+
{/* the layout's SupportDrawer never mounts when this screen replaces it */}
From f28bab861ef3c7e77e72736021f150adf433ab5b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:35:29 +0530 Subject: [PATCH 14/33] style(migration): remind-me-later cta at text-sm --- src/components/Migration/MigrationDownloadModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx index a26ae9cdf..482bcf4d7 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -66,7 +66,7 @@ export default function MigrationDownloadModal({ const remindLaterCta = { text: t('downloadPrompt.remindLater'), variant: 'transparent' as const, - className: 'underline h-6', + className: 'underline h-6 text-sm', onClick: snooze, } From 863e816c43ce3d29ec516f7f2889961af14e9482 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:45:35 +0530 Subject: [PATCH 15/33] copy(migration): banner says the app is where Peanut is going, not a perks pitch --- src/i18n/app/messages/en.json | 2 +- src/i18n/app/messages/es-419.json | 2 +- src/i18n/app/messages/pt-BR.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 9514901d8..c0d34ca25 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2727,7 +2727,7 @@ }, "banner": { "title": "Get the Peanut app", - "description": "Faster payments and alerts when money lands" + "description": "Peanut is moving to the app — download it for a smoother experience" }, "review": { "title": "Loving Peanut so far?", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index d45515e58..7cacfde09 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2727,7 +2727,7 @@ }, "banner": { "title": "Descarga la app de Peanut", - "description": "Pagos más rápidos y alertas cuando llega tu dinero" + "description": "Peanut se muda a la app — descárgala para una experiencia más fluida" }, "review": { "title": "¿Te está gustando Peanut?", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 88cc86988..3f0ba66b3 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2727,7 +2727,7 @@ }, "banner": { "title": "Baixe o app do Peanut", - "description": "Pagamentos mais rápidos e alertas quando o dinheiro chega" + "description": "O Peanut está migrando para o app — baixe para uma experiência mais fluida" }, "review": { "title": "Está gostando do Peanut?", From 700248fdfd27fa6535ffdf7b6b6e2a3bc17483a2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:52:27 +0530 Subject: [PATCH 16/33] =?UTF-8?q?style(migration):=20sunset=20screen=2050/?= =?UTF-8?q?50=20split=20=E2=80=94=20row=20on=20desktop,=20hero/content=20s?= =?UTF-8?q?tack=20on=20mobile=20with=20bottom-pinned=20cta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Migration/SunsetScreen.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx index 9c5df133e..e38853957 100644 --- a/src/components/Migration/SunsetScreen.tsx +++ b/src/components/Migration/SunsetScreen.tsx @@ -29,9 +29,12 @@ export default function SunsetScreen() { }, []) 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. +
{STARS.map((pos) => ( @@ -49,13 +52,15 @@ export default function SunsetScreen() { alt="Peanut" width={200} height={200} - className="z-0 h-44 w-auto object-contain" + className="z-0 h-44 w-auto object-contain md:h-56" />
-
-

{t('sunset.heading')}

-

{t('sunset.sub')}

-
+
+
+

{t('sunset.heading')}

+

{t('sunset.sub')}

+
+
{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 index 482bcf4d7..ec911511f 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -5,7 +5,12 @@ 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, STORE_NAME } from '@/constants/migration.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' @@ -63,8 +68,12 @@ export default function MigrationDownloadModal({ 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('downloadPrompt.remindLater'), + text: t(isUrgent ? 'downloadPrompt.remindLater' : 'downloadPrompt.maybeLater'), variant: 'transparent' as const, className: 'underline h-6 text-sm', onClick: snooze, @@ -75,8 +84,10 @@ export default function MigrationDownloadModal({ visible={visible} onClose={snooze} icon="mobile-install" - title={t('downloadPrompt.title')} - description={t('downloadPrompt.description', { days: daysLeft })} + title={t(isUrgent ? 'downloadPrompt.title' : 'downloadPrompt.earlyTitle')} + description={ + isUrgent ? t('downloadPrompt.description', { days: daysLeft }) : t('downloadPrompt.earlyDescription') + } content={isDesktop ? : undefined} ctaClassName="md:flex-col gap-4" ctas={ diff --git a/src/components/Migration/ReviewPromptModal.tsx b/src/components/Migration/ReviewPromptModal.tsx index cf43c0db9..764c0ba33 100644 --- a/src/components/Migration/ReviewPromptModal.tsx +++ b/src/components/Migration/ReviewPromptModal.tsx @@ -30,7 +30,7 @@ export default function ReviewPromptModal() { const migrationOn = useMigrationFlag() const { deviceType } = useDeviceType() const { user } = useUserStore() - const { setIsSupportModalOpen } = useModalsContext() + const { openSupportWithMessage } = useModalsContext() const { data: latestHistory } = useTransactionHistory({ mode: 'latest', limit: 50 }) const [visible, setVisible] = useState(false) @@ -80,7 +80,8 @@ export default function ReviewPromptModal() { shadowSize: '4', onClick: () => { close('meh') - setIsSupportModalOpen(true) + // 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 index 06e5c230f..cbcc9a5c8 100644 --- a/src/components/Migration/ScanToDownloadModal.tsx +++ b/src/components/Migration/ScanToDownloadModal.tsx @@ -22,7 +22,6 @@ export default function ScanToDownloadModal({ onClose={onClose} icon="qr-code" title={t('qr.title')} - description={t('qr.description')} content={} ctas={[{ text: t('qr.done'), variant: 'purple', shadowSize: '4', onClick: onClose }]} /> diff --git a/src/constants/migration.consts.ts b/src/constants/migration.consts.ts index 299c90b0d..434f07c27 100644 --- a/src/constants/migration.consts.ts +++ b/src/constants/migration.consts.ts @@ -17,6 +17,10 @@ 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 diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index c0d34ca25..611a82911 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2710,19 +2710,23 @@ }, "migration": { "downloadPrompt": { - "title": "Peanut is becoming an app", - "description": "Peanut is moving to the App Store and Google Play. In {days, plural, one {# day} other {# days}} it will only work in the app — download it now to keep using your account.", + "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": "Maybe 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 lives on your phone now", - "sub": "The website has closed. Download the app to get back into your account — your money is safe.", - "supportLink": "Can't download the app? Contact support" + "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": "Can't use the app? We'll help — contact support" }, "qr": { - "title": "Scan to download Peanut", - "description": "Pick your phone's store, then scan the code with your camera.", - "scanHint": "Scan with your phone camera", + "title": "Get the Peanut app", + "scanHint": "Scan with your phone camera to download.", + "openIos": "Open App Store ↗", + "openAndroid": "Open Google Play ↗", "done": "Done" }, "banner": { @@ -2733,7 +2737,8 @@ "title": "Loving Peanut so far?", "description": "A quick rating helps other people find us.", "loveIt": "Love it", - "meh": "Could be better" + "meh": "Could be better", + "supportPrefill": "I have some feedback about the app — here's what could be better: " }, "downloadNow": "Download now" } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 7cacfde09..d523bf5ce 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2710,19 +2710,23 @@ }, "migration": { "downloadPrompt": { - "title": "Peanut se convierte en una app", - "description": "Peanut se muda al App Store y Google Play. En {days, plural, one {# día} other {# días}} solo funcionará en la app — descárgala ahora para seguir usando tu cuenta.", + "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": "Quizás después", + "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 vive en tu teléfono", - "sub": "El sitio web cerró. Descarga la app para volver a tu cuenta — tu dinero está seguro.", - "supportLink": "¿No puedes descargar la app? Contacta a soporte" + "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": "¿No puedes usar la app? Te ayudamos — contacta a soporte" }, "qr": { - "title": "Escanea para descargar Peanut", - "description": "Elige la tienda de tu teléfono y escanea el código con tu cámara.", - "scanHint": "Escanea con la cámara de tu teléfono", + "title": "Descarga la app de Peanut", + "scanHint": "Escanea con la cámara de tu celular para descargar.", + "openIos": "Abrir App Store ↗", + "openAndroid": "Abrir Google Play ↗", "done": "Listo" }, "banner": { @@ -2733,7 +2737,8 @@ "title": "¿Te está gustando Peanut?", "description": "Una calificación rápida ayuda a que otros nos encuentren.", "loveIt": "Me encanta", - "meh": "Podría mejorar" + "meh": "Podría mejorar", + "supportPrefill": "Tengo comentarios sobre la app — esto es lo que podría mejorar: " }, "downloadNow": "Descargar ahora" } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 3f0ba66b3..b010642fb 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2710,19 +2710,23 @@ }, "migration": { "downloadPrompt": { - "title": "O Peanut está virando um app", - "description": "O Peanut está migrando para a App Store e o Google Play. Em {days, plural, one {# dia} other {# dias}} ele só funcionará no app — baixe agora para continuar usando sua conta.", + "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": "Talvez 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 mora no seu celular", - "sub": "O site foi encerrado. Baixe o app para voltar à sua conta — seu dinheiro está seguro.", - "supportLink": "Não consegue baixar o app? Fale com o suporte" + "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": "Não consegue usar o app? A gente ajuda — fale com o suporte" }, "qr": { - "title": "Escaneie para baixar o Peanut", - "description": "Escolha a loja do seu celular e escaneie o código com a câmera.", - "scanHint": "Escaneie com a câmera do seu celular", + "title": "Baixe o app do Peanut", + "scanHint": "Escaneie com a câmera do seu celular para baixar.", + "openIos": "Abrir App Store ↗", + "openAndroid": "Abrir Google Play ↗", "done": "Pronto" }, "banner": { @@ -2733,7 +2737,8 @@ "title": "Está gostando do Peanut?", "description": "Uma avaliação rápida ajuda outras pessoas a nos encontrar.", "loveIt": "Adorei", - "meh": "Pode melhorar" + "meh": "Pode melhorar", + "supportPrefill": "Tenho um feedback sobre o app — isso poderia melhorar: " }, "downloadNow": "Baixar agora" } From 881e4e6e6fdf1742c8e23fd21f627a5a7312697a Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:50:00 +0530 Subject: [PATCH 19/33] =?UTF-8?q?feat(migration):=20smart=20QR=20=E2=80=94?= =?UTF-8?q?=20one=20code,=20the=20scanning=20device=20picks=20the=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replaces the per-store QR toggle Hugo flagged (off-design-system, and toggling inflated migration_qr_shown). Every QR now encodes /app, a new public smart-link page that redirects iOS/Android scanners to their store (client redirect so the capacitor static export builds unchanged; desktop fallback shows both links). qr_shown drops the store dimension and fires once per display. 'app' reserved in DEDICATED_ROUTES. --- src/app/app/page.tsx | 43 +++++++++++++++++++++++++ src/components/Migration/DownloadQR.tsx | 30 +++++------------ src/constants/analytics.consts.ts | 4 ++- src/constants/routes.ts | 1 + 4 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 src/app/app/page.tsx diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx new file mode 100644 index 000000000..b81959611 --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,43 @@ +'use client' + +// smart store link: peanut.me/app — every download QR points here so a single +// code serves both stores; the scanning device decides. phones bounce straight +// to their store, desktop gets both links. client redirect (not a route +// handler) so the capacitor static export builds unchanged. + +import { useEffect, useState } from 'react' +import { STORE_URL } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' + +export default function SmartStoreRedirect() { + const { deviceType } = useDeviceType() + const [redirecting, setRedirecting] = useState(true) + + useEffect(() => { + if (deviceType === DeviceType.IOS) { + window.location.replace(STORE_URL.ios) + } else if (deviceType === DeviceType.ANDROID) { + window.location.replace(STORE_URL.android) + } else { + setRedirecting(false) + } + }, [deviceType]) + + return ( +
+

Get the Peanut app

+ {redirecting ? ( +

Taking you to the store…

+ ) : ( + + )} +
+ ) +} diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx index 243ef7862..68a31b5fc 100644 --- a/src/components/Migration/DownloadQR.tsx +++ b/src/components/Migration/DownloadQR.tsx @@ -1,39 +1,25 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' -import { Button } from '@/components/0_Bruddle/Button' import QRCodeWrapper from '@/components/Global/QRCodeWrapper' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { STORE_NAME, STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' +import { SELF_URL } from '@/constants/general.consts' +import { STORE_URL, type MigrationSurface } from '@/constants/migration.consts' import { trackStoreClick } from '@/utils/migration.utils' -// scan-to-download with a store toggle. on desktop we can't know the visitor's -// phone OS, so we show both store QRs behind a toggle instead of guessing. +// 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') - const [store, setStore] = useState('ios') useEffect(() => { - posthog.capture(ANALYTICS_EVENTS.MIGRATION_QR_SHOWN, { surface, store }) - }, [surface, store]) + posthog.capture(ANALYTICS_EVENTS.MIGRATION_QR_SHOWN, { surface }) + }, [surface]) return (
-
- {(['ios', 'android'] as const).map((s) => ( - - ))} -
- + {t('qr.scanHint')} {/* desktop can install directly too (e.g. Google Play from the browser) */}
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 89ff24ce7..1c6af5bef 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -274,7 +274,9 @@ export const ANALYTICS_EVENTS = { // ── 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` ∈ 'ios' | 'android'. + // `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', MIGRATION_STORE_CTA_CLICKED: 'migration_store_cta_clicked', MIGRATION_QR_SHOWN: 'migration_qr_shown', diff --git a/src/constants/routes.ts b/src/constants/routes.ts index a69edabd5..4785ec75a 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', From cf18e4591204fc09e9a62cc500c7103b2243c878 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:16:29 +0530 Subject: [PATCH 20/33] =?UTF-8?q?style(migration):=20QA=20round=202=20?= =?UTF-8?q?=E2=80=94=20centered=20sunset=20copy,=20cleaner=20/app=20page,?= =?UTF-8?q?=20banner=20logo=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/app/page.tsx | 54 +++++++++++++++------- src/components/Migration/MigrationHero.tsx | 35 ++++++++++++++ src/components/Migration/SunsetScreen.tsx | 37 ++------------- src/hooks/useHomeCarouselCTAs.tsx | 1 - 4 files changed, 77 insertions(+), 50 deletions(-) create mode 100644 src/components/Migration/MigrationHero.tsx diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index b81959611..ddfef5cb1 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -2,11 +2,15 @@ // smart store link: peanut.me/app — every download QR points here so a single // code serves both stores; the scanning device decides. phones bounce straight -// to their store, desktop gets both links. client redirect (not a route -// handler) so the capacitor static export builds unchanged. +// to their store, desktop gets both store buttons. client redirect (not a +// route handler) so the capacitor static export builds unchanged. same visual +// language as the sunset screen (MigrationHero + 50/50 split). import { useEffect, useState } from 'react' -import { STORE_URL } from '@/constants/migration.consts' +import { Button } from '@/components/0_Bruddle/Button' +import Loading from '@/components/Global/Loading' +import MigrationHero from '@/components/Migration/MigrationHero' +import { STORE_NAME, STORE_URL } from '@/constants/migration.consts' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' export default function SmartStoreRedirect() { @@ -24,20 +28,38 @@ export default function SmartStoreRedirect() { }, [deviceType]) return ( -
-

Get the Peanut app

- {redirecting ? ( -

Taking you to the store…

- ) : ( -
- - App Store - - - Google Play - +
+ +
+
+

Get the Peanut app

+

+ {redirecting + ? 'Taking you to the store…' + : 'Global cash, local feel — pick your store to download.'} +

- )} +
+ {redirecting ? ( +
+ +
+ ) : ( + <> + + + + + + + + )} +
+
) } diff --git a/src/components/Migration/MigrationHero.tsx b/src/components/Migration/MigrationHero.tsx new file mode 100644 index 000000000..cbbe055f0 --- /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) => ( + + ))} + Peanut +
+ ) +} diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx index 5ad76a407..65be01304 100644 --- a/src/components/Migration/SunsetScreen.tsx +++ b/src/components/Migration/SunsetScreen.tsx @@ -1,23 +1,14 @@ 'use client' import { useEffect } from 'react' -import Image from 'next/image' 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' -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 /** * Full-screen block once the website is switched off (TASK-20827) — rendered @@ -38,30 +29,10 @@ export default function SunsetScreen() { // the CTA pinned to the bottom. desktop (md+): 50/50 row, hero left, // content centered right.
-
- {STARS.map((pos) => ( - - ))} - Peanut -
+
-
+ {/* centered on desktop to match the centered store CTA below */} +

{t('sunset.heading')}

{t('sunset.sub')}

diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index bf63fe9ac..0688721ff 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -150,7 +150,6 @@ export const useHomeCarouselCTAs = () => { id: 'app-install', title: tMigration('banner.title'), description: tMigration('banner.description'), - iconContainerClassName: 'bg-secondary-1', icon: 'mobile-install', logo: PEANUTMAN_MOBILE, iconSize: 16, From bedf004726a3c100a5727122dcf3a8c538ae80ec Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:53:28 +0530 Subject: [PATCH 21/33] feat(migration): notice window closes NEW web signups everywhere; /app loads inside the CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/app/(setup)/setup/page.tsx | 8 +++- src/app/app/page.tsx | 65 +++++++++++++------------- src/components/Setup/Views/Landing.tsx | 60 +++++++++--------------- 3 files changed, 63 insertions(+), 70 deletions(-) diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index d5d537f6d..311aeb8fe 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -11,6 +11,7 @@ import { setupSteps as masterSetupSteps } from '../../../components/Setup/Setup. import UnsupportedBrowserModal from '@/components/Global/UnsupportedBrowserModal' import { isLikelyWebview, isDeviceOsSupported } from '@/components/Setup/Setup.utils' import { isCapacitor } from '@/utils/capacitor' +import { isPwaSunsetOn } from '@/utils/migration.utils' import { getFromCookie } from '@/utils/general.utils' import { useSearchParams } from 'next/navigation' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' @@ -100,7 +101,12 @@ function SetupPageContent() { // onto Signup, unable to log back in (regression from PR #2346). const inviteCodeFromCookie = getFromCookie('inviteCode') const userInviteCode = inviteCode || inviteCodeFromCookie - const skipInviteGate = !!userInviteCode || searchParams.get('step') === 'signup' + // pwa-sunset notice window: web signups are closed (Landing hides + // Sign up), so the ?step=signup / invite-code jump must not skip + // past the landing gate — otherwise claim/invite links deep-link + // straight into the signup form. Native app keeps the fast path. + const webSignupClosed = isPwaSunsetOn() && !isCapacitor() + const skipInviteGate = (!!userInviteCode || searchParams.get('step') === 'signup') && !webSignupClosed const localDeviceType = detectedDeviceType diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index ddfef5cb1..9f8189ff1 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -2,30 +2,35 @@ // smart store link: peanut.me/app — every download QR points here so a single // code serves both stores; the scanning device decides. phones bounce straight -// to their store, desktop gets both store buttons. client redirect (not a -// route handler) so the capacitor static export builds unchanged. same visual -// language as the sunset screen (MigrationHero + 50/50 split). +// to their store (their store button shows the loading state while the +// redirect happens; if it doesn't take, the buttons settle clickable), +// desktop just gets both buttons. client redirect (not a route handler) so +// the capacitor static export builds unchanged. same visual language as the +// sunset screen (MigrationHero + 50/50 split). import { useEffect, useState } from 'react' import { Button } from '@/components/0_Bruddle/Button' -import Loading from '@/components/Global/Loading' import MigrationHero from '@/components/Migration/MigrationHero' -import { STORE_NAME, STORE_URL } from '@/constants/migration.consts' +import { STORE_NAME, STORE_URL, type StoreKind } from '@/constants/migration.consts' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' export default function SmartStoreRedirect() { const { deviceType } = useDeviceType() - const [redirecting, setRedirecting] = useState(true) + const targetStore: StoreKind | null = + deviceType === DeviceType.IOS ? 'ios' : deviceType === DeviceType.ANDROID ? 'android' : null + const [redirecting, setRedirecting] = useState(targetStore !== null) useEffect(() => { - if (deviceType === DeviceType.IOS) { - window.location.replace(STORE_URL.ios) - } else if (deviceType === DeviceType.ANDROID) { - window.location.replace(STORE_URL.android) - } else { - setRedirecting(false) - } - }, [deviceType]) + if (!targetStore) return + window.location.replace(STORE_URL[targetStore]) + // if the store didn't take over (blocked, offline), settle to buttons + const fallback = setTimeout(() => setRedirecting(false), 4000) + return () => clearTimeout(fallback) + }, [targetStore]) + + const stores: StoreKind[] = targetStore + ? [targetStore, targetStore === 'ios' ? 'android' : 'ios'] + : ['ios', 'android'] return (
@@ -40,24 +45,20 @@ export default function SmartStoreRedirect() {

- {redirecting ? ( -
- -
- ) : ( - <> - - - - - - - - )} + {stores.map((s, i) => ( + 0 ? 'hidden' : 'block'}> + + + ))}
diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 92d385700..41c37c13f 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -12,24 +12,23 @@ import { useEffect } from 'react' import { disableDemoMode } from '@/utils/demo' import DocsLink from '@/components/Global/DocsLink' import { useTranslations } from 'next-intl' -import DownloadQR from '@/components/Migration/DownloadQR' 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 { deviceType } = useDeviceType() const hasKeepWebBypass = useKeepWebBypass() - // migration window, desktop, no support bypass: the app is the product — - // web signup/login is closed, download is the only path. the keep-web - // link support hands out restores the normal auth screen. - const downloadOnly = migrationOn && deviceType === DeviceType.WEB && !hasKeepWebBypass + // 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() @@ -56,30 +55,26 @@ const LandingStep = () => { } } - if (downloadOnly) { - return ( - - -

{tMigration('banner.title')}

- -
-
- ) - } - return ( - + {blockSignup ? ( +
+

{tMigration('banner.title')}

+ +
+ ) : ( + + )}
- {/* pwa-sunset notice window on mobile: signup stays open, but the - store is offered up front (TASK-20600). bypass users came to - keep using the web — don't push the app at them. */} - {migrationOn && deviceType !== DeviceType.WEB && !hasKeepWebBypass && ( -
-

{tMigration('banner.title')}

- -
- )} ) From f8ed01d2e05e096ad0aa07c2fc7998b17d51b41e Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:09:37 +0530 Subject: [PATCH 22/33] =?UTF-8?q?style(migration):=20setup=20download-only?= =?UTF-8?q?=20polish=20=E2=80=94=20heading=20only=20above=20the=20QR,=20no?= =?UTF-8?q?=20title-block=20gap,=20centered=20desktop=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Setup/Views/Landing.tsx | 8 ++++++- .../Setup/components/SetupWrapper.tsx | 24 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 41c37c13f..6845dcab6 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -14,6 +14,7 @@ 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' @@ -23,6 +24,7 @@ const LandingStep = () => { 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 @@ -60,7 +62,11 @@ const LandingStep = () => { {blockSignup ? (
-

{tMigration('banner.title')}

+ {/* heading only above the desktop QR — a lone store button + explains itself */} + {deviceType === DeviceType.WEB && ( +

{tMigration('banner.title')}

+ )}
) : ( diff --git a/src/components/Setup/components/SetupWrapper.tsx b/src/components/Setup/components/SetupWrapper.tsx index de8ca585b..a39ae0c42 100644 --- a/src/components/Setup/components/SetupWrapper.tsx +++ b/src/components/Setup/components/SetupWrapper.tsx @@ -6,6 +6,9 @@ import { type BeforeInstallPromptEvent, type LayoutType, type ScreenId } from '@ import InstallPWA from '@/components/Setup/Views/InstallPWA' import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState' import { DeviceType } from '@/hooks/useGetDeviceType' +import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { isCapacitor } from '@/utils/capacitor' import classNames from 'classnames' import { motion, useReducedMotion } from 'framer-motion' import { useTranslations } from 'next-intl' @@ -218,6 +221,12 @@ export const SetupWrapper = memo(function SetupWrapper({ const { isBrave } = useBravePWAInstallState() const [showBraveSuccessMessage, setShowBraveSuccessMessage] = useState(false) const prefersReducedMotion = useReducedMotion() + const migrationOn = useMigrationFlag() + const hasKeepWebBypass = useKeepWebBypass() + // migration notice window's download-only landing: drop the fixed-height + // title block (it left a big gap above the QR) and center the copy on + // desktop to match the centered store content. legacy landing untouched. + const sunsetLanding = screenId === 'landing' && migrationOn && !isCapacitor() && !hasKeepWebBypass // Slide the white panel up on first paint for a native bottom-sheet feel. // Mobile + landing only; read synchronously so the offset is correct on mount. @@ -274,20 +283,31 @@ export const SetupWrapper = memo(function SetupWrapper({
{headingTitle && (

{headingTitle}

)} - {headingDescription &&

{headingDescription}

} + {headingDescription && ( +

+ {headingDescription} +

+ )}
{/* main content area */}
From b2c02072566bc9ec036625658c83b2131467c3b7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:34:25 +0530 Subject: [PATCH 23/33] fix(migration): QR encodes the serving origin, not SELF_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a preview's QR pointed scanners at prod and a LAN-served dev build at localhost — window.location.origin is correct everywhere. --- src/components/Migration/DownloadQR.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Migration/DownloadQR.tsx b/src/components/Migration/DownloadQR.tsx index 68a31b5fc..95960a8d6 100644 --- a/src/components/Migration/DownloadQR.tsx +++ b/src/components/Migration/DownloadQR.tsx @@ -17,9 +17,14 @@ export default function DownloadQR({ surface }: { surface: MigrationSurface }) { 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) */}
From 2c2e53cf5db2e747c40a77d96783737ecc9e1e7c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:33:45 +0530 Subject: [PATCH 24/33] =?UTF-8?q?style(migration):=20Hugo=20review=20?= =?UTF-8?q?=E2=80=94=20brand=20icons,=20store=20badge=20pair,=20no=20more?= =?UTF-8?q?=20underline=20pile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/app/app/page.tsx | 4 +-- src/components/Global/Icons/Icon.tsx | 5 +++ src/components/Global/Icons/store-brands.tsx | 22 ++++++++++++ .../LandingPage/LandingPageClient.tsx | 8 ++++- src/components/LandingPage/hero.tsx | 5 ++- src/components/Migration/DownloadQR.tsx | 19 ++--------- .../Migration/MigrationDownloadModal.tsx | 2 +- src/components/Migration/StoreBadges.tsx | 34 +++++++++++++++++++ src/components/Migration/StoreButtons.tsx | 8 ++++- 9 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 src/components/Global/Icons/store-brands.tsx create mode 100644 src/components/Migration/StoreBadges.tsx diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 9f8189ff1..a8c39fbae 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -41,7 +41,7 @@ export default function SmartStoreRedirect() {

{redirecting ? 'Taking you to the store…' - : 'Global cash, local feel — pick your store to download.'} + : 'Global cash, local feel. Pick your store to download.'}

@@ -50,7 +50,7 @@ export default function SmartStoreRedirect() {
) } diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx index ec911511f..ccc73941e 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -98,7 +98,7 @@ export default function MigrationDownloadModal({ text: STORE_NAME[store], variant: 'purple', shadowSize: '4', - icon: 'mobile-install', + 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, diff --git a/src/components/Migration/StoreBadges.tsx b/src/components/Migration/StoreBadges.tsx new file mode 100644 index 000000000..3fb6d6273 --- /dev/null +++ b/src/components/Migration/StoreBadges.tsx @@ -0,0 +1,34 @@ +'use client' +import { Icon } from '@/components/Global/Icons/Icon' +import { STORE_NAME, STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' +import { trackStoreClick } from '@/utils/migration.utils' + +// the classic app-store badge pair (black pills with brand marks) used under +// download CTAs and QRs. english-only on purpose: real store badges are. +const BADGE_SUB: Record = { + ios: 'Download on the', + android: 'GET IT ON', +} + +export default function StoreBadges({ surface }: { surface: MigrationSurface }) { + return ( + + ) +} diff --git a/src/components/Migration/StoreButtons.tsx b/src/components/Migration/StoreButtons.tsx index 62f934e5d..e8f02cac9 100644 --- a/src/components/Migration/StoreButtons.tsx +++ b/src/components/Migration/StoreButtons.tsx @@ -11,7 +11,13 @@ export default function StoreButtons({ surface }: { surface: MigrationSurface }) if (deviceType === DeviceType.WEB) return const store: StoreKind = deviceType === DeviceType.ANDROID ? 'android' : 'ios' return ( - ) From 7e74cc703610f644aee2223815ed9766a2da3e48 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:33:45 +0530 Subject: [PATCH 25/33] =?UTF-8?q?copy(migration):=20Hugo=20review=20?= =?UTF-8?q?=E2=80=94=20celebratory=20bang,=20honest=20CTAs,=20no=20em-dash?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - '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) --- src/i18n/app/messages/en.json | 20 +++++++++----------- src/i18n/app/messages/es-419.json | 20 +++++++++----------- src/i18n/app/messages/pt-BR.json | 20 +++++++++----------- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 611a82911..736ecc662 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2710,35 +2710,33 @@ }, "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": "Maybe later", + "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.", + "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": "Can't use the app? We'll help — contact support" + "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.", - "openIos": "Open App Store ↗", - "openAndroid": "Open Google Play ↗", "done": "Done" }, "banner": { - "title": "Get the Peanut app", - "description": "Peanut is moving to the app — download it for a smoother experience" + "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: " + "supportPrefill": "I have some feedback about the app, here's what could be better: " }, "downloadNow": "Download now" } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index d523bf5ce..88070d441 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2710,35 +2710,33 @@ }, "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": "Quizás después", + "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.", + "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": "¿No puedes usar la app? Te ayudamos — contacta a soporte" + "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.", - "openIos": "Abrir App Store ↗", - "openAndroid": "Abrir Google Play ↗", "done": "Listo" }, "banner": { - "title": "Descarga la app de Peanut", - "description": "Peanut se muda a la app — descárgala para una experiencia más fluida" + "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: " + "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: " }, "downloadNow": "Descargar ahora" } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index b010642fb..de60a70d5 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2710,35 +2710,33 @@ }, "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": "Talvez depois", + "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.", + "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": "Não consegue usar o app? A gente ajuda — fale com o suporte" + "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.", - "openIos": "Abrir App Store ↗", - "openAndroid": "Abrir Google Play ↗", "done": "Pronto" }, "banner": { - "title": "Baixe o app do Peanut", - "description": "O Peanut está migrando para o app — baixe para uma experiência mais fluida" + "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: " + "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: " }, "downloadNow": "Baixar agora" } From 2daa2480b2f128dd7c689625b9e687c4ad455d27 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:48:35 +0530 Subject: [PATCH 26/33] style(migration): store badge pair uses Bruddle buttons, not knock-off store badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Migration/StoreBadges.tsx | 28 +++++++++++------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/Migration/StoreBadges.tsx b/src/components/Migration/StoreBadges.tsx index 3fb6d6273..e0de981bd 100644 --- a/src/components/Migration/StoreBadges.tsx +++ b/src/components/Migration/StoreBadges.tsx @@ -1,15 +1,10 @@ 'use client' -import { Icon } from '@/components/Global/Icons/Icon' -import { STORE_NAME, STORE_URL, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' +import { Button } from '@/components/0_Bruddle/Button' +import { STORE_NAME, STORE_URL, type MigrationSurface } from '@/constants/migration.consts' import { trackStoreClick } from '@/utils/migration.utils' -// the classic app-store badge pair (black pills with brand marks) used under -// download CTAs and QRs. english-only on purpose: real store badges are. -const BADGE_SUB: Record = { - ios: 'Download on the', - android: 'GET IT ON', -} - +// compact store-button pair under download CTAs and QRs — same design +// language as /app: purple primary for the App Store, stroke for Google Play. export default function StoreBadges({ surface }: { surface: MigrationSurface }) { return (
@@ -20,13 +15,16 @@ export default function StoreBadges({ surface }: { surface: MigrationSurface }) target="_blank" rel="noopener noreferrer" onClick={() => trackStoreClick(s, surface)} - className="flex items-center gap-2 rounded-sm border border-n-1 bg-black px-3 py-1.5 text-white" > - - - {BADGE_SUB[s]} - {STORE_NAME[s]} - + ))}
From d93f31726bb883ef7d6b885af3dfa11faccb0ab3 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:57:48 +0530 Subject: [PATCH 27/33] =?UTF-8?q?style(migration):=20quieter=20remind-late?= =?UTF-8?q?r=20cta=20=E2=80=94=20text-xs,=20no=20bold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Migration/MigrationDownloadModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Migration/MigrationDownloadModal.tsx b/src/components/Migration/MigrationDownloadModal.tsx index ccc73941e..5bc04110f 100644 --- a/src/components/Migration/MigrationDownloadModal.tsx +++ b/src/components/Migration/MigrationDownloadModal.tsx @@ -75,7 +75,7 @@ export default function MigrationDownloadModal({ const remindLaterCta = { text: t(isUrgent ? 'downloadPrompt.remindLater' : 'downloadPrompt.maybeLater'), variant: 'transparent' as const, - className: 'underline h-6 text-sm', + className: 'underline h-6 text-xs font-normal', onClick: snooze, } From 3fd7da6a1affdc9f3c2d47616430453a55f32f57 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:14:46 +0530 Subject: [PATCH 28/33] =?UTF-8?q?style(migration):=20device-based=20hero?= =?UTF-8?q?=20CTAs=20=E2=80=94=20one=20store=20button=20per=20phone,=20whi?= =?UTF-8?q?te=20pair=20on=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../LandingPage/LandingPageClient.tsx | 41 +++++++------------ .../LandingPage/StickyMobileCTA.tsx | 7 +++- src/components/LandingPage/hero.tsx | 21 +++++++--- src/components/LandingPage/landing.types.ts | 3 ++ src/components/Migration/StoreBadges.tsx | 24 ++++++++--- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 879f3fb35..028f37860 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -8,7 +8,6 @@ import { SUPPORTED_RAILS_FAQ_ID } from '@/constants/faq.consts' import TweetCarousel from '@/components/LandingPage/TweetCarousel' import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA' import underMaintenanceConfig from '@/config/underMaintenance.config' -import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal' import StoreBadges from '@/components/Migration/StoreBadges' import { type CTAButton } from '@/components/LandingPage/landing.types' import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts' @@ -55,33 +54,26 @@ export function LandingPageClient({ const { isFooterVisible } = useFooterVisibility() const migrationOn = useMigrationFlag() const { deviceType } = useDeviceType() - const [qrModalOpen, setQrModalOpen] = useState(false) + const isDesktop = deviceType === DeviceType.WEB - // pwa-sunset: the hero CTA becomes "Download now" — the visitor's store on - // mobile, the scan-to-download QR on desktop. English-only like the rest of - // this surface; the permanent label change goes through the content system - // post-cutover, at which point this override is deleted (TASK-20600). - const primaryCta = useMemo((): CTAButton => { + // pwa-sunset hero CTAs are device-based: phones get one "Download now" + // with their store's mark deep-linking to it; desktop drops the primary + // and shows the equal store-button pair instead (customCta below). + // English-only like the rest of this surface; the permanent CTA change + // goes through the content system post-cutover (TASK-20600). + const primaryCta = useMemo((): CTAButton | undefined => { if (!migrationOn) return heroConfig.primaryCta - if (deviceType === DeviceType.WEB) { - return { - label: 'Download now', - href: '#', - onClick: (e) => { - e.preventDefault() - setQrModalOpen(true) - }, - } - } + if (isDesktop) return undefined const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' return { label: 'Download now', href: STORE_URL[store], isExternal: true, + icon: store === 'ios' ? 'apple-logo' : 'google-play', // the anchor navigates; only track here onClick: () => trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO), } - }, [migrationOn, deviceType, heroConfig.primaryCta]) + }, [migrationOn, deviceType, isDesktop, heroConfig.primaryCta]) // Memoized: this component re-renders per scroll frame during the button // animation — don't rebuild the FAQ array + rich answer element each time. @@ -232,15 +224,12 @@ export function LandingPageClient({ primaryCta={primaryCta} buttonVisible={buttonVisible} buttonScale={buttonScale} - belowPrimaryCta={migrationOn ? : undefined} + customCta={ + migrationOn && isDesktop ? ( + + ) : undefined + } /> - {qrModalOpen && ( - setQrModalOpen(false)} - surface={MIGRATION_SURFACES.LANDING_HERO} - /> - )} {mantecaSlot} diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx index 751182b71..adff0d065 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -60,7 +60,12 @@ export function StickyMobileCTA() { className="pointer-events-auto block" onClick={() => trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO)} > - diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index e321b3467..75531217a 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -78,8 +78,8 @@ type HeroProps = { secondaryCta?: CTAButton buttonVisible?: boolean buttonScale?: number - /** rendered under the primary CTA (app-store badge pair during the migration window) */ - belowPrimaryCta?: React.ReactNode + /** replaces the primary button entirely (store-button pair on desktop during the migration window) */ + customCta?: React.ReactNode } const getInitialAnimation = (variant: 'primary' | 'secondary') => ({ @@ -109,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, belowPrimaryCta }: HeroProps) { +export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1, customCta }: HeroProps) { const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => { return ( From 33a86674b2d55d96e715638f9f7cf031953f32b7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:28:30 +0530 Subject: [PATCH 29/33] fix(migration): keep the hero subtext (Join +10,000 cool people) on both device CTA paths --- src/components/LandingPage/LandingPageClient.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 028f37860..b21049f18 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -70,6 +70,8 @@ export function LandingPageClient({ href: STORE_URL[store], isExternal: true, icon: store === 'ios' ? 'apple-logo' : 'google-play', + // keep the content-system subtext (e.g. "Join +10,000 cool people") + subtext: heroConfig.primaryCta.subtext, // the anchor navigates; only track here onClick: () => trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO), } @@ -226,7 +228,14 @@ export function LandingPageClient({ buttonScale={buttonScale} customCta={ migrationOn && isDesktop ? ( - +
+ + {heroConfig.primaryCta.subtext && ( + + {heroConfig.primaryCta.subtext} + + )} +
) : undefined } /> From 1980d24dbf777e6f0c7bbbd935cbb920faa8792d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:59:45 +0530 Subject: [PATCH 30/33] =?UTF-8?q?fix(migration):=20Hugo=20round=203=20?= =?UTF-8?q?=E2=80=94=20/app=20hardened,=20downloadNow=20wired,=20sunset=20?= =?UTF-8?q?gate=20extracted=20+=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- src/app/(mobile-ui)/layout.tsx | 10 +- src/app/(setup)/layout.tsx | 4 +- src/app/app/page.tsx | 106 +++++++++++++---- .../LandingPage/LandingPageClient.tsx | 12 +- .../LandingPage/StickyMobileCTA.tsx | 6 +- src/i18n/app/messages/en.json | 6 +- src/i18n/app/messages/es-419.json | 6 +- src/i18n/app/messages/pt-BR.json | 6 +- src/utils/__tests__/migration.utils.test.ts | 112 ++++++++++++++++++ src/utils/migration.utils.ts | 22 +++- 10 files changed, 244 insertions(+), 46 deletions(-) create mode 100644 src/utils/__tests__/migration.utils.test.ts diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 08865b261..3d14009d9 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -35,7 +35,7 @@ import { isDemoMode, enableDemoMode } from '@/utils/demo' import SunsetScreen from '@/components/Migration/SunsetScreen' import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' import { useMigrationFlag } from '@/hooks/useMigrationFlag' -import { getMigrationCutoverTime } from '@/utils/migration.utils' +import { shouldShowSunsetBlock } from '@/utils/migration.utils' const Layout = ({ children }: { children: React.ReactNode }) => { useNativePlugins() @@ -162,13 +162,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => { // native app is the only way forward (keep-web cookie bypasses, public // guest links keep working). Must precede the PWA-install and waitlist // screens: the web is gone either way. - if ( - migrationOn && - !isPublicPath && - !isCapacitor() && - !hasKeepWebBypass && - Date.now() >= getMigrationCutoverTime() - ) { + if (shouldShowSunsetBlock({ migrationOn, hasKeepWebBypass, isPublic: isPublicPath })) { return } diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index f01ab658b..ac73f5628 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -14,7 +14,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh' import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' import { useMigrationFlag } from '@/hooks/useMigrationFlag' import SunsetScreen from '@/components/Migration/SunsetScreen' -import { getMigrationCutoverTime, isPwaSunsetOn } from '@/utils/migration.utils' +import { isPwaSunsetOn, shouldShowSunsetBlock } from '@/utils/migration.utils' import { isCapacitor } from '@/utils/capacitor' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { @@ -103,7 +103,7 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { // pwa-sunset: past the cutover the web signup is switched off too — same // block as the mobile-ui layout (this route group has its own layout, so // it needs its own gate). keep-web cookie/param bypasses. - if (migrationOn && !isCapacitor() && !hasKeepWebBypass && Date.now() >= getMigrationCutoverTime()) { + if (shouldShowSunsetBlock({ migrationOn, hasKeepWebBypass })) { return } diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index a8c39fbae..51cfba90f 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -2,31 +2,79 @@ // smart store link: peanut.me/app — every download QR points here so a single // code serves both stores; the scanning device decides. phones bounce straight -// to their store (their store button shows the loading state while the +// to their store (their store button carries the loading state while the // redirect happens; if it doesn't take, the buttons settle clickable), // desktop just gets both buttons. client redirect (not a route handler) so // the capacitor static export builds unchanged. same visual language as the // sunset screen (MigrationHero + 50/50 split). +// +// flag-gated like every migration surface: until the pwa-sunset flag resolves +// ON this page 404s — otherwise merging would put a live public page with +// dead store links on peanut.me. posthog flags arrive async for first-time +// visitors, so we wait for the flag callback (or a short timeout when posthog +// is blocked) before deciding page-vs-404. +// +// hydration: SSR and the first client render show the same neutral loading +// state (mounted guard) — deriving the redirect state from useDeviceType at +// first render tripped React #418 on phones (device is WEB on the server). import { useEffect, useState } from 'react' +import { notFound } from 'next/navigation' +import posthog from 'posthog-js' +import { useTranslations } from 'next-intl' import { Button } from '@/components/0_Bruddle/Button' +import Loading from '@/components/Global/Loading' import MigrationHero from '@/components/Migration/MigrationHero' import { STORE_NAME, STORE_URL, type StoreKind } from '@/constants/migration.consts' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { isPwaSunsetOn } from '@/utils/migration.utils' + +const FLAG_WAIT_MS = 4000 export default function SmartStoreRedirect() { + const t = useTranslations('migration') const { deviceType } = useDeviceType() - const targetStore: StoreKind | null = - deviceType === DeviceType.IOS ? 'ios' : deviceType === DeviceType.ANDROID ? 'android' : null - const [redirecting, setRedirecting] = useState(targetStore !== null) + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + // wait for posthog to deliver flags (or time out) before judging the flag + const [flagsSettled, setFlagsSettled] = useState(false) useEffect(() => { - if (!targetStore) return + if (isPwaSunsetOn()) { + setFlagsSettled(true) + return + } + const unsubscribe = posthog.onFeatureFlags(() => setFlagsSettled(true)) + const timeout = setTimeout(() => setFlagsSettled(true), FLAG_WAIT_MS) + return () => { + unsubscribe?.() + clearTimeout(timeout) + } + }, []) + + const migrationOn = mounted && isPwaSunsetOn() + const settled = mounted && flagsSettled + + const targetStore: StoreKind | null = !mounted + ? null + : deviceType === DeviceType.IOS + ? 'ios' + : deviceType === DeviceType.ANDROID + ? 'android' + : null + + const [redirecting, setRedirecting] = useState(false) + useEffect(() => { + if (!settled || !migrationOn || !targetStore) return + setRedirecting(true) window.location.replace(STORE_URL[targetStore]) // if the store didn't take over (blocked, offline), settle to buttons const fallback = setTimeout(() => setRedirecting(false), 4000) return () => clearTimeout(fallback) - }, [targetStore]) + }, [settled, migrationOn, targetStore]) + + if (settled && !migrationOn) notFound() const stores: StoreKind[] = targetStore ? [targetStore, targetStore === 'ios' ? 'android' : 'ios'] @@ -37,28 +85,34 @@ export default function SmartStoreRedirect() {
-

Get the Peanut app

-

- {redirecting - ? 'Taking you to the store…' - : 'Global cash, local feel. Pick your store to download.'} -

+

{t('qr.title')}

+ {settled && migrationOn && ( +

+ {redirecting ? t('smartLink.redirecting') : t('smartLink.pickStore')} +

+ )}
- {stores.map((s, i) => ( - 0 ? 'hidden' : 'block'}> - - - ))} + {settled && migrationOn ? ( + stores.map((s, i) => ( + 0 ? 'hidden' : 'block'}> + + + )) + ) : ( +
+ +
+ )}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index b21049f18..9a3debb01 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -13,6 +13,7 @@ import { type CTAButton } from '@/components/LandingPage/landing.types' 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' type FAQQuestion = { @@ -53,20 +54,23 @@ export function LandingPageClient({ }: LandingPageClientProps) { const { isFooterVisible } = useFooterVisibility() const migrationOn = useMigrationFlag() + // app-locale translation (LatAm-first funnel); the flag-off label still + // comes from the content system per landing locale + const tMigration = useTranslations('migration') const { deviceType } = useDeviceType() const isDesktop = deviceType === DeviceType.WEB // pwa-sunset hero CTAs are device-based: phones get one "Download now" // with their store's mark deep-linking to it; desktop drops the primary // and shows the equal store-button pair instead (customCta below). - // English-only like the rest of this surface; the permanent CTA change - // goes through the content system post-cutover (TASK-20600). + // the permanent flag-off CTA change goes through the content system + // post-cutover (TASK-20600). const primaryCta = useMemo((): CTAButton | undefined => { if (!migrationOn) return heroConfig.primaryCta if (isDesktop) return undefined const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' return { - label: 'Download now', + label: tMigration('downloadNow'), href: STORE_URL[store], isExternal: true, icon: store === 'ios' ? 'apple-logo' : 'google-play', @@ -75,7 +79,7 @@ export function LandingPageClient({ // the anchor navigates; only track here onClick: () => trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO), } - }, [migrationOn, deviceType, isDesktop, heroConfig.primaryCta]) + }, [migrationOn, deviceType, isDesktop, heroConfig.primaryCta, tMigration]) // Memoized: this component re-renders per scroll frame during the button // animation — don't rebuild the FAQ array + rich answer element each time. diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx index adff0d065..99b3206a4 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -7,6 +7,7 @@ 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() { @@ -14,6 +15,7 @@ export function StickyMobileCTA() { 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' @@ -64,9 +66,9 @@ export function StickyMobileCTA() { variant="purple" shadowSize="4" icon={store === 'ios' ? 'apple-logo' : 'google-play'} - className="w-full py-3 text-base font-extrabold" + className="w-full py-3 text-base font-extrabold uppercase" > - DOWNLOAD NOW + {tMigration('downloadNow')} ) : ( diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 736ecc662..b92fc3dac 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2738,6 +2738,10 @@ "meh": "Could be better", "supportPrefill": "I have some feedback about the app, here's what could be better: " }, - "downloadNow": "Download now" + "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 88070d441..5db8e4338 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2738,6 +2738,10 @@ "meh": "Podría mejorar", "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: " }, - "downloadNow": "Descargar ahora" + "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 de60a70d5..7a76b8859 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2738,6 +2738,10 @@ "meh": "Pode melhorar", "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: " }, - "downloadNow": "Baixar agora" + "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 000000000..eb4d82af9 --- /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/migration.utils.ts b/src/utils/migration.utils.ts index 1cf3ada2a..ccfe25242 100644 --- a/src/utils/migration.utils.ts +++ b/src/utils/migration.utils.ts @@ -9,7 +9,7 @@ import { type StoreKind, } from '@/constants/migration.consts' import { isFeatureFlagEnabled } from '@/utils/featureFlag.utils' -import { openExternalUrl } from '@/utils/capacitor' +import { isCapacitor, openExternalUrl } from '@/utils/capacitor' /** * Flag read with a dev-only localStorage override. Local dev never inits @@ -24,6 +24,26 @@ export function isPwaSunsetOn(): boolean { 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 From 75960757639d7740d140f89b7ea30ef43d0e459a Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:22:56 +0530 Subject: [PATCH 31/33] =?UTF-8?q?feat(migration):=20guest-flow=20store=20h?= =?UTF-8?q?andoff=20=E2=80=94=20Join=20Peanut=20hands=20guests=20to=20the?= =?UTF-8?q?=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Claim/Link/SendLinkActionList.tsx | 6 ++ src/constants/migration.consts.ts | 1 + .../shared/components/SendWithPeanutCta.tsx | 60 +++++++++++-------- src/hooks/useGuestStoreHandoff.tsx | 40 +++++++++++++ 4 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useGuestStoreHandoff.tsx diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index a155b26f1..865e10bf6 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -52,6 +52,7 @@ import { validateMinimumAmount, } from '@/constants/payment.consts' import { useAppDispatch } from '@/redux/hooks' +import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff' import { useTranslations } from 'next-intl' const SHOW_INVITE_MODAL_FOR_DEVCONNECT = false @@ -95,6 +96,7 @@ export default function SendLinkActionList({ const [selectedMethod, setSelectedMethod] = useState(null) const [showInviteModal, setShowInviteModal] = useState(false) const { user } = useAuth() + const { interceptGuestCta, storeHandoffModal } = useGuestStoreHandoff() const { setSelectedTokenAddress, setSelectedChainID, @@ -187,6 +189,9 @@ export default function SendLinkActionList({ } const handleContinueWithPeanut = () => { + // migration window: web signups are closed — hand the guest to the + // app stores instead (QR modal on desktop, store link on mobile) + if (!isLoggedIn && interceptGuestCta()) return addParamStep('claim') const redirectUri = encodeURIComponent(window.location.pathname + window.location.search + window.location.hash) const rawUsername = claimLinkData?.sender?.username @@ -214,6 +219,7 @@ export default function SendLinkActionList({ return (
+ {storeHandoffModal} {showDevconnectMethod && ( <> + <> + {storeHandoffModal} + + ) } diff --git a/src/hooks/useGuestStoreHandoff.tsx b/src/hooks/useGuestStoreHandoff.tsx new file mode 100644 index 000000000..a83aac7a7 --- /dev/null +++ b/src/hooks/useGuestStoreHandoff.tsx @@ -0,0 +1,40 @@ +'use client' +import { useState } from 'react' +import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal' +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() { + const migrationOn = useMigrationFlag() + const { deviceType } = useDeviceType() + const [qrOpen, setQrOpen] = useState(false) + + 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 } +} From 010c0aca1f9940dd9ccbe67a0b05bd429074f72a Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:42:33 +0530 Subject: [PATCH 32/33] =?UTF-8?q?style(migration):=20drop=20the=20Done=20c?= =?UTF-8?q?ta=20from=20the=20scan-to-download=20modal=20=E2=80=94=20X/over?= =?UTF-8?q?lay=20close=20suffices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Migration/ScanToDownloadModal.tsx | 1 - src/i18n/app/messages/en.json | 3 +-- src/i18n/app/messages/es-419.json | 3 +-- src/i18n/app/messages/pt-BR.json | 3 +-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/Migration/ScanToDownloadModal.tsx b/src/components/Migration/ScanToDownloadModal.tsx index cbcc9a5c8..1ca1fd01b 100644 --- a/src/components/Migration/ScanToDownloadModal.tsx +++ b/src/components/Migration/ScanToDownloadModal.tsx @@ -23,7 +23,6 @@ export default function ScanToDownloadModal({ icon="qr-code" title={t('qr.title')} content={} - ctas={[{ text: t('qr.done'), variant: 'purple', shadowSize: '4', onClick: onClose }]} /> ) } diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index b92fc3dac..73d91adf8 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -2724,8 +2724,7 @@ }, "qr": { "title": "Get the Peanut app", - "scanHint": "Scan with your phone camera to download.", - "done": "Done" + "scanHint": "Scan with your phone camera to download." }, "banner": { "title": "The Peanut app is here!", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 5db8e4338..1b6a34538 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -2724,8 +2724,7 @@ }, "qr": { "title": "Descarga la app de Peanut", - "scanHint": "Escanea con la cámara de tu celular para descargar.", - "done": "Listo" + "scanHint": "Escanea con la cámara de tu celular para descargar." }, "banner": { "title": "¡La app de Peanut ya está aquí!", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 7a76b8859..81fd01d96 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -2724,8 +2724,7 @@ }, "qr": { "title": "Baixe o app do Peanut", - "scanHint": "Escaneie com a câmera do seu celular para baixar.", - "done": "Pronto" + "scanHint": "Escaneie com a câmera do seu celular para baixar." }, "banner": { "title": "O app do Peanut chegou!", From 6a5641c8376eba967dc57bbff6067cac3113039d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:54:30 +0530 Subject: [PATCH 33/33] =?UTF-8?q?feat(migration):=20guest-cta=20impression?= =?UTF-8?q?=20event=20=E2=80=94=20the=20missing=20funnel=20leg=20(TASK-209?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Claim/Link/SendLinkActionList.tsx | 2 +- src/constants/analytics.consts.ts | 3 +++ .../shared/components/SendWithPeanutCta.tsx | 4 +++- src/hooks/useGuestStoreHandoff.tsx | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 865e10bf6..af2f1520f 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -96,7 +96,7 @@ export default function SendLinkActionList({ const [selectedMethod, setSelectedMethod] = useState(null) const [showInviteModal, setShowInviteModal] = useState(false) const { user } = useAuth() - const { interceptGuestCta, storeHandoffModal } = useGuestStoreHandoff() + const { interceptGuestCta, storeHandoffModal } = useGuestStoreHandoff({ trackImpressionWhenGuest: !isLoggedIn }) const { setSelectedTokenAddress, setSelectedChainID, diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 1c6af5bef..0c51ab3eb 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -278,6 +278,9 @@ export const ANALYTICS_EVENTS = { // `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', diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx index 87c94bb79..320477b3e 100644 --- a/src/features/payments/shared/components/SendWithPeanutCta.tsx +++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx @@ -57,7 +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() + const { interceptGuestCta, storeHandoffModal } = useGuestStoreHandoff({ + trackImpressionWhenGuest: requiresAuth && !isFetchingUser && !isLoggedIn, + }) const handleClick = (e: React.MouseEvent) => { // don't act while auth is still resolving diff --git a/src/hooks/useGuestStoreHandoff.tsx b/src/hooks/useGuestStoreHandoff.tsx index a83aac7a7..5653d5dc6 100644 --- a/src/hooks/useGuestStoreHandoff.tsx +++ b/src/hooks/useGuestStoreHandoff.tsx @@ -1,6 +1,8 @@ 'use client' -import { useState } from 'react' +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' @@ -17,11 +19,24 @@ import { openStore } from '@/utils/migration.utils' * click was handled here) and `storeHandoffModal` (render it next to the CTA). * Native app guests keep the normal in-app flow. */ -export function useGuestStoreHandoff() { +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) {