Skip to content

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

Merged
merged 57 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
af83f5b
feat: init funnel stepper
ilasw Apr 4, 2025
bdb5c1c
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 4, 2025
fff4582
feat: add funnel tracking hooks and refactor FunnelStepper
ilasw Apr 4, 2025
81f5636
feat: implement funnel navigation in FunnelStepper
ilasw Apr 4, 2025
9fb40f2
feat: update FormWrapper story title for better organization
ilasw Apr 5, 2025
eb12faf
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 8, 2025
24762ad
feat: refactor funnel step types and add funnel event tracking
ilasw Apr 8, 2025
89d2cdd
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 8, 2025
3cedb22
feat: better funnel step interfaces and navigation
ilasw Apr 8, 2025
80f965e
feat: added navigate function and back/skip navigation, also added tr…
ilasw Apr 8, 2025
1ce0b5e
feat: updated funnel navigation with back and skip objects
ilasw Apr 8, 2025
164f8dc
feat: add funnel event tracking on mount and unmount
ilasw Apr 8, 2025
ddb46dd
feat: fullscreen layout on sb
ilasw Apr 8, 2025
f01627f
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 8, 2025
629ecab
fix: merge conflicts
ilasw Apr 8, 2025
89e17d0
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 8, 2025
1e42975
feat: moved background logic in stepper
ilasw Apr 8, 2025
c63c8d8
feat: add transition effects to background
ilasw Apr 8, 2025
e0caee6
fix: dark mode on storybook funnel
ilasw Apr 8, 2025
1db8ed8
feat: add data tracking attributes to funnel components
ilasw Apr 8, 2025
c594e0f
feat: add hover event capture
ilasw Apr 8, 2025
827a407
feat: add hover event capture
ilasw Apr 8, 2025
fb76d7b
fix: linter
ilasw Apr 8, 2025
b329a4c
feat: add pricing into stepper
ilasw Apr 8, 2025
7bc6fb4
feat: add scroll tracking to funnel events
ilasw Apr 8, 2025
23c1938
feat: (wip) funnel boot support
idoshamun Apr 9, 2025
3dc69b4
feat: track on step change
ilasw Apr 9, 2025
beab578
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 9, 2025
2d914a9
feat: update FunnelStepTransitionCallback and add put after each step
ilasw Apr 9, 2025
53bb7c3
fix: handle potential null in query parameters
ilasw Apr 9, 2025
5b3b2cb
fix: typo
ilasw Apr 9, 2025
4dcfe2d
refactor: removed useEffect and moved logic in `navigate`
ilasw Apr 9, 2025
46d3565
feat: funnel boot support
idoshamun Apr 9, 2025
2e2a476
refactor: updated chapter structure and duration event
ilasw Apr 9, 2025
5831e81
Update packages/webapp/public/robots.txt
idoshamun Apr 9, 2025
41c7aaa
Update packages/shared/src/features/onboarding/hooks/useFunnelBoot.ts
idoshamun Apr 9, 2025
132b312
refactor: update funnel tracking for quiz input and step actions
ilasw Apr 9, 2025
439b8d3
fix: build issue
idoshamun Apr 9, 2025
690295c
refactor: add sessionId to funnel tracking
ilasw Apr 9, 2025
34c1fdd
fix: lint issue
idoshamun Apr 9, 2025
014062e
refactor: add onComplete callback to FunnelStepper
ilasw Apr 9, 2025
5755316
refactor: extract functions from useFunnelNavigation
ilasw Apr 9, 2025
d5164bb
refactor: use ref for onScroll callback in useWindowScroll
ilasw Apr 9, 2025
adaf1d9
refactor: remove unused import from useFunnelTracking
ilasw Apr 9, 2025
503d73d
refactor: remove unused funnel step components from FunnelStepper
ilasw Apr 9, 2025
e7b6317
Merge branch 'MI-859' of https://github.com/dailydotdev/apps into MI-…
ilasw Apr 9, 2025
8803039
refactor: revert app router changes
ilasw Apr 9, 2025
069f138
fix: lint dep array
ilasw Apr 9, 2025
5d41c8c
refactor: update selector for funnel tracking events
ilasw Apr 9, 2025
e6d69bf
feat: added tests for stepper and onComplete handling in skip cases
ilasw Apr 9, 2025
b924fdf
feat: show header only for quiz and updated storybook
ilasw Apr 9, 2025
9e605e9
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 9, 2025
57e66c2
feat: add FunnelRegistration component and refactor skip navigation l…
ilasw Apr 9, 2025
1886766
refactor: simplify FunnelStepper moving function outside
ilasw Apr 9, 2025
383496b
Merge branch 'feat-web-funnel' of https://github.com/dailydotdev/apps…
ilasw Apr 9, 2025
de88f33
refactor: rename FunnelInformative to FunnelFact and update related t…
ilasw Apr 9, 2025
48f97e9
feat: export utils from index.ts
ilasw Apr 9, 2025
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: 2 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
"check-password-strength": "^2.0.10",
"fetch-event-stream": "^0.1.5",
"graphql-ws": "^5.5.5",
"jotai": "^2.12.2",
"jotai-history": "^0.4.2",
"node-fetch": "^2.6.6",
"react-markdown": "^8.0.7",
"react-onesignal": "^3.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ButtonSize,
} from '../../../components/buttons/Button';
import type { ButtonProps } from '../../../components/buttons/Button';
import { FunnelTargetId } from '../../onboarding/types/funnelEvents';

type CheckboxValue = string;
type CheckboxValues = string[];
Expand Down Expand Up @@ -72,8 +73,9 @@ const FormInputCheckbox = ({
aria-checked={isSelected}
aria-label={item.label}
className={classNames(isVertical ? 'typo-subhead' : 'typo-body')}
pressed={isSelected}
data-funnel-track={FunnelTargetId.QuizInput}
name={name}
pressed={isSelected}
role="checkbox"
type="button"
value={item.value}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactElement, PropsWithChildren, ComponentProps } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonVariant } from '../../../components/buttons/Button';
import { FunnelTargetId } from '../../onboarding/types/funnelEvents';

type RatingValue = string;
type OptionItem = {
Expand Down Expand Up @@ -57,6 +58,7 @@ export const FormInputRating = ({
'h-16 min-w-10 flex-1 justify-center border border-border-subtlest-tertiary',
className,
)}
data-funnel-track={FunnelTargetId.QuizInput}
key={item.value}
name={name}
onClick={() => onSelect(item.value)}
Expand Down
14 changes: 0 additions & 14 deletions packages/shared/src/features/common/hooks/useRouterQuery.ts

This file was deleted.

27 changes: 27 additions & 0 deletions packages/shared/src/features/common/hooks/useWindowScroll.ts
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);
};
}, []);
};
181 changes: 181 additions & 0 deletions packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useMemo, useState, useEffect, useCallback } from 'react';
Copy link
Member

@idoshamun idoshamun Apr 9, 2025

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated ✔️

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we need atoms to reshare between things here?
Don't we already have exposes context for funnels specifically?

const [history, dispatchHistory] = useAtom(funnelPositionHistoryAtom);

const chapters: Chapters = useMemo(
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it mean we finished the funnel?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
};
};
Loading