-
Notifications
You must be signed in to change notification settings - Fork 262
feat(funnels): funnel stepper #4345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
af83f5b
bdb5c1c
fff4582
81f5636
9fb40f2
eb12faf
24762ad
89d2cdd
3cedb22
80f965e
1ce0b5e
164f8dc
ddb46dd
f01627f
629ecab
89e17d0
1e42975
c63c8d8
e0caee6
1db8ed8
c594e0f
827a407
fb76d7b
b329a4c
7bc6fb4
23c1938
3dc69b4
beab578
2d914a9
53bb7c3
5b3b2cb
4dcfe2d
46d3565
2e2a476
5831e81
41c7aaa
132b312
439b8d3
690295c
34c1fdd
014062e
5755316
d5164bb
adaf1d9
503d73d
e7b6317
8803039
069f138
5d41c8c
e6d69bf
b924fdf
9e605e9
57e66c2
1886766
383496b
de88f33
48f97e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
'use client'; | ||
|
||
import { useEffect, useRef } from 'react'; | ||
|
||
interface UseWindowScrollProps { | ||
onScroll?: (scrollY: number) => void; | ||
} | ||
|
||
export const useWindowScroll = (options?: UseWindowScrollProps): void => { | ||
const { onScroll } = options || {}; | ||
const onScrollRef = useRef(onScroll); | ||
|
||
useEffect(() => { | ||
onScrollRef.current = onScroll; | ||
}, [onScroll]); | ||
|
||
useEffect(() => { | ||
const handleScroll = () => { | ||
onScrollRef.current?.(window.scrollY); | ||
}; | ||
|
||
globalThis?.addEventListener?.('scroll', handleScroll, { passive: true }); | ||
return () => { | ||
globalThis?.removeEventListener?.('scroll', handleScroll); | ||
}; | ||
}, []); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
import { useMemo, useState, useEffect, useCallback } from 'react'; | ||
import { useSearchParams, useRouter, usePathname } from 'next/navigation'; | ||
import { useAtom } from 'jotai/react'; | ||
import { UNDO } from 'jotai-history'; | ||
import type { FunnelJSON, FunnelPosition, FunnelStep } from '../types/funnel'; | ||
import { FunnelStepTransitionType } from '../types/funnel'; | ||
import type { TrackOnNavigate } from './useFunnelTracking'; | ||
import { | ||
funnelPositionAtom, | ||
getFunnelStepByPosition, | ||
funnelPositionHistoryAtom, | ||
} from '../store/funnelStore'; | ||
|
||
interface UseFunnelNavigationProps { | ||
funnel: FunnelJSON; | ||
onNavigation: TrackOnNavigate; | ||
} | ||
|
||
type Chapters = Array<{ steps: number }>; | ||
type StepMap = Record<FunnelStep['id'], { position: FunnelPosition }>; | ||
type NavigateFunction = (options: { | ||
to: FunnelStep['id']; | ||
type?: FunnelStepTransitionType; | ||
}) => void; | ||
|
||
interface HeaderNavigation { | ||
hasTarget: boolean; | ||
navigate: () => void; | ||
} | ||
|
||
export interface UseFunnelNavigationReturn { | ||
chapters: Chapters; | ||
navigate: NavigateFunction; | ||
position: FunnelPosition; | ||
step: FunnelStep; | ||
back: HeaderNavigation; | ||
skip: Omit<HeaderNavigation, 'navigate'>; | ||
} | ||
|
||
function getStepMap(funnel: FunnelJSON): StepMap { | ||
const stepMap: StepMap = {}; | ||
|
||
funnel.chapters.forEach((chapter, chapterIndex) => { | ||
chapter.steps.forEach((step, stepIndex) => { | ||
stepMap[step.id] = { | ||
position: { | ||
chapter: chapterIndex, | ||
step: stepIndex, | ||
}, | ||
}; | ||
}); | ||
}); | ||
|
||
return stepMap; | ||
} | ||
|
||
function updateURLWithStepId({ | ||
pathname, | ||
router, | ||
searchParams, | ||
stepId, | ||
}: { | ||
pathname: string; | ||
router: ReturnType<typeof useRouter>; | ||
searchParams: URLSearchParams; | ||
stepId: string; | ||
}) { | ||
const params = new URLSearchParams(searchParams.toString()); | ||
params.set('stepId', stepId); | ||
router.replace(`/${pathname}?${params.toString()}`, { scroll: false }); | ||
} | ||
|
||
export const useFunnelNavigation = ({ | ||
funnel, | ||
onNavigation, | ||
}: UseFunnelNavigationProps): UseFunnelNavigationReturn => { | ||
const router = useRouter(); | ||
const searchParams = useSearchParams(); | ||
const pathname = usePathname(); | ||
const [stepTimerStart, setStepTimerStart] = useState<number>(0); | ||
const [position, setPosition] = useAtom(funnelPositionAtom); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess we need atoms to reshare between things here? |
||
const [history, dispatchHistory] = useAtom(funnelPositionHistoryAtom); | ||
|
||
const chapters: Chapters = useMemo( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure that this is how we do the chapters on the backend. we'll need to check There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is how the Header component's props is shaped, just passing to it |
||
() => funnel.chapters.map((chapter) => ({ steps: chapter.steps.length })), | ||
[funnel], | ||
); | ||
|
||
const stepMap: StepMap = useMemo(() => getStepMap(funnel), [funnel]); | ||
|
||
const step: FunnelStep = useMemo( | ||
() => getFunnelStepByPosition(funnel, position), | ||
[funnel, position], | ||
); | ||
|
||
const navigate: NavigateFunction = useCallback( | ||
({ to, type = FunnelStepTransitionType.Complete }) => { | ||
if (!step) { | ||
return; | ||
} | ||
|
||
const from = step.id; | ||
const timeDuration = Date.now() - stepTimerStart; | ||
|
||
if (!stepMap[to]?.position) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it mean we finished the funnel? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or that the editor have put a non existing step as target 👀 |
||
return; | ||
} | ||
|
||
// update the position in the store | ||
const newPosition = stepMap[to]?.position; | ||
setPosition(newPosition); | ||
|
||
// track the navigation event | ||
onNavigation({ from, to, timeDuration, type }); | ||
|
||
// Reset the timer when the step changes | ||
setStepTimerStart(Date.now()); | ||
|
||
// update URL with new stepId | ||
updateURLWithStepId({ router, pathname, searchParams, stepId: to }); | ||
}, | ||
[ | ||
onNavigation, | ||
pathname, | ||
router, | ||
searchParams, | ||
setPosition, | ||
step, | ||
stepMap, | ||
stepTimerStart, | ||
], | ||
); | ||
|
||
const back: HeaderNavigation = useMemo(() => { | ||
return { | ||
hasTarget: history.canUndo, | ||
navigate: () => { | ||
if (!history.canUndo) { | ||
return; | ||
} | ||
dispatchHistory(UNDO); | ||
}, | ||
}; | ||
}, [dispatchHistory, history.canUndo]); | ||
|
||
const skip: UseFunnelNavigationReturn['skip'] = useMemo( | ||
() => ({ | ||
hasTarget: !!step?.transitions?.some( | ||
({ on, destination }) => | ||
on === FunnelStepTransitionType.Skip && !!destination, | ||
), | ||
}), | ||
[step?.transitions], | ||
); | ||
|
||
useEffect( | ||
() => { | ||
// on load check if stepId is in the URL and set the position | ||
const stepId = searchParams.get('stepId'); | ||
|
||
if (!stepId) { | ||
return; | ||
} | ||
|
||
const newPosition = stepMap[stepId]?.position; | ||
setPosition(newPosition); | ||
}, | ||
// only run on mount | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
[], | ||
); | ||
|
||
return { | ||
back, | ||
chapters, | ||
navigate, | ||
position, | ||
skip, | ||
step, | ||
}; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need some more comments here to make it maintainable or breakdown to smaller pieces even within the same file
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated ✔️