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', + ); + + // The onboarding panel duplicates its swipe actions (one block for + // mobile, one for tablet) so the row stays visible at every breakpoint; + // JSDOM renders both, so just assert presence of at least one of each. + expect( + screen.getAllByRole('button', { name: 'Not interesting' })[0], + ).toBeVisible(); + expect( + screen.getAllByRole('button', { name: 'Interesting' })[0], + ).toBeVisible(); + expect( + screen.getAllByRole('img', { name: 'daily.dev source icon' })[0], + ).toBeVisible(); + expect(screen.getAllByText('Starter feed ready')[0]).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index d1294480ad3..a4fe14ab405 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'; @@ -11,7 +17,9 @@ import { useLogContext } from '../../../contexts/LogContext'; import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent, Origin } from '../../../lib/log'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { IconSize } from '../../Icon'; import { HotIcon } from '../../icons/Hot'; +import { MiniCloseIcon, VIcon } from '../../icons'; import { Typography, TypographyType, @@ -21,10 +29,19 @@ 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'; import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common'; 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,575 @@ 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, title, and topic tags). */ +const ONBOARDING_POST_CARD_HEIGHT = 'clamp(13rem, 28dvh, 16rem)'; +/** Swipe stack area: card height plus back-card vertical offset (8px). */ +const ONBOARDING_SWIPE_AREA_HEIGHT = `calc(${ONBOARDING_POST_CARD_HEIGHT} + 0.5rem)`; +const ONBOARDING_SWIPE_FADE_DISTANCE = SWIPE_THRESHOLD * 4; + +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 ONBOARDING_SCREEN_TRANSITION_KEYFRAMES = ` + @keyframes onboardingScreenEnter { + from { + opacity: 0; + transform: translateY(0.75rem) scale(0.985); + filter: blur(0.25rem); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } + } + + .onboarding-screen-enter { + animation: onboardingScreenEnter 0.34s cubic-bezier(0.16, 1, 0.3, 1) both; + } + + @media (prefers-reduced-motion: reduce) { + .onboarding-screen-enter { + animation: none; + opacity: 1; + transform: none; + filter: none; + } + } +`; + +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 OnboardingSwipeEdgeHalos = ({ + deltaX, + isDragging, +}: { + deltaX: number; + isDragging: boolean; +}): ReactElement | null => { + if (!isDragging) { + return null; + } + + const intensity = Math.min( + Math.max(Math.abs(deltaX) / SWIPE_THRESHOLD, 0.18), + 1, + ); + const activeDirection = deltaX >= 0 ? 'right' : 'left'; + + const renderHalo = (direction: 'left' | 'right'): ReactElement => { + const isRight = direction === 'right'; + const accentColor = isRight + ? 'var(--theme-accent-avocado-default)' + : 'var(--theme-accent-bacon-default)'; + const strength = + direction === activeDirection ? intensity : intensity * 0.35; + + return ( + + ); + }; + + return <>{(['left', 'right'] as const).map(renderHalo)}; +}; + +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 +1380,216 @@ 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 swipeFadeProgress = + isTop && (isDragging || isDismissAnimating) + ? Math.min(Math.abs(swipeDelta) / ONBOARDING_SWIPE_FADE_DISTANCE, 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, opacity 0.2s 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 - swipeFadeProgress : 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' ? 'Good' : 'Meh'} +
+ ) : null} +
+
+ + {card.title || 'Popular developer story'} + +
+ {card.tags && card.tags.length > 0 && ( +
+ {card.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +}; + +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 = ({ onAddOwnHotTakeClick, username, @@ -832,10 +1628,77 @@ 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; + bodyClassName?: string; + headerSlot?: ReactNode; + topSlot?: ReactNode; + progressSlot?: ReactNode; + bottomSlot?: ReactNode; + onboardingFooterSlot?: 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', + bodyClassName, + headerSlot, + topSlot, + progressSlot, + bottomSlot, + onboardingFooterSlot, + 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(); @@ -853,6 +1716,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; @@ -879,6 +1789,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, @@ -924,20 +1939,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 @@ -946,21 +1985,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; @@ -984,7 +2029,7 @@ const HotAndColdModal = ({ setSwipeDelta(initialPush); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs, flyDelayMs: isButtonSource ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSwipeDelta(flyDistance), @@ -992,30 +2037,93 @@ const HotAndColdModal = ({ }, [ currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, + onSwipeAction, swipeDelta, + abortOnboardingIntro, ], ); + useEffect(() => { + if ( + !props.isOpen || + !hasOnboardingCards || + hasOnboardingContent || + isModalLoading || + !currentOnboardingCard || + isAnimating + ) { + return undefined; + } + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.repeat) { + return; + } + + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + const { target } = event; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target.matches('input, textarea, select, [role="textbox"]')) + ) { + return; + } + + event.preventDefault(); + handleDismiss(event.key === 'ArrowRight' ? 'right' : 'left', 'button'); + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [ + currentOnboardingCard, + handleDismiss, + hasOnboardingCards, + hasOnboardingContent, + isAnimating, + isModalLoading, + props.isOpen, + ]); + 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), @@ -1024,12 +2132,21 @@ const HotAndColdModal = ({ [ cancelHotTakeVote, currentTake, + currentOnboardingCard, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, logEvent, + onSwipeAction, + abortOnboardingIntro, ], ); + const currentCardId = hasOnboardingCards + ? currentOnboardingCard?.id + : currentTake?.id; + const handleAddOwnHotTakeClick = useCallback( (e: React.MouseEvent) => { onRequestClose?.(e); @@ -1038,10 +2155,19 @@ const HotAndColdModal = ({ ); 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); @@ -1058,9 +2184,17 @@ const HotAndColdModal = ({ const handlers = useSwipeable({ onSwiping: (e) => { if (!isAnimating) { + if (isOnboardingMode && e.event.cancelable) { + e.event.preventDefault(); + } + 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); @@ -1073,6 +2207,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 @@ -1092,118 +2233,306 @@ 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 ? ( +
{headerSlot}
+ ) : null} + {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 && ( - - )} - -
- -
- + {onboardingFooterSlot ? ( +
+ {onboardingFooterSlot}
- )} + ) : null} )} + + {!isModalLoading && + !isModalEmpty && + !isOnboardingMode && + currentCardId && ( + <> + {topSlot} + {cardSwipeArea} + {showDefaultActions && ( +
+
+ )} + {bottomSlot} + {showAddHotTakeButton && user?.username && ( +
+ +
+ )} + + )} ); diff --git a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx index c27f3403765..5ca3b95bd8a 100644 --- a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx +++ b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx @@ -2,19 +2,6 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PersonaSelector } from './PersonaSelector'; -import { - broadcastPersonaSelection, - broadcastRecommendRequest, -} from './onboardingPopBus'; - -jest.mock('./onboardingPopBus', () => { - 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 }); @@ -67,7 +54,7 @@ describe('PersonaSelector', () => { expect(screen.getByText('Backend')).toBeInTheDocument(); }); - it('follows tags and broadcasts pop + recommend on click', async () => { + it('follows tags on click', async () => { renderComponent(); fireEvent.click(await screen.findByText('Frontend')); await waitFor(() => @@ -76,8 +63,6 @@ describe('PersonaSelector', () => { requireLogin: true, }), ); - expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']); - expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']); }); it('allows multi-select without unfollowing previous persona', async () => { diff --git a/packages/shared/src/components/onboarding/PersonaSelector.tsx b/packages/shared/src/components/onboarding/PersonaSelector.tsx index 49d838436d4..2c42078b37c 100644 --- a/packages/shared/src/components/onboarding/PersonaSelector.tsx +++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx @@ -13,24 +13,33 @@ 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; +const personaButtonClassName = + 'w-full !justify-start text-left tablet:w-auto tablet:!justify-center'; + +export type PersonaSelectorMode = 'follow' | 'seed'; + interface PersonaSelectorProps { className?: string; feedId?: string; + mode?: PersonaSelectorMode; + initialActiveIds?: string[]; + onSelectionChange?: (selected: GQLPersona[]) => void; } export function PersonaSelector({ className, feedId, + initialActiveIds = [], + mode = 'follow', + onSelectionChange, }: PersonaSelectorProps): ReactElement | null { const { logEvent } = useLogContext(); - const [activeIds, setActiveIds] = useState>(new Set()); + const [activeIds, setActiveIds] = useState>( + () => new Set(initialActiveIds), + ); const { onFollowTags, onUnfollowTags } = useTagAndSource({ origin: Origin.OnboardingPersona, feedId, @@ -56,6 +65,13 @@ export function PersonaSelector({ 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; @@ -75,21 +91,25 @@ export function PersonaSelector({ }); if (isActive) { - await onUnfollowTags({ tags: persona.tags }); + 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); - await onFollowTags({ tags: persona.tags, requireLogin: true }); - broadcastRecommendRequest(persona.tags); + if (mode === 'follow') { + await onFollowTags({ tags: persona.tags, requireLogin: true }); + } setActiveIds((prev) => { const next = new Set(prev); next.add(persona.id); + emitSelection(next); return next; }); }; @@ -106,7 +126,7 @@ export function PersonaSelector({ aria-label="Pick a role to follow related tags" aria-busy={isPending} className={classNames( - 'flex w-full max-w-4xl flex-wrap justify-center gap-3', + 'flex w-full max-w-4xl flex-col gap-2 tablet:flex-row tablet:flex-wrap tablet:justify-center tablet:gap-3', className, )} > @@ -115,7 +135,7 @@ export function PersonaSelector({ ))} {!isPending && @@ -124,10 +144,10 @@ export function PersonaSelector({ const isDisabled = !isActive && isAtCap; const buttonContent = ( <> - + {persona.emoji} - {persona.title} + {persona.title} ); @@ -136,6 +156,7 @@ export function PersonaSelector({ + ) : null} + +
+ ); +} + +function SwipeOnboardingBackButton({ + onBack, +}: { + onBack: () => void; +}): ReactElement { + return ( +
+ +
+ ); +} + +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 SwipeOnboardingStarterFeedReady({ + cta, + isCompleting, + onComplete, +}: { + cta?: string; + isCompleting: boolean; + onComplete: () => void; +}): ReactElement { + return ( + <> +
+

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

+ +
+
+ +
+ + ); +} + +function SwipeOnboardingCompleteView({ + cta, + isCompleting, + onComplete, +}: { + cta?: string; + isCompleting: boolean; + onComplete: () => void; +}): ReactElement { + return ( +
+
+

+ 100% complete +

+
+ +
+ ); +} + +function FunnelSwipeOnboardingStepComponent({ + parameters: { cta }, + onTransition, +}: FunnelStepEditTags): ReactElement { + const router = useRouter(); + const { user } = useAuthContext(); + const [swipesCount, setSwipesCount] = useState(0); + const [selectedPersonas, setSelectedPersonas] = useState([]); + const [promptLoading, setPromptLoading] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); + const [isSwipeMode, setIsSwipeMode] = useState(false); + const [isIntroExiting, setIsIntroExiting] = useState(false); + const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState< + Set + >(() => new Set()); + 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(); + + 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 }); + setIsIntroExiting(true); + await waitMs(SWIPE_ONBOARDING_TRANSITION_MS); + setIsSwipeMode(true); + } finally { + setPromptLoading(false); + } + }, [promptLoading, selectedPersonas, startDeck, user?.experienceLevel]); + + 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; + const isRefineComplete = swipesCount >= SWIPE_ONBOARDING_REFINE_TARGET; + + if (!isSwipeMode) { + return ( + <> +
+ +
+
+
+ +
+ +
+ + ); + } + + const handleBackFromSwipe = (): void => { + setIsIntroExiting(false); + setIsSwipeMode(false); + }; + + const handleCompleteClick = (): void => { + handleComplete().catch(() => null); + }; + + const bottomSlot = isRefineComplete ? null : ( + <> +
+ {canContinue ? ( + + ) : null} +
+
+ + {canContinue ? ( + + ) : null} +
+ + ); + + return ( + +
+ +
+
+ +
+ + ) + } + shouldCloseOnOverlayClick={false} + dismissedOnboardingCardIds={dismissedOnboardingCardIds} + onDismissedOnboardingCardsChange={setDismissedOnboardingCardIds} + onboardingActionLayout="sides" + onboardingCards={adaptiveCards} + onboardingCardsLoading={isAdaptiveLoading} + onboardingFeedRefetching={isAdaptiveLoading} + onboardingContent={ + isRefineComplete ? ( + + ) : undefined + } + onOnboardingFeedRetry={() => { + retryFetch(); + }} + onSwipeAction={(direction, meta) => { + handleSwipeInteraction(direction, meta); + }} + topSlot={ + isRefineComplete ? undefined : ( + <> +
+ +
+
+ + +
+ + ) + } + progressSlot={ + isRefineComplete ? undefined : ( +
+ +
+ ) + } + bottomSlot={bottomSlot} + 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..1f3ae5bb870 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx @@ -0,0 +1,147 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + getSwipeOnboardingBarProgress, + SWIPE_ONBOARDING_REFINE_TARGET, +} from '../../lib/swipeOnboardingGuidance'; + +/** Keep in sync with .swipe-onboarding-progress-label in swipe-onboarding.css */ +const PROGRESS_LABEL_TRANSITION_MS = 340; + +export type SwipeOnboardingProgressHeaderProps = { + /** Swipe count and/or tag selections — same scale as onboarding swipes (0–40+). */ + progressCount: number; +}; + +function getProgressGradientStyle(fillPercent: number): CSSProperties { + const gradientProgress = Math.max(fillPercent, 0.01); + + return { + backgroundImage: `linear-gradient( + 90deg, + var(--theme-accent-cabbage-default) 0%, + var(--theme-accent-avocado-default) ${gradientProgress * 0.22}%, + var(--theme-accent-cheese-default) ${gradientProgress * 0.44}%, + var(--theme-accent-cabbage-default) ${gradientProgress * 0.66}%, + var(--theme-accent-avocado-default) ${gradientProgress * 0.88}%, + var(--theme-accent-cheese-default) ${gradientProgress}%, + var(--theme-text-quaternary) ${gradientProgress}%, + var(--theme-text-quaternary) 100% + )`, + }; +} + +function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updatePreference = (): void => { + setPrefersReducedMotion(mediaQuery.matches); + }; + + updatePreference(); + mediaQuery.addEventListener('change', updatePreference); + + return () => mediaQuery.removeEventListener('change', updatePreference); + }, []); + + return prefersReducedMotion; +} + +function useAnimatedProgressPercent( + targetPercent: number, + prefersReducedMotion: boolean, +): number { + const [displayPercent, setDisplayPercent] = useState(targetPercent); + const displayPercentRef = useRef(displayPercent); + displayPercentRef.current = displayPercent; + + useEffect(() => { + if (prefersReducedMotion) { + setDisplayPercent(targetPercent); + return undefined; + } + + const startPercent = displayPercentRef.current; + if (startPercent === targetPercent) { + return undefined; + } + + let animationFrame = 0; + const startTime = performance.now(); + + const tick = (now: number): void => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / PROGRESS_LABEL_TRANSITION_MS, 1); + const eased = 1 - (1 - progress) ** 3; + const nextPercent = Math.round( + startPercent + (targetPercent - startPercent) * eased, + ); + + setDisplayPercent(nextPercent); + + if (progress < 1) { + animationFrame = window.requestAnimationFrame(tick); + } + }; + + animationFrame = window.requestAnimationFrame(tick); + + return () => window.cancelAnimationFrame(animationFrame); + }, [prefersReducedMotion, targetPercent]); + + return displayPercent; +} + +const progressLabelClassName = + 'col-start-1 row-start-1 inline-block max-w-full whitespace-nowrap bg-clip-text text-[min(2rem,7vw)] font-black leading-[1.05] tracking-[-0.05em] text-transparent tablet:text-[2.25rem]'; + +export function SwipeOnboardingProgressHeader({ + progressCount, +}: SwipeOnboardingProgressHeaderProps): ReactElement { + const progress = getSwipeOnboardingBarProgress(progressCount); + const progressValue = Math.min(progressCount, SWIPE_ONBOARDING_REFINE_TARGET); + const targetPercent = Math.round(progress); + const isComplete = progressValue >= SWIPE_ONBOARDING_REFINE_TARGET; + const prefersReducedMotion = usePrefersReducedMotion(); + const displayPercent = useAnimatedProgressPercent( + targetPercent, + prefersReducedMotion, + ); + const progressAnnouncement = isComplete + ? '100% complete' + : `${displayPercent}% to complete`; + + return ( +
+ {progressAnnouncement} +

+ {displayPercent}% to complete +

+

+ 100% complete +

+
+ ); +} diff --git a/packages/webapp/components/onboarding/SwipePersonaIntro.tsx b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx new file mode 100644 index 00000000000..837cf4e2e49 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx @@ -0,0 +1,267 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MAX_PERSONAS, + PersonaSelector, +} from '@dailydotdev/shared/src/components/onboarding/PersonaSelector'; +import { MagicIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { RootPortal } from '@dailydotdev/shared/src/components/tooltips/Portal'; +import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings'; + +interface SwipePersonaIntroProps { + initialSelectedPersonas?: GQLPersona[]; + onSelectionChange: (selected: GQLPersona[]) => void; + onStart: () => void; + loading: boolean; + loadingLabel?: string; +} + +const surfaceClassName = + 'w-full overflow-hidden rounded-[2rem] bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]'; + +const panelClassName = 'overflow-hidden'; + +const floatingEmojiPlacements = [ + { + className: 'left-3 top-[18%]', + tx: '4rem', + ty: '-3rem', + rz: '-18deg', + ry: '32deg', + }, + { + className: 'right-3 top-[24%]', + tx: '-4.5rem', + ty: '-2rem', + rz: '18deg', + ry: '-34deg', + }, + { + className: 'bottom-[14%] left-6', + tx: '5rem', + ty: '-4.5rem', + rz: '12deg', + ry: '28deg', + }, + { + className: 'bottom-[18%] right-6', + tx: '-5rem', + ty: '-4rem', + rz: '-12deg', + ry: '-28deg', + }, +] as const; + +type FloatingEmoji = { + id: string; + emoji: string; + placementIndex: number; +}; + +type SwipePersonaIntroHeadingVariant = 'persona' | 'swipe'; + +const swipePersonaIntroHeadingCopy: Record< + SwipePersonaIntroHeadingVariant, + { + eyebrow: string; + title: string; + description: ReactElement; + } +> = { + persona: { + eyebrow: 'Personalize your daily.dev feed', + title: 'What kind of dev are you?', + description: ( + <> + Pick up to 3 roles. +
+ We'll line up posts you'll actually want to read. + + ), + }, + swipe: { + eyebrow: 'Teach us what you like', + title: 'Swipe to tune your feed', + description: ( + <> + Swipe right → on posts you want more of. +
+ Swipe left ← on posts that are not for you. + + ), + }, +}; + +export function SwipePersonaIntroHeading({ + variant = 'persona', +}: { + variant?: SwipePersonaIntroHeadingVariant; +}): ReactElement { + const copy = swipePersonaIntroHeadingCopy[variant]; + + return ( +
+ + + {copy.eyebrow} + +

+ {copy.title} +

+

+ {copy.description} +

+
+ ); +} + +export function SwipePersonaIntro({ + initialSelectedPersonas = [], + onSelectionChange, + onStart, + loading, + loadingLabel, +}: SwipePersonaIntroProps): ReactElement { + const [selectedCount, setSelectedCount] = useState( + initialSelectedPersonas.length, + ); + const [floatingEmojis, setFloatingEmojis] = useState([]); + const selectedPersonasRef = useRef>( + new Set(initialSelectedPersonas.map((persona) => persona.id)), + ); + const floatingEmojiCountRef = useRef(0); + const cleanupTimersRef = useRef([]); + const shouldShowActions = selectedCount >= MAX_PERSONAS; + const handleSelectionChange = useCallback( + (selected: GQLPersona[]) => { + const previousIds = selectedPersonasRef.current; + const addedPersona = selected.find( + (persona) => !previousIds.has(persona.id), + ); + selectedPersonasRef.current = new Set( + selected.map((persona) => persona.id), + ); + + if (addedPersona) { + const count = floatingEmojiCountRef.current; + floatingEmojiCountRef.current += 1; + const floatingEmoji: FloatingEmoji = { + id: `${addedPersona.id}-${count}`, + emoji: addedPersona.emoji, + placementIndex: count % floatingEmojiPlacements.length, + }; + + setFloatingEmojis((current) => [...current, floatingEmoji]); + const timer = window.setTimeout(() => { + setFloatingEmojis((current) => + current.filter((item) => item.id !== floatingEmoji.id), + ); + }, 3200); + cleanupTimersRef.current.push(timer); + } + + setSelectedCount(selected.length); + onSelectionChange(selected); + }, + [onSelectionChange], + ); + + useEffect(() => { + const timersRef = cleanupTimersRef; + return () => { + timersRef.current.forEach((timer) => window.clearTimeout(timer)); + }; + }, []); + + const renderNextButton = (): ReactElement => ( + + ); + + return ( +
+ {floatingEmojis.length > 0 && ( +
+ {floatingEmojis.map((item) => { + const placement = floatingEmojiPlacements[item.placementIndex]; + + return ( + + {item.emoji} + + ); + })} +
+ )} +
+ +
+
+ persona.id, + )} + mode="seed" + onSelectionChange={handleSelectionChange} + /> +
+ {shouldShowActions ? ( + <> + +
+
+ {renderNextButton()} +
+
+
+
+ {renderNextButton()} +
+ + ) : null} +
+
+
+ ); +} diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts new file mode 100644 index 00000000000..ff7a63aa5b1 --- /dev/null +++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts @@ -0,0 +1,349 @@ +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'; +import { fetchSwipeOnboardingPopularDeck } from '../lib/swipeOnboardingPopularDeck'; + +// 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, + }; +} + +function postToSummary(post: Post): PostSummary { + const title = post.title ?? ''; + return { + postId: post.id, + title, + summary: post.summary ?? title, + tags: post.tags ?? [], + url: post.permalink ?? post.commentsPermalink ?? '', + sourceId: post.source?.id ?? '', + }; +} + +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[]; +} + +export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck { + const [cards, setCards] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + + // Refs for mutable state that persists across batches + const tagScoresRef = useRef>({}); + const tagSeenCountRef = useRef>({}); + const seenIdsRef = useRef>(new Set()); + const likedTitlesRef = useRef([]); + const startDeckOptionsRef = useRef(undefined); + 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 registerPosts = useCallback((posts: PostSummary[]): void => { + posts.forEach((post) => { + seenIdsRef.current.add(post.postId); + postLookupRef.current.set(post.postId, post); + }); + }, []); + + const fetchPopularPosts = useCallback( + async (limit: number): Promise => { + const popularPosts = await fetchSwipeOnboardingPopularDeck(); + return popularPosts + .filter((post) => !seenIdsRef.current.has(post.id)) + .slice(0, limit) + .map(postToSummary); + }, + [], + ); + + const fetchAdaptiveOrPopular = useCallback( + async ( + fetchAdaptive: () => Promise, + limit = BATCH_SIZE, + ): Promise => { + try { + const adaptivePosts = await fetchAdaptive(); + if (adaptivePosts.length > 0) { + registerPosts(adaptivePosts); + return adaptivePosts; + } + } catch { + // Fall through to the popular deck when adaptive discovery fails. + } + + try { + const popularPosts = await fetchPopularPosts(limit); + registerPosts(popularPosts); + return popularPosts; + } catch { + return []; + } + }, + [fetchPopularPosts, registerPosts], + ); + + const doFetch = useCallback( + async (n = BATCH_SIZE): Promise => { + return fetchAdaptiveOrPopular(async () => { + const result = await discoverPosts({ + selectedTags: selectedTagsRef.current, + confirmedTags: getConfirmedTags(), + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n, + }); + return result.posts; + }, n); + }, + [fetchAdaptiveOrPopular, getConfirmedTags, getSaturatedTags], + ); + + 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 { + if (options) { + startDeckOptionsRef.current = options; + } + // 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; + } + const posts = await fetchAdaptiveOrPopular(async () => { + const result = await discoverPosts({ + prompt: options?.prompt ?? '', + selectedTags: selectedTagsRef.current, + likedTitles: likedTitlesRef.current, + excludeIds: [...seenIdsRef.current], + saturatedTags: getSaturatedTags(), + n: BATCH_SIZE, + }); + return result.posts; + }); + loadBatch(posts); + } finally { + setIsLoading(false); + isFetchingRef.current = false; + } + }, + [fetchAdaptiveOrPopular, getSaturatedTags, loadBatch], + ); + + const retryFetch = useCallback(async () => { + await startDeck(startDeckOptionsRef.current); + }, [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); + } + + // --- 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, + }; +} diff --git a/packages/webapp/lib/buildSwipePrompt.spec.ts b/packages/webapp/lib/buildSwipePrompt.spec.ts new file mode 100644 index 00000000000..fc1827d5ce4 --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.spec.ts @@ -0,0 +1,89 @@ +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."); + }); + + it('does not duplicate engineer for engineering persona titles', () => { + expect( + buildSwipePrompt({ + personas: [ + { + ...frontend, + title: 'Site Reliability Engineer', + tags: [], + }, + ], + }), + ).toBe("I'm a site reliability engineer."); + }); + + it('preserves engineer in multi-persona titles without stripping other roles', () => { + expect( + buildSwipePrompt({ + personas: [ + { + ...frontend, + title: 'Site Reliability Engineer', + tags: [], + }, + backend, + ], + }), + ).toBe( + "I work across site reliability engineer and backend. I'm interested in: node, typescript, sql.", + ); + }); +}); diff --git a/packages/webapp/lib/buildSwipePrompt.ts b/packages/webapp/lib/buildSwipePrompt.ts new file mode 100644 index 00000000000..52a7d66cd9c --- /dev/null +++ b/packages/webapp/lib/buildSwipePrompt.ts @@ -0,0 +1,39 @@ +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)); + +const normalizeRoleLabel = (role: string): string => + role.toLowerCase().replace(/\s+engineer$/, ''); + +export function buildSwipePrompt({ + personas, + experienceLevel, +}: BuildSwipePromptArgs): string { + if (!personas?.length) { + return ''; + } + + const roleClause = + personas.length === 1 + ? `I'm a ${normalizeRoleLabel(personas[0].title)} engineer` + : `I work across ${personas + .map((p) => p.title.toLowerCase()) + .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/swipeOnboardingEligiblePosts.spec.ts b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts new file mode 100644 index 00000000000..54e7f8ab0e6 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts @@ -0,0 +1,98 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { PostType } from '@dailydotdev/shared/src/types'; +import { + isSwipeOnboardingEligiblePost, + isSwipeOnboardingRelaxedEligiblePost, +} from './swipeOnboardingEligiblePosts'; + +function post( + type: PostType, + sourceType: SourceType, +): Pick { + return { + type, + source: { + id: 's', + type: sourceType, + } as Post['source'], + }; +} + +describe('isSwipeOnboardingEligiblePost', () => { + it('accepts article, video, and poll from machine sources', () => { + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.Machine)), + ).toBe(true); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.VideoYouTube, SourceType.Machine), + ), + ).toBe(true); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Poll, SourceType.Machine)), + ).toBe(true); + }); + + it('rejects non-machine sources', () => { + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.Squad)), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Article, SourceType.User)), + ).toBe(false); + }); + + it('rejects collection, share, freeform, and social posts even from machine', () => { + expect( + isSwipeOnboardingEligiblePost( + post(PostType.Collection, SourceType.Machine), + ), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost(post(PostType.Share, SourceType.Machine)), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.Freeform, SourceType.Machine), + ), + ).toBe(false); + expect( + isSwipeOnboardingEligiblePost( + post(PostType.SocialTwitter, SourceType.Machine), + ), + ).toBe(false); + }); + + it('rejects when source is missing', () => { + expect( + isSwipeOnboardingEligiblePost({ + type: PostType.Article, + source: undefined, + }), + ).toBe(false); + }); +}); + +describe('isSwipeOnboardingRelaxedEligiblePost', () => { + it('accepts article types from any source', () => { + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Article, SourceType.Squad), + ), + ).toBe(true); + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Article, SourceType.User), + ), + ).toBe(true); + }); + + it('rejects types outside onboarding feed set', () => { + expect( + isSwipeOnboardingRelaxedEligiblePost( + post(PostType.Share, SourceType.Machine), + ), + ).toBe(false); + }); +}); diff --git a/packages/webapp/lib/swipeOnboardingEligiblePosts.ts b/packages/webapp/lib/swipeOnboardingEligiblePosts.ts new file mode 100644 index 00000000000..30e6e9909a8 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingEligiblePosts.ts @@ -0,0 +1,40 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import { PostType } from '@dailydotdev/shared/src/types'; + +/** Post types we request for swipe onboarding (no shares, collections, freeform, etc.). */ +export const SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES: readonly PostType[] = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Poll, +]; + +const swipeOnboardingEligibleTypes: ReadonlySet = new Set( + SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES, +); + +/** + * Swipe onboarding should only surface publication (machine source) posts — not squads, + * user sources, collections, shares, or freeform. + */ +export function isSwipeOnboardingEligiblePost( + post: Pick, +): boolean { + if (!post.source || post.source.type !== SourceType.Machine) { + return false; + } + return swipeOnboardingEligibleTypes.has(post.type); +} + +/** + * Same allowed post types as strict onboarding, any source — used to pad the deck when + * machine-only results are thin (still excludes types not returned by the feed query). + */ +export function isSwipeOnboardingRelaxedEligiblePost( + post: Pick, +): boolean { + if (!post.source) { + return false; + } + return swipeOnboardingEligibleTypes.has(post.type); +} diff --git a/packages/webapp/lib/swipeOnboardingGuidance.spec.ts b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts new file mode 100644 index 00000000000..3f4eb3cad89 --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts @@ -0,0 +1,19 @@ +import { getSwipeOnboardingBarProgress } from './swipeOnboardingGuidance'; + +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..5a2620baf6c --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.ts @@ -0,0 +1,19 @@ +/** 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; +} diff --git a/packages/webapp/lib/swipeOnboardingPopularDeck.ts b/packages/webapp/lib/swipeOnboardingPopularDeck.ts new file mode 100644 index 00000000000..e54f0fdce6a --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingPopularDeck.ts @@ -0,0 +1,107 @@ +import type { Connection } from '@dailydotdev/shared/src/graphql/common'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { MOST_UPVOTED_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { + isSwipeOnboardingEligiblePost, + isSwipeOnboardingRelaxedEligiblePost, + SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES, +} from './swipeOnboardingEligiblePosts'; + +const PAGE_SIZE = 50; +const MAX_PAGES = 12; +const TARGET_STRICT_COUNT = 40; +/** After paging, if strict (machine) posts are below this, append relaxed same-type posts. */ +const STRICT_MIN_BEFORE_RELAXED = 24; +const MAX_DECK_POSTS = 100; + +type MostUpvotedFeedResponse = { + page?: Connection; +}; + +type SwipeOnboardingDeckState = { + seenIds: Set; + strictPosts: Post[]; + relaxedPosts: Post[]; +}; + +function appendEligiblePosts( + edges: NonNullable['edges']>, + state: SwipeOnboardingDeckState, +): void { + edges.reduce((acc, { node }) => { + if (acc.seenIds.has(node.id)) { + return acc; + } + + acc.seenIds.add(node.id); + + if (isSwipeOnboardingEligiblePost(node)) { + acc.strictPosts.push(node); + return acc; + } + + if (isSwipeOnboardingRelaxedEligiblePost(node)) { + acc.relaxedPosts.push(node); + } + + return acc; + }, state); +} + +async function collectSwipeOnboardingPosts( + state: SwipeOnboardingDeckState, + pages = 0, + cursor?: string, +): Promise { + if (pages >= MAX_PAGES) { + return; + } + + const data = await gqlClient.request( + MOST_UPVOTED_FEED_QUERY, + { + first: PAGE_SIZE, + period: 30, + ...(cursor !== undefined ? { after: cursor } : {}), + supportedTypes: [...SWIPE_ONBOARDING_FEED_SUPPORTED_TYPES], + }, + ); + + const conn = data.page; + const edges = conn?.edges ?? []; + + appendEligiblePosts(edges, state); + + const hasNext = conn?.pageInfo?.hasNextPage === true; + const endCursor = conn?.pageInfo?.endCursor ?? undefined; + const shouldStop = + state.strictPosts.length >= TARGET_STRICT_COUNT || !hasNext || !endCursor; + + if (shouldStop) { + return; + } + + await collectSwipeOnboardingPosts(state, pages + 1, endCursor); +} + +/** + * Paginates mostUpvotedFeed for swipe onboarding: prefers machine-sourced posts, then + * fills with the same post types from any source when the strict list is short. + */ +export async function fetchSwipeOnboardingPopularDeck(): Promise { + const state: SwipeOnboardingDeckState = { + seenIds: new Set(), + strictPosts: [], + relaxedPosts: [], + }; + + await collectSwipeOnboardingPosts(state); + + if (state.strictPosts.length >= STRICT_MIN_BEFORE_RELAXED) { + return state.strictPosts.slice(0, MAX_DECK_POSTS); + } + + const merged = [...state.strictPosts, ...state.relaxedPosts]; + return merged.slice(0, MAX_DECK_POSTS); +} 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 cac9d520332..1d1379c0a7d 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -119,6 +119,7 @@ const mainFeedPathnames = new Set([ const hotAndColdModalQueryKey = 'openModal'; const hotAndColdModalQueryValue = 'hottakes'; const hotAndColdModalLegacyQueryValue = 'hotAndCold'; +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; const isOnboardingExcludedPath = (pathname: string): boolean => onboardingExcludedPaths.some((path) => pathname.startsWith(path)); @@ -198,6 +199,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) { @@ -246,7 +255,10 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { return; } - router.replace('/onboarding'); + const destination = isSwipeOnboardingPreviewForced + ? '/onboarding?swipeOnboardingPreview=1' + : '/onboarding'; + router.replace(destination); // `router.pathname` is depended on explicitly because the `router` ref is // stable across in-app navigations. }, [ @@ -256,6 +268,7 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { router.pathname, isOnboardingComplete, isInlineLoginEnabled, + isSwipeOnboardingPreviewForced, ]); useEffect(() => { diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index fd07902fa3e..e1c8f2d2db3 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -26,6 +26,8 @@ import { 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 { swipeOnboardingFeature } from '@dailydotdev/shared/src/lib/featureManagement'; import type { AuthOptionsProps, AuthProps, @@ -62,7 +64,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 seoTitles = getPageSeoTitles('Get started'); @@ -78,6 +82,13 @@ type PageProps = { showCookieBanner?: boolean; }; +const isSwipeOnboardingPreviewQueryForced = ( + query: string | string[] | undefined, +): boolean => + query === '1' || + query === 'true' || + (Array.isArray(query) && (query.includes('1') || query.includes('true'))); + export const getServerSideProps: GetServerSideProps = async ({ query, req, @@ -158,14 +169,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; @@ -239,8 +251,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, @@ -268,7 +282,7 @@ const useOnboardingAuth = () => { }; }; -function Onboarding({ initialStepId }: PageProps): ReactElement { +function Onboarding({ initialStepId }: PageProps): ReactElement | null { const router = useRouter(); const { isAuthenticating, @@ -280,6 +294,30 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { const { isOnboardingComplete, isOnboardingActionsReady, completeStep } = useOnboardingActions(); const [isFunnelReady, setFunnelReady] = useState(false); + const { value: isSwipeOnboardingEnabled } = useConditionalFeature({ + feature: swipeOnboardingFeature, + shouldEvaluate: isAuthReady && !!funnelState, + }); + const swipeOnboardingPreviewQuery = router.query.swipeOnboardingPreview; + const isSwipeOnboardingPreviewForced = isSwipeOnboardingPreviewQueryForced( + swipeOnboardingPreviewQuery, + ); + const stepComponentOverrides = useMemo( + () => + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? { + [FunnelStepType.EditTags]: FunnelSwipeOnboardingStep, + } + : undefined, + [isSwipeOnboardingEnabled, isSwipeOnboardingPreviewForced], + ); + const swipeOnboardingStepId = useMemo( + () => + funnelState?.funnel.chapters + .flatMap((chapter) => chapter.steps) + .find((step) => step.type === FunnelStepType.EditTags)?.id, + [funnelState?.funnel.chapters], + ); const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); @@ -307,7 +345,7 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { return; } - if (isOnboardingComplete) { + if (isOnboardingComplete && !isSwipeOnboardingPreviewForced) { // If the user is logged in and has completed the onboarding steps, // AND no active stepId is there, redirect them to app. redirectToApp(router); @@ -322,6 +360,7 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isAuthReady, isAuthenticating, isFunnelReady, + isSwipeOnboardingPreviewForced, isLoggedIn, isOnboardingActionsReady, router, @@ -343,17 +382,22 @@ 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..674206a97ee --- /dev/null +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -0,0 +1,20 @@ +import type { GetServerSideProps } from 'next'; + +/** Legacy URL: keep redirecting old `/onboarding/swipe` links into the funnel. */ +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; +}