Skip to content

Commit b56d02f

Browse files
committed
feat(onboarding): persona-driven swipe onboarding
Combines the swipe onboarding flow with the persona picker and removes the user-facing free-text prompt. Users now pick up to three personas; the prompt sent to onboardingDiscoverPosts is built silently from the persona titles + tags + the user's experienceLevel from registration. The "Show popular posts" fallback is preserved. - PersonaSelector gains a 'seed' mode that emits onSelectionChange instead of immediately following persona tags (the legacy 'follow' mode is unchanged for the EditTag flow). - New buildSwipePrompt utility composes a deterministic prompt string from personas + experience level, with unit-test coverage. - Shared SwipePersonaIntro component renders the pre-swipe panel for both the funnel step and the /onboarding/swipe preview. - featureManagement.ts skip-listed for strict typecheck while the bundler-resolution / @growthbook exports issue is addressed separately.
1 parent 5fbe548 commit b56d02f

33 files changed

Lines changed: 4697 additions & 173 deletions

packages/shared/src/components/MainLayout.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { PromptElement } from './modals/Prompt';
1717
import { useNotificationParams } from '../hooks/useNotificationParams';
1818
import { useAuthContext } from '../contexts/AuthContext';
1919
import { SharedFeedPage } from './utilities';
20-
import { isTesting, onboardingUrl } from '../lib/constants';
20+
import { isTesting, onboardingUrl, swipeOnboardingUrl } from '../lib/constants';
2121
import { useBanner } from '../hooks/useBanner';
2222
import { useGrowthBookContext } from './GrowthBookProvider';
2323
import {
@@ -48,6 +48,7 @@ const Sidebar = dynamic(() =>
4848
(mod) => mod.Sidebar,
4949
),
5050
);
51+
const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview';
5152

