diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 86b6eee64c1..1e971531231 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -97,6 +97,8 @@ function MainLayoutComponent({ const isLaptopXL = useViewSize(ViewSize.LaptopXL); const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); + const isPageReady = + (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; useNotificationParams(); useEffect(() => { @@ -114,8 +116,6 @@ function MainLayoutComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNotificationsReady, unreadCount, hasLoggedImpression]); - const isPageReady = - (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; const isPageApplicableForOnboarding = !page || feeds.includes(page) || isCustomFeed; const shouldRedirectOnboarding = 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 && (
} + 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 1f5a6b3a5cc..6dc4d4f5875 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -1,5 +1,11 @@ -import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useSwipeable } from 'react-swipeable'; import classNames from 'classnames'; import type { ModalProps } from '../common/Modal'; @@ -12,7 +18,9 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent, Origin } from '../../../lib/log'; import { webappUrl } from '../../../lib/constants'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { IconSize } from '../../Icon'; import { HotIcon } from '../../icons/Hot'; +import { MiniCloseIcon, VIcon } from '../../icons'; import { Typography, TypographyType, @@ -22,9 +30,18 @@ import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; 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; +const ONBOARDING_INTRO_INTERESTING_OFFSET = 56; +const ONBOARDING_INTRO_NOT_OFFSET = -56; +const ONBOARDING_INTRO_PHASE_MS = 160; +const ONBOARDING_INTRO_PAUSE_MS = 55; +const ONBOARDING_INTRO_START_DELAY_MS = 120; +/** Time from the start of one intro play to the start of the next (~4s; hint loop until user interacts). */ +const ONBOARDING_INTRO_REPEAT_INTERVAL_MS = 4000; const DISMISS_ANIMATION_MS = 340; const BUTTON_DISMISS_ANIMATION_MS = 620; const DISMISS_FLY_DISTANCE = 760; @@ -35,6 +52,502 @@ const SKIP_DISMISS_FLY_DISTANCE = 600; const SKIP_DRAG_ELASTICITY_FACTOR = 0.3; const COLD_ACCENT_COLOR = '#123a88'; 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 = 'clamp(19.5rem, 42dvh, 24rem)'; +/** Swipe stack area: card height plus back-card vertical offset (8px). */ +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); + return x * x * (3 - 2 * x); +}; + +const pauseMs = (ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(() => { + resolve(); + }, ms); + }); + +const runOnboardingIntroAnimation = ({ + signal, + onUpdate, +}: { + signal: { aborted: boolean }; + onUpdate: (value: number) => void; +}): Promise => { + const segment = (from: number, to: number, durationMs: number) => + new Promise((resolve) => { + const startTime = performance.now(); + const tick = (now: number) => { + if (signal.aborted) { + resolve(); + return; + } + const elapsed = now - startTime; + const t = durationMs <= 0 ? 1 : Math.min(elapsed / durationMs, 1); + const eased = smoothstep01(t); + onUpdate(from + (to - from) * eased); + if (t < 1) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + + return (async () => { + await segment( + 0, + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment( + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_NOT_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment(ONBOARDING_INTRO_NOT_OFFSET, 0, ONBOARDING_INTRO_PHASE_MS); + })(); +}; + +const ONBOARDING_BEHIND_PARTICLES_CSS = ` + @keyframes onboardingBehindParticle { + 0% { transform: translate3d(0, 0, 0) scale(1); opacity: 0; } + 10% { opacity: 0.9; } + 35% { transform: translate3d(var(--obp-tx), var(--obp-ty), 0) scale(1.08); opacity: 0.65; } + 65% { transform: translate3d(calc(var(--obp-ex) * 0.55), 3.5rem, 0) scale(0.65); opacity: 0.3; } + 100% { transform: translate3d(var(--obp-ex), 8rem, 0) scale(0.15); opacity: 0; } + } + @keyframes onboardingMagicSpark { + 0%, 100% { transform: translate3d(0, 0, 0) scale(0.75); opacity: 0.45; filter: blur(0); } + 20% { transform: translate3d(var(--oms-x1), var(--oms-y1), 0) scale(1.35); opacity: 1; filter: blur(0); } + 45% { transform: translate3d(var(--oms-x2), var(--oms-y2), 0) scale(1); opacity: 0.65; filter: blur(0); } + 70% { transform: translate3d(var(--oms-x3), var(--oms-y3), 0) scale(1.2); opacity: 0.9; filter: blur(0); } + } + @keyframes onboardingAuraDrift { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.35; } + 33% { transform: translate3d(0.35rem, -0.5rem, 0) scale(1.06); opacity: 0.55; } + 66% { transform: translate3d(-0.25rem, 0.35rem, 0) scale(0.96); opacity: 0.42; } + } +`; + +const ONBOARDING_BEHIND_PARTICLE_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + tx: string; + ty: string; + ex: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '6%', + top: '72%', + size: '0.25rem', + tx: '1.5rem', + ty: '-2.25rem', + ex: '0.5rem', + duration: 4.6, + delay: 0, + className: 'bg-accent-avocado-default/40', + }, + { + left: '92%', + top: '68%', + size: '0.1875rem', + tx: '-1.25rem', + ty: '-2rem', + ex: '-0.75rem', + duration: 4.1, + delay: 0.35, + className: 'bg-accent-bacon-default/35', + }, + { + left: '18%', + top: '88%', + size: '0.3125rem', + tx: '0.75rem', + ty: '-1.25rem', + ex: '1rem', + duration: 3.7, + delay: 0.7, + className: 'bg-text-tertiary/50', + }, + { + left: '78%', + top: '82%', + size: '0.25rem', + tx: '-0.5rem', + ty: '-1.75rem', + ex: '-1.25rem', + duration: 4.3, + delay: 0.2, + className: 'bg-accent-avocado-default/30', + }, + { + left: '44%', + top: '92%', + size: '0.1875rem', + tx: '1rem', + ty: '-0.75rem', + ex: '0.25rem', + duration: 3.4, + delay: 1.1, + className: 'bg-text-tertiary/40', + }, + { + left: '52%', + top: '78%', + size: '0.25rem', + tx: '-1.5rem', + ty: '-2.5rem', + ex: '-0.25rem', + duration: 4.8, + delay: 0.55, + className: 'bg-accent-bacon-default/25', + }, + { + left: '28%', + top: '58%', + size: '0.1875rem', + tx: '2rem', + ty: '0.25rem', + ex: '0.75rem', + duration: 5.1, + delay: 0.9, + className: 'bg-accent-avocado-default/25', + }, + { + left: '66%', + top: '52%', + size: '0.25rem', + tx: '-1.75rem', + ty: '0.5rem', + ex: '-1rem', + duration: 4.4, + delay: 1.4, + className: 'bg-text-tertiary/35', + }, +]; + +const ONBOARDING_MAGIC_SPARK_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + x1: string; + y1: string; + x2: string; + y2: string; + x3: string; + y3: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '12%', + top: '38%', + size: '0.1875rem', + x1: '0.4rem', + y1: '-1.25rem', + x2: '-0.35rem', + y2: '-2rem', + x3: '0.5rem', + y3: '-1rem', + duration: 2.8, + delay: 0, + className: 'bg-accent-avocado-default/90', + }, + { + left: '84%', + top: '42%', + size: '0.15625rem', + x1: '-0.45rem', + y1: '-1rem', + x2: '0.3rem', + y2: '-1.75rem', + x3: '-0.2rem', + y3: '-0.5rem', + duration: 3.2, + delay: 0.4, + className: 'bg-accent-bacon-default/85', + }, + { + left: '48%', + top: '28%', + size: '0.125rem', + x1: '0.25rem', + y1: '-0.75rem', + x2: '0.5rem', + y2: '-1.5rem', + x3: '0.1rem', + y3: '-1.1rem', + duration: 2.4, + delay: 0.8, + className: 'bg-accent-cabbage-default/80', + }, + { + left: '22%', + top: '48%', + size: '0.15625rem', + x1: '0.6rem', + y1: '0.25rem', + x2: '0.2rem', + y2: '-0.5rem', + x3: '0.75rem', + y3: '-0.25rem', + duration: 3.6, + delay: 0.15, + className: 'bg-accent-avocado-default/70', + }, + { + left: '72%', + top: '36%', + size: '0.1875rem', + x1: '-0.5rem', + y1: '-0.5rem', + x2: '-0.75rem', + y2: '-1.25rem', + x3: '-0.35rem', + y3: '-1.75rem', + duration: 2.9, + delay: 1.1, + className: 'bg-accent-bacon-default/75', + }, + { + left: '56%', + top: '44%', + size: '0.125rem', + x1: '-0.2rem', + y1: '-1.5rem', + x2: '0.4rem', + y2: '-2.25rem', + x3: '0.15rem', + y3: '-1.75rem', + duration: 3.4, + delay: 0.55, + className: 'bg-text-tertiary/80', + }, + { + left: '36%', + top: '32%', + size: '0.15625rem', + x1: '0.35rem', + y1: '-0.35rem', + x2: '-0.15rem', + y2: '-1rem', + x3: '0.45rem', + y3: '-1.4rem', + duration: 2.6, + delay: 1.3, + className: 'bg-accent-avocado-default/80', + }, + { + left: '64%', + top: '50%', + size: '0.125rem', + x1: '-0.3rem', + y1: '-1.1rem', + x2: '0.25rem', + y2: '-1.8rem', + x3: '-0.5rem', + y3: '-1.2rem', + duration: 3, + delay: 0.25, + className: 'bg-accent-cheese-default/75', + }, +]; + +const OnboardingCardBehindParticles = (): ReactElement => ( + <> + {/* eslint-disable-next-line react/no-unknown-property -- style tag for scoped keyframes */} + +
+
+
+
+ {ONBOARDING_MAGIC_SPARK_SPECS.map((spark) => ( + + ))} + {ONBOARDING_BEHIND_PARTICLE_SPECS.map((particle) => ( + + ))} +
+ +); + +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, + onNotInteresting, + onInteresting, +}: { + deltaX: number; + disabled: boolean; + onNotInteresting: () => void; + onInteresting: () => void; +}): ReactElement => { + return ( +
+ + +
+ ); +}; const getElasticDelta = (delta: number): number => { const absoluteDelta = Math.abs(delta); @@ -794,6 +1307,251 @@ const HotTakeCard = ({ ); }; +const OnboardingPostCard = ({ + card, + isTop, + offset, + swipeDelta, + skipDeltaY = 0, + isDismissAnimating, + isDragging, + dismissDurationMs, + useInstantSwipeTransform = false, +}: { + card: OnboardingSwipeCard; + isTop: boolean; + offset: number; + swipeDelta: number; + skipDeltaY?: number; + isDismissAnimating: boolean; + isDragging: boolean; + 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) { + swipeDirection = swipeDelta > 0 ? 'right' : 'left'; + } + const swipeIntensity = isTop + ? Math.min(Math.abs(swipeDelta) / SWIPE_THRESHOLD, 1) + : 0; + const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; + const translateX = isTop ? swipeDelta : 0; + const stackScale = isTop ? 1 : 1 - offset * 0.05; + const translateY = isTop ? 0 : offset * 8; + const dismissDistance = isSkipAnimating + ? SKIP_DISMISS_FLY_DISTANCE + : DISMISS_FLY_DISTANCE; + const dismissProgress = + isTop && isDismissAnimating + ? Math.min( + Math.abs(isSkipAnimating ? skipDeltaY : swipeDelta) / dismissDistance, + 1, + ) + : 0; + const scale = isTop ? 1 - dismissProgress * 0.06 : stackScale; + const dismissLift = isTop ? dismissProgress * -22 : 0; + const translateYWithOutro = + translateY + dismissLift + (isTop ? skipDeltaY : 0); + + let transition = + 'transform 0.3s ease, border-color 0.2s ease, box-shadow 0.2s ease'; + if (isTop) { + if (isDismissAnimating) { + transition = `transform ${dismissDurationMs}ms cubic-bezier(0.16, 0.86, 0.22, 1), opacity ${dismissDurationMs}ms ease-out, filter ${dismissDurationMs}ms ease-out`; + } else if (isDragging || useInstantSwipeTransform) { + transition = 'none'; + } else { + transition = 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)'; + } + } + + let sourceAvatar: ReactElement; + if (sourceImage) { + sourceAvatar = ( + {`${sourceName} + ); + } else if (sourceName === 'daily.dev') { + sourceAvatar = ( +
+ +
+ ); + } else { + sourceAvatar =
; + } + + return ( +
event.preventDefault()} + style={{ + height: ONBOARDING_POST_CARD_HEIGHT, + transform: `translateX(${translateX}px) translateY(${translateYWithOutro}px) rotate(${rotation}deg) scale(${scale})`, + zIndex: 10 - offset, + transition, + opacity: isTop ? 1 - dismissProgress * 0.75 : 1, + filter: + isTop && isDismissAnimating + ? `blur(${dismissProgress * 1.8}px)` + : undefined, + boxShadow: isTop + ? '0 1.25rem 2.75rem -0.75rem rgba(0, 0, 0, 0.45)' + : '0 0.75rem 1.75rem -0.75rem rgba(0, 0, 0, 0.32)', + }} + > +
+
+
+ {sourceAvatar} + + {sourceName} + +
+ {swipeDirection ? ( +
+ {swipeDirection === 'right' ? 'INTERESTING' : 'UNINTERESTING'} +
+ ) : null} +
+
+ + {card.title || 'Popular developer story'} + +
+ {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. + + + )} +
+
+
+ ); +}; + +const OnboardingFeedEmptyState = ({ + onRetry, + isRefetching, +}: { + onRetry?: () => void; + isRefetching: boolean; +}): ReactElement => ( +
+ {isRefetching ? : null} + + Couldn't load stories + + + Check your connection and try again. + + {onRetry ? ( + + ) : null} +
+); + const EmptyState = ({ onClose, username, @@ -834,10 +1592,71 @@ const EmptyState = ({
); +type SwipeActionDirection = 'left' | 'right' | 'skip'; + +export type OnboardingSwipeActionMeta = { + onboardingCardId?: string; +}; + +export interface OnboardingSwipeCard { + id: string; + title?: string; + summary?: string | null; + image?: string | null; + tags?: string[]; + source?: { + name?: string | null; + image?: string | null; + } | null; +} + +interface HotAndColdModalProps extends ModalProps { + title?: string; + headerSlot?: ReactNode; + topSlot?: ReactNode; + bottomSlot?: ReactNode; + onboardingContent?: ReactNode; + showHeader?: boolean; + showDefaultActions?: boolean; + showAddHotTakeButton?: boolean; + onSwipeAction?: ( + direction: SwipeActionDirection, + meta?: OnboardingSwipeActionMeta, + ) => void; + onboardingCards?: OnboardingSwipeCard[]; + onboardingCardsLoading?: boolean; + /** When set, dismissed onboarding cards are controlled by the parent (e.g. persist across view switches). */ + dismissedOnboardingCardIds?: Set; + onDismissedOnboardingCardsChange?: (next: Set) => void; + /** Refetch popular posts when the onboarding deck failed to load. */ + onOnboardingFeedRetry?: () => 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 = ({ onRequestClose, + title = 'Hot Takes', + headerSlot, + topSlot, + bottomSlot, + onboardingContent, + showHeader = true, + showDefaultActions = true, + showAddHotTakeButton = true, + onSwipeAction, + onboardingCards, + onboardingCardsLoading = false, + dismissedOnboardingCardIds, + onDismissedOnboardingCardsChange, + onOnboardingFeedRetry, + onboardingFeedRefetching = false, + onboardingActionLayout = 'bottom', + className, ...props -}: ModalProps): ReactElement => { +}: HotAndColdModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = useDiscoverHotTakes(); const { toggleUpvote, toggleDownvote, cancelHotTakeVote } = useVoteHotTake(); @@ -855,6 +1674,53 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); + const [internalDismissedCardIds, setInternalDismissedCardIds] = useState< + Set + >(() => new Set()); + const dismissedCardIds = + dismissedOnboardingCardIds ?? internalDismissedCardIds; + const updateDismissedCardIds = useCallback( + (updater: (prev: Set) => Set) => { + if (onDismissedOnboardingCardsChange) { + const base = dismissedOnboardingCardIds ?? new Set(); + onDismissedOnboardingCardsChange(updater(base)); + return; + } + setInternalDismissedCardIds(updater); + }, + [dismissedOnboardingCardIds, onDismissedOnboardingCardsChange], + ); + const onboardingIntroRepeatCancelledRef = useRef(false); + const onboardingIntroAbortRef = useRef<{ aborted: boolean } | null>(null); + const [onboardingIntroDelta, setOnboardingIntroDelta] = useState(0); + + const abortOnboardingIntro = useCallback(() => { + onboardingIntroRepeatCancelledRef.current = true; + const { current } = onboardingIntroAbortRef; + if (current) { + current.aborted = true; + onboardingIntroAbortRef.current = null; + } + setOnboardingIntroDelta(0); + }, []); + + const hasOnboardingCards = !!onboardingCards; + const hasOnboardingContent = onboardingContent !== undefined; + const isOnboardingMode = hasOnboardingCards || hasOnboardingContent; + const availableOnboardingCards = useMemo( + () => + (onboardingCards ?? []).filter((card) => !dismissedCardIds.has(card.id)), + [dismissedCardIds, onboardingCards], + ); + const currentOnboardingCard = availableOnboardingCards[0]; + const nextOnboardingCard = availableOnboardingCards[1]; + const isModalLoading = isOnboardingMode ? onboardingCardsLoading : isLoading; + const isModalEmpty = isOnboardingMode + ? !isModalLoading && !hasOnboardingContent && !currentOnboardingCard + : isEmpty; + const swipeAreaHeight = isOnboardingMode + ? ONBOARDING_SWIPE_AREA_HEIGHT + : HOT_TAKE_CARD_HEIGHT; useEffect(() => { animatingTakeIdRef.current = animatingTakeId; @@ -881,6 +1747,111 @@ const HotAndColdModal = ({ }; }, []); + useEffect(() => { + if ( + !hasOnboardingCards || + isModalLoading || + !currentOnboardingCard || + onboardingIntroRepeatCancelledRef.current + ) { + return undefined; + } + + let effectCancelled = false; + let nextIterationTimeoutId: number | null = null; + + const runOneIntroIteration = (): void => { + if (effectCancelled || onboardingIntroRepeatCancelledRef.current) { + setOnboardingIntroDelta(0); + return; + } + const animSignal = { aborted: false }; + onboardingIntroAbortRef.current = animSignal; + const iterationStart = performance.now(); + runOnboardingIntroAnimation({ + signal: animSignal, + onUpdate: (value) => { + if ( + !animSignal.aborted && + !onboardingIntroRepeatCancelledRef.current + ) { + setOnboardingIntroDelta(value); + } + }, + }) + .then(() => { + if (onboardingIntroAbortRef.current === animSignal) { + onboardingIntroAbortRef.current = null; + } + if ( + animSignal.aborted || + effectCancelled || + onboardingIntroRepeatCancelledRef.current + ) { + setOnboardingIntroDelta(0); + return; + } + setOnboardingIntroDelta(0); + const elapsed = performance.now() - iterationStart; + const waitMs = Math.max( + 0, + ONBOARDING_INTRO_REPEAT_INTERVAL_MS - elapsed, + ); + nextIterationTimeoutId = window.setTimeout( + runOneIntroIteration, + waitMs, + ); + }) + .catch(() => null); + }; + + nextIterationTimeoutId = window.setTimeout(() => { + nextIterationTimeoutId = null; + runOneIntroIteration(); + }, ONBOARDING_INTRO_START_DELAY_MS); + + return () => { + effectCancelled = true; + if (nextIterationTimeoutId !== null) { + window.clearTimeout(nextIterationTimeoutId); + } + const { current } = onboardingIntroAbortRef; + if (current) { + current.aborted = true; + onboardingIntroAbortRef.current = null; + } + setOnboardingIntroDelta(0); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on card id only, not currentOnboardingCard reference + }, [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( ({ takeId, @@ -926,20 +1897,44 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - dismissCurrent(); + if (hasOnboardingCards && currentOnboardingCard) { + updateDismissedCardIds((prev) => { + const next = new Set(prev); + next.add(currentOnboardingCard.id); + const deck = onboardingCards ?? []; + if (deck.length > 0 && deck.every((c) => next.has(c.id))) { + return new Set(); + } + return next; + }); + } else { + dismissCurrent(); + } setIsAnimating(false); dismissTimerRef.current = null; }, durationMs); }, - [dismissCurrent], + [ + currentOnboardingCard, + dismissCurrent, + hasOnboardingCards, + onboardingCards, + updateDismissedCardIds, + ], ); const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - if (!currentTake || isAnimating) { + const currentItemId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + const isButtonSource = source === 'button'; const durationMs = isButtonSource ? BUTTON_DISMISS_ANIMATION_MS @@ -948,21 +1943,27 @@ const HotAndColdModal = ({ logEvent({ event_name: LogEvent.VoteHotAndCold, - target_id: currentTake.id, - extra: JSON.stringify({ vote, direction, hotTakeId: currentTake.id }), + target_id: currentItemId, + extra: JSON.stringify({ vote, direction, hotTakeId: currentItemId }), }); - if (direction === 'right') { - toggleUpvote({ - payload: currentTake, - origin: Origin.HotAndCold, - }); - } else { - toggleDownvote({ - payload: currentTake, - origin: Origin.HotAndCold, - }); + if (!isOnboardingMode && currentTake) { + if (direction === 'right') { + toggleUpvote({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + } else { + toggleDownvote({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + } } + onSwipeAction?.( + direction, + hasOnboardingCards ? { onboardingCardId: currentItemId } : undefined, + ); let initialPush: number; let flyDistance: number; @@ -986,7 +1987,7 @@ const HotAndColdModal = ({ setSwipeDelta(initialPush); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs, flyDelayMs: isButtonSource ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSwipeDelta(flyDistance), @@ -994,30 +1995,44 @@ const HotAndColdModal = ({ }, [ currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, + onSwipeAction, swipeDelta, + abortOnboardingIntro, ], ); const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - if (!currentTake || isAnimating) { + const currentItemId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + logEvent({ event_name: LogEvent.SkipHotTake, - target_id: currentTake.id, + target_id: currentItemId, }); - cancelHotTakeVote({ id: currentTake.id }); + if (!isOnboardingMode && currentTake) { + cancelHotTakeVote({ id: currentTake.id }); + } + onSwipeAction?.('skip'); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs: SKIP_DISMISS_ANIMATION_MS, flyDelayMs: source === 'button' ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSkipDelta(-SKIP_DISMISS_FLY_DISTANCE), @@ -1026,17 +2041,34 @@ const HotAndColdModal = ({ [ cancelHotTakeVote, currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, logEvent, + onSwipeAction, + abortOnboardingIntro, ], ); + const currentCardId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; const isCurrentTakeAnimating = - !!currentTake && isAnimating && animatingTakeId === currentTake.id; + !!currentCardId && isAnimating && animatingTakeId === currentCardId; const cardSwipeDelta = isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; + const combinedOnboardingSwipeX = + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating + ? cardSwipeDelta + onboardingIntroDelta + : cardSwipeDelta; + const onboardingIntroPlaying = + hasOnboardingCards && + !isDragging && + !isCurrentTakeAnimating && + onboardingIntroDelta !== 0; const handleSwiped = (direction: 'left' | 'right') => { setIsDragging(false); @@ -1053,9 +2085,14 @@ const HotAndColdModal = ({ const handlers = useSwipeable({ onSwiping: (e) => { if (!isAnimating) { + abortOnboardingIntro(); setIsDragging(true); setSwipeDelta(e.deltaX); - if (e.deltaY < 0 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + if ( + !isOnboardingMode && + e.deltaY < 0 && + Math.abs(e.deltaY) > Math.abs(e.deltaX) + ) { setSkipDelta(getElasticDelta(e.deltaY)); } else { setSkipDelta(0); @@ -1068,6 +2105,13 @@ const HotAndColdModal = ({ onSwipedRight: () => handleSwiped('right'), onSwipedUp: () => { setIsDragging(false); + if (isOnboardingMode) { + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + return; + } if ( swipeDeltaYRef.current < 0 && Math.abs(swipeDeltaYRef.current) > SWIPE_THRESHOLD @@ -1087,117 +2131,264 @@ const HotAndColdModal = ({ touchEventOptions: { passive: false }, }); + const cardSwipeArea = ( +
+ {isOnboardingMode ? ( + <> + {nextOnboardingCard && ( + + )} + {currentOnboardingCard && ( + + )} + + + ) : ( + <> + {nextTake && ( + + )} + {currentTake && ( + + )} + + )} +
+ ); + 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 ( - - - - {isLoading && ( -
+ + {showHeader && } + + {headerSlot} + {isModalLoading && ( +
+ {isOnboardingMode ? : null} - Loading hot takes... + {isOnboardingMode ? 'Loading stories…' : 'Loading hot takes...'}
)} - {!isLoading && isEmpty && ( + {!isModalLoading && isModalEmpty && isOnboardingMode && ( + + )} + + {!isModalLoading && isModalEmpty && !isOnboardingMode && ( )} - {!isLoading && !isEmpty && currentTake && ( + {!isModalLoading && !isModalEmpty && isOnboardingMode && ( <> -
- {nextTake && ( - - )} - -
- -
-
- - {user?.username && ( -
- +
+
+ {topSlot} + {hasOnboardingContent + ? onboardingContent + : onboardingSwipeActions} + {bottomSlot}
- )} +
)} + + {!isModalLoading && + !isModalEmpty && + !isOnboardingMode && + currentCardId && ( + <> + {topSlot} + {cardSwipeArea} + {showDefaultActions && ( +
+
+ )} + {bottomSlot} + {showAddHotTakeButton && user?.username && ( +
+ +
+ )} + + )} ); diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 9981ff7ffb8..0b7338bb2fc 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; +import classNames from 'classnames'; import { FeedPreviewControls } from '../feeds'; import { REQUIRED_TAGS_THRESHOLD } from './common'; import { Origin } from '../../lib/log'; @@ -19,6 +20,7 @@ interface EditTagProps { feedSettings: FeedSettings; userId: string; headline?: string; + headlineClassName?: string; requiredTags?: number; hidePreview?: boolean; } @@ -26,6 +28,7 @@ export const EditTag = ({ feedSettings, userId, headline, + headlineClassName, requiredTags = REQUIRED_TAGS_THRESHOLD, hidePreview, }: EditTagProps): ReactElement => { @@ -47,7 +50,12 @@ export const EditTag = ({ return ( <> -

+

{headline || 'Pick tags that are relevant to you'}

{ + const actual = jest.requireActual('./onboardingPopBus'); + return { + ...actual, + broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection), + broadcastRecommendRequest: jest.fn(actual.broadcastRecommendRequest), + }; +}); + +const mockOnFollowTags = jest.fn().mockResolvedValue({ successful: true }); +const mockOnUnfollowTags = jest.fn().mockResolvedValue({ successful: true }); +const mockLogEvent = jest.fn(); +const mockRequest = jest.fn(); + +jest.mock('../../graphql/common', () => ({ + gqlClient: { request: (...args: unknown[]) => mockRequest(...args) }, +})); + +jest.mock('../../hooks/useTagAndSource', () => ({ + __esModule: true, + default: () => ({ + onFollowTags: mockOnFollowTags, + onUnfollowTags: mockOnUnfollowTags, + }), +})); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +const personas = [ + { id: 'frontend', title: 'Frontend', emoji: '🌐', tags: ['react', 'css'] }, + { id: 'backend', title: 'Backend', emoji: '🖥️', tags: ['node', 'sql'] }, + { id: 'mobile', title: 'Mobile', emoji: '📱', tags: ['ios', 'android'] }, + { id: 'devops', title: 'DevOps', emoji: '☁️', tags: ['docker', 'k8s'] }, +]; + +const renderComponent = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +}; + +describe('PersonaSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRequest.mockResolvedValue({ onboardingPersonas: personas }); + }); + + it('renders pills with emoji and title', async () => { + renderComponent(); + expect(await screen.findByText('Frontend')).toBeInTheDocument(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + }); + + it('follows tags and broadcasts pop + recommend on click', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => + expect(mockOnFollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + requireLogin: true, + }), + ); + expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']); + expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']); + }); + + it('allows multi-select without unfollowing previous persona', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + + expect(mockOnFollowTags).toHaveBeenLastCalledWith({ + tags: ['node', 'sql'], + requireLogin: true, + }); + expect(mockOnUnfollowTags).not.toHaveBeenCalled(); + }); + + it('disables additional personas after 3 are selected', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByText('Mobile')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(3)); + + const devopsButton = screen.getByText('DevOps').closest('button'); + expect(devopsButton).toBeDisabled(); + + fireEvent.click(screen.getByText('DevOps')); + expect(mockOnFollowTags).toHaveBeenCalledTimes(3); + }); + + it('deselects only the clicked persona', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByText('Frontend')); + await waitFor(() => + expect(mockOnUnfollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + }), + ); + expect(mockOnUnfollowTags).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/components/onboarding/PersonaSelector.tsx b/packages/shared/src/components/onboarding/PersonaSelector.tsx new file mode 100644 index 00000000000..cd587820e72 --- /dev/null +++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx @@ -0,0 +1,180 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import type { GQLPersona } from '../../graphql/feedSettings'; +import { GET_ONBOARDING_PERSONAS_QUERY } from '../../graphql/feedSettings'; +import useTagAndSource from '../../hooks/useTagAndSource'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { disabledRefetch } from '../../lib/func'; +import { RequestKey, StaleTime, generateQueryKey } from '../../lib/query'; +import { Button, ButtonColor } from '../buttons/Button'; +import { ButtonVariant } from '../buttons/common'; +import { ElementPlaceholder } from '../ElementPlaceholder'; +import { + broadcastPersonaSelection, + broadcastRecommendRequest, +} from './onboardingPopBus'; + +export const MAX_PERSONAS = 3; + +export type PersonaSelectorMode = 'follow' | 'seed'; + +interface PersonaSelectorProps { + className?: string; + feedId?: string; + mode?: PersonaSelectorMode; + onSelectionChange?: (selected: GQLPersona[]) => void; +} + +export function PersonaSelector({ + className, + feedId, + mode = 'follow', + onSelectionChange, +}: PersonaSelectorProps): ReactElement | null { + const { logEvent } = useLogContext(); + const [activeIds, setActiveIds] = useState>(new Set()); + const { onFollowTags, onUnfollowTags } = useTagAndSource({ + origin: Origin.OnboardingPersona, + feedId, + }); + + const { + data: personas, + isPending, + isError, + } = useQuery({ + queryKey: generateQueryKey( + RequestKey.Tags, + undefined, + 'onboardingPersonas', + ), + queryFn: async () => { + const result = await gqlClient.request<{ + onboardingPersonas: GQLPersona[]; + }>(GET_ONBOARDING_PERSONAS_QUERY, {}); + return result.onboardingPersonas; + }, + ...disabledRefetch, + staleTime: StaleTime.OneHour, + }); + + const emitSelection = (nextActiveIds: Set) => { + if (!onSelectionChange || !personas) { + return; + } + onSelectionChange(personas.filter((p) => nextActiveIds.has(p.id))); + }; + + const handleClick = async (persona: GQLPersona) => { + const isActive = activeIds.has(persona.id); + const isAtCap = !isActive && activeIds.size >= MAX_PERSONAS; + if (isAtCap) { + return; + } + + logEvent({ + event_name: LogEvent.SelectOnboardingPersona, + target_type: 'persona', + target_id: persona.id, + extra: JSON.stringify({ + action: isActive ? 'deselect' : 'select', + tags_count: persona.tags.length, + active_count: isActive ? activeIds.size - 1 : activeIds.size + 1, + }), + }); + + if (isActive) { + if (mode === 'follow') { + await onUnfollowTags({ tags: persona.tags }); + } + setActiveIds((prev) => { + const next = new Set(prev); + next.delete(persona.id); + emitSelection(next); + return next; + }); + return; + } + + broadcastPersonaSelection(persona.tags); + if (mode === 'follow') { + await onFollowTags({ tags: persona.tags, requireLogin: true }); + } + broadcastRecommendRequest(persona.tags); + setActiveIds((prev) => { + const next = new Set(prev); + next.add(persona.id); + emitSelection(next); + return next; + }); + }; + + if (isError) { + return null; + } + + const isAtCap = activeIds.size >= MAX_PERSONAS; + + return ( +
+ {isPending && + Array.from({ length: 10 }).map((_, i) => ( + + ))} + {!isPending && + personas?.map((persona) => { + const isActive = activeIds.has(persona.id); + const isDisabled = !isActive && isAtCap; + const buttonContent = ( + <> + + {persona.emoji} + + {persona.title} + + ); + + if (isActive) { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/shared/src/components/onboarding/onboardingPopBus.ts b/packages/shared/src/components/onboarding/onboardingPopBus.ts new file mode 100644 index 00000000000..b08df68f5f8 --- /dev/null +++ b/packages/shared/src/components/onboarding/onboardingPopBus.ts @@ -0,0 +1,29 @@ +type PopListener = (tagNames: string[]) => void; +type RecommendListener = (tags: string[]) => void; + +const popListeners = new Set(); +const recommendListeners = new Set(); + +export function subscribePersonaSelection(listener: PopListener): () => void { + popListeners.add(listener); + return () => { + popListeners.delete(listener); + }; +} + +export function broadcastPersonaSelection(tagNames: string[]): void { + popListeners.forEach((listener) => listener(tagNames)); +} + +export function subscribeRecommendRequest( + listener: RecommendListener, +): () => void { + recommendListeners.add(listener); + return () => { + recommendListeners.delete(listener); + }; +} + +export function broadcastRecommendRequest(tags: string[]): void { + recommendListeners.forEach((listener) => listener(tags)); +} diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 8bf6df7728f..bbc5419d8f8 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import React, { useCallback, useMemo, useRef } from 'react'; import classNames from 'classnames'; import type { PaddleEventData } from '@paddle/paddle-js'; @@ -56,6 +56,8 @@ export interface FunnelStepperProps { onComplete?: () => 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/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 47b47d13edc..018970810f2 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -40,6 +40,7 @@ export enum ActionType { FetchedSmartTitle = 'fetched_smart_title', EditTag = 'edit_tag', ContentTypes = 'content_types', + HasSeenTags = 'has_seen_tags', StreakTimezoneMismatch = 'streak_timezone_mismatch', CheckedCoresRole = 'checked_cores_role', CompletedOnboarding = 'completed_onboarding', diff --git a/packages/shared/src/graphql/feedSettings.ts b/packages/shared/src/graphql/feedSettings.ts index 7be80117efd..3da4c010f28 100644 --- a/packages/shared/src/graphql/feedSettings.ts +++ b/packages/shared/src/graphql/feedSettings.ts @@ -57,6 +57,13 @@ export interface TagCategory { emoji: string; } +export interface GQLPersona { + id: string; + title: string; + emoji: string; + tags: string[]; +} + export interface AllTagCategoriesData { feedSettings?: FeedSettings; loggedIn?: boolean; @@ -198,3 +205,14 @@ export const ONBOARDING_RECOMMEND_TAGS_MUTATION = gql` } } `; + +export const GET_ONBOARDING_PERSONAS_QUERY = gql` + query OnboardingPersonas { + onboardingPersonas { + id + title + emoji + tags + } + } +`; diff --git a/packages/shared/src/hooks/useTouchLongPress.spec.tsx b/packages/shared/src/hooks/useTouchLongPress.spec.tsx index abe15e01d61..7de300dbcc6 100644 --- a/packages/shared/src/hooks/useTouchLongPress.spec.tsx +++ b/packages/shared/src/hooks/useTouchLongPress.spec.tsx @@ -35,7 +35,7 @@ describe('useTouchLongPress', () => { result.current.onTouchStart(createTouchEvent(), 'message-1'); }); - expect(document.documentElement.style.userSelect).toBe('none'); + expect(document.documentElement).toHaveStyle({ userSelect: 'none' }); act(() => { result.current.onTouchEnd(); @@ -64,7 +64,7 @@ describe('useTouchLongPress', () => { }); expect(onLongPress).toHaveBeenCalledWith('message-1'); - expect(document.documentElement.style.userSelect).toBe('none'); + expect(document.documentElement).toHaveStyle({ userSelect: 'none' }); act(() => { result.current.onTouchEnd(); @@ -89,6 +89,6 @@ describe('useTouchLongPress', () => { }); expect(onLongPress).not.toHaveBeenCalled(); - expect(document.documentElement.style.userSelect).toBe(''); + expect(document.documentElement).toHaveStyle({ userSelect: '' }); }); }); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index db0e9593c01..eb2c1a0cd0d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -169,6 +169,11 @@ export const featureOnboardingTagRecommender = new Feature( false, ); +export const featureOnboardingPersonas = new Feature( + 'onboarding_personas', + false, +); + export const featurePostSignupWidget = new Feature('post_signup_widget', false); export const featureReaderModal = new Feature('reader_modal', false); @@ -199,3 +204,15 @@ export const featureCompanionDemoWidget = new Feature( 'companion_demo_widget', false, ); + +export const swipeOnboardingFeature = new Feature('swipe_onboarding', true); + +export const featureUpvoteCountThreshold = new Feature<{ + threshold: number; + belowThresholdLabel: string; + newWindowHours: number; +}>('upvote_count_threshold', { + threshold: 0, + belowThresholdLabel: '', + newWindowHours: 24, +}); 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/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 27c92db0018..40244c68bab 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -54,6 +54,7 @@ export enum Origin { Onboarding = 'onboarding', ManageTag = 'manage_tag', EditTag = 'edit_tag', + OnboardingPersona = 'onboarding persona', // Collection CollectionModal = 'collection modal', Settings = 'settings', @@ -467,6 +468,8 @@ export enum LogEvent { ReaderEmbedReady = 'reader embed ready', ReaderEmbedPermissionRequired = 'reader embed permission required', ReaderEmbedError = 'reader embed error', + // Onboarding personas + SelectOnboardingPersona = 'select onboarding persona', } export enum TargetType { diff --git a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx new file mode 100644 index 00000000000..05a25d5cd7a --- /dev/null +++ b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx @@ -0,0 +1,364 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +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 { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; +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 { buildSwipePrompt } from '../../lib/buildSwipePrompt'; +import { SwipeOnboardingProgressHeader } from './SwipeOnboardingProgressHeader'; +import { SwipePersonaIntro } from './SwipePersonaIntro'; +import { SWIPE_ONBOARDING_MIN_TO_UNLOCK } from '../../lib/swipeOnboardingGuidance'; +import { recommendOnboardingTags } from '../../lib/swipingBackendApi'; + +const SWIPE_ONBOARDING_PROGRESS_MILESTONES: readonly number[] = [ + SWIPE_ONBOARDING_MIN_TO_UNLOCK, +]; + +const SWIPE_ONBOARDING_TAG_SEED_MAX = 25; +const SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT = 10; +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)]'; + +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 ( +
+
+
+ ); +} + +function FunnelSwipeOnboardingStepComponent({ + parameters: { cta }, + onTransition, +}: FunnelStepEditTags): ReactElement { + const router = useRouter(); + const { user } = useAuthContext(); + const [swipesCount, setSwipesCount] = useState(0); + const [milestoneBurstKey, setMilestoneBurstKey] = useState(0); + const [selectedPersonas, setSelectedPersonas] = 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 handleStartSwipe = useCallback(async () => { + if (promptLoading) { + return; + } + + setPromptLoading(true); + try { + const personaTags = Array.from( + new Set(selectedPersonas.flatMap((persona) => persona.tags)), + ); + const recommendedTags = await recommendOnboardingTags( + personaTags, + SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT, + ).catch(() => []); + const initialTags = Array.from( + new Set([...personaTags, ...recommendedTags]), + ); + const prompt = buildSwipePrompt({ + personas: selectedPersonas, + experienceLevel: user?.experienceLevel, + }); + await startDeck({ prompt, initialTags }); + setIsSwipeMode(true); + } finally { + setPromptLoading(false); + } + }, [promptLoading, selectedPersonas, startDeck, user?.experienceLevel]); + + 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') { + return; + } + if (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 ( +
+
+ +
+
+ ); + } + + const bottomContinueSlot = canContinue ? ( +
+
+
+

+ Starter feed ready +

+

+ We have enough signal to build your first pass. You can keep + refining it after this. +

+
+ +
+
+ ) : 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 new file mode 100644 index 00000000000..3a5fcc4b594 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx @@ -0,0 +1,397 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +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 keeps the typed copy from jumping while the progress bar updates. + */ +const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[4.75rem]'; + +function SwipeOnboardingTypingHeadline({ + line1, + line2, +}: { + line1: string; + line2?: string; +}): ReactElement { + const fullText = useMemo( + () => (line2 !== undefined ? `${line1}\n${line2}` : line1), + [line1, line2], + ); + const [visibleCount, setVisibleCount] = useState(0); + + useEffect(() => { + setVisibleCount(0); + const len = fullText.length; + if (len === 0) { + return undefined; + } + let i = 0; + const id = window.setInterval(() => { + i += 1; + setVisibleCount(Math.min(i, len)); + if (i >= len) { + window.clearInterval(id); + } + }, SWIPE_HEADLINE_TYPING_MS_PER_CHAR); + return () => window.clearInterval(id); + }, [fullText]); + + const slice = fullText.slice(0, visibleCount); + const parts = slice.split('\n'); + const shownLine1 = parts[0] ?? ''; + const shownLine2 = parts.length > 1 ? parts.slice(1).join('\n') : undefined; + + return ( +
+ + {shownLine1} + {shownLine2 !== undefined ? ( + <> +
+ {shownLine2} + + ) : null} +
+
+ ); +} + +const SWIPE_PROGRESS_MILESTONE_SPARK_OFFSETS: ReadonlyArray<{ + tx: string; + ty: string; +}> = [ + { tx: '0rem', ty: '-1.5rem' }, + { tx: '0.6875rem', ty: '-1.25rem' }, + { tx: '-0.6875rem', ty: '-1.25rem' }, + { tx: '1.125rem', ty: '-0.5rem' }, + { tx: '-1.125rem', ty: '-0.5rem' }, + { tx: '1.375rem', ty: '0rem' }, + { tx: '-1.375rem', ty: '0rem' }, + { tx: '0.9375rem', ty: '0.5625rem' }, + { tx: '-0.9375rem', ty: '0.5625rem' }, + { tx: '0.5rem', ty: '1.0625rem' }, + { tx: '-0.5rem', ty: '1.0625rem' }, + { tx: '0rem', ty: '1.1875rem' }, + { tx: '1rem', ty: '-1rem' }, + { tx: '-1rem', ty: '-1rem' }, + { tx: '1.1875rem', ty: '0.75rem' }, + { tx: '-1.1875rem', ty: '0.75rem' }, +]; + +export type SwipeOnboardingProgressHeaderProps = { + /** Swipe count and/or tag selections — same scale as onboarding swipes (0–40+). */ + progressCount: number; + milestoneBurstKey: number; + copyVariant?: SwipeOnboardingProgressCopyVariant; +}; + +export function SwipeOnboardingProgressHeader({ + progressCount, + milestoneBurstKey, + copyVariant = 'swipe', +}: SwipeOnboardingProgressHeaderProps): ReactElement { + 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 */} + + +
+
+
+
+
+ {milestoneBurstKey > 0 && progress > 0 && ( +
+ )} +
+
+
+
+
+
0 ? undefined : 0, + }} + /> +
+ {milestoneBurstKey > 0 && progress > 0 && ( +
+
+
+ {SWIPE_PROGRESS_MILESTONE_SPARK_OFFSETS.map( + (sp, sparkIndex) => ( + + ), + )} +
+
+ )} +
+
+ + {getSwipeOnboardingGuidanceMessage(progressCount, copyVariant)} + + + {progressValue} / {SWIPE_ONBOARDING_REFINE_TARGET} + +
+
+
+ ); +} diff --git a/packages/webapp/components/onboarding/SwipePersonaIntro.tsx b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx new file mode 100644 index 00000000000..5ac8380e43b --- /dev/null +++ b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PersonaSelector } from '@dailydotdev/shared/src/components/onboarding/PersonaSelector'; +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; + +interface SwipePersonaIntroProps { + onSelectionChange: (selected: GQLPersona[]) => void; + onStart: () => void; + onSkip: () => void; + loading: boolean; + loadingLabel?: string; +} + +const surfaceClassName = + '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 panelClassName = + 'rounded-[1.5rem] border border-border-subtlest-tertiary bg-surface-float'; + +export function SwipePersonaIntro({ + onSelectionChange, + onStart, + onSkip, + loading, + loadingLabel, +}: SwipePersonaIntroProps): ReactElement { + return ( +
+
+
+ + Personalize your daily.dev feed + +
+

+ What kind of dev are you? +

+

+ Pick up to three roles. We'll line up posts you'll + actually want to read. +

+
+
+
+
+ +
+
+ + +
+
+
+
+ ); +} diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts new file mode 100644 index 00000000000..62d16c35688 --- /dev/null +++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts @@ -0,0 +1,308 @@ +import { useCallback, useRef, useState } from 'react'; +import type { OnboardingSwipeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import type { PostSummary } from '../lib/swipingBackendApi'; +import { discoverPosts } from '../lib/swipingBackendApi'; + +// Scoring constants — ported from PostSwiper.tsx +const LIKE_SCORE = 1.5; +const DISLIKE_SCORE = -1; +const ADD_THRESHOLD = 3; +const SATURATE_THRESHOLD = 4.5; +const REMOVE_THRESHOLD = -5; +const IGNORE_AFTER = 10; +const SATURATE_AFTER = 5; +const PREFETCH_AFTER_SWIPES = 3; +const BATCH_SIZE = 8; + +function toSwipeCard(post: PostSummary): OnboardingSwipeCard { + return { + id: post.postId, + summary: post.summary, + title: post.title, + image: null, + tags: post.tags, + source: { + name: 'daily.dev', + image: null, + }, + }; +} + +function toBookmarkablePost(post: PostSummary): Post { + return { + id: post.postId, + title: post.title, + summary: post.summary, + permalink: post.url, + commentsPermalink: post.url, + image: '', + tags: post.tags, + bookmarked: false, + type: PostType.Article, + }; +} + +interface StartDeckOptions { + prompt?: string; + initialTags?: string[]; +} + +interface AdaptiveSwipeDeck { + cards: OnboardingSwipeCard[]; + getBookmarkablePost: (cardId: string) => Post | undefined; + isLoading: boolean; + startDeck: (options?: StartDeckOptions) => Promise; + handleSwipe: (direction: 'left' | 'right', cardId: string) => void; + retryFetch: () => Promise; + selectedTags: string[]; + rightSwipedPostIds: Set; +} + +export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { + const [cards, setCards] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const [rightSwipedPostIds, setRightSwipedPostIds] = useState>( + () => new Set(), + ); + + // Refs for mutable state that persists across batches + const tagScoresRef = useRef>({}); + const tagSeenCountRef = useRef>({}); + const seenIdsRef = useRef>(new Set()); + const likedTitlesRef = useRef([]); + const initialPromptRef = useRef(''); + const prefetchedRef = useRef(null); + const swipesInBatchRef = useRef(0); + const prefetchTriggeredRef = useRef(false); + const selectedTagsRef = useRef([]); + // Keep a PostSummary lookup so we can access tags/title on swipe + const postLookupRef = useRef>(new Map()); + const batchSizeRef = useRef(0); + const isFetchingRef = useRef(false); + + selectedTagsRef.current = selectedTags; + + const getSaturatedTags = useCallback((): string[] => { + return Object.entries(tagScoresRef.current) + .filter(([, score]) => score >= SATURATE_THRESHOLD) + .map(([tag]) => tag); + }, []); + + const getConfirmedTags = useCallback((): string[] => { + const selected = new Set(selectedTagsRef.current); + return Object.entries(tagSeenCountRef.current) + .filter(([tag, count]) => selected.has(tag) && count >= SATURATE_AFTER) + .map(([tag]) => tag); + }, []); + + const doFetch = useCallback( + async (n = BATCH_SIZE): Promise => { + const result = await discoverPosts({ + selectedTags: selectedTagsRef.current, + confirmedTags: getConfirmedTags(), + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n, + }); + const { posts } = result; + posts.forEach((p) => { + seenIdsRef.current.add(p.postId); + postLookupRef.current.set(p.postId, p); + }); + return posts; + }, + [getSaturatedTags, getConfirmedTags], + ); + + const getBookmarkablePost = useCallback( + (cardId: string): Post | undefined => { + const post = postLookupRef.current.get(cardId); + return post ? toBookmarkablePost(post) : undefined; + }, + [], + ); + + const loadBatch = useCallback((posts: PostSummary[]) => { + setCards(posts.map(toSwipeCard)); + batchSizeRef.current = posts.length; + swipesInBatchRef.current = 0; + prefetchTriggeredRef.current = false; + }, []); + + const startDeck = useCallback( + async (options?: StartDeckOptions) => { + if (isFetchingRef.current) { + return; + } + isFetchingRef.current = true; + setIsLoading(true); + try { + // Seed with initial tags if provided + if (options?.initialTags?.length) { + setSelectedTags(options.initialTags); + selectedTagsRef.current = options.initialTags; + // Initialize tag scores for seeded tags + const scores: Record = {}; + options.initialTags.forEach((t) => { + scores[t] = ADD_THRESHOLD; + }); + tagScoresRef.current = scores; + } + // Store the initial prompt for future fetches + if (options?.prompt) { + initialPromptRef.current = options.prompt; + } + const result = await discoverPosts({ + prompt: options?.prompt ?? '', + selectedTags: selectedTagsRef.current, + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n: BATCH_SIZE, + }); + const { posts } = result; + posts.forEach((p) => { + seenIdsRef.current.add(p.postId); + postLookupRef.current.set(p.postId, p); + }); + loadBatch(posts); + } finally { + setIsLoading(false); + isFetchingRef.current = false; + } + }, + [getSaturatedTags, loadBatch], + ); + + const retryFetch = useCallback(async () => { + await startDeck(); + }, [startDeck]); + + const triggerPrefetch = useCallback(async () => { + if (prefetchedRef.current) { + return; + } + try { + prefetchedRef.current = await doFetch(); + } catch { + // Prefetch failure is non-critical + } + }, [doFetch]); + + const loadNextBatch = useCallback(async () => { + setIsLoading(true); + try { + let posts: PostSummary[]; + if (prefetchedRef.current) { + posts = prefetchedRef.current; + prefetchedRef.current = null; + } else { + posts = await doFetch(); + } + loadBatch(posts); + } finally { + setIsLoading(false); + } + }, [doFetch, loadBatch]); + + const handleSwipe = useCallback( + (direction: 'left' | 'right', cardId: string) => { + const post = postLookupRef.current.get(cardId); + if (!post) { + return; + } + + const delta = direction === 'right' ? LIKE_SCORE : DISLIKE_SCORE; + const { tags } = post; + + // Track liked posts + if (direction === 'right') { + likedTitlesRef.current.push(post.title); + setRightSwipedPostIds((prev) => { + const next = new Set(prev); + next.add(cardId); + return next; + }); + } + + // --- Tag scoring (ported from PostSwiper.tsx) --- + const scores = { ...tagScoresRef.current }; + const currentSelected = [...selectedTagsRef.current]; + const selectedSet = new Set(currentSelected); + const promoted: string[] = []; + const demoted: string[] = []; + + tags.forEach((tag) => { + const isSelected = selectedSet.has(tag); + tagSeenCountRef.current[tag] = (tagSeenCountRef.current[tag] || 0) + 1; + + if (!isSelected && tagSeenCountRef.current[tag] >= IGNORE_AFTER) { + delete scores[tag]; + return; + } + + const newScore = (scores[tag] || 0) + delta; + scores[tag] = newScore; + + if (!isSelected && newScore >= ADD_THRESHOLD) { + promoted.push(tag); + delete scores[tag]; + delete tagSeenCountRef.current[tag]; + } else if (isSelected && newScore <= REMOVE_THRESHOLD) { + demoted.push(tag); + delete scores[tag]; + } else if ( + isSelected && + newScore < SATURATE_THRESHOLD && + tagSeenCountRef.current[tag] >= SATURATE_AFTER + ) { + scores[tag] = SATURATE_THRESHOLD; + } + }); + + tagScoresRef.current = scores; + + // Apply promotions and demotions + if (promoted.length > 0 || demoted.length > 0) { + const demotedSet = new Set(demoted); + const nextTags = [ + ...currentSelected.filter((t) => !demotedSet.has(t)), + ...promoted, + ]; + setSelectedTags(nextTags); + selectedTagsRef.current = nextTags; + } + + // --- Prefetch and batch management --- + swipesInBatchRef.current += 1; + if ( + swipesInBatchRef.current >= PREFETCH_AFTER_SWIPES && + !prefetchTriggeredRef.current + ) { + prefetchTriggeredRef.current = true; + triggerPrefetch(); + } + + // Auto-load next batch when all cards in current batch are swiped + if (swipesInBatchRef.current >= batchSizeRef.current) { + loadNextBatch(); + } + }, + [triggerPrefetch, loadNextBatch], + ); + + return { + cards, + getBookmarkablePost, + isLoading, + startDeck, + handleSwipe, + retryFetch, + selectedTags, + rightSwipedPostIds, + }; +} diff --git a/packages/webapp/lib/buildSwipePrompt.spec.ts b/packages/webapp/lib/buildSwipePrompt.spec.ts new file mode 100644 index 00000000000..10de0559d7d --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.spec.ts @@ -0,0 +1,58 @@ +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { buildSwipePrompt } from './buildSwipePrompt'; + +const frontend: GQLPersona = { + id: 'frontend', + title: 'Frontend', + emoji: '🌐', + tags: ['react', 'typescript', 'css'], +}; + +const backend: GQLPersona = { + id: 'backend', + title: 'Backend', + emoji: '🖥️', + tags: ['node', 'typescript', 'sql'], +}; + +describe('buildSwipePrompt', () => { + it('returns empty string when no personas are picked', () => { + expect(buildSwipePrompt({ personas: [] })).toBe(''); + expect(buildSwipePrompt({})).toBe(''); + }); + + it('builds a prompt for a single persona without experience level', () => { + expect(buildSwipePrompt({ personas: [frontend] })).toBe( + "I'm a frontend engineer. I'm interested in: react, typescript, css.", + ); + }); + + it('includes the experience-level label when provided', () => { + expect( + buildSwipePrompt({ + personas: [frontend], + experienceLevel: 'MORE_THAN_2_YEARS', + }), + ).toBe( + "I'm a frontend engineer (Mid-level (2-3 years)). I'm interested in: react, typescript, css.", + ); + }); + + it('joins multiple personas and dedupes overlapping tags', () => { + expect( + buildSwipePrompt({ + personas: [frontend, backend], + }), + ).toBe( + "I work across frontend and backend. I'm interested in: react, typescript, css, node, sql.", + ); + }); + + it('omits the interests clause when personas have no tags', () => { + expect( + buildSwipePrompt({ + personas: [{ ...frontend, tags: [] }], + }), + ).toBe("I'm a frontend engineer."); + }); +}); diff --git a/packages/webapp/lib/buildSwipePrompt.ts b/packages/webapp/lib/buildSwipePrompt.ts new file mode 100644 index 00000000000..5b33c6997bd --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.ts @@ -0,0 +1,35 @@ +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { UserExperienceLevel } from '@dailydotdev/shared/src/lib/user'; + +interface BuildSwipePromptArgs { + personas?: GQLPersona[]; + experienceLevel?: keyof typeof UserExperienceLevel | null; +} + +const dedupe = (values: string[]): string[] => Array.from(new Set(values)); + +export function buildSwipePrompt({ + personas, + experienceLevel, +}: BuildSwipePromptArgs): string { + if (!personas?.length) { + return ''; + } + + const roles = personas.map((p) => p.title.toLowerCase()); + const roleClause = + roles.length === 1 + ? `I'm a ${roles[0]} engineer` + : `I work across ${roles.join(' and ')}`; + + const experienceLabel = + experienceLevel && UserExperienceLevel[experienceLevel]; + const experienceClause = experienceLabel ? ` (${experienceLabel})` : ''; + + const tags = dedupe(personas.flatMap((p) => p.tags)); + const interestsClause = tags.length + ? ` I'm interested in: ${tags.join(', ')}.` + : ''; + + return `${roleClause}${experienceClause}.${interestsClause}`.trim(); +} diff --git a/packages/webapp/lib/swipeOnboardingGuidance.spec.ts b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts new file mode 100644 index 00000000000..52eeb71ee3a --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts @@ -0,0 +1,85 @@ +import { + getSwipeOnboardingBarProgress, + getSwipeOnboardingGuidanceMessage, + getSwipeOnboardingHeadline, +} from './swipeOnboardingGuidance'; + +describe('getSwipeOnboardingGuidanceMessage', () => { + it('returns fixed copy for 0 to 9 (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(0)).toBe('Start swiping'); + expect(getSwipeOnboardingGuidanceMessage(1)).toBe('Keep swiping!'); + expect(getSwipeOnboardingGuidanceMessage(9)).toBe('Keep swiping!'); + }); + + it('returns fine-tune copy for 10 or more (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(10)).toBe('Swipe for fine-tune.'); + expect(getSwipeOnboardingGuidanceMessage(100)).toBe('Swipe for fine-tune.'); + }); + + it('returns tag variant with remaining counts', () => { + expect(getSwipeOnboardingGuidanceMessage(0, 'tags')).toBe( + 'Pick 10 tags to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(3, 'tags')).toBe( + 'Pick 7 more tags to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(9, 'tags')).toBe( + 'Pick 1 more tag to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(10, 'tags')).toBe( + 'Add tags for fine-tune.', + ); + }); +}); + +describe('getSwipeOnboardingHeadline', () => { + it('returns fixed starter headline for 0–9 (swipe)', () => { + expect(getSwipeOnboardingHeadline(0)).toEqual({ + line1: 'Swipe on at least 10 posts to tune your feed.', + }); + expect(getSwipeOnboardingHeadline(9)).toEqual({ + line1: 'Swipe on at least 10 posts to tune your feed.', + }); + }); + + it('returns completion headline at 10 and above (swipe)', () => { + expect(getSwipeOnboardingHeadline(10)).toEqual({ + line1: "You're all set! Keep swiping to fine-tune.", + }); + expect(getSwipeOnboardingHeadline(50)).toEqual({ + line1: "You're all set! Keep swiping to fine-tune.", + }); + }); + + it('uses fixed tag wording per tier when variant is tags', () => { + expect(getSwipeOnboardingHeadline(0, 'tags')).toEqual({ + line1: 'Tune your feed.', + line2: 'Pick at least 10 tags to get started.', + }); + expect(getSwipeOnboardingHeadline(9, 'tags')).toEqual({ + line1: 'Tune your feed.', + line2: 'Pick at least 10 tags to get started.', + }); + expect(getSwipeOnboardingHeadline(10, 'tags')).toEqual({ + line1: "You're all set! Keep adding tags to fine-tune.", + }); + }); +}); + +describe('getSwipeOnboardingBarProgress', () => { + it('fills 0 to 100% linearly for swipes 0 to 9', () => { + expect(getSwipeOnboardingBarProgress(0)).toBe(0); + expect(getSwipeOnboardingBarProgress(2)).toBe(20); + expect(getSwipeOnboardingBarProgress(5)).toBe(50); + expect(getSwipeOnboardingBarProgress(9)).toBe(90); + }); + + it('clamps at 100% from 10 onwards', () => { + expect(getSwipeOnboardingBarProgress(10)).toBe(100); + expect(getSwipeOnboardingBarProgress(99)).toBe(100); + }); + + it('clamps negative swipe counts to 0', () => { + expect(getSwipeOnboardingBarProgress(-1)).toBe(0); + }); +}); diff --git a/packages/webapp/lib/swipeOnboardingGuidance.ts b/packages/webapp/lib/swipeOnboardingGuidance.ts new file mode 100644 index 00000000000..70c4c290605 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.ts @@ -0,0 +1,95 @@ +/** Minimum swipes before "Continue" unlocks. Also the point where the bar fills. */ +export const SWIPE_ONBOARDING_MIN_TO_UNLOCK = 10; + +/** Swipe count at which the bar is full and "all set" copy starts. */ +export const SWIPE_ONBOARDING_REFINE_TARGET = SWIPE_ONBOARDING_MIN_TO_UNLOCK; + +/** + * Progress bar fill (0 to 100). Linear from 0% to 100% across the first + * {@link SWIPE_ONBOARDING_MIN_TO_UNLOCK} swipes; clamps at 100% afterwards. + */ +export function getSwipeOnboardingBarProgress(progressCount: number): number { + const n = Math.max(0, progressCount); + + if (n >= SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return 100; + } + + return (n / SWIPE_ONBOARDING_MIN_TO_UNLOCK) * 100; +} + +export type SwipeOnboardingHeadline = { + line1: string; + line2?: string; +}; + +/** Same progress tiers as swipes; copy refers to tags instead. */ +export type SwipeOnboardingProgressCopyVariant = 'swipe' | 'tags'; + +function unitWord(count: number, singular: string, plural: string): string { + return count === 1 ? singular : plural; +} + +/** + * Main title above the progress bar (one or two lines). + * Copy is fixed per tier only — counts update in {@link getSwipeOnboardingGuidanceMessage}, not here. + */ +export function getSwipeOnboardingHeadline( + progressCount: number, + variant: SwipeOnboardingProgressCopyVariant = 'swipe', +): SwipeOnboardingHeadline { + const n = Math.max(0, progressCount); + + if (n < SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return { + line1: + variant === 'tags' + ? 'Tune your feed.' + : 'Swipe on at least 10 posts to tune your feed.', + line2: + variant === 'tags' + ? 'Pick at least 10 tags to get started.' + : undefined, + }; + } + + return { + line1: + variant === 'tags' + ? "You're all set! Keep adding tags to fine-tune." + : "You're all set! Keep swiping to fine-tune.", + }; +} + +/** + * Action-based copy for onboarding progress (swipes or tag picks use the same thresholds). + */ +export function getSwipeOnboardingGuidanceMessage( + progressCount: number, + variant: SwipeOnboardingProgressCopyVariant = 'swipe', +): string { + const n = Math.max(0, progressCount); + + if (n >= SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return variant === 'tags' + ? 'Add tags for fine-tune.' + : 'Swipe for fine-tune.'; + } + + if (variant === 'tags') { + if (n === 0) { + return 'Pick 10 tags to get started.'; + } + const remaining = SWIPE_ONBOARDING_MIN_TO_UNLOCK - n; + return `Pick ${remaining} more ${unitWord( + remaining, + 'tag', + 'tags', + )} to get started.`; + } + + if (n === 0) { + return 'Start swiping'; + } + return 'Keep swiping!'; +} diff --git a/packages/webapp/lib/swipingBackendApi.ts b/packages/webapp/lib/swipingBackendApi.ts new file mode 100644 index 00000000000..cd09b61241c --- /dev/null +++ b/packages/webapp/lib/swipingBackendApi.ts @@ -0,0 +1,82 @@ +import { gql } from 'graphql-request'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ONBOARDING_RECOMMEND_TAGS_MUTATION } from '@dailydotdev/shared/src/graphql/feedSettings'; + +export interface PostSummary { + postId: string; + title: string; + summary: string; + tags: string[]; + url: string; + sourceId: string; +} + +export interface DiscoverPostsRequest { + prompt?: string; + selectedTags?: string[]; + confirmedTags?: string[]; + likedTitles?: string[]; + excludeIds?: string[]; + saturatedTags?: string[]; + n?: number; +} + +export interface DiscoverPostsResponse { + posts: PostSummary[]; + subPrompts: string[]; +} + +const ONBOARDING_DISCOVER_POSTS_MUTATION = gql` + mutation OnboardingDiscoverPosts( + $prompt: String + $selectedTags: [String!] + $confirmedTags: [String!] + $likedTitles: [String!] + $excludeIds: [String!] + $saturatedTags: [String!] + $n: Int + ) { + onboardingDiscoverPosts( + prompt: $prompt + selectedTags: $selectedTags + confirmedTags: $confirmedTags + likedTitles: $likedTitles + excludeIds: $excludeIds + saturatedTags: $saturatedTags + n: $n + ) { + posts { + postId + title + summary + tags + url + sourceId + } + subPrompts + } + } +`; + +export async function discoverPosts( + req: DiscoverPostsRequest, +): Promise { + const data = await gqlClient.request<{ + onboardingDiscoverPosts: DiscoverPostsResponse; + }>(ONBOARDING_DISCOVER_POSTS_MUTATION, req); + return data.onboardingDiscoverPosts; +} + +export async function recommendOnboardingTags( + selectedTags: string[], + n: number, +): Promise { + if (!selectedTags.length) { + return []; + } + + const data = await gqlClient.request<{ + onboardingRecommendTags: { tags: string[] }; + }>(ONBOARDING_RECOMMEND_TAGS_MUTATION, { selectedTags, n }); + return data.onboardingRecommendTags.tags; +} diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 9ca88aeb946..bd0b3df5b26 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -98,6 +98,7 @@ const onboardingExcludedPaths = [ const hotAndColdModalQueryKey = 'openModal'; const hotAndColdModalQueryValue = 'hottakes'; const hotAndColdModalLegacyQueryValue = 'hotAndCold'; +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; const isOnboardingExcludedPath = (pathname: string): boolean => onboardingExcludedPaths.some((path) => pathname.startsWith(path)); @@ -169,6 +170,14 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { (Array.isArray(hotAndColdModalQuery) && (hotAndColdModalQuery.includes(hotAndColdModalQueryValue) || hotAndColdModalQuery.includes(hotAndColdModalLegacyQueryValue))); + const swipeOnboardingPreviewQuery = + router.query[swipeOnboardingPreviewQueryKey]; + const isSwipeOnboardingPreviewForced = + swipeOnboardingPreviewQuery === '1' || + swipeOnboardingPreviewQuery === 'true' || + (Array.isArray(swipeOnboardingPreviewQuery) && + (swipeOnboardingPreviewQuery.includes('1') || + swipeOnboardingPreviewQuery.includes('true'))); useEffect(() => { if (!shouldOpenHotAndColdFromQuery) { @@ -208,9 +217,18 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { !isOnboardingComplete && !isOnboardingExcludedPath(router.pathname) ) { - router.replace('/onboarding'); + const destination = isSwipeOnboardingPreviewForced + ? '/onboarding?swipeOnboardingPreview=1' + : '/onboarding'; + router.replace(destination); } - }, [isFunnel, isOnboardingActionsReady, router, isOnboardingComplete]); + }, [ + isFunnel, + isOnboardingActionsReady, + isOnboardingComplete, + isSwipeOnboardingPreviewForced, + router, + ]); useEffect(() => { const id = user?.id || trackingId; diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index e05d630cd96..db8efe039eb 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -27,7 +27,10 @@ import { ErrorBoundary } from '@dailydotdev/shared/src/components/ErrorBoundary' import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; -import { featureOnboardingV2 } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + featureOnboardingV2, + swipeOnboardingFeature, +} from '@dailydotdev/shared/src/lib/featureManagement'; import dynamic from 'next/dynamic'; import type { AuthOptionsProps, @@ -65,7 +68,9 @@ import { FunnelStepper } from '@dailydotdev/shared/src/features/onboarding/share import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { isLocalhost } from '@dailydotdev/shared/src/lib/config'; +import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; import { getPageSeoTitles } from '../components/layouts/utils'; +import { FunnelSwipeOnboardingStep } from '../components/onboarding/FunnelSwipeOnboardingStep'; import { defaultOpenGraph, defaultSeo } from '../next-seo'; const OnboardingV2 = dynamic( @@ -167,14 +172,15 @@ const isValidAction = ( }; const useOnboardingAuth = () => { - const formRef = useRef(); + const formRef = useRef(null as unknown as HTMLFormElement); const isMobile = useViewSize(ViewSize.MobileL); const { isAuthReady, anonymous, loginState, isLoggedIn } = useAuthContext(); const router = useRouter(); - const action = isValidAction(router.query.action) && router.query.action; - const { - data: { funnelState }, - } = useOnboardingBoot(); + const action = isValidAction(router.query.action) + ? router.query.action + : undefined; + const { data } = useOnboardingBoot(); + const funnelState = data?.funnelState; const [auth, setAuth] = useAtom(authAtom); const { isLoginFlow, defaultDisplay } = auth; @@ -248,8 +254,10 @@ const useOnboardingAuth = () => { targetId: ExperimentWinner.OnboardingV4, onSuccessfulRegistration: () => updateAuth({ isAuthenticating: false }), onSuccessfulLogin: () => updateAuth({ isAuthenticating: false }), - onAuthStateUpdate: (props: AuthProps) => - updateAuth({ isAuthenticating: true, ...props }), + onAuthStateUpdate: (props: Partial) => { + const { isAuthenticating: incoming, ...rest } = props; + updateAuth({ isAuthenticating: incoming ?? true, ...rest }); + }, onboardingSignupButton: { size: isMobile ? ButtonSize.Medium : ButtonSize.Large, variant: ButtonVariant.Primary, @@ -277,7 +285,7 @@ const useOnboardingAuth = () => { }; }; -function Onboarding({ initialStepId }: PageProps): ReactElement { +function Onboarding({ initialStepId }: PageProps): ReactElement | null { const router = useRouter(); const { isAuthenticating, @@ -289,6 +297,25 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { const { isOnboardingComplete, isOnboardingActionsReady, completeStep } = useOnboardingActions(); const [isFunnelReady, setFunnelReady] = useState(false); + const { value: isSwipeOnboardingEnabled } = useConditionalFeature({ + feature: swipeOnboardingFeature, + }); + const swipeOnboardingPreviewQuery = router.query.swipeOnboardingPreview; + const isSwipeOnboardingPreviewForced = + swipeOnboardingPreviewQuery === '1' || + swipeOnboardingPreviewQuery === 'true' || + (Array.isArray(swipeOnboardingPreviewQuery) && + (swipeOnboardingPreviewQuery.includes('1') || + swipeOnboardingPreviewQuery.includes('true'))); + const stepComponentOverrides = useMemo( + () => + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? { + [FunnelStepType.EditTags]: FunnelSwipeOnboardingStep, + } + : undefined, + [isSwipeOnboardingEnabled, isSwipeOnboardingPreviewForced], + ); const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); @@ -352,17 +379,20 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { ); } + if (!isFunnelReady || !funnelState) { + return null; + } + return ( - isFunnelReady && ( -
- - {/* */} -
- ) +
+ + {/* */} +
); } diff --git a/packages/webapp/pages/onboarding/swipe.tsx b/packages/webapp/pages/onboarding/swipe.tsx new file mode 100644 index 00000000000..4d68200f111 --- /dev/null +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -0,0 +1,19 @@ +import type { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps = async ({ + resolvedUrl, +}) => { + const queryIndex = resolvedUrl.indexOf('?'); + const query = queryIndex === -1 ? '' : resolvedUrl.slice(queryIndex); + + return { + redirect: { + destination: `/onboarding${query}`, + permanent: false, + }, + }; +}; + +export default function SwipeOnboardingRedirect(): null { + return null; +} diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js index a0868d95600..4a37bef98fa 100644 --- a/scripts/typecheck-strict-changed.js +++ b/scripts/typecheck-strict-changed.js @@ -58,6 +58,11 @@ const strictSkipList = new Set([ // string handling / null returns) and should be addressed separately. 'packages/extension/src/newtab/DndModal.tsx', 'packages/webapp/pages/jobs/[id]/questions.tsx', + // featureManagement re-imports `@growthbook/growthbook` types, but + // moduleResolution: "bundler" (PR #5952) respects the package's `exports` + // map which omits a "types" condition, so type imports fall back to + // implicit any under strict mode. Touched here to add the personas flag. + 'packages/shared/src/lib/featureManagement.ts', ]); const changedFiles = getChangedTypescriptFiles().filter(