diff --git a/eslint.config.js b/eslint.config.js index 43394020d5..a6117a8c1d 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/instrumentation-client.ts b/instrumentation-client.ts index 53dec5d251..ad1ed95807 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 diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 78d48a144d..53b865f886 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 @@ -48,6 +50,9 @@ 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 ScanToDownloadModal = lazy(() => import('@/components/Migration/ScanToDownloadModal')) +const ReviewPromptModal = lazy(() => import('@/components/Migration/ReviewPromptModal')) 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 @@ -56,6 +61,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() @@ -79,6 +85,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 @@ -92,6 +101,13 @@ export default function Home() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps + // the migration prompt outranks the post-signup modal; unmounting the + // manager skips its onVisibilityChange(false), so clear the state here or + // it stays stuck true and suppresses the balance-warning/review modals + useEffect(() => { + if (showMigrationModal) setIsPostSignupActionModalVisible(false) + }, [showMigrationModal]) + // Show the "You're unlocked" celebration exactly once: the user has a usable // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is // null, stamped server-side on dismiss). A KYC re-approval can't resurface it @@ -157,14 +173,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 +266,39 @@ export default function Home() { /> - {showPermissionModal && !showBalanceWarningModal && ( + {showPermissionModal && !showBalanceWarningModal && !showMigrationModal && ( )} + + + + + + + + {/* 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 */} {/* setShowAddMoneyPromptModal(false)} /> */} {/* these modals manage their own state internally */} - {!showBalanceWarningModal && ( + {!showBalanceWarningModal && !showMigrationModal && ( <> @@ -273,7 +317,7 @@ export default function Home() { { // close the modal immediately for better ux setShowKycModal(false) @@ -295,7 +339,7 @@ export default function Home() { { setShowBalanceWarningModal(false) updateUserPreferences(user!.user.userId, { @@ -317,7 +361,24 @@ export default function Home() { {/* Card Pioneer Modal - Show to all users who haven't purchased */} {/* Eligibility check happens during the flow (geo screen), not here */} - + {/* unmounted while the migration prompt shows (it re-checks on + remount); the effect below clears its stuck visibility state */} + {!showMigrationModal && ( + + )} + + {/* App review nudge (native only, once ever) — lowest priority */} + {!showMigrationModal && + !showPermissionModal && + !showBalanceWarningModal && + !showKycModal && + !isPostSignupActionModalVisible && ( + + + + + + )} ) } diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 1ddebfa490..3d14009d94 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -32,6 +32,10 @@ import { useNativePlugins } from '@/hooks/useNativePlugins' import '@/hooks/useSafeBack' import { isCapacitor } from '@/utils/capacitor' import { isDemoMode, enableDemoMode } from '@/utils/demo' +import SunsetScreen from '@/components/Migration/SunsetScreen' +import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { shouldShowSunsetBlock } from '@/utils/migration.utils' const Layout = ({ children }: { children: React.ReactNode }) => { useNativePlugins() @@ -51,6 +55,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const alignStart = isHome || isHistory || isSupport const router = useRouter() const { showIosPwaInstallScreen } = useSetupStore() + const migrationOn = useMigrationFlag() + const hasKeepWebBypass = useKeepWebBypass() // detect online/offline status for full-page offline screen const { isOnline, isInitialized } = useNetworkStatus() @@ -152,6 +158,14 @@ 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 (shouldShowSunsetBlock({ migrationOn, hasKeepWebBypass, isPublic: isPublicPath })) { + return + } + // After setup flow is completed, show ios pwa install screen if not in pwa if (!isPublicPath && showIosPwaInstallScreen) { return diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index ad3538abd6..ac73f5628b 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -3,7 +3,7 @@ import { usePWAStatus } from '@/hooks/usePWAStatus' import { useAppDispatch } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' -import { useEffect, useState, Suspense } from 'react' +import { useEffect, useRef, useState, Suspense } from 'react' import { setupSteps } from '../../components/Setup/Setup.consts' import '../../styles/globals.css' import PeanutLoading from '@/components/Global/PeanutLoading' @@ -11,12 +11,18 @@ import { Banner } from '@/components/Global/Banner' import SupportDrawer from '@/components/Global/SupportDrawer' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { usePullToRefresh } from '@/hooks/usePullToRefresh' +import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import SunsetScreen from '@/components/Migration/SunsetScreen' +import { isPwaSunsetOn, shouldShowSunsetBlock } from '@/utils/migration.utils' import { isCapacitor } from '@/utils/capacitor' function SetupLayoutContent({ children }: { children?: React.ReactNode }) { const dispatch = useAppDispatch() const isPWA = usePWAStatus() const { deviceType } = useDeviceType() + const migrationOn = useMigrationFlag() + const hasKeepWebBypass = useKeepWebBypass() /* * Bottom-inset fill color. Periwinkle is for Android 15 edge-to-edge (matches @@ -51,9 +57,31 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { .catch(() => {}) }, []) + // Latched ONCE at the first effect run instead of reacting to the async + // PostHog flag load: a mid-load true-flip would re-dispatch setSteps, whose + // new identity re-runs determineInitialStep and yanks a mid-flow user back + // to the landing step (losing e.g. a typed username). Returning visitors + // read the cached flag correctly; only first-ever visitors in the seconds + // before flags cache get the legacy flow — acceptable transitional cohort. + const migrationOnAtEntry = useRef(null) + useEffect(() => { + if (migrationOnAtEntry.current === null) { + migrationOnAtEntry.current = isPwaSunsetOn() + } + const migrationSteps = migrationOnAtEntry.current + // 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 ( + migrationSteps && + ['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,7 +90,8 @@ 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 (!migrationSteps && deviceType === DeviceType.IOS && !isPWA) { dispatch(setupActions.setShowIosPwaInstallScreen(true)) } else { dispatch(setupActions.setShowIosPwaInstallScreen(false)) @@ -71,6 +100,13 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { 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/param bypasses. + if (shouldShowSunsetBlock({ migrationOn, hasKeepWebBypass })) { + return + } + return ( <> {/* Status-bar safe zone + feedback ribbon. diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index d5d537f6db..311aeb8fe1 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 new file mode 100644 index 0000000000..51cfba90fc --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,120 @@ +'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 (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 [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 (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) + }, [settled, migrationOn, targetStore]) + + if (settled && !migrationOn) notFound() + + const stores: StoreKind[] = targetStore + ? [targetStore, targetStore === 'ios' ? 'android' : 'ios'] + : ['ios', 'android'] + + return ( +
+ +
+
+

