Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import SupportDrawer from '@/components/Global/SupportDrawer'
import JoinWaitlistPage from '@/components/Invites/JoinWaitlistPage'
import { useRouter } from 'next/navigation'
import { Banner } from '@/components/Global/Banner'
import { useSetupStore } from '@/redux/hooks'
import ForceIOSPWAInstall from '@/components/ForceIOSPWAInstall'
import { isPublicRoute } from '@/constants/routes'
import { saveRedirectUrl } from '@/utils/general.utils'
Expand All @@ -39,6 +38,7 @@ import SunsetScreen from '@/components/Migration/SunsetScreen'
import { useKeepWebBypass } from '@/hooks/useKeepWebBypass'
import { useMigrationFlag } from '@/hooks/useMigrationFlag'
import { shouldShowSunsetBlock } from '@/utils/migration.utils'
import { useIosPwaInstallGate } from '@/hooks/useIosPwaInstallGate'

const Layout = ({ children }: { children: React.ReactNode }) => {
useNativePlugins()
Expand All @@ -60,7 +60,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
const isDev = pathName?.startsWith('/dev') ?? false
const alignStart = isHome || isHistory || isSupport
const router = useRouter()
const { showIosPwaInstallScreen } = useSetupStore()
const { showIosPwaInstallScreen } = useIosPwaInstallGate()
const migrationOn = useMigrationFlag()
const hasKeepWebBypass = useKeepWebBypass()

Expand Down
25 changes: 14 additions & 11 deletions src/app/(setup)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'

import { usePWAStatus } from '@/hooks/usePWAStatus'
import { useAppDispatch } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
import { SetupFlowProvider, useSetupFlowContext } from '@/features/setup/SetupFlowContext'
import { useIosPwaInstallGate } from '@/hooks/useIosPwaInstallGate'
import { useEffect, useRef, useState, Suspense } from 'react'
import { setupSteps } from '../../components/Setup/Setup.consts'
import '../../styles/globals.css'
Expand All @@ -19,7 +19,8 @@ import { isPwaSunsetOn, shouldShowSunsetBlock } from '@/utils/migration.utils'
import { isCapacitor } from '@/utils/capacitor'

function SetupLayoutContent({ children }: { children?: React.ReactNode }) {
const dispatch = useAppDispatch()
const { setSteps } = useSetupFlowContext()
const { setShowIosPwaInstallScreen } = useIosPwaInstallGate()
const isPWA = usePWAStatus()
const { deviceType } = useDeviceType()
const migrationOn = useMigrationFlag()
Expand Down Expand Up @@ -71,7 +72,7 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) {
}
const migrationSteps = migrationOnAtEntry.current

// filter steps and set them in redux state
// filter steps and hand them to the setup flow provider
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
Expand All @@ -87,16 +88,16 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) {

return true
})
dispatch(setupActions.setSteps(filteredSteps))
setSteps(filteredSteps)

// if ios and not in pwa, show ios pwa install screen after setup flow is completed
// (retired during the migration window — the app download replaces the PWA)
if (!migrationSteps && deviceType === DeviceType.IOS && !isPWA) {
dispatch(setupActions.setShowIosPwaInstallScreen(true))
setShowIosPwaInstallScreen(true)
} else {
dispatch(setupActions.setShowIosPwaInstallScreen(false))
setShowIosPwaInstallScreen(false)
}
}, [isPWA, deviceType, dispatch])
}, [isPWA, deviceType, setSteps, setShowIosPwaInstallScreen])

usePullToRefresh()

Expand All @@ -121,9 +122,11 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) {

const SetupLayout = ({ children }: { children?: React.ReactNode }) => {
return (
<Suspense fallback={<Loading variant="mascot" coverFullScreen />}>
<SetupLayoutContent>{children}</SetupLayoutContent>
</Suspense>
<SetupFlowProvider>
<Suspense fallback={<Loading variant="mascot" coverFullScreen />}>
<SetupLayoutContent>{children}</SetupLayoutContent>
</Suspense>
</SetupFlowProvider>
)
}

Expand Down
99 changes: 52 additions & 47 deletions src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@
import { SetupWrapper } from '@/components/Setup/components/SetupWrapper'
import { type BeforeInstallPromptEvent, type ScreenId, type ISetupStep } from '@/components/Setup/Setup.types'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useSetupStepUrlSync } from '@/hooks/useSetupStepUrlSync'
import { useSetupBackHandler } from '@/hooks/useSetupBackHandler'
import { useAppDispatch, useSetupStore } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
import { Suspense, useEffect, useState } from 'react'
import { useSetupFlowContext } from '@/features/setup/SetupFlowContext'
import { useSetupStepAnalytics } from '@/features/setup/useSetupStepAnalytics'
import { useIosPwaInstallGate } from '@/hooks/useIosPwaInstallGate'
import { readInviteCode, stashInvite } from '@/utils/invite-stash'
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { setupSteps as masterSetupSteps } from '../../../components/Setup/Setup.consts'
import { hasKnownDeviceCredentials, resolveSetupEntryStep } from '@/components/Setup/setup-entry'
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, saveToCookie, toInviteCode } from '@/utils/general.utils'
import { toInviteCode } from '@/utils/general.utils'
import { useSearchParams } from 'next/navigation'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useGeoLocation } from '@/hooks/useGeoLocation'
Expand All @@ -26,19 +27,24 @@
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useTranslations } from 'next-intl'
import { EInviteType } from '@/services/services.types'

function SetupPageContent() {
const t = useTranslations('setup')
const { steps, inviteCode } = useSetupStore()
const { step, handleNext, handleBack, setScreenId } = useSetupFlow()
const { steps, resetSetupFlow, setNoBackLockScreenId } = useSetupFlowContext()
const { step, currentIndex: currentStepIndex, direction, handleNext, handleBack, setScreenId } = useSetupFlow()
const { logoutUser, isLoggingOut, user, isFetchingUser } = useAuth()
const { setShowIosPwaInstallScreen } = useIosPwaInstallGate()
const router = useRouter()
const [direction, setDirection] = useState(0)
const [currentStepIndex, setCurrentStepIndex] = useState(0)
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [canInstall, setCanInstall] = useState(false)
const [deviceType, setDeviceType] = useState<DeviceType>(DeviceType.WEB)
const dispatch = useAppDispatch()
// The entry effect must run once per steps-identity, never per step change:
// setScreenId's identity moves with the cursor, so it rides a ref.
const setScreenIdRef = useRef(setScreenId)
useLayoutEffect(() => {
setScreenIdRef.current = setScreenId
}, [setScreenId])
const [isLoading, setIsLoading] = useState(true)
const [showDeviceNotSupportedModal, setShowDeviceNotSupportedModal] = useState(false)
const [showBrowserNotSupportedModal, setShowBrowserNotSupportedModal] = useState(false)
Expand All @@ -49,15 +55,15 @@
useGeoLocation()
const searchParams = useSearchParams()
// The init effect must key on the VALUES it reads, not the searchParams
// object: the step-URL mirror rewrites ?screen= on every step, and a dep
// on the object identity would re-run determineInitialStep mid-flow and
// bounce the user back to the entry step.
// object: the stepper rewrites ?screen= on every step, and a dep on the
// object identity would re-run determineInitialStep mid-flow and bounce
// the user back to the entry step.
const inviteCodeParam = searchParams.get('code')
const legacyStepParam = searchParams.get('step')
const [sessionChecked, setSessionChecked] = useState(false)
const [existingSessionUsername, setExistingSessionUsername] = useState<string | null>(null)

// only mirror steps that actually render: not while the entry step is
// only count steps that actually render: not while the entry step is
// being determined, and not behind the existing-session interstitial
// or the unsupported-device/browser modals
const stepRendered =
Expand All @@ -67,11 +73,21 @@
!showDeviceNotSupportedModal &&
!showBrowserNotSupportedModal

useSetupStepUrlSync({
// Arm the point of no return only for a step the user actually SEES —
// stepRendered excludes entry-resolution loading, the existing-session
// interstitial, and the unsupported modals. A stale terminal URL
// (?screen=sign-test-transaction in a fresh session) must stay unlockable
// so the entry resolver can replace it (Chip review round 2).
useEffect(() => {
if (stepRendered && step && step.showBackButton === false) {
setNoBackLockScreenId(step.screenId)
}
}, [stepRendered, step, setNoBackLockScreenId])

useSetupStepAnalytics({
enabled: stepRendered,
step,
steps,
goToScreen: setScreenId,
})
useSetupBackHandler({ step, canStepBack: stepRendered, onBack: handleBack })

Expand Down Expand Up @@ -110,15 +126,17 @@
posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_CONTINUED)
// Mounting the (setup) layout armed the post-setup iOS install wall
// (setShowIosPwaInstallScreen in (setup)/layout.tsx). This visit was not a
// setup session and the soft nav keeps the store alive, so disarm it —
// otherwise /home renders the no-escape ForceIOSPWAInstall screen.
dispatch(setupActions.setShowIosPwaInstallScreen(false))
// setup session, so disarm it — otherwise /home renders the no-escape
// ForceIOSPWAInstall screen.
setShowIosPwaInstallScreen(false)
router.push('/home')
}

const handleStartFresh = async () => {
posthog.capture(ANALYTICS_EVENTS.SIGNUP_EXISTING_SESSION_LOGGED_OUT)
await logoutUser()
// the setup provider stays mounted through this logout — clear the typed state
resetSetupFlow()
setExistingSessionUsername(null)
}

Expand Down Expand Up @@ -152,10 +170,9 @@
*/
const codeFromUrl = inviteCodeParam
if (codeFromUrl && toInviteCode(codeFromUrl)) {
saveToCookie('inviteCode', toInviteCode(codeFromUrl))
stashInvite(toInviteCode(codeFromUrl), EInviteType.DIRECT)
}
const inviteCodeFromCookie = getFromCookie('inviteCode')
const userInviteCode = inviteCode || inviteCodeFromCookie
const userInviteCode = readInviteCode()
// 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
Expand All @@ -180,10 +197,10 @@
deviceType: localDeviceType,
isStandalonePWA: false,
})
const stepIndex = steps.findIndex((s: ISetupStep) => s.screenId === targetStep)
if (stepIndex !== -1) {
dispatch(setupActions.setStep(stepIndex + 1))
}
// replace, not push: the entry step overwrites any stale
// ?screen= from a reload or shared link — the URL is only the
// source of truth for IN-FLOW navigation (TASK-21460)
setScreenIdRef.current(targetStep, { history: 'replace' })
setIsLoading(false)
return
}
Expand Down Expand Up @@ -265,19 +282,15 @@
isStandalonePWA,
})

if (determinedSetupInitialStepId) {
const initialStepIndex = steps.findIndex((s: ISetupStep) => s.screenId === determinedSetupInitialStepId)
if (initialStepIndex !== -1) {
dispatch(setupActions.setStep(initialStepIndex + 1))
} else {
console.warn(
`Could not find step index for screenId: ${determinedSetupInitialStepId}. Defaulting to step 1.`
)
dispatch(setupActions.setStep(1))
}
// Entry always REPLACES — a stale ?screen= must never survive a
// fresh load into a step whose prerequisite state is gone.
if (determinedSetupInitialStepId && steps.some((s) => s.screenId === determinedSetupInitialStepId)) {
setScreenIdRef.current(determinedSetupInitialStepId, { history: 'replace' })
} else {
console.warn('No specific initial step ID determined. Defaulting to step 1.')
dispatch(setupActions.setStep(1))
console.warn(
`Could not resolve entry screenId (${determinedSetupInitialStepId ?? 'none'}). Defaulting to the first step.`
)
setScreenIdRef.current(steps[0].screenId, { history: 'replace' })
}

setIsLoading(false)
Expand All @@ -295,15 +308,7 @@
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
}
}, [dispatch, steps, inviteCodeParam, legacyStepParam])

useEffect(() => {
if (step) {
const newIndex = steps.findIndex((s: ISetupStep) => s.screenId === step.screenId)
setDirection(newIndex > currentStepIndex ? 1 : -1)
setCurrentStepIndex(newIndex)
}
}, [step, currentStepIndex, steps])
}, [steps, inviteCodeParam, legacyStepParam])

Check warning on line 311 in src/app/(setup)/setup/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'detectedDeviceType'. Either include it or remove the dependency array