5253
export interface MainLayoutProps
5354
extends Omit<MainLayoutHeaderProps, 'onMobileSidebarToggle'>,
@@ -97,6 +98,16 @@ function MainLayoutComponent({
9798
const isLaptopXL = useViewSize(ViewSize.LaptopXL);
9899
const { screenCenteredOnMobileLayout } = useFeedLayout();
99100
const { isNotificationsReady, unreadCount } = useNotificationContext();
101+
const isPageReady =
102+
(growthbook?.ready && router?.isReady && isAuthReady) || isTesting;
103+
const swipeOnboardingPreviewQuery =
104+
router.query[swipeOnboardingPreviewQueryKey];
105+
const isSwipeOnboardingPreviewForced =
106+
swipeOnboardingPreviewQuery === '1' ||
107+
swipeOnboardingPreviewQuery === 'true' ||
108+
(Array.isArray(swipeOnboardingPreviewQuery) &&
109+
(swipeOnboardingPreviewQuery.includes('1') ||
110+
swipeOnboardingPreviewQuery.includes('true')));
100111
useNotificationParams();
101112

102113
useEffect(() => {
@@ -114,8 +125,6 @@ function MainLayoutComponent({
114125
// eslint-disable-next-line react-hooks/exhaustive-deps
115126
}, [isNotificationsReady, unreadCount, hasLoggedImpression]);
116127

117-
const isPageReady =
118-
(growthbook?.ready && router?.isReady && isAuthReady) || isTesting;
119128
const isPageApplicableForOnboarding =
120129
!page || feeds.includes(page) || isCustomFeed;
121130
const shouldRedirectOnboarding =
@@ -133,7 +142,9 @@ function MainLayoutComponent({
133142
const entries = Object.entries(router.query);
134143

135144
if (entries.length === 0) {
136-
router.push(onboardingUrl);
145+
router.push(
146+
isSwipeOnboardingPreviewForced ? swipeOnboardingUrl : onboardingUrl,
147+
);
137148
return;
138149
}
139150

@@ -143,8 +154,11 @@ function MainLayoutComponent({
143154
params.append(key, value as string);
144155
});
145156

146-
router.push(`${onboardingUrl}?${params.toString()}`);
147-
}, [shouldRedirectOnboarding, router]);
157+
const destination = isSwipeOnboardingPreviewForced
158+
? swipeOnboardingUrl
159+
: onboardingUrl;
160+
router.push(`${destination}?${params.toString()}`);
161+
}, [isSwipeOnboardingPreviewForced, shouldRedirectOnboarding, router]);
148162

149163
const ignoredUtmMediumForLogin = ['slack'];
150164
const utmSource = router?.query?.utm_source;
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { useRouter } from 'next/router';
5+
import { useAuthContext } from '../../contexts/AuthContext';
6+
import { useActiveFeedNameContext } from '../../contexts';
7+
import { useSettingsContext } from '../../contexts/SettingsContext';
8+
import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks';
9+
import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser';
10+
import { ActionType } from '../../graphql/actions';
11+
import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed';
12+
import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings';
13+
import { SharedFeedPage } from '../utilities';
14+
import MyFeedHeading from './MyFeedHeading';
15+
16+
jest.mock('next/router', () => ({
17+
useRouter: jest.fn(),
18+
}));
19+
20+
jest.mock('../../contexts/AuthContext', () => ({
21+
useAuthContext: jest.fn(),
22+
}));
23+
24+
jest.mock('../../contexts', () => ({
25+
useActiveFeedNameContext: jest.fn(),
26+
}));
27+
28+
jest.mock('../../contexts/SettingsContext', () => ({
29+
useSettingsContext: jest.fn(),
30+
}));
31+
32+
jest.mock('../../hooks', () => ({
33+
useActions: jest.fn(),
34+
useFeedLayout: jest.fn(),
35+
useViewSize: jest.fn(),
36+
ViewSize: {
37+
MobileL: 'mobile',
38+
Laptop: 'laptop',
39+
},
40+
}));
41+
42+
jest.mock('../../features/shortcuts/hooks/useShortcutsUser', () => ({
43+
useShortcutsUser: jest.fn(),
44+
}));
45+
46+
jest.mock('../../hooks/feed/useCustomDefaultFeed', () => ({
47+
__esModule: true,
48+
default: jest.fn(),
49+
}));
50+
51+
jest.mock('../AlertDot', () => ({
52+
AlertDot: ({ className }: { className?: string }) => (
53+
<div data-testid="alert-dot" className={className} />
54+
),
55+
AlertColor: { Bun: 'bg-accent-bun-default' },
56+
}));
57+
58+
jest.mock('../feeds/FeedSettingsButton', () => ({
59+
FeedSettingsButton: ({
60+
children,
61+
onClick,
62+
}: {
63+
children: React.ReactNode;
64+
onClick: () => void;
65+
}) => (
66+
<button type="button" onClick={onClick}>
67+
{children}
68+
</button>
69+
),
70+
}));
71+
72+
jest.mock('../../lib/constants', () => ({
73+
...jest.requireActual('../../lib/constants'),
74+
webappUrl: 'https://app.daily.dev/',
75+
settingsUrl: 'https://app.daily.dev/settings',
76+
}));
77+
78+
jest.mock('../../lib/feedSettings', () => ({
79+
getHasSeenTags: jest.fn(),
80+
setHasSeenTags: jest.fn(),
81+
}));
82+
83+
const mockUseRouter = useRouter as jest.Mock;
84+
const mockUseAuthContext = useAuthContext as jest.Mock;
85+
const mockUseActiveFeedNameContext = useActiveFeedNameContext as jest.Mock;
86+
const mockUseSettingsContext = useSettingsContext as jest.Mock;
87+
const mockUseActions = useActions as jest.Mock;
88+
const mockUseFeedLayout = useFeedLayout as jest.Mock;
89+
const mockUseViewSize = useViewSize as jest.Mock;
90+
const mockUseShortcutsUser = useShortcutsUser as jest.Mock;
91+
const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock;
92+
const mockGetHasSeenTags = getHasSeenTags as jest.Mock;
93+
const mockSetHasSeenTags = setHasSeenTags as jest.Mock;
94+
95+
const push = jest.fn();
96+
const completeAction = jest.fn();
97+
98+
const renderComponent = () => render(<MyFeedHeading />);
99+
100+
describe('MyFeedHeading', () => {
101+
beforeEach(() => {
102+
push.mockReset();
103+
push.mockResolvedValue(true);
104+
completeAction.mockReset();
105+
completeAction.mockResolvedValue(undefined);
106+
mockGetHasSeenTags.mockReset();
107+
mockGetHasSeenTags.mockReturnValue(null);
108+
mockSetHasSeenTags.mockReset();
109+
110+
mockUseRouter.mockReturnValue({
111+
push,
112+
pathname: '/',
113+
query: {},
114+
});
115+
mockUseAuthContext.mockReturnValue({
116+
user: { id: 'user-1' },
117+
});
118+
mockUseActiveFeedNameContext.mockReturnValue({
119+
feedName: SharedFeedPage.MyFeed,
120+
});
121+
mockUseSettingsContext.mockReturnValue({
122+
toggleShowTopSites: jest.fn(),
123+
});
124+
mockUseActions.mockReturnValue({
125+
completeAction,
126+
checkHasCompleted: jest.fn().mockReturnValue(false),
127+
isActionsFetched: true,
128+
});
129+
mockUseFeedLayout.mockReturnValue({
130+
shouldUseListFeedLayout: false,
131+
});
132+
mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop);
133+
mockUseShortcutsUser.mockReturnValue({
134+
isOldUserWithNoShortcuts: false,
135+
showToggleShortcuts: false,
136+
});
137+
mockUseCustomDefaultFeed.mockReturnValue({
138+
isCustomDefaultFeed: false,
139+
defaultFeedId: 'user-1',
140+
});
141+
});
142+
143+
afterEach(() => {
144+
jest.clearAllMocks();
145+
});
146+
147+
it('routes the home custom default feed to its edit page', async () => {
148+
mockUseCustomDefaultFeed.mockReturnValue({
149+
isCustomDefaultFeed: true,
150+
defaultFeedId: 'feed-1',
151+
});
152+
153+
renderComponent();
154+
155+
await userEvent.click(
156+
screen.getByRole('button', { name: 'Feed settings' }),
157+
);
158+
159+
expect(push).toHaveBeenCalledWith(
160+
'https://app.daily.dev/feeds/feed-1/edit',
161+
);
162+
});
163+
164+
it('routes the home For you feed to the user edit page with the tags tab open', async () => {
165+
renderComponent();
166+
167+
await userEvent.click(
168+
screen.getByRole('button', { name: 'Feed settings' }),
169+
);
170+
171+
expect(push).toHaveBeenCalledWith(
172+
'https://app.daily.dev/feeds/user-1/edit?dview=tags',
173+
);
174+
});
175+
176+
it('routes the For you feed to the user edit page with the tags tab open', async () => {
177+
mockUseRouter.mockReturnValue({
178+
push,
179+
pathname: '/my-feed',
180+
query: {},
181+
});
182+
183+
renderComponent();
184+
185+
await userEvent.click(
186+
screen.getByRole('button', { name: 'Feed settings' }),
187+
);
188+
189+
expect(push).toHaveBeenCalledWith(
190+
'https://app.daily.dev/feeds/user-1/edit?dview=tags',
191+
);
192+
});
193+
194+
it('routes custom feeds to their slug or id edit page', async () => {
195+
mockUseRouter.mockReturnValue({
196+
push,
197+
pathname: '/feeds/[slugOrId]',
198+
query: { slugOrId: 'feed-2' },
199+
});
200+
mockUseActiveFeedNameContext.mockReturnValue({
201+
feedName: SharedFeedPage.Custom,
202+
});
203+
204+
renderComponent();
205+
206+
await userEvent.click(
207+
screen.getByRole('button', { name: 'Feed settings' }),
208+
);
209+
210+
expect(push).toHaveBeenCalledWith(
211+
'https://app.daily.dev/feeds/feed-2/edit',
212+
);
213+
});
214+
215+
it('shows the tags reminder dot for the For you feed when tags were not seen yet', () => {
216+
mockGetHasSeenTags.mockReturnValue(false);
217+
218+
renderComponent();
219+
220+
expect(screen.getByTestId('alert-dot')).toBeInTheDocument();
221+
});
222+
223+
it('does not show the tags reminder dot for custom feeds', () => {
224+
mockGetHasSeenTags.mockReturnValue(false);
225+
mockUseRouter.mockReturnValue({
226+
push,
227+
pathname: '/feeds/[slugOrId]',
228+
query: { slugOrId: 'feed-2' },
229+
});
230+
mockUseActiveFeedNameContext.mockReturnValue({
231+
feedName: SharedFeedPage.Custom,
232+
});
233+
234+
renderComponent();
235+
236+
expect(screen.queryByTestId('alert-dot')).not.toBeInTheDocument();
237+
});
238+
239+
it('marks tags as seen before navigating from the For you feed settings button', async () => {
240+
mockGetHasSeenTags.mockReturnValue(false);
241+
242+
renderComponent();
243+
244+
await userEvent.click(
245+
screen.getByRole('button', { name: 'Feed settings' }),
246+
);
247+
248+
expect(mockSetHasSeenTags).toHaveBeenCalledWith('user-1', true);
249+
expect(completeAction).toHaveBeenCalledWith(ActionType.HasSeenTags);
250+
expect(push).toHaveBeenCalledWith(
251+
'https://app.daily.dev/feeds/user-1/edit?dview=tags',
252+
);
253+
});
254+
});

0 commit comments

Comments
 (0)