Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1dbcdfb
feat(migration): pwa-sunset flag scaffold + analytics taxonomy (TASK-…
kushagrasarathe Jul 30, 2026
0f327c6
feat(migration): download prompt, sunset block and keep-web bypass (T…
kushagrasarathe Jul 30, 2026
560837d
feat(migration): store links on landing + setup, retire PWA install, …
kushagrasarathe Jul 30, 2026
00fff73
feat(migration): money-alerts push pre-prompt snooze + app review nud…
kushagrasarathe Jul 30, 2026
95bfa3b
test(migration): pin the download-prompt gating contract
kushagrasarathe Jul 30, 2026
8ffe38c
fix(migration): apply CodeRabbit findings
kushagrasarathe Jul 30, 2026
ef7ab3c
fix(migration): apply /code-review findings — hydration, mid-flow res…
kushagrasarathe Jul 30, 2026
a5643b5
refactor(migration): use Bruddle Button for every interactive element
kushagrasarathe Jul 30, 2026
4955bd2
refactor(landing): dedupe CTAButton into landing.types.ts
kushagrasarathe Jul 31, 2026
a99b6e8
chore(analytics): expose window.posthog like the official snippet
kushagrasarathe Jul 31, 2026
4671ebe
docs(migration): correct the flag-override console recipe
kushagrasarathe Jul 31, 2026
54df03c
fix(migration): preview QA round 1 — sticky CTA, desktop setup gate, …
kushagrasarathe Jul 31, 2026
00df097
fix(migration): sunset screen is full-bleed, not a bordered card
kushagrasarathe Jul 31, 2026
f28bab8
style(migration): remind-me-later cta at text-sm
kushagrasarathe Jul 31, 2026
863e816
copy(migration): banner says the app is where Peanut is going, not a …
kushagrasarathe Jul 31, 2026
700248f
style(migration): sunset screen 50/50 split — row on desktop, hero/co…
kushagrasarathe Jul 31, 2026
a886ae4
style(migration): fourth star on the sunset hero, bottom-left balance
kushagrasarathe Jul 31, 2026
ed78d53
copy(migration): Hugo's review — celebrate first, force later
kushagrasarathe Aug 2, 2026
881e4e6
feat(migration): smart QR — one code, the scanning device picks the s…
kushagrasarathe Aug 2, 2026
cf18e45
style(migration): QA round 2 — centered sunset copy, cleaner /app pag…
kushagrasarathe Aug 3, 2026
bedf004
feat(migration): notice window closes NEW web signups everywhere; /ap…
kushagrasarathe Aug 3, 2026
f8ed01d
style(migration): setup download-only polish — heading only above the…
kushagrasarathe Aug 3, 2026
b2c0207
fix(migration): QR encodes the serving origin, not SELF_URL
kushagrasarathe Aug 3, 2026
2c2e53c
style(migration): Hugo review — brand icons, store badge pair, no mor…
kushagrasarathe Aug 3, 2026
7e74cc7
copy(migration): Hugo review — celebratory bang, honest CTAs, no em-d…
kushagrasarathe Aug 3, 2026
2daa248
style(migration): store badge pair uses Bruddle buttons, not knock-of…
kushagrasarathe Aug 3, 2026
d93f317
style(migration): quieter remind-later cta — text-xs, no bold
kushagrasarathe Aug 3, 2026
3fd7da6
style(migration): device-based hero CTAs — one store button per phone…
kushagrasarathe Aug 3, 2026
33a8667
fix(migration): keep the hero subtext (Join +10,000 cool people) on b…
kushagrasarathe Aug 3, 2026
1980d24
fix(migration): Hugo round 3 — /app hardened, downloadNow wired, suns…
kushagrasarathe Aug 4, 2026
7596075
feat(migration): guest-flow store handoff — Join Peanut hands guests …
kushagrasarathe Aug 4, 2026
010c0ac
style(migration): drop the Done cta from the scan-to-download modal —…
kushagrasarathe Aug 4, 2026
6a5641c
feat(migration): guest-cta impression event — the missing funnel leg …
kushagrasarathe Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
5 changes: 5 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 68 additions & 7 deletions src/app/(mobile-ui)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <PeanutLoading coverFullScreen />
Expand Down Expand Up @@ -241,20 +266,39 @@ export default function Home() {
/>
</div>

{showPermissionModal && !showBalanceWarningModal && (
{showPermissionModal && !showBalanceWarningModal && !showMigrationModal && (
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<SetupNotificationsModal />
</Suspense>
</LazyLoadErrorBoundary>
)}

<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<MigrationDownloadModal onVisibilityChange={setShowMigrationModal} />
</Suspense>
</LazyLoadErrorBoundary>
Comment thread
kushagrasarathe marked this conversation as resolved.

{/* desktop target of the get-the-app carousel CTA */}
{isGetAppModalOpen && (
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<ScanToDownloadModal
visible={isGetAppModalOpen}
onClose={() => setIsGetAppModalOpen(false)}
surface={MIGRATION_SURFACES.HOME_BANNER}
/>
</Suspense>
</LazyLoadErrorBoundary>
)}
</div>
{/* Add Money Prompt Modal */}
{/* TODO @dev Disabling this, re-enable after properly fixing */}
{/* <AddMoneyPromptModal visible={showAddMoneyPromptModal} onClose={() => setShowAddMoneyPromptModal(false)} /> */}

{/* these modals manage their own state internally */}
{!showBalanceWarningModal && (
{!showBalanceWarningModal && !showMigrationModal && (
<>
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
Expand All @@ -273,7 +317,7 @@ export default function Home() {
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<WelcomeUnlockModal
isOpen={showKycModal && !showBalanceWarningModal}
isOpen={showKycModal && !showBalanceWarningModal && !showMigrationModal}
onClose={async () => {
// close the modal immediately for better ux
setShowKycModal(false)
Expand All @@ -295,7 +339,7 @@ export default function Home() {
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<BalanceWarningModal
visible={showBalanceWarningModal}
visible={showBalanceWarningModal && !showMigrationModal}
onCloseAction={() => {
setShowBalanceWarningModal(false)
updateUserPreferences(user!.user.userId, {
Expand All @@ -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 */}
<PostSignupActionManager onActionModalVisibilityChange={setIsPostSignupActionModalVisible} />
{/* unmounted while the migration prompt shows (it re-checks on
remount); the effect below clears its stuck visibility state */}
{!showMigrationModal && (
<PostSignupActionManager onActionModalVisibilityChange={setIsPostSignupActionModalVisible} />
)}

{/* App review nudge (native only, once ever) — lowest priority */}
{!showMigrationModal &&
!showPermissionModal &&
!showBalanceWarningModal &&
!showKycModal &&
!isPostSignupActionModalVisible && (
<LazyLoadErrorBoundary>
<Suspense fallback={null}>
<ReviewPromptModal />
</Suspense>
</LazyLoadErrorBoundary>
)}
</PageContainer>
)
}
Expand Down
14 changes: 14 additions & 0 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 <SunsetScreen />
}

// After setup flow is completed, show ios pwa install screen if not in pwa
if (!isPublicPath && showIosPwaInstallScreen) {
return <ForceIOSPWAInstall />
Expand Down
40 changes: 38 additions & 2 deletions src/app/(setup)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,26 @@
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'
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
Expand Down Expand Up @@ -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<boolean | null>(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

Expand All @@ -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))
Expand All @@ -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 <SunsetScreen />
}

return (
<>
{/* Status-bar safe zone + feedback ribbon.
Expand Down
8 changes: 7 additions & 1 deletion src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading