diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index 9c14aad3cad..b566c7f33af 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useAtom } from 'jotai/react'; import { UNDO } from 'jotai-history'; @@ -10,12 +10,12 @@ import { getFunnelStepByPosition, funnelPositionHistoryAtom, } from '../store/funnelStore'; -import type { FunnelSession } from '../types/funnelBoot'; +import { useToggle } from '../../../hooks/useToggle'; interface UseFunnelNavigationProps { funnel: FunnelJSON; + initialStepId?: string | null; onNavigation: TrackOnNavigate; - session: FunnelSession; } type Chapters = Array<{ steps: number }>; @@ -32,6 +32,7 @@ interface HeaderNavigation { export interface UseFunnelNavigationReturn { chapters: Chapters; + isReady: boolean; navigate: NavigateFunction; position: FunnelPosition; step: FunnelStep; @@ -74,8 +75,8 @@ function updateURLWithStepId({ export const useFunnelNavigation = ({ funnel, + initialStepId, onNavigation, - session, }: UseFunnelNavigationProps): UseFunnelNavigationReturn => { const router = useRouter(); const searchParams = useSearchParams(); @@ -84,6 +85,9 @@ export const useFunnelNavigation = ({ const [position, setPosition] = useAtom(funnelPositionAtom); const [history, dispatchHistory] = useAtom(funnelPositionHistoryAtom); const isFirstStep = !position.step && !position.chapter; + const isInitialized = useRef(false); + const [isReady, setIsReady] = useToggle(false); + const urlStepId = searchParams.get('stepId'); const chapters: Chapters = useMemo( () => funnel.chapters.map((chapter) => ({ steps: chapter.steps.length })), @@ -92,6 +96,16 @@ export const useFunnelNavigation = ({ const stepMap: StepMap = useMemo(() => getStepMap(funnel), [funnel]); + const setPositionById = useCallback( + (stepId: FunnelStep['id']) => { + const newPosition = stepMap[stepId]?.position; + if (newPosition) { + setPosition(newPosition); + } + }, + [setPosition, stepMap], + ); + const step: FunnelStep = useMemo( () => getFunnelStepByPosition(funnel, position), [funnel, position], @@ -111,8 +125,7 @@ export const useFunnelNavigation = ({ } // update the position in the store - const newPosition = stepMap[to]?.position; - setPosition(newPosition); + setPositionById(to); // track the navigation event onNavigation({ from, to, timeDuration, type }); @@ -128,7 +141,7 @@ export const useFunnelNavigation = ({ pathname, router, searchParams, - setPosition, + setPositionById, step, stepMap, stepTimerStart, @@ -160,22 +173,43 @@ export const useFunnelNavigation = ({ [isFirstStep, step?.transitions], ); - useEffect( - () => { - // Check if the URL has a stepId parameter or if there is a session - const stepId = searchParams.get('stepId') ?? session.currentStep; + // On load: Update the initial position in state and URL + useEffect(() => { + if (isInitialized.current) { + return; + } - if (!stepId) { - return; - } + if (initialStepId) { + setPositionById(initialStepId); + } - const newPosition = stepMap[stepId]?.position; - setPosition(newPosition); - }, - // only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - [funnel.id], - ); + updateURLWithStepId({ + router, + pathname, + searchParams, + stepId: initialStepId || step.id, + }); + + isInitialized.current = true; + setIsReady(true); + }, [ + initialStepId, + pathname, + router, + searchParams, + setPositionById, + step.id, + setIsReady, + ]); + + // After load: update the position when the URL's stepId changes + useEffect(() => { + if (!urlStepId || !isInitialized.current) { + return; + } + + setPositionById(urlStepId); + }, [setPositionById, urlStepId]); return { back, @@ -184,5 +218,6 @@ export const useFunnelNavigation = ({ position, skip, step, + isReady, }; }; diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx index 608675985d7..66864704121 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx @@ -86,6 +86,7 @@ describe('FunnelStepper component', () => { position: { chapter: 0, step: 0 }, chapters: [{ steps: 1 }], step: mockStep, + isReady: true, }); (useFunnelTracking as jest.Mock).mockReturnValue({ @@ -273,6 +274,7 @@ describe('FunnelStepper component', () => { position: { chapter: 0, step: 0 }, chapters: [{ steps: 2 }], step: quizStep, + isReady: true, }); renderComponent(mockFunnelWithMultipleSteps); diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 1a3b0e4eb57..87304fa9309 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -38,9 +38,10 @@ import classed from '../../../lib/classed'; export interface FunnelStepperProps { funnel: FunnelJSON; + initialStepId?: string | null; + onComplete?: () => void; session: FunnelSession; showCookieBanner?: boolean; - onComplete?: () => void; } const stepComponentMap = { @@ -78,6 +79,7 @@ const HiddenStep = classed('div', 'hidden'); export const FunnelStepper = ({ funnel, + initialStepId, session, showCookieBanner, onComplete, @@ -90,8 +92,12 @@ export const FunnelStepper = ({ trackOnComplete, trackFunnelEvent, } = useFunnelTracking({ funnel, session }); - const { back, chapters, navigate, position, skip, step } = - useFunnelNavigation({ funnel, onNavigation: trackOnNavigate, session }); + const { back, chapters, navigate, position, skip, step, isReady } = + useFunnelNavigation({ + funnel, + initialStepId, + onNavigation: trackOnNavigate, + }); const { transition: sendTransition } = useStepTransition(session.id); const { showBanner, ...cookieConsentProps } = useFunnelCookies({ defaultOpen: showCookieBanner, @@ -128,6 +134,10 @@ export const FunnelStepper = ({ } }; + if (!isReady) { + return null; + } + return (
= async ({ // Check if the user already accepted cookies const hasAcceptedCookies = allCookies.includes(GdprConsentKey.Marketing); + // Determine the initial step ID + const queryStepId = query?.stepId as string | undefined; + const initialStepId: string | null = + queryStepId ?? boot.data?.funnelState?.session?.currentStep; + // Return props including the dehydrated state return { props: { dehydratedState: dehydrate(queryClient), showCookieBanner: !hasAcceptedCookies, + initialStepId, }, }; }; export default function HelloWorldPage({ dehydratedState, + initialStepId, showCookieBanner, }: PageProps): ReactElement { const { data: funnelBoot } = useFunnelBoot(); @@ -112,6 +120,7 @@ export default function HelloWorldPage({ {!!funnel && !!session.id && ( router.replace('/onboarding')}