diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx
index 6e1ba91063a..bccdd76faf6 100644
--- a/packages/shared/src/components/MainLayout.tsx
+++ b/packages/shared/src/components/MainLayout.tsx
@@ -33,8 +33,6 @@ import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner';
import usePlusEntry from '../hooks/usePlusEntry';
import { SearchProvider } from '../contexts/search/SearchContext';
import { FeedbackWidget } from './feedback';
-import { useConditionalFeature } from '../hooks/useConditionalFeature';
-import { swipeOnboardingFeature } from '../lib/featureManagement';
const GoBackHeaderMobile = dynamic(
() =>
@@ -99,10 +97,6 @@ function MainLayoutComponent({
const { isNotificationsReady, unreadCount } = useNotificationContext();
const isPageReady =
(growthbook?.ready && router?.isReady && isAuthReady) || isTesting;
- const { value: isSwipeOnboardingEnabled } = useConditionalFeature({
- feature: swipeOnboardingFeature,
- shouldEvaluate: !user,
- });
const swipeOnboardingPreviewQuery =
router.query[swipeOnboardingPreviewQueryKey];
const isSwipeOnboardingPreviewForced =
@@ -142,9 +136,7 @@ function MainLayoutComponent({
if (entries.length === 0) {
router.push(
- isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced
- ? swipeOnboardingUrl
- : onboardingUrl,
+ isSwipeOnboardingPreviewForced ? swipeOnboardingUrl : onboardingUrl,
);
return;
}
@@ -155,17 +147,11 @@ function MainLayoutComponent({
params.append(key, value as string);
});
- const destination =
- isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced
- ? swipeOnboardingUrl
- : onboardingUrl;
+ const destination = isSwipeOnboardingPreviewForced
+ ? swipeOnboardingUrl
+ : onboardingUrl;
router.push(`${destination}?${params.toString()}`);
- }, [
- isSwipeOnboardingEnabled,
- isSwipeOnboardingPreviewForced,
- shouldRedirectOnboarding,
- router,
- ]);
+ }, [isSwipeOnboardingPreviewForced, shouldRedirectOnboarding, router]);
const ignoredUtmMediumForLogin = ['slack'];
const utmSource = router?.query?.utm_source;
diff --git a/packages/shared/src/components/filters/MyFeedHeading.spec.tsx b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx
new file mode 100644
index 00000000000..64f9976a271
--- /dev/null
+++ b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx
@@ -0,0 +1,254 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useRouter } from 'next/router';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useActiveFeedNameContext } from '../../contexts';
+import { useSettingsContext } from '../../contexts/SettingsContext';
+import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks';
+import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser';
+import { ActionType } from '../../graphql/actions';
+import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed';
+import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings';
+import { SharedFeedPage } from '../utilities';
+import MyFeedHeading from './MyFeedHeading';
+
+jest.mock('next/router', () => ({
+ useRouter: jest.fn(),
+}));
+
+jest.mock('../../contexts/AuthContext', () => ({
+ useAuthContext: jest.fn(),
+}));
+
+jest.mock('../../contexts', () => ({
+ useActiveFeedNameContext: jest.fn(),
+}));
+
+jest.mock('../../contexts/SettingsContext', () => ({
+ useSettingsContext: jest.fn(),
+}));
+
+jest.mock('../../hooks', () => ({
+ useActions: jest.fn(),
+ useFeedLayout: jest.fn(),
+ useViewSize: jest.fn(),
+ ViewSize: {
+ MobileL: 'mobile',
+ Laptop: 'laptop',
+ },
+}));
+
+jest.mock('../../features/shortcuts/hooks/useShortcutsUser', () => ({
+ useShortcutsUser: jest.fn(),
+}));
+
+jest.mock('../../hooks/feed/useCustomDefaultFeed', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('../AlertDot', () => ({
+ AlertDot: ({ className }: { className?: string }) => (
+
+ ),
+ AlertColor: { Bun: 'bg-accent-bun-default' },
+}));
+
+jest.mock('../feeds/FeedSettingsButton', () => ({
+ FeedSettingsButton: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick: () => void;
+ }) => (
+
+ ),
+}));
+
+jest.mock('../../lib/constants', () => ({
+ ...jest.requireActual('../../lib/constants'),
+ webappUrl: 'https://app.daily.dev/',
+ settingsUrl: 'https://app.daily.dev/settings',
+}));
+
+jest.mock('../../lib/feedSettings', () => ({
+ getHasSeenTags: jest.fn(),
+ setHasSeenTags: jest.fn(),
+}));
+
+const mockUseRouter = useRouter as jest.Mock;
+const mockUseAuthContext = useAuthContext as jest.Mock;
+const mockUseActiveFeedNameContext = useActiveFeedNameContext as jest.Mock;
+const mockUseSettingsContext = useSettingsContext as jest.Mock;
+const mockUseActions = useActions as jest.Mock;
+const mockUseFeedLayout = useFeedLayout as jest.Mock;
+const mockUseViewSize = useViewSize as jest.Mock;
+const mockUseShortcutsUser = useShortcutsUser as jest.Mock;
+const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock;
+const mockGetHasSeenTags = getHasSeenTags as jest.Mock;
+const mockSetHasSeenTags = setHasSeenTags as jest.Mock;
+
+const push = jest.fn();
+const completeAction = jest.fn();
+
+const renderComponent = () => render();
+
+describe('MyFeedHeading', () => {
+ beforeEach(() => {
+ push.mockReset();
+ push.mockResolvedValue(true);
+ completeAction.mockReset();
+ completeAction.mockResolvedValue(undefined);
+ mockGetHasSeenTags.mockReset();
+ mockGetHasSeenTags.mockReturnValue(null);
+ mockSetHasSeenTags.mockReset();
+
+ mockUseRouter.mockReturnValue({
+ push,
+ pathname: '/',
+ query: {},
+ });
+ mockUseAuthContext.mockReturnValue({
+ user: { id: 'user-1' },
+ });
+ mockUseActiveFeedNameContext.mockReturnValue({
+ feedName: SharedFeedPage.MyFeed,
+ });
+ mockUseSettingsContext.mockReturnValue({
+ toggleShowTopSites: jest.fn(),
+ });
+ mockUseActions.mockReturnValue({
+ completeAction,
+ checkHasCompleted: jest.fn().mockReturnValue(false),
+ isActionsFetched: true,
+ });
+ mockUseFeedLayout.mockReturnValue({
+ shouldUseListFeedLayout: false,
+ });
+ mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop);
+ mockUseShortcutsUser.mockReturnValue({
+ isOldUserWithNoShortcuts: false,
+ showToggleShortcuts: false,
+ });
+ mockUseCustomDefaultFeed.mockReturnValue({
+ isCustomDefaultFeed: false,
+ defaultFeedId: 'user-1',
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('routes the home custom default feed to its edit page', async () => {
+ mockUseCustomDefaultFeed.mockReturnValue({
+ isCustomDefaultFeed: true,
+ defaultFeedId: 'feed-1',
+ });
+
+ renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Feed settings' }),
+ );
+
+ expect(push).toHaveBeenCalledWith(
+ 'https://app.daily.dev/feeds/feed-1/edit',
+ );
+ });
+
+ it('routes the home For you feed to the user edit page with the tags tab open', async () => {
+ renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Feed settings' }),
+ );
+
+ expect(push).toHaveBeenCalledWith(
+ 'https://app.daily.dev/feeds/user-1/edit?dview=tags',
+ );
+ });
+
+ it('routes the For you feed to the user edit page with the tags tab open', async () => {
+ mockUseRouter.mockReturnValue({
+ push,
+ pathname: '/my-feed',
+ query: {},
+ });
+
+ renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Feed settings' }),
+ );
+
+ expect(push).toHaveBeenCalledWith(
+ 'https://app.daily.dev/feeds/user-1/edit?dview=tags',
+ );
+ });
+
+ it('routes custom feeds to their slug or id edit page', async () => {
+ mockUseRouter.mockReturnValue({
+ push,
+ pathname: '/feeds/[slugOrId]',
+ query: { slugOrId: 'feed-2' },
+ });
+ mockUseActiveFeedNameContext.mockReturnValue({
+ feedName: SharedFeedPage.Custom,
+ });
+
+ renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Feed settings' }),
+ );
+
+ expect(push).toHaveBeenCalledWith(
+ 'https://app.daily.dev/feeds/feed-2/edit',
+ );
+ });
+
+ it('shows the tags reminder dot for the For you feed when tags were not seen yet', () => {
+ mockGetHasSeenTags.mockReturnValue(false);
+
+ renderComponent();
+
+ expect(screen.getByTestId('alert-dot')).toBeInTheDocument();
+ });
+
+ it('does not show the tags reminder dot for custom feeds', () => {
+ mockGetHasSeenTags.mockReturnValue(false);
+ mockUseRouter.mockReturnValue({
+ push,
+ pathname: '/feeds/[slugOrId]',
+ query: { slugOrId: 'feed-2' },
+ });
+ mockUseActiveFeedNameContext.mockReturnValue({
+ feedName: SharedFeedPage.Custom,
+ });
+
+ renderComponent();
+
+ expect(screen.queryByTestId('alert-dot')).not.toBeInTheDocument();
+ });
+
+ it('marks tags as seen before navigating from the For you feed settings button', async () => {
+ mockGetHasSeenTags.mockReturnValue(false);
+
+ renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Feed settings' }),
+ );
+
+ expect(mockSetHasSeenTags).toHaveBeenCalledWith('user-1', true);
+ expect(completeAction).toHaveBeenCalledWith(ActionType.HasSeenTags);
+ expect(push).toHaveBeenCalledWith(
+ 'https://app.daily.dev/feeds/user-1/edit?dview=tags',
+ );
+ });
+});
diff --git a/packages/shared/src/components/filters/MyFeedHeading.tsx b/packages/shared/src/components/filters/MyFeedHeading.tsx
index 70f9a0d5cc9..333976ec477 100644
--- a/packages/shared/src/components/filters/MyFeedHeading.tsx
+++ b/packages/shared/src/components/filters/MyFeedHeading.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { FilterIcon, PlusIcon } from '../icons';
import {
@@ -12,9 +12,13 @@ import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks';
import { ActionType } from '../../graphql/actions';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { FeedSettingsButton } from '../feeds/FeedSettingsButton';
+import { AlertColor, AlertDot } from '../AlertDot';
+import { FeedSettingsMenu } from '../feeds/FeedSettings/types';
import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser';
import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed';
+import { useAuthContext } from '../../contexts/AuthContext';
import { settingsUrl, webappUrl } from '../../lib/constants';
+import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings';
import { SharedFeedPage } from '../utilities';
import { useActiveFeedNameContext } from '../../contexts';
@@ -26,7 +30,7 @@ function MyFeedHeading({
onOpenFeedFilters,
}: MyFeedHeadingProps): ReactElement {
const { push, pathname, query } = useRouter();
- const { completeAction } = useActions();
+ const { completeAction, checkHasCompleted, isActionsFetched } = useActions();
const { toggleShowTopSites } = useSettingsContext();
const { isOldUserWithNoShortcuts, showToggleShortcuts } = useShortcutsUser();
const isMobile = useViewSize(ViewSize.MobileL);
@@ -34,38 +38,90 @@ function MyFeedHeading({
const isLaptop = useViewSize(ViewSize.Laptop);
const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed();
const { feedName } = useActiveFeedNameContext();
+ const { user } = useAuthContext();
+ const [hasSeenTagsState, setHasSeenTagsState] = useState(
+ null,
+ );
+
+ const hasSeenTagsAction =
+ isActionsFetched && checkHasCompleted(ActionType.HasSeenTags);
const editFeedUrl = useMemo(() => {
if (isCustomDefaultFeed && pathname === '/') {
return `${webappUrl}feeds/${defaultFeedId}/edit`;
}
+ if (feedName === SharedFeedPage.MyFeed && user?.id) {
+ return `${webappUrl}feeds/${user.id}/edit?dview=${FeedSettingsMenu.Tags}`;
+ }
+
if (feedName === SharedFeedPage.Custom) {
return `${webappUrl}feeds/${query.slugOrId}/edit`;
}
return `${settingsUrl}/feed/general`;
- }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query]);
+ }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query, user?.id]);
+
+ useEffect(() => {
+ if (!user?.id) {
+ setHasSeenTagsState(null);
+ return;
+ }
+
+ if (hasSeenTagsAction) {
+ setHasSeenTags(user.id, true);
+ setHasSeenTagsState(true);
+ return;
+ }
+
+ setHasSeenTagsState(getHasSeenTags(user.id));
+ }, [hasSeenTagsAction, user?.id]);
+
+ const shouldShowTagsReminder =
+ feedName === SharedFeedPage.MyFeed && hasSeenTagsState === false;
const onClick = useCallback(() => {
+ if (shouldShowTagsReminder && user?.id) {
+ setHasSeenTags(user.id, true);
+ setHasSeenTagsState(true);
+ completeAction(ActionType.HasSeenTags).catch(() => null);
+ }
+
onOpenFeedFilters?.();
return push(editFeedUrl);
- }, [editFeedUrl, onOpenFeedFilters, push]);
+ }, [
+ completeAction,
+ editFeedUrl,
+ onOpenFeedFilters,
+ push,
+ shouldShowTagsReminder,
+ user?.id,
+ ]);
return (
<>
- }
- iconPosition={
- shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined
- }
- >
- {!isMobile ? 'Feed settings' : null}
-
+
+
}
+ iconPosition={
+ shouldUseListFeedLayout
+ ? ButtonIconPosition.Right
+ : ButtonIconPosition.Left
+ }
+ >
+ {!isMobile ? 'Feed settings' : null}
+
+ {shouldShowTagsReminder && (
+
+ )}
+
{showToggleShortcuts && (
}
iconPosition={
- shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined
+ shouldUseListFeedLayout
+ ? ButtonIconPosition.Right
+ : ButtonIconPosition.Left
}
>
Shortcuts
diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx
index b3564855588..78d88a6f433 100644
--- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx
+++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx
@@ -282,4 +282,42 @@ describe('HotAndColdModal', () => {
'line-clamp-3',
);
});
+
+ it('should keep onboarding mode clipped and scrollable inside the modal shell', () => {
+ render(
+ Progress header}
+ bottomSlot={Starter feed ready
}
+ />,
+ );
+
+ const modalBody = document.querySelector('section');
+ expect(modalBody).toHaveClass('overflow-y-auto', 'overflow-x-hidden');
+ expect(modalBody).not.toHaveClass(
+ 'tablet:!overflow-x-visible',
+ 'tablet:!overflow-y-visible',
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Not interesting' }),
+ ).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Interesting' })).toBeVisible();
+ expect(
+ screen.getByRole('img', { name: 'daily.dev source icon' }),
+ ).toBeVisible();
+ expect(screen.getByText('Starter feed ready')).toBeVisible();
+ });
});
diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx
index d58821bd584..6dc4d4f5875 100644
--- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx
+++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx
@@ -31,6 +31,7 @@ import { ReputationUserBadge } from '../../ReputationUserBadge';
import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge';
import { PlusUserBadge } from '../../PlusUserBadge';
import { Loader } from '../../Loader';
+import LogoIcon from '../../../svg/LogoIcon';
import type { HotTake } from '../../../graphql/user/userHotTake';
const SWIPE_THRESHOLD = 80;
@@ -54,9 +55,9 @@ const HOT_TAKE_CARD_HEIGHT = '28rem';
/** Title3 Γ 3 lines (typo-title3 line-height 1.625rem in tailwind/typography.ts). */
const ONBOARDING_CARD_TITLE_MIN_HEIGHT = '4.875rem';
/** Fixed onboarding post card (source + 3-line title + 4:3 image + padding). */
-const ONBOARDING_POST_CARD_HEIGHT = '24rem';
+const ONBOARDING_POST_CARD_HEIGHT = 'clamp(19.5rem, 42dvh, 24rem)';
/** Swipe stack area: card height plus back-card vertical offset (8px). */
-const ONBOARDING_SWIPE_AREA_HEIGHT = '24.5rem';
+const ONBOARDING_SWIPE_AREA_HEIGHT = `calc(${ONBOARDING_POST_CARD_HEIGHT} + 0.5rem)`;
const smoothstep01 = (t: number): number => {
const x = Math.min(Math.max(t, 0), 1);
@@ -453,6 +454,72 @@ const OnboardingCardBehindParticles = (): ReactElement => (
>
);
+const OnboardingSwipeHintButton = ({
+ deltaX,
+ direction,
+ disabled,
+ onClick,
+}: {
+ deltaX: number;
+ direction: 'left' | 'right';
+ disabled: boolean;
+ onClick: () => void;
+}): ReactElement => {
+ const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1);
+ const isLeftDirection = direction === 'left';
+ let visualStrength = 0;
+ if (isLeftDirection && deltaX < 0) {
+ visualStrength = swipeVisualIntensity;
+ }
+ if (!isLeftDirection && deltaX > 0) {
+ visualStrength = swipeVisualIntensity;
+ }
+ const accentColor = isLeftDirection
+ ? 'var(--theme-accent-bacon-default)'
+ : 'var(--theme-accent-avocado-default)';
+ const isEmphasized = visualStrength > 0;
+ const restingClassName = isLeftDirection
+ ? 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-bacon-default enabled:hover:text-accent-bacon-default enabled:focus-visible:border-accent-bacon-default enabled:focus-visible:text-accent-bacon-default enabled:active:border-accent-bacon-default enabled:active:text-accent-bacon-default'
+ : 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-avocado-default enabled:hover:text-accent-avocado-default enabled:focus-visible:border-accent-avocado-default enabled:focus-visible:text-accent-avocado-default enabled:active:border-accent-avocado-default enabled:active:text-accent-avocado-default';
+
+ return (
+
+ );
+};
+
const OnboardingSwipeHintIcons = ({
deltaX,
disabled,
@@ -464,81 +531,20 @@ const OnboardingSwipeHintIcons = ({
onNotInteresting: () => void;
onInteresting: () => void;
}): ReactElement => {
- const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1);
- const leftVisualStrength = deltaX < 0 ? swipeVisualIntensity : 0;
- const rightVisualStrength = deltaX > 0 ? swipeVisualIntensity : 0;
-
- const leftAccentColor = 'var(--theme-accent-bacon-default)';
- const rightAccentColor = 'var(--theme-accent-avocado-default)';
- const leftSwipeEmphasized = leftVisualStrength > 0;
- const rightSwipeEmphasized = rightVisualStrength > 0;
-
return (
-
-
+
-
-
+ />
);
};
@@ -1322,6 +1328,8 @@ const OnboardingPostCard = ({
dismissDurationMs: number;
useInstantSwipeTransform?: boolean;
}): ReactElement => {
+ const sourceName = card.source?.name || 'daily.dev';
+ const sourceImage = card.source?.image;
const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0;
let swipeDirection: 'left' | 'right' | null = null;
if (isTop && Math.abs(swipeDelta) > 20) {
@@ -1361,6 +1369,30 @@ const OnboardingPostCard = ({
}
}
+ let sourceAvatar: ReactElement;
+ if (sourceImage) {
+ sourceAvatar = (
+
+ );
+ } else if (sourceName === 'daily.dev') {
+ sourceAvatar = (
+
+
+
+ );
+ } else {
+ sourceAvatar = ;
+ }
+
return (
- {swipeDirection && (
-
- {swipeDirection === 'right' ? 'INTERESTING' : 'NOT'}
-
- )}
-
- {card.source?.image ? (
-

- ) : (
-
- )}
-
- {card.source?.name || 'daily.dev'}
-
+
+
+ {sourceAvatar}
+
+ {sourceName}
+
+
+ {swipeDirection ? (
+
+ {swipeDirection === 'right' ? 'INTERESTING' : 'UNINTERESTING'}
+
+ ) : null}
-
- {card.image ? (
-

+ {card.tags && card.tags.length > 0 && (
+
+ {card.tags.slice(0, 5).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {card.summary ? (
+ <>
+
+ TLDR
+
+
+ {card.summary}
+
+ >
) : (
-
+ <>
+
+ TLDR
+
+
+ No summary available for this post yet.
+
+ >
)}
@@ -1458,9 +1522,7 @@ const OnboardingFeedEmptyState = ({
isRefetching: boolean;
}): ReactElement => (
- {isRefetching ? (
-
- ) : null}
+ {isRefetching ?
: null}
void;
/** True while onboarding deck query is fetching (initial or retry). */
onboardingFeedRefetching?: boolean;
+ /** Renders onboarding swipe actions under the card or beside it on wider viewports. */
+ onboardingActionLayout?: 'bottom' | 'sides';
}
const HotAndColdModal = ({
@@ -1576,6 +1642,7 @@ const HotAndColdModal = ({
headerSlot,
topSlot,
bottomSlot,
+ onboardingContent,
showHeader = true,
showDefaultActions = true,
showAddHotTakeButton = true,
@@ -1586,6 +1653,7 @@ const HotAndColdModal = ({
onDismissedOnboardingCardsChange,
onOnboardingFeedRetry,
onboardingFeedRefetching = false,
+ onboardingActionLayout = 'bottom',
className,
...props
}: HotAndColdModalProps): ReactElement => {
@@ -1636,7 +1704,9 @@ const HotAndColdModal = ({
setOnboardingIntroDelta(0);
}, []);
- const isOnboardingMode = !!onboardingCards;
+ const hasOnboardingCards = !!onboardingCards;
+ const hasOnboardingContent = onboardingContent !== undefined;
+ const isOnboardingMode = hasOnboardingCards || hasOnboardingContent;
const availableOnboardingCards = useMemo(
() =>
(onboardingCards ?? []).filter((card) => !dismissedCardIds.has(card.id)),
@@ -1646,7 +1716,7 @@ const HotAndColdModal = ({
const nextOnboardingCard = availableOnboardingCards[1];
const isModalLoading = isOnboardingMode ? onboardingCardsLoading : isLoading;
const isModalEmpty = isOnboardingMode
- ? !isModalLoading && !currentOnboardingCard
+ ? !isModalLoading && !hasOnboardingContent && !currentOnboardingCard
: isEmpty;
const swipeAreaHeight = isOnboardingMode
? ONBOARDING_SWIPE_AREA_HEIGHT
@@ -1679,7 +1749,7 @@ const HotAndColdModal = ({
useEffect(() => {
if (
- !isOnboardingMode ||
+ !hasOnboardingCards ||
isModalLoading ||
!currentOnboardingCard ||
onboardingIntroRepeatCancelledRef.current
@@ -1753,7 +1823,34 @@ const HotAndColdModal = ({
setOnboardingIntroDelta(0);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- depend on card id only, not currentOnboardingCard reference
- }, [isOnboardingMode, isModalLoading, currentOnboardingCard?.id]);
+ }, [hasOnboardingCards, isModalLoading, currentOnboardingCard?.id]);
+
+ useEffect(() => {
+ if (hasOnboardingCards) {
+ return;
+ }
+
+ abortOnboardingIntro();
+
+ if (flyTimerRef.current) {
+ clearTimeout(flyTimerRef.current);
+ flyTimerRef.current = null;
+ }
+ if (dismissTimerRef.current) {
+ clearTimeout(dismissTimerRef.current);
+ dismissTimerRef.current = null;
+ }
+
+ animatingTakeIdRef.current = null;
+ setAnimatingTakeId(null);
+ setDismissDurationMs(DISMISS_ANIMATION_MS);
+ setIsAnimating(false);
+ setIsDragging(false);
+ setSwipeDelta(0);
+ swipeDeltaRef.current = 0;
+ setSkipDelta(0);
+ swipeDeltaYRef.current = 0;
+ }, [hasOnboardingCards, abortOnboardingIntro]);
const startDismissAnimation = useCallback(
({
@@ -1800,7 +1897,7 @@ const HotAndColdModal = ({
swipeDeltaYRef.current = 0;
animatingTakeIdRef.current = null;
setAnimatingTakeId(null);
- if (isOnboardingMode && currentOnboardingCard) {
+ if (hasOnboardingCards && currentOnboardingCard) {
updateDismissedCardIds((prev) => {
const next = new Set(prev);
next.add(currentOnboardingCard.id);
@@ -1820,7 +1917,7 @@ const HotAndColdModal = ({
[
currentOnboardingCard,
dismissCurrent,
- isOnboardingMode,
+ hasOnboardingCards,
onboardingCards,
updateDismissedCardIds,
],
@@ -1828,7 +1925,7 @@ const HotAndColdModal = ({
const handleDismiss = useCallback(
(direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => {
- const currentItemId = isOnboardingMode
+ const currentItemId = hasOnboardingCards
? currentOnboardingCard?.id
: currentTake?.id;
@@ -1865,7 +1962,7 @@ const HotAndColdModal = ({
}
onSwipeAction?.(
direction,
- isOnboardingMode ? { onboardingCardId: currentItemId } : undefined,
+ hasOnboardingCards ? { onboardingCardId: currentItemId } : undefined,
);
let initialPush: number;
@@ -1899,8 +1996,9 @@ const HotAndColdModal = ({
[
currentTake,
currentOnboardingCard,
- isOnboardingMode,
+ hasOnboardingCards,
isAnimating,
+ isOnboardingMode,
startDismissAnimation,
toggleDownvote,
toggleUpvote,
@@ -1913,7 +2011,7 @@ const HotAndColdModal = ({
const handleSkip = useCallback(
(source: 'swipe' | 'button' = 'button') => {
- const currentItemId = isOnboardingMode
+ const currentItemId = hasOnboardingCards
? currentOnboardingCard?.id
: currentTake?.id;
@@ -1944,8 +2042,9 @@ const HotAndColdModal = ({
cancelHotTakeVote,
currentTake,
currentOnboardingCard,
- isOnboardingMode,
+ hasOnboardingCards,
isAnimating,
+ isOnboardingMode,
startDismissAnimation,
logEvent,
onSwipeAction,
@@ -1953,7 +2052,7 @@ const HotAndColdModal = ({
],
);
- const currentCardId = isOnboardingMode
+ const currentCardId = hasOnboardingCards
? currentOnboardingCard?.id
: currentTake?.id;
const isCurrentTakeAnimating =
@@ -1962,11 +2061,11 @@ const HotAndColdModal = ({
isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta;
const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta;
const combinedOnboardingSwipeX =
- isOnboardingMode && !isDragging && !isCurrentTakeAnimating
+ hasOnboardingCards && !isDragging && !isCurrentTakeAnimating
? cardSwipeDelta + onboardingIntroDelta
: cardSwipeDelta;
const onboardingIntroPlaying =
- isOnboardingMode &&
+ hasOnboardingCards &&
!isDragging &&
!isCurrentTakeAnimating &&
onboardingIntroDelta !== 0;
@@ -2036,10 +2135,10 @@ const HotAndColdModal = ({
);
+ const showOnboardingSideActions = onboardingActionLayout === 'sides';
+ const onboardingSwipeActions = showOnboardingSideActions ? (
+
+
+ handleDismiss('left', 'button')}
+ />
+
+
+ {cardSwipeArea}
+
+
+ handleDismiss('right', 'button')}
+ />
+
+
+ handleDismiss('right', 'button')}
+ onNotInteresting={() => handleDismiss('left', 'button')}
+ />
+
+
+ ) : (
+ <>
+
{cardSwipeArea}
+
+ handleDismiss('right', 'button')}
+ onNotInteresting={() => handleDismiss('left', 'button')}
+ />
+
+ >
+ );
return (
}
{headerSlot}
@@ -2151,99 +2294,101 @@ const HotAndColdModal = ({
)}
- {!isModalLoading && !isModalEmpty && currentCardId && (
+ {!isModalLoading && !isModalEmpty && isOnboardingMode && (
<>
- {!isOnboardingMode && topSlot}
- {isOnboardingMode ? (
-
+
+
{topSlot}
- {cardSwipeArea}
-
- handleDismiss('right', 'button')}
- onNotInteresting={() => handleDismiss('left', 'button')}
- />
-
+ {hasOnboardingContent
+ ? onboardingContent
+ : onboardingSwipeActions}
{bottomSlot}
- ) : (
- <>
- {cardSwipeArea}
- {showDefaultActions && (
-
-
- )}
- {bottomSlot}
- {showAddHotTakeButton && user?.username && (
-
- {
- onRequestClose?.(e);
- }}
- >
- Add your own hot take
-
-
- )}
- >
- )}
+
>
)}
+
+ {!isModalLoading &&
+ !isModalEmpty &&
+ !isOnboardingMode &&
+ currentCardId && (
+ <>
+ {topSlot}
+ {cardSwipeArea}
+ {showDefaultActions && (
+
+
+ βοΈ
+
+ }
+ onClick={() => handleDismiss('left', 'button')}
+ disabled={isAnimating}
+ className="!size-14 rounded-full"
+ aria-label="Cold take - downvote"
+ />
+
+ π
+
+ }
+ onClick={() => handleSkip('button')}
+ disabled={isAnimating}
+ className="!size-12 rounded-full"
+ aria-label="Skip hot take"
+ />
+
+ π₯
+
+ }
+ onClick={() => handleDismiss('right', 'button')}
+ disabled={isAnimating}
+ className="!size-14 rounded-full"
+ aria-label="Hot take - upvote"
+ />
+
+ )}
+ {bottomSlot}
+ {showAddHotTakeButton && user?.username && (
+
+ {
+ onRequestClose?.(e);
+ }}
+ >
+ Add your own hot take
+
+
+ )}
+ >
+ )}
);
diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx
index 9f19d54c9ae..6d55aff3839 100644
--- a/packages/shared/src/components/onboarding/EditTag.tsx
+++ b/packages/shared/src/components/onboarding/EditTag.tsx
@@ -1,7 +1,7 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
+import classNames from 'classnames';
import { Origin } from '../../lib/log';
-import type { FeedSettings } from '../../graphql/feedSettings';
import { TagSelection } from '../tags/TagSelection';
import useDebounceFn from '../../hooks/useDebounceFn';
import { useTagSearch } from '../../hooks/useTagSearch';
@@ -9,12 +9,12 @@ import { useViewSize, ViewSize } from '../../hooks/useViewSize';
import { SearchField } from '../fields/SearchField';
interface EditTagProps {
- feedSettings: FeedSettings;
headline?: string;
+ headlineClassName?: string;
}
export const EditTag = ({
- feedSettings,
headline,
+ headlineClassName,
}: EditTagProps): ReactElement => {
const isMobile = useViewSize(ViewSize.MobileL);
@@ -31,7 +31,12 @@ export const EditTag = ({
return (
<>
-
+
{headline || 'Pick tags that are relevant to you'}
void;
session: FunnelSession;
showCookieBanner?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime
+ stepComponentOverrides?: Partial>>;
}
const stepComponentMap = {
@@ -79,9 +81,18 @@ const stepComponentMap = {
[FunnelStepType.UploadCv]: FunnelUploadCv,
} as const;
-function FunnelStepComponent(props: Step) {
- const { type } = props;
- const Component = stepComponentMap[type];
+function FunnelStepComponent(props: {
+ stepComponentOverrides?: FunnelStepperProps['stepComponentOverrides'];
+ [key: string]: unknown;
+}) {
+ const { stepComponentOverrides, type } = props;
+ const stepType = type as FunnelStepType;
+ const Component =
+ stepComponentOverrides?.[stepType] ??
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime
+ (stepComponentMap as Partial>>)[
+ stepType
+ ];
if (!Component) {
return null;
@@ -96,7 +107,8 @@ export const FunnelStepper = ({
session,
showCookieBanner,
onComplete,
-}: FunnelStepperProps): ReactElement => {
+ stepComponentOverrides,
+}: FunnelStepperProps): ReactElement | null => {
const steps = useMemo(
() => funnel?.chapters?.flatMap((chapter) => chapter?.steps),
[funnel?.chapters],
@@ -123,7 +135,9 @@ export const FunnelStepper = ({
defaultOpen: showCookieBanner,
trackFunnelEvent,
});
- useEventListener(globalThis, 'scrollend', trackOnScroll, { passive: true });
+ useEventListener(globalThis.window, 'scrollend', trackOnScroll, {
+ passive: true,
+ });
const shouldSkipRef = useRef>>({});
const currentNavigationRef = useRef({ step, position });
@@ -187,11 +201,12 @@ export const FunnelStepper = ({
);
const successCallback = useCallback(
- (event?: PaddleEventData) =>
+ (event: unknown) =>
onTransition({
type: FunnelStepTransitionType.Complete,
details: {
- subscribed: event?.data?.customer?.email,
+ subscribed: (event as PaddleEventData | undefined)?.data?.customer
+ ?.email,
},
}),
[onTransition],
@@ -254,7 +269,7 @@ export const FunnelStepper = ({
!layout.isFullWidth && 'tablet:max-w-md laptopXL:max-w-lg',
)}
>
- {layout.hasBanner && (
+ {layout.hasBanner && funnel.parameters.banner && (
)}
);
diff --git a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx
index 831dfdc91a2..d0c3af8d5b8 100644
--- a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx
+++ b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx
@@ -42,7 +42,7 @@ function FunnelEditTagsComponent({
containerClassName="flex w-full flex-1 flex-col items-center laptop:justify-center overflow-hidden"
>
-
+
);
diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts
index 1b918eb10d9..89e5309a70b 100644
--- a/packages/shared/src/graphql/actions.ts
+++ b/packages/shared/src/graphql/actions.ts
@@ -39,6 +39,7 @@ export enum ActionType {
FetchedSmartTitle = 'fetched_smart_title',
EditTag = 'edit_tag',
ContentTypes = 'content_types',
+ HasSeenTags = 'has_seen_tags',
StreakTimezoneMismatch = 'streak_timezone_mismatch',
SmartPrompt = 'smart_prompt',
CheckedCoresRole = 'checked_cores_role',
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index f8d5ab1f86d..5fa84f62a93 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -151,7 +151,7 @@ export const sharedPostPreviewFeature = new Feature(
false,
);
-export const swipeOnboardingFeature = new Feature('swipe_onboarding', false);
+export const swipeOnboardingFeature = new Feature('swipe_onboarding', true);
export const featureUpvoteCountThreshold = new Feature<{
threshold: number;
diff --git a/packages/shared/src/lib/feedSettings.ts b/packages/shared/src/lib/feedSettings.ts
new file mode 100644
index 00000000000..98057213a1a
--- /dev/null
+++ b/packages/shared/src/lib/feedSettings.ts
@@ -0,0 +1,25 @@
+import { generateStorageKey, StorageTopic } from './storage';
+import { storageWrapper } from './storageWrapper';
+
+const hasSeenTagsStorageKey = 'hasSeenTags';
+
+export const getHasSeenTagsStorageKey = (userId: string): string =>
+ generateStorageKey(StorageTopic.Onboarding, hasSeenTagsStorageKey, userId);
+
+export const getHasSeenTags = (userId?: string | null): boolean | null => {
+ if (!userId) {
+ return null;
+ }
+
+ const value = storageWrapper.getItem(getHasSeenTagsStorageKey(userId));
+
+ if (value === null) {
+ return null;
+ }
+
+ return value === 'true';
+};
+
+export const setHasSeenTags = (userId: string, hasSeenTags: boolean): void => {
+ storageWrapper.setItem(getHasSeenTagsStorageKey(userId), String(hasSeenTags));
+};
diff --git a/packages/webapp/.env b/packages/webapp/.env
index 3f3cf591931..3b23f637971 100644
--- a/packages/webapp/.env
+++ b/packages/webapp/.env
@@ -17,4 +17,3 @@ NEXT_PUBLIC_ANDROID_APP="https://play.google.com/store/apps/details?id=dev.daily
NEXT_PUBLIC_TURNSTILE_KEY=1x00000000000000000000AA
-NEXT_PUBLIC_SWIPING_BACKEND_URL=http://localhost:8000
diff --git a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx
new file mode 100644
index 00000000000..404c34105c2
--- /dev/null
+++ b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx
@@ -0,0 +1,425 @@
+import type { ReactElement } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+import { useRouter } from 'next/router';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { ArrowIcon } from '@dailydotdev/shared/src/components/icons';
+import HotAndColdModal from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal';
+import { useBookmarkPost } from '@dailydotdev/shared/src/hooks/useBookmarkPost';
+import useFeedSettings from '@dailydotdev/shared/src/hooks/useFeedSettings';
+import useTagAndSource from '@dailydotdev/shared/src/hooks/useTagAndSource';
+import { Origin } from '@dailydotdev/shared/src/lib/log';
+import { withIsActiveGuard } from '@dailydotdev/shared/src/features/onboarding/shared/withActiveGuard';
+import type { FunnelStepEditTags } from '@dailydotdev/shared/src/features/onboarding/types/funnel';
+import { FunnelStepTransitionType } from '@dailydotdev/shared/src/features/onboarding/types/funnel';
+import { useAdaptiveSwipeDeck } from '../../hooks/useAdaptiveSwipeDeck';
+import { extractTags } from '../../lib/swipingBackendApi';
+import { SwipeOnboardingProgressHeader } from './SwipeOnboardingProgressHeader';
+import {
+ SWIPE_ONBOARDING_IMPROVE_MILESTONE,
+ SWIPE_ONBOARDING_MIN_TO_UNLOCK,
+ SWIPE_ONBOARDING_REFINE_TARGET,
+} from '../../lib/swipeOnboardingGuidance';
+
+const SWIPE_ONBOARDING_PROGRESS_MILESTONES: readonly number[] = [
+ SWIPE_ONBOARDING_MIN_TO_UNLOCK,
+ SWIPE_ONBOARDING_IMPROVE_MILESTONE,
+ SWIPE_ONBOARDING_REFINE_TARGET,
+];
+
+const SWIPE_ONBOARDING_TAG_SEED_MAX = 25;
+const SWIPE_ONBOARDING_LOADING_LABELS = [
+ 'cooking',
+ 'optimizing',
+ 'thinking',
+ 'shuffling the good stuff',
+ 'bribing the feed gremlins',
+ 'warming up the swipe deck',
+] as const;
+
+const swipeOnboardingModalShellClassName =
+ 'tablet:!h-[calc(100vh-2rem)] tablet:!max-h-[calc(100vh-2rem)] tablet:!w-[42rem] tablet:!max-w-[calc(100vw-2rem)] tablet:!overflow-hidden tablet:!rounded-[2rem] tablet:!border-border-subtlest-secondary tablet:shadow-[0_32px_120px_-48px_rgba(0,0,0,0.58)]';
+
+const swipeOnboardingSurfaceClassName =
+ 'w-full overflow-hidden rounded-[2rem] border border-border-subtlest-secondary bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]';
+
+const swipeOnboardingPanelClassName =
+ 'rounded-[1.5rem] border border-border-subtlest-tertiary bg-surface-float';
+
+function useAnimatedLoadingLabel(isActive: boolean): string {
+ const [labelIndex, setLabelIndex] = useState(0);
+ const [dotCount, setDotCount] = useState(1);
+
+ useEffect(() => {
+ if (!isActive) {
+ setLabelIndex(0);
+ setDotCount(1);
+ return undefined;
+ }
+
+ const interval = window.setInterval(() => {
+ setDotCount((currentDotCount) => {
+ if (currentDotCount === 3) {
+ setLabelIndex(
+ (currentLabelIndex) =>
+ (currentLabelIndex + 1) % SWIPE_ONBOARDING_LOADING_LABELS.length,
+ );
+ return 1;
+ }
+
+ return currentDotCount + 1;
+ });
+ }, 550);
+
+ return () => window.clearInterval(interval);
+ }, [isActive]);
+
+ return `${SWIPE_ONBOARDING_LOADING_LABELS[labelIndex]}${'.'.repeat(
+ dotCount,
+ )}`;
+}
+
+function SwipeOnboardingToolbar({
+ onBack,
+}: {
+ onBack: () => void;
+}): ReactElement {
+ return (
+
+
+
}
+ size={ButtonSize.Small}
+ type="button"
+ variant={ButtonVariant.Tertiary}
+ onClick={onBack}
+ />
+
+
+
+ );
+}
+
+function FunnelSwipeOnboardingStepComponent({
+ parameters: { cta },
+ onTransition,
+}: FunnelStepEditTags): ReactElement {
+ const router = useRouter();
+ const [swipesCount, setSwipesCount] = useState(0);
+ const [milestoneBurstKey, setMilestoneBurstKey] = useState(0);
+ const [promptText, setPromptText] = useState('');
+ const [promptLoading, setPromptLoading] = useState(false);
+ const [isCompleting, setIsCompleting] = useState(false);
+ const [isSwipeMode, setIsSwipeMode] = useState(false);
+ const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState<
+ Set
+ >(() => new Set());
+ const prevSwipesForMilestoneRef = useRef(null);
+ const animatedPromptLoadingLabel = useAnimatedLoadingLabel(promptLoading);
+ const { feedSettings } = useFeedSettings();
+ const { onFollowTags } = useTagAndSource({
+ origin: Origin.Onboarding,
+ });
+ const { toggleBookmark } = useBookmarkPost();
+ const {
+ cards: adaptiveCards,
+ getBookmarkablePost,
+ isLoading: isAdaptiveLoading,
+ startDeck,
+ handleSwipe: handleAdaptiveSwipe,
+ retryFetch,
+ selectedTags: adaptiveSelectedTags,
+ } = useAdaptiveSwipeDeck();
+
+ useEffect(() => {
+ const prev = prevSwipesForMilestoneRef.current;
+ prevSwipesForMilestoneRef.current = swipesCount;
+ if (prev === null) {
+ return;
+ }
+ const crossedMilestone = SWIPE_ONBOARDING_PROGRESS_MILESTONES.find(
+ (milestone) => prev < milestone && swipesCount >= milestone,
+ );
+ if (crossedMilestone !== undefined) {
+ setMilestoneBurstKey((currentKey) => currentKey + 1);
+ }
+ }, [swipesCount]);
+
+ const handlePromptSubmit = useCallback(async () => {
+ if (promptLoading) {
+ return;
+ }
+
+ setPromptLoading(true);
+ try {
+ let initialTags: string[] = [];
+ if (promptText.trim()) {
+ initialTags = await extractTags(promptText.trim());
+ }
+ await startDeck({ prompt: promptText.trim(), initialTags });
+ setIsSwipeMode(true);
+ } finally {
+ setPromptLoading(false);
+ }
+ }, [promptLoading, promptText, startDeck]);
+
+ const handleSkipPrompt = useCallback(async () => {
+ if (promptLoading) {
+ return;
+ }
+
+ setPromptLoading(true);
+ try {
+ await startDeck();
+ setIsSwipeMode(true);
+ } finally {
+ setPromptLoading(false);
+ }
+ }, [promptLoading, startDeck]);
+
+ const bookmarkRightSwipePost = useCallback(
+ (cardId: string) => {
+ const bookmarkPost = getBookmarkablePost(cardId);
+ if (!bookmarkPost) {
+ return;
+ }
+
+ // Capture the current card payload before deck state changes.
+ toggleBookmark({
+ post: bookmarkPost,
+ origin: Origin.Onboarding,
+ disableToast: true,
+ }).catch(() => null);
+ },
+ [getBookmarkablePost, toggleBookmark],
+ );
+
+ const handleSwipeInteraction = useCallback(
+ (
+ direction: 'left' | 'right' | 'skip',
+ meta?: { onboardingCardId?: string },
+ ) => {
+ if (direction === 'left' || direction === 'right') {
+ setSwipesCount((currentValue) => currentValue + 1);
+ if (meta?.onboardingCardId) {
+ if (direction === 'right') {
+ bookmarkRightSwipePost(meta.onboardingCardId);
+ }
+ handleAdaptiveSwipe(direction, meta.onboardingCardId);
+ }
+ }
+ },
+ [bookmarkRightSwipePost, handleAdaptiveSwipe],
+ );
+
+ const tagsFromSwipes = useMemo(
+ () => adaptiveSelectedTags.slice(0, SWIPE_ONBOARDING_TAG_SEED_MAX),
+ [adaptiveSelectedTags],
+ );
+
+ const handleComplete = useCallback(async () => {
+ if (isCompleting) {
+ return;
+ }
+
+ setIsCompleting(true);
+ const currentTags = feedSettings?.includeTags ?? [];
+ const currentTagsSet = new Set(currentTags);
+ const tagsToFollow = tagsFromSwipes.filter(
+ (tag) => !currentTagsSet.has(tag),
+ );
+ const finalTags = [...currentTags, ...tagsToFollow];
+
+ try {
+ if (tagsToFollow.length) {
+ await onFollowTags({ tags: tagsToFollow });
+ }
+ } catch {
+ // Let the funnel continue even if persisting tags fails.
+ } finally {
+ setIsCompleting(false);
+ }
+
+ onTransition({
+ type: FunnelStepTransitionType.Complete,
+ details: {
+ tags: finalTags,
+ },
+ });
+ }, [
+ feedSettings?.includeTags,
+ isCompleting,
+ onFollowTags,
+ onTransition,
+ tagsFromSwipes,
+ ]);
+
+ const canContinue = swipesCount >= SWIPE_ONBOARDING_MIN_TO_UNLOCK;
+
+ if (!isSwipeMode) {
+ return (
+
+
+
+
+
+
+ Personalize your daily.dev feed
+
+
+
+ What do you want to read more about?
+
+
+ Describe your interests and we'll turn that into a
+ swipe deck that feels closer to a real conversation than a
+ blank setup form.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const bottomContinueSlot = canContinue ? (
+
+
+
+
+ Starter feed ready
+
+
+ We have enough signal to build your first pass. You can keep
+ refining it after this.
+
+
+
{
+ handleComplete().catch(() => null);
+ }}
+ >
+ {cta || 'Next'}
+
+
+
+ ) : null;
+
+ return (
+ {
+ retryFetch();
+ }}
+ onSwipeAction={(direction, meta) => {
+ handleSwipeInteraction(direction, meta);
+ }}
+ headerSlot={
+ {
+ router.back();
+ }}
+ />
+ }
+ topSlot={
+
+ }
+ bottomSlot={bottomContinueSlot}
+ onRequestClose={() => {
+ router.back();
+ }}
+ />
+ );
+}
+
+export const FunnelSwipeOnboardingStep = withIsActiveGuard(
+ FunnelSwipeOnboardingStepComponent,
+);
diff --git a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx
index 64ff996b483..3a5fcc4b594 100644
--- a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx
+++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx
@@ -10,16 +10,16 @@ import {
getSwipeOnboardingBarProgress,
getSwipeOnboardingGuidanceMessage,
getSwipeOnboardingHeadline,
+ SWIPE_ONBOARDING_REFINE_TARGET,
type SwipeOnboardingProgressCopyVariant,
} from '../../lib/swipeOnboardingGuidance';
/** Typing speed; full headline refresh when swipe tier copy changes. */
const SWIPE_HEADLINE_TYPING_MS_PER_CHAR = 12;
/**
- * Stable min height = 3 Γ typo-title2 line-height (1.875rem) so headline changes do not
- * shift the progress bar.
+ * Stable min height keeps the typed copy from jumping while the progress bar updates.
*/
-const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[5.625rem]';
+const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[4.75rem]';
function SwipeOnboardingTypingHeadline({
line1,
@@ -59,14 +59,14 @@ function SwipeOnboardingTypingHeadline({
return (
{shownLine1}
{shownLine2 !== undefined ? (
@@ -117,9 +117,10 @@ export function SwipeOnboardingProgressHeader({
const progress = getSwipeOnboardingBarProgress(progressCount);
const { line1: headlineLine1, line2: headlineLine2 } =
getSwipeOnboardingHeadline(progressCount, copyVariant);
+ const progressValue = Math.min(progressCount, SWIPE_ONBOARDING_REFINE_TARGET);
return (
-
+
{/* eslint-disable-next-line react/no-unknown-property -- scoped keyframes for progress bar */}