{t('qr.title')}

+ {settled && migrationOn && ( +

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

+ )} +
+
+ {settled && migrationOn ? ( + stores.map((s, i) => ( + 0 ? 'hidden' : 'block'}> + + + )) + ) : ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index a155b26f10..af2f1520fa 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({ trackImpressionWhenGuest: !isLoggedIn }) 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 && ( <>
+ ) : undefined + } + /> {mantecaSlot} diff --git a/src/components/LandingPage/LandingPageShell.tsx b/src/components/LandingPage/LandingPageShell.tsx index 94a8488603..9fe52d9c75 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 3e8a87181d..99b3206a4b 100644 --- a/src/components/LandingPage/StickyMobileCTA.tsx +++ b/src/components/LandingPage/StickyMobileCTA.tsx @@ -4,11 +4,20 @@ import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import Link from 'next/link' import { Button } from '@/components/0_Bruddle/Button' +import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { useTranslations } from 'next-intl' +import { trackStoreClick } from '@/utils/migration.utils' export function StickyMobileCTA() { const [visible, setVisible] = useState(false) const rafId = useRef(0) const lastVisible = useRef(false) + const migrationOn = useMigrationFlag() + const tMigration = useTranslations('migration') + const { deviceType } = useDeviceType() + const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios' useEffect(() => { const check = () => { @@ -43,11 +52,32 @@ export function StickyMobileCTA() { transition={{ type: 'spring', damping: 20, stiffness: 300 }} className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 border-t-2 border-n-1 bg-white px-4 py-3 md:hidden" > - - - + {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/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx index 30e9e11d85..75531217a6 100644 --- a/src/components/LandingPage/hero.tsx +++ b/src/components/LandingPage/hero.tsx @@ -8,6 +8,7 @@ import Image from 'next/image' import { useEffect, useCallback, useRef } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { CloudsCss } from './CloudsCss' +import { type CTAButton } from '@/components/LandingPage/landing.types' /** * Peanut mascot that positions itself so only 6% of its height (the feet) @@ -72,18 +73,13 @@ function PeanutMascot() { ) } -type CTAButton = { - label: string - href: string - isExternal?: boolean - subtext?: string -} - type HeroProps = { primaryCta?: CTAButton secondaryCta?: CTAButton buttonVisible?: boolean buttonScale?: number + /** replaces the primary button entirely (store-button pair on desktop during the migration window) */ + customCta?: React.ReactNode } const getInitialAnimation = (variant: 'primary' | 'secondary') => ({ @@ -113,7 +109,7 @@ const transitionConfig = { type: 'spring', damping: 15 } as const const getButtonContainerClasses = (variant: 'primary' | 'secondary') => `relative z-20 mt-8 md:mt-12 flex flex-col items-center justify-center ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}` -export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1 }: HeroProps) { +export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1, customCta }: HeroProps) { const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => { return ( + + ))} +
+ ) +} diff --git a/src/components/Migration/StoreButtons.tsx b/src/components/Migration/StoreButtons.tsx new file mode 100644 index 0000000000..e8f02cac9c --- /dev/null +++ b/src/components/Migration/StoreButtons.tsx @@ -0,0 +1,24 @@ +'use client' +import { Button } from '@/components/0_Bruddle/Button' +import DownloadQR from '@/components/Migration/DownloadQR' +import { STORE_NAME, type MigrationSurface, type StoreKind } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { openStore } from '@/utils/migration.utils' + +// one primary CTA per device: the visitor's store on mobile, scan-to-download QR on desktop. +export default function StoreButtons({ surface }: { surface: MigrationSurface }) { + const { deviceType } = useDeviceType() + if (deviceType === DeviceType.WEB) return + const store: StoreKind = deviceType === DeviceType.ANDROID ? 'android' : 'ios' + return ( + + ) +} diff --git a/src/components/Migration/SunsetScreen.tsx b/src/components/Migration/SunsetScreen.tsx new file mode 100644 index 0000000000..65be01304a --- /dev/null +++ b/src/components/Migration/SunsetScreen.tsx @@ -0,0 +1,54 @@ +'use client' +import { useEffect } from 'react' +import posthog from 'posthog-js' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/0_Bruddle/Button' +import MigrationHero from '@/components/Migration/MigrationHero' +import StoreButtons from '@/components/Migration/StoreButtons' +import SupportDrawer from '@/components/Global/SupportDrawer' +import { useModalsContext } from '@/context/ModalsContext' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' + +/** + * Full-screen block once the website is switched off (TASK-20827) — rendered + * by the mobile-ui layout instead of the app. Download is the only way + * forward; the support link covers people who can't install (keep-web + * bypass is handed out there). + */ +export default function SunsetScreen() { + const t = useTranslations('migration') + const { setIsSupportModalOpen } = useModalsContext() + + useEffect(() => { + posthog.capture(ANALYTICS_EVENTS.MIGRATION_SUNSET_VIEWED) + }, []) + + return ( + // mobile: 50/50 vertical split, copy at the top of the lower half and + // the CTA pinned to the bottom. desktop (md+): 50/50 row, hero left, + // content centered right. +
+ +
+ {/* centered on desktop to match the centered store CTA below */} +
+