if (isLoading || !sessionChecked)
return (
Expand Down
7 changes: 2 additions & 5 deletions src/components/Claim/Link/SendLinkActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import useSavedAccounts from '@/hooks/useSavedAccounts'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { DEVCONNECT_CLAIM_METHODS, type PaymentMethod } from '@/constants/actionlist.consts'
import useClaimLink from '../useClaimLink'
import { setupActions } from '@/redux/slices/setup-slice'
import starStraightImage from '@/assets/icons/starStraight.svg'
import { useAuth } from '@/context/authContext'
import { EInviteType } from '@/services/services.types'
Expand All @@ -46,9 +45,9 @@ import SupportCTA from '../../Global/SupportCTA'
import DEVCONNECT_LOGO from '@/assets/logos/devconnect.svg'
import { useCapabilities } from '@/hooks/useCapabilities'
import { CLAIM_RAIL_MINIMUMS, validateMinimumAmount } from '@/constants/payment.consts'
import { useAppDispatch } from '@/redux/hooks'
import { useGuestStoreHandoff } from '@/hooks/useGuestStoreHandoff'
import { useTranslations } from 'next-intl'
import { stashInvite } from '@/utils/invite-stash'

const SHOW_INVITE_MODAL_FOR_DEVCONNECT = false

Expand Down Expand Up @@ -104,7 +103,6 @@ export default function SendLinkActionList({
// `isUserMantecaKycApproved`; mapped to canDo('pay', { provider: 'manteca' }) so a Sumsub-
// approved user with only the pool-tier pay rail correctly sees these methods as available.
const isMantecaPayEnabled = useCapabilities().canDo('pay', { provider: 'manteca' })
const dispatch = useAppDispatch()

const requiresVerification = useMemo(() => {
return claimType === BankClaimType.GuestKycNeeded || claimType === BankClaimType.ReceiverKycNeeded
Expand Down Expand Up @@ -201,8 +199,7 @@ export default function SendLinkActionList({
const redirectUri = encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)
if (isInviteLink && !userHasAppAccess && rawUsername) {
const inviteCode = toInviteCode(rawUsername)
dispatch(setupActions.setInviteCode(inviteCode))
dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK))
stashInvite(inviteCode, EInviteType.PAYMENT_LINK)
router.push(inviteFlowUrl(inviteCode, redirectUri))
} else {
router.push(`/setup?redirect_uri=${redirectUri}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { fireEvent, screen } from '@testing-library/react'
import { renderWithIntl } from '@/test-utils/intl'
import ForceIOSPWAInstall from '../index'

const mockDispatch = jest.fn()
const mockSetShowIosPwaInstallScreen = jest.fn()

jest.mock('@/redux/hooks', () => ({
useAppDispatch: () => mockDispatch,
jest.mock('@/hooks/useIosPwaInstallGate', () => ({
useIosPwaInstallGate: () => ({
showIosPwaInstallScreen: true,
setShowIosPwaInstallScreen: mockSetShowIosPwaInstallScreen,
}),
}))

jest.mock('@/hooks/useGetBrowserType', () => ({
Expand All @@ -21,8 +24,6 @@ describe('ForceIOSPWAInstall', () => {

fireEvent.click(screen.getByRole('button', { name: /continue in the browser/i }))

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'setup/setShowIosPwaInstallScreen', payload: false })
)
expect(mockSetShowIosPwaInstallScreen).toHaveBeenCalledWith(false)
})
})
7 changes: 3 additions & 4 deletions src/components/ForceIOSPWAInstall/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ import { Icon } from '../Global/Icons/Icon'
import { Button } from '@/components/0_Bruddle/Button'
import { twMerge } from '@/utils/tw'
import { useGetBrowserType, BrowserType } from '@/hooks/useGetBrowserType'
import { useAppDispatch } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
import { useTranslations } from 'next-intl'
import { useIosPwaInstallGate } from '@/hooks/useIosPwaInstallGate'

const ForceIOSPWAInstall = () => {
const t = useTranslations('global')
const dispatch = useAppDispatch()
const { setShowIosPwaInstallScreen } = useIosPwaInstallGate()
const { browserType, isLoading } = useGetBrowserType()

const STAR_POSITIONS = [
Expand Down Expand Up @@ -88,7 +87,7 @@ const ForceIOSPWAInstall = () => {
</p>
{/* Installing is a nudge, not a gate: without this the screen has
no control at all and a user who can't install is stranded. */}
<Button variant="stroke" onClick={() => dispatch(setupActions.setShowIosPwaInstallScreen(false))}>
<Button variant="stroke" onClick={() => setShowIosPwaInstallScreen(false)}>
{t('forceIosPwaInstall.continueInBrowser')}
</Button>
</section>
Expand Down
Loading
Loading