{t('sunset.heading')}

+

{t('sunset.sub')}

+
+
+ + +
+
+ {/* the layout's SupportDrawer never mounts when this screen replaces it */} + +
+ ) +} diff --git a/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx new file mode 100644 index 0000000000..b13e52bd17 --- /dev/null +++ b/src/components/Migration/__tests__/MigrationDownloadModal.test.tsx @@ -0,0 +1,126 @@ +/** @jest-environment jsdom */ +/** + * MigrationDownloadModal — the pwa-sunset "Peanut is becoming an app" prompt. + * + * Gating contract: flag ON + logged-in web user + before the cutover + snooze + * expired. Flag OFF (today's default) must render nothing — the key + * flag-off-regression check for the migration PR. + */ +import React from 'react' +import { render as rtlRender, screen, fireEvent } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import { DOWNLOAD_PROMPT_SNOOZE_DAYS, MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts' + +const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) + +// freeze "now" 30 days before the cutover so the notice-window cases don't +// start failing once the real calendar passes MIGRATION_CUTOVER_DATE +const DAY_MS = 24 * 60 * 60 * 1000 +const FROZEN_NOW = MIGRATION_CUTOVER_DATE.getTime() - 30 * DAY_MS + +let mockFlagOn = false +jest.mock('@/hooks/useMigrationFlag', () => ({ + useMigrationFlag: () => mockFlagOn, +})) + +let mockIsCapacitor = false +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: () => mockIsCapacitor, + openExternalUrl: jest.fn(), +})) + +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => ({ user: { user: { userId: 'user-1' } } }), +})) + +const mockGetPrefs = jest.fn() +const mockUpdatePrefs = jest.fn() +jest.mock('@/utils/general.utils', () => ({ + getUserPreferences: (...args: unknown[]) => mockGetPrefs(...args), + updateUserPreferences: (...args: unknown[]) => mockUpdatePrefs(...args), +})) + +jest.mock('posthog-js', () => ({ capture: jest.fn() })) + +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: (props: { visible: boolean; title?: string; ctas?: { text: string; onClick?: () => void }[] }) => + props.visible ? ( +
+

{props.title}

+ {props.ctas?.map((c) => ( + + ))} +
+ ) : null, +})) + +import MigrationDownloadModal from '../MigrationDownloadModal' + +let nowSpy: jest.SpyInstance +beforeEach(() => { + jest.clearAllMocks() + mockFlagOn = false + mockIsCapacitor = false + mockGetPrefs.mockReturnValue(undefined) + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(FROZEN_NOW) +}) +afterEach(() => { + nowSpy.mockRestore() +}) + +describe('MigrationDownloadModal', () => { + it('renders nothing while the pwa-sunset flag is off', () => { + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('shows for a logged-in web user during the notice window', () => { + mockFlagOn = true + render() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('stays hidden inside the native app', () => { + mockFlagOn = true + mockIsCapacitor = true + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('stays hidden while a recent snooze is active, reappears after it expires', () => { + mockFlagOn = true + mockGetPrefs.mockReturnValue({ migrationPromptSnoozedAt: new Date(FROZEN_NOW).toISOString() }) + const { unmount } = render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + unmount() + + const justExpired = new Date(FROZEN_NOW - (DOWNLOAD_PROMPT_SNOOZE_DAYS + 1) * DAY_MS).toISOString() + mockGetPrefs.mockReturnValue({ migrationPromptSnoozedAt: justExpired }) + render() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('stays hidden past the cutover (the sunset block owns that state)', () => { + mockFlagOn = true + nowSpy.mockReturnValue(MIGRATION_CUTOVER_DATE.getTime() + 1000) + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('remind-me-later snoozes and reports visibility', () => { + mockFlagOn = true + const onVisibilityChange = jest.fn() + render() + expect(onVisibilityChange).toHaveBeenLastCalledWith(true) + + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(mockUpdatePrefs).toHaveBeenCalledWith('user-1', { + migrationPromptSnoozedAt: expect.any(String), + }) + expect(onVisibilityChange).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/src/components/Notifications/SetupNotificationsModal.tsx b/src/components/Notifications/SetupNotificationsModal.tsx index d3539c2902..3a9432ee12 100644 --- a/src/components/Notifications/SetupNotificationsModal.tsx +++ b/src/components/Notifications/SetupNotificationsModal.tsx @@ -4,9 +4,13 @@ import ActionModal from '../Global/ActionModal' import posthog from 'posthog-js' import { useTranslations } from 'next-intl' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' export default function SetupNotificationsModal() { const t = useTranslations('notifications') + // migration-era copy ("Get money alerts") only ships when the pwa-sunset + // flag is on — flag off keeps today's prompt byte-for-byte (TASK-20771) + const migrationOn = useMigrationFlag() const { showPermissionModal, requestPermission, @@ -46,8 +50,8 @@ export default function SetupNotificationsModal() { visible={showPermissionModal} onClose={handleCloseNotifsSetupModal} modalPanelClassName="m-0 max-w-[90%]" - title={t('setupTitle')} - description={t('setupDescription')} + title={t(migrationOn ? 'migrationSetupTitle' : 'setupTitle')} + description={t(migrationOn ? 'migrationSetupDescription' : 'setupDescription')} icon="bell" ctaClassName="md:flex-col gap-4" ctas={[ diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx index 29e4077af7..6845dcab6a 100644 --- a/src/components/Setup/Views/Landing.tsx +++ b/src/components/Setup/Views/Landing.tsx @@ -12,9 +12,25 @@ import { useEffect } from 'react' import { disableDemoMode } from '@/utils/demo' import DocsLink from '@/components/Global/DocsLink' import { useTranslations } from 'next-intl' +import StoreButtons from '@/components/Migration/StoreButtons' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { useKeepWebBypass } from '@/hooks/useKeepWebBypass' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { isCapacitor } from '@/utils/capacitor' const LandingStep = () => { const t = useTranslations('setup') + const tMigration = useTranslations('migration') + const migrationOn = useMigrationFlag() + const hasKeepWebBypass = useKeepWebBypass() + const { deviceType } = useDeviceType() + + // migration notice window on web (any device): NEW signups are closed — + // don't onboard users into a product that shuts in weeks; the app is the + // path. Existing users keep Log In until the cutover. Native app and + // keep-web bypass users see the normal card. + const blockSignup = migrationOn && !isCapacitor() && !hasKeepWebBypass const { handleNext } = useSetupFlow() const { handleLoginClick, isLoggingIn } = useLogin() const toast = useToast() @@ -44,16 +60,27 @@ const LandingStep = () => { return ( - + {blockSignup ? ( +
+ {/* heading only above the desktop QR — a lone store button + explains itself */} + {deviceType === DeviceType.WEB && ( +

{tMigration('banner.title')}

+ )} + +
+ ) : ( + + )} + <> + {storeHandoffModal} + + ) } diff --git a/src/hooks/useGuestStoreHandoff.tsx b/src/hooks/useGuestStoreHandoff.tsx new file mode 100644 index 0000000000..5653d5dc68 --- /dev/null +++ b/src/hooks/useGuestStoreHandoff.tsx @@ -0,0 +1,55 @@ +'use client' +import { useEffect, useRef, useState } from 'react' +import posthog from 'posthog-js' +import ScanToDownloadModal from '@/components/Migration/ScanToDownloadModal' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' +import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' +import { useMigrationFlag } from '@/hooks/useMigrationFlag' +import { isCapacitor } from '@/utils/capacitor' +import { openStore } from '@/utils/migration.utils' + +/** + * Guest-flow store handoff for the migration window (mockup §03/§08): when a + * logged-out web visitor taps "Join Peanut" / "Continue with Peanut" on a + * claim/request page, don't route them into a signup that's closed — desktop + * opens the scan-to-download QR modal, phones deep-link their store. + * + * Returns `interceptGuestCta` (call it first in the CTA handler; true = the + * click was handled here) and `storeHandoffModal` (render it next to the CTA). + * Native app guests keep the normal in-app flow. + */ +export function useGuestStoreHandoff({ + trackImpressionWhenGuest = false, +}: { trackImpressionWhenGuest?: boolean } = {}) { + const migrationOn = useMigrationFlag() + const { deviceType } = useDeviceType() + const [qrOpen, setQrOpen] = useState(false) + + // guest-funnel impression (TASK-20939): fires once per mount when the CTA + // is actually shown to a logged-out web visitor during the window. The + // caller passes its settled guest state so we don't count the pre-auth + // flash where every visitor briefly looks logged-out. + const impressionFired = useRef(false) + useEffect(() => { + if (!trackImpressionWhenGuest || !migrationOn || isCapacitor() || impressionFired.current) return + impressionFired.current = true + posthog.capture(ANALYTICS_EVENTS.MIGRATION_GUEST_CTA_SHOWN, { surface: MIGRATION_SURFACES.GUEST_FLOW }) + }, [trackImpressionWhenGuest, migrationOn]) + + const interceptGuestCta = (): boolean => { + if (!migrationOn || isCapacitor()) return false + if (deviceType === DeviceType.WEB) { + setQrOpen(true) + return true + } + openStore(deviceType === DeviceType.ANDROID ? 'android' : 'ios', MIGRATION_SURFACES.GUEST_FLOW) + return true + } + + const storeHandoffModal = qrOpen ? ( + setQrOpen(false)} surface={MIGRATION_SURFACES.GUEST_FLOW} /> + ) : null + + return { interceptGuestCta, storeHandoffModal } +} diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 8ebe0b36a3..0688721ffc 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -20,6 +20,10 @@ import { useTransactionHistory } from './useTransactionHistory' import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useToast } from '@/components/0_Bruddle/Toast' +import { PEANUTMAN_MOBILE } from '@/assets/mascot' +import { MIGRATION_SURFACES } from '@/constants/migration.consts' +import { useMigrationFlag } from './useMigrationFlag' +import { openStore } from '@/utils/migration.utils' // Days a dismissed CTA stays hidden before reappearing. Set above 1 so dismiss feels // "sticky" but below 14 so we still nudge users about valuable actions they haven't @@ -73,6 +77,8 @@ const getDismissedCTAs = (userId: string | undefined): Map => { export const useHomeCarouselCTAs = () => { const t = useTranslations('home.carousel') + const tMigration = useTranslations('migration') + const migrationOn = useMigrationFlag() const [carouselCTAs, setCarouselCTAs] = useState([]) const { user } = useAuth() const dismissedRef = useRef>(new Map()) @@ -94,7 +100,7 @@ export const useHomeCarouselCTAs = () => { const isInFlight = rails.some((rail) => rail.status === 'pending' || rail.status === 'requires-info') const { deviceType } = useDeviceType() const isPwa = usePWAStatus() - const { setIsIosPwaInstallModalOpen, openSupportWithMessage } = useModalsContext() + const { setIsIosPwaInstallModalOpen, openSupportWithMessage, setIsGetAppModalOpen } = useModalsContext() const { setIsQRScannerOpen } = useModalsContext() const { countryCode: userCountryCode } = useGeoLocation() @@ -136,6 +142,27 @@ export const useHomeCarouselCTAs = () => { const _carouselCTAs: CarouselCTA[] = [] const b = (chunks: React.ReactNode) => {chunks} + // pwa-sunset notice window: get-the-app nudge leads the carousel and + // supersedes the ios-pwa-install CTA below (TASK-20829). Mobile goes + // straight to the visitor's store; desktop opens the scan-to-download QR. + if (migrationOn && !isCapacitor()) { + _carouselCTAs.push({ + id: 'app-install', + title: tMigration('banner.title'), + description: tMigration('banner.description'), + icon: 'mobile-install', + logo: PEANUTMAN_MOBILE, + iconSize: 16, + onClick: () => { + if (deviceType === DeviceType.WEB) { + setIsGetAppModalOpen(true) + } else { + openStore(deviceType === DeviceType.ANDROID ? 'android' : 'ios', MIGRATION_SURFACES.HOME_BANNER) + } + }, + }) + } + // Home CTAs gate on "user can do a bank deposit or a pay" — provider-blind. // Rain (card) does NOT count; a card-only user must still see the verify CTA. const hasKycApproval = bankRails().some((r) => r.status === 'enabled') || canDo('pay') @@ -187,8 +214,21 @@ export const useHomeCarouselCTAs = () => { // the user must reinstall — so route to the install modal. On native // the OS prompt falls back to the Settings app (handled in requestPermission), // so let it through instead of showing a PWA-install dead end. + // During the migration window the reinstall answer is the native + // app, not the retiring PWA. if (isPermissionDenied && !isCapacitor()) { - setIsIosPwaInstallModalOpen(true) + if (migrationOn) { + if (deviceType === DeviceType.WEB) { + setIsGetAppModalOpen(true) + } else { + openStore( + deviceType === DeviceType.ANDROID ? 'android' : 'ios', + MIGRATION_SURFACES.HOME_BANNER + ) + } + } else { + setIsIosPwaInstallModalOpen(true) + } return } const result = await requestPermission() @@ -203,7 +243,7 @@ export const useHomeCarouselCTAs = () => { }) } - if (deviceType === DeviceType.IOS && !isPwa && !isCapacitor()) { + if (!migrationOn && deviceType === DeviceType.IOS && !isPwa && !isCapacitor()) { _carouselCTAs.push({ id: 'ios-pwa-install', title: t('iosPwa.title'), @@ -320,6 +360,9 @@ export const useHomeCarouselCTAs = () => { toast, dismissCTA, openSupportWithMessage, + migrationOn, + tMigration, + setIsGetAppModalOpen, ]) useEffect(() => { diff --git a/src/hooks/useKeepWebBypass.ts b/src/hooks/useKeepWebBypass.ts new file mode 100644 index 0000000000..f817c753b1 --- /dev/null +++ b/src/hooks/useKeepWebBypass.ts @@ -0,0 +1,27 @@ +'use client' +import { useEffect, useState } from 'react' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { KEEP_WEB_COOKIE, KEEP_WEB_COOKIE_DAYS, KEEP_WEB_TOKEN } from '@/constants/migration.consts' +import { getFromCookie, saveToCookie } from '@/utils/general.utils' + +/** + * Support escape hatch for the sunset block: `?keep-web=` (DM'd by + * support) persists a 90-day cookie that lets this browser keep using the web + * app. Shared by every layout that renders the sunset gate so the token works + * no matter which route the user lands on. + */ +export function useKeepWebBypass(): boolean { + const [hasBypass, setHasBypass] = useState( + () => typeof document !== 'undefined' && getFromCookie(KEEP_WEB_COOKIE) === KEEP_WEB_TOKEN + ) + useEffect(() => { + const param = new URLSearchParams(window.location.search).get(KEEP_WEB_COOKIE) + if (param === KEEP_WEB_TOKEN) { + saveToCookie(KEEP_WEB_COOKIE, KEEP_WEB_TOKEN, KEEP_WEB_COOKIE_DAYS) + posthog.capture(ANALYTICS_EVENTS.MIGRATION_KEEP_WEB_USED) + setHasBypass(true) + } + }, []) + return hasBypass +} diff --git a/src/hooks/useMigrationFlag.ts b/src/hooks/useMigrationFlag.ts new file mode 100644 index 0000000000..df2234ac27 --- /dev/null +++ b/src/hooks/useMigrationFlag.ts @@ -0,0 +1,37 @@ +'use client' +import { useEffect, useState } from 'react' +import { useFeatureFlags } from '@/hooks/useFeatureFlag' +import { isPwaSunsetOn } from '@/utils/migration.utils' + +/** + * Is the PWA-sunset migration live? Fails closed (false) until PostHog flags + * load; re-renders when they do (see useFeatureFlags). + * + * Deliberately no `nonProdBypass`: it would force the sunset block on for all + * of staging/previews once the cutover date passes, bricking QA of the + * un-flagged state. + * + * Testing with the flag on: + * - PostHog UI (project 138913): add a release condition on `pwa-sunset` + * matching your `email` at 100%. + * - On a preview/prod build: run + * `posthog.featureFlags.overrideFeatureFlags({ flags: { 'pwa-sunset': true } })` + * in the console (persists for the session; posthog-js >=1.3xx requires the + * `flags` wrapper — a flat object is silently ignored). Clear with + * `overrideFeatureFlags(false)`. + * - Local dev (posthog never inits): `localStorage.setItem('pwa-sunset', 'true')` + * + reload; cutover via `localStorage.setItem('pwa-sunset-cutover', '2020-01-01')`. + * See isPwaSunsetOn / getMigrationCutoverTime. + */ +export function useMigrationFlag(): boolean { + // subscribe to posthog flag-load events so consumers re-render when flags + // arrive; the actual read goes through isPwaSunsetOn (dev override aware) + useFeatureFlags() + // hydration-safe: posthog serves CACHED flags synchronously for returning + // visitors, so a render-time read would disagree with the flag-off SSR + // HTML on prerendered surfaces (landing, setup) and hard-fail hydration. + // false until mounted keeps server and first client render identical. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + return mounted && isPwaSunsetOn() +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 16594670d2..bd7557d676 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -8,8 +8,12 @@ import { isDemoMode } from '@/utils/demo' import { useUserStore } from '@/redux/hooks' import posthog from 'posthog-js' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { NOTIF_PROMPT_SNOOZE_DAYS } from '@/constants/migration.consts' +import { isPwaSunsetOn } from '@/utils/migration.utils' import { UTM_SOURCES, UTM_MEDIUMS } from '@/utils/utm.utils' +const NOTIF_PROMPT_SNOOZE_MS = NOTIF_PROMPT_SNOOZE_DAYS * 24 * 60 * 60 * 1000 + /* * Notification state lives in a module-level store shared by every * useNotifications() consumer. The hook used to keep per-instance useState and @@ -118,7 +122,18 @@ async function evaluateVisibility() { } const userPreferences = getUserPreferences(currentExternalId ?? undefined) - const modalClosed = userPreferences?.notifModalClosed ?? false + // migration window (TASK-20771): "Not now" snoozes instead of dismissing + // forever — the custom pre-prompt exists so we CAN re-ask later. Legacy + // `notifModalClosed: true` (no timestamp) converts to a snooze starting + // now, same trick as getDismissedCTAs. Flag off keeps the old + // closed-forever behavior. + let closedAt = userPreferences?.notifModalClosedAt + if (!closedAt && userPreferences?.notifModalClosed) { + closedAt = new Date().toISOString() + updateUserPreferences(currentExternalId ?? undefined, { notifModalClosedAt: closedAt }) + } + const snoozeExpired = !!closedAt && Date.now() - new Date(closedAt).getTime() >= NOTIF_PROMPT_SNOOZE_MS + const modalClosed = !!closedAt && !(isPwaSunsetOn() && snoozeExpired) // don't show modal if permission is denied (carousel cta will handle it) if (state.permissionState === 'denied') { @@ -266,14 +281,22 @@ async function requestPermission(): Promise { // close modal when user dismisses it function closePermissionModal() { setState({ showPermissionModal: false }) - updateUserPreferences(currentExternalId ?? undefined, { notifModalClosed: true }) + // legacy boolean kept so bundles predating notifModalClosedAt stay closed + updateUserPreferences(currentExternalId ?? undefined, { + notifModalClosed: true, + notifModalClosedAt: new Date().toISOString(), + }) posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.NOTIFICATIONS }) } // update permission state after user interacts with permission prompt async function afterPermissionAttempt() { - // mark modal as closed to prevent it from showing again - updateUserPreferences(currentExternalId ?? undefined, { notifModalClosed: true }) + // mark modal as closed (permanent flag-off; 14-day snooze while the + // pwa-sunset flag is on — see evaluateVisibility) + updateUserPreferences(currentExternalId ?? undefined, { + notifModalClosed: true, + notifModalClosedAt: new Date().toISOString(), + }) await refreshPermissionState() } diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 1f75e7bec1..73d91adf89 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -1838,6 +1838,8 @@ "iconAlt": "icon", "setupTitle": "Turn on notifications?", "setupDescription": "Enable notifications and get alerts for all wallet activity.", + "migrationSetupTitle": "Get money alerts", + "migrationSetupDescription": "We'll ping you the moment money lands or someone asks you to pay.", "enable": "Enable notifications", "requesting": "Requesting...", "notNow": "Not now" @@ -2705,5 +2707,40 @@ "promptFailed": "Unlock again to open the app.", "unlock": "Unlock", "logOut": "Log out" + }, + "migration": { + "downloadPrompt": { + "earlyTitle": "The Peanut app is here!", + "earlyDescription": "Peanut now lives on the App Store and Google Play. Faster, smoother, and it pings you the moment money lands. Your account comes with you, nothing to set up.", + "maybeLater": "I'll download it later", + "title": "Peanut is going app-only", + "description": "In {days, plural, one {# day} other {# days}} Peanut moves fully to the app. Download it now and pick up right where you left off. Your account and money move with you automatically.", + "remindLater": "Remind me later" + }, + "sunset": { + "heading": "Peanut is now app-only", + "sub": "Download the app to pick up right where you left off. Your account and money are already there waiting for you.", + "supportLink": "Having trouble downloading the app? Chat with our support" + }, + "qr": { + "title": "Get the Peanut app", + "scanHint": "Scan with your phone camera to download." + }, + "banner": { + "title": "The Peanut app is here!", + "description": "Faster, smoother, and it pings you when money lands." + }, + "review": { + "title": "Loving Peanut so far?", + "description": "A quick rating helps other people find us.", + "loveIt": "Love it", + "meh": "Could be better", + "supportPrefill": "I have some feedback about the app, here's what could be better: " + }, + "downloadNow": "Download now", + "smartLink": { + "redirecting": "Taking you to the store…", + "pickStore": "Global cash, local feel. Pick your store to download." + } } } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 8d97233e65..1b6a34538a 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -1838,6 +1838,8 @@ "iconAlt": "ícono", "setupTitle": "¿Activar las notificaciones?", "setupDescription": "Activa las notificaciones y recibe alertas de toda la actividad de tu billetera.", + "migrationSetupTitle": "Recibe alertas de dinero", + "migrationSetupDescription": "Te avisamos al instante cuando llegue dinero o alguien te pida un pago.", "enable": "Activar notificaciones", "requesting": "Solicitando...", "notNow": "Ahora no" @@ -2705,5 +2707,40 @@ "promptFailed": "Desbloquea de nuevo para abrir la app.", "unlock": "Desbloquear", "logOut": "Cerrar sesión" + }, + "migration": { + "downloadPrompt": { + "earlyTitle": "¡La app de Peanut ya está aquí!", + "earlyDescription": "Peanut ya vive en el App Store y Google Play. Más rápida, más fluida y te avisa al instante cuando llega tu dinero. Tu cuenta va contigo, sin configurar nada.", + "maybeLater": "La descargo más tarde", + "title": "Peanut será solo app", + "description": "En {days, plural, one {# día} other {# días}} Peanut se muda por completo a la app. Descárgala ahora y continúa justo donde quedaste. Tu cuenta y tu dinero se mudan contigo automáticamente.", + "remindLater": "Recordarme más tarde" + }, + "sunset": { + "heading": "Peanut ahora es solo app", + "sub": "Descarga la app y continúa justo donde quedaste. Tu cuenta y tu dinero ya te están esperando ahí.", + "supportLink": "¿Problemas para descargar la app? Habla con nuestro soporte" + }, + "qr": { + "title": "Descarga la app de Peanut", + "scanHint": "Escanea con la cámara de tu celular para descargar." + }, + "banner": { + "title": "¡La app de Peanut ya está aquí!", + "description": "Más rápida, más fluida y te avisa cuando llega tu dinero." + }, + "review": { + "title": "¿Te está gustando Peanut?", + "description": "Una calificación rápida ayuda a que otros nos encuentren.", + "loveIt": "Me encanta", + "meh": "Podría mejorar", + "supportPrefill": "Tengo comentarios sobre la app, esto es lo que podría mejorar: " + }, + "downloadNow": "Descargar ahora", + "smartLink": { + "redirecting": "Te llevamos a la tienda…", + "pickStore": "Elige tu tienda y descarga la app." + } } } diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index baa909ed1f..81fd01d966 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -1838,6 +1838,8 @@ "iconAlt": "ícone", "setupTitle": "Ativar as notificações?", "setupDescription": "Ative as notificações e receba alertas de toda a atividade da sua carteira.", + "migrationSetupTitle": "Receba alertas de dinheiro", + "migrationSetupDescription": "Avisamos na hora quando o dinheiro chegar ou alguém pedir um pagamento.", "enable": "Ativar notificações", "requesting": "Solicitando...", "notNow": "Agora não" @@ -2705,5 +2707,40 @@ "promptFailed": "Desbloqueie novamente para abrir o app.", "unlock": "Desbloquear", "logOut": "Sair" + }, + "migration": { + "downloadPrompt": { + "earlyTitle": "O app do Peanut chegou!", + "earlyDescription": "O Peanut agora vive na App Store e no Google Play. Mais rápido, mais fluido e te avisa na hora que o dinheiro chega. Sua conta vai com você, sem configurar nada.", + "maybeLater": "Baixo depois", + "title": "O Peanut vai ser só app", + "description": "Em {days, plural, one {# dia} other {# dias}} o Peanut se muda por completo para o app. Baixe agora e continue de onde parou. Sua conta e seu dinheiro se mudam com você automaticamente.", + "remindLater": "Lembrar depois" + }, + "sunset": { + "heading": "O Peanut agora é só app", + "sub": "Baixe o app e continue de onde parou. Sua conta e seu dinheiro já estão lá esperando por você.", + "supportLink": "Problemas para baixar o app? Fale com nosso suporte" + }, + "qr": { + "title": "Baixe o app do Peanut", + "scanHint": "Escaneie com a câmera do seu celular para baixar." + }, + "banner": { + "title": "O app do Peanut chegou!", + "description": "Mais rápido, mais fluido e te avisa quando o dinheiro chega." + }, + "review": { + "title": "Está gostando do Peanut?", + "description": "Uma avaliação rápida ajuda outras pessoas a nos encontrar.", + "loveIt": "Adorei", + "meh": "Pode melhorar", + "supportPrefill": "Tenho um feedback sobre o app, isso poderia melhorar: " + }, + "downloadNow": "Baixar agora", + "smartLink": { + "redirecting": "Levando você para a loja…", + "pickStore": "Escolha sua loja e baixe o app." + } } } diff --git a/src/utils/__tests__/migration.utils.test.ts b/src/utils/__tests__/migration.utils.test.ts new file mode 100644 index 0000000000..eb4d82af93 --- /dev/null +++ b/src/utils/__tests__/migration.utils.test.ts @@ -0,0 +1,112 @@ +/** @jest-environment jsdom */ +/** + * migration.utils — the pwa-sunset primitives. + * + * shouldShowSunsetBlock is the single predicate that can make the whole app + * inaccessible (three call sites: both layouts + implicitly /app's flag gate), + * so its matrix is pinned here. The dev-only localStorage overrides are what + * local e2e QA rides on — a silent break there blinds every future QA round. + */ + +let mockFlagEnabled = false +jest.mock('@/utils/featureFlag.utils', () => ({ + isFeatureFlagEnabled: () => mockFlagEnabled, +})) + +let mockIsCapacitor = false +jest.mock('@/utils/capacitor', () => ({ + isCapacitor: () => mockIsCapacitor, + openExternalUrl: jest.fn(), +})) + +// the localStorage overrides are dev-only; force the dev branch in tests +jest.mock('@/constants/general.consts', () => ({ + ...jest.requireActual('@/constants/general.consts'), + IS_DEV: true, +})) + +jest.mock('posthog-js', () => ({ capture: jest.fn() })) + +import { MIGRATION_CUTOVER_DATE } from '@/constants/migration.consts' +import { getMigrationCutoverTime, isPwaSunsetOn, shouldShowSunsetBlock } from '@/utils/migration.utils' + +const CUTOVER = MIGRATION_CUTOVER_DATE.getTime() +const AFTER = CUTOVER + 1000 +const BEFORE = CUTOVER - 1000 + +beforeEach(() => { + localStorage.clear() + mockFlagEnabled = false + mockIsCapacitor = false +}) + +describe('isPwaSunsetOn', () => { + it('fails closed by default', () => { + expect(isPwaSunsetOn()).toBe(false) + }) + + it('follows the posthog flag', () => { + mockFlagEnabled = true + expect(isPwaSunsetOn()).toBe(true) + }) + + it('dev localStorage override turns it on without posthog', () => { + localStorage.setItem('pwa-sunset', 'true') + expect(isPwaSunsetOn()).toBe(true) + }) + + it('ignores non-"true" override values', () => { + localStorage.setItem('pwa-sunset', 'false') + expect(isPwaSunsetOn()).toBe(false) + }) +}) + +describe('getMigrationCutoverTime', () => { + it('returns the constant by default', () => { + expect(getMigrationCutoverTime()).toBe(CUTOVER) + }) + + it('dev localStorage override moves the cutover', () => { + localStorage.setItem('pwa-sunset-cutover', '2020-01-01') + expect(getMigrationCutoverTime()).toBe(new Date('2020-01-01').getTime()) + }) + + it('garbage override falls back to the constant', () => { + localStorage.setItem('pwa-sunset-cutover', 'not-a-date') + expect(getMigrationCutoverTime()).toBe(CUTOVER) + }) +}) + +describe('shouldShowSunsetBlock', () => { + const base = { migrationOn: true, hasKeepWebBypass: false, now: AFTER } + + it('blocks past the cutover with the flag on', () => { + expect(shouldShowSunsetBlock(base)).toBe(true) + }) + + it('never blocks with the flag off', () => { + expect(shouldShowSunsetBlock({ ...base, migrationOn: false })).toBe(false) + }) + + it('never blocks before the cutover (notice window)', () => { + expect(shouldShowSunsetBlock({ ...base, now: BEFORE })).toBe(false) + }) + + it('public guest paths pass through', () => { + expect(shouldShowSunsetBlock({ ...base, isPublic: true })).toBe(false) + }) + + it('the native app is never blocked', () => { + mockIsCapacitor = true + expect(shouldShowSunsetBlock(base)).toBe(false) + }) + + it('the keep-web support bypass passes through', () => { + expect(shouldShowSunsetBlock({ ...base, hasKeepWebBypass: true })).toBe(false) + }) + + it('respects the dev cutover override', () => { + localStorage.setItem('pwa-sunset-cutover', '2020-01-01') + expect(shouldShowSunsetBlock({ ...base, now: BEFORE })).toBe(true) + }) +}) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 55e3a2b1ea..b4c58e417d 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -493,6 +493,14 @@ export type UserPreferences = { * Read by useHomeCarouselCTAs to apply a per-CTA cooldown before re-showing. * Legacy shape was `string[]` (permanent dismissal); both are accepted on read. */ dismissedCarouselCTAs?: string[] | Record + /** ISO timestamp of the last "Remind me later" on the app-migration download prompt. */ + migrationPromptSnoozedAt?: string + /** ISO timestamp the notifications pre-prompt was dismissed — replaces the + * legacy permanent `notifModalClosed` so we can re-ask after a cooldown + * during the migration window. */ + notifModalClosedAt?: string + /** ISO timestamp the app-review prompt was shown (asked once, ever). */ + reviewPromptShownAt?: string } export const updateUserPreferences = ( diff --git a/src/utils/migration.utils.ts b/src/utils/migration.utils.ts new file mode 100644 index 0000000000..ccfe25242e --- /dev/null +++ b/src/utils/migration.utils.ts @@ -0,0 +1,73 @@ +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { IS_DEV } from '@/constants/general.consts' +import { + MIGRATION_CUTOVER_DATE, + PWA_SUNSET_FLAG, + STORE_URL, + type MigrationSurface, + type StoreKind, +} from '@/constants/migration.consts' +import { isFeatureFlagEnabled } from '@/utils/featureFlag.utils' +import { isCapacitor, openExternalUrl } from '@/utils/capacitor' + +/** + * Flag read with a dev-only localStorage override. Local dev never inits + * posthog (instrumentation-client gates on NODE_ENV), so e2e QA flips the + * flag with `localStorage.setItem('pwa-sunset', 'true')` + reload instead. + * Inert outside dev builds. + */ +export function isPwaSunsetOn(): boolean { + if (IS_DEV && typeof localStorage !== 'undefined' && localStorage.getItem(PWA_SUNSET_FLAG) === 'true') { + return true + } + return isFeatureFlagEnabled(PWA_SUNSET_FLAG) +} + +/** + * The one sunset-block predicate, shared by every layout that can replace the + * app with the download screen ((mobile-ui) and (setup)). Public paths are the + * caller's concern: guest claim/request links must keep working, so the + * mobile-ui layout passes `isPublic`. + */ +export function shouldShowSunsetBlock({ + migrationOn, + hasKeepWebBypass, + isPublic = false, + now = Date.now(), +}: { + migrationOn: boolean + hasKeepWebBypass: boolean + isPublic?: boolean + now?: number +}): boolean { + return migrationOn && !isPublic && !isCapacitor() && !hasKeepWebBypass && now >= getMigrationCutoverTime() +} + +/** + * Cutover timestamp with a dev-only localStorage override + * (`localStorage.setItem('pwa-sunset-cutover', '2020-01-01')` + reload) so the + * post-cutover sunset block can be QA'd locally without editing the constant. + */ +export function getMigrationCutoverTime(): number { + if (IS_DEV && typeof localStorage !== 'undefined') { + const iso = localStorage.getItem('pwa-sunset-cutover') + if (iso) { + const t = new Date(iso).getTime() + if (!Number.isNaN(t)) return t + } + } + return MIGRATION_CUTOVER_DATE.getTime() +} + +/** track a store CTA click without navigating (for anchors that navigate themselves). */ +export function trackStoreClick(store: StoreKind, surface: MigrationSurface) { + posthog.capture(ANALYTICS_EVENTS.MIGRATION_STORE_CTA_CLICKED, { surface, store }) +} + +/** navigate to the app store, tracking which surface sent the user there. */ +export function openStore(store: StoreKind, surface: MigrationSurface) { + trackStoreClick(store, surface) + // fire-and-forget: native Browser plugin or window.open on web + void openExternalUrl(STORE_URL[store]) +}