diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 6e1ba91063a..bccdd76faf6 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -33,8 +33,6 @@ import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner'; import usePlusEntry from '../hooks/usePlusEntry'; import { SearchProvider } from '../contexts/search/SearchContext'; import { FeedbackWidget } from './feedback'; -import { useConditionalFeature } from '../hooks/useConditionalFeature'; -import { swipeOnboardingFeature } from '../lib/featureManagement'; const GoBackHeaderMobile = dynamic( () => @@ -99,10 +97,6 @@ function MainLayoutComponent({ const { isNotificationsReady, unreadCount } = useNotificationContext(); const isPageReady = (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; - const { value: isSwipeOnboardingEnabled } = useConditionalFeature({ - feature: swipeOnboardingFeature, - shouldEvaluate: !user, - }); const swipeOnboardingPreviewQuery = router.query[swipeOnboardingPreviewQueryKey]; const isSwipeOnboardingPreviewForced = @@ -142,9 +136,7 @@ function MainLayoutComponent({ if (entries.length === 0) { router.push( - isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced - ? swipeOnboardingUrl - : onboardingUrl, + isSwipeOnboardingPreviewForced ? swipeOnboardingUrl : onboardingUrl, ); return; } @@ -155,17 +147,11 @@ function MainLayoutComponent({ params.append(key, value as string); }); - const destination = - isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced - ? swipeOnboardingUrl - : onboardingUrl; + const destination = isSwipeOnboardingPreviewForced + ? swipeOnboardingUrl + : onboardingUrl; router.push(`${destination}?${params.toString()}`); - }, [ - isSwipeOnboardingEnabled, - isSwipeOnboardingPreviewForced, - shouldRedirectOnboarding, - router, - ]); + }, [isSwipeOnboardingPreviewForced, shouldRedirectOnboarding, router]); const ignoredUtmMediumForLogin = ['slack']; const utmSource = router?.query?.utm_source; diff --git a/packages/shared/src/components/filters/MyFeedHeading.spec.tsx b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx new file mode 100644 index 00000000000..64f9976a271 --- /dev/null +++ b/packages/shared/src/components/filters/MyFeedHeading.spec.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useActiveFeedNameContext } from '../../contexts'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks'; +import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser'; +import { ActionType } from '../../graphql/actions'; +import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings'; +import { SharedFeedPage } from '../utilities'; +import MyFeedHeading from './MyFeedHeading'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../contexts', () => ({ + useActiveFeedNameContext: jest.fn(), +})); + +jest.mock('../../contexts/SettingsContext', () => ({ + useSettingsContext: jest.fn(), +})); + +jest.mock('../../hooks', () => ({ + useActions: jest.fn(), + useFeedLayout: jest.fn(), + useViewSize: jest.fn(), + ViewSize: { + MobileL: 'mobile', + Laptop: 'laptop', + }, +})); + +jest.mock('../../features/shortcuts/hooks/useShortcutsUser', () => ({ + useShortcutsUser: jest.fn(), +})); + +jest.mock('../../hooks/feed/useCustomDefaultFeed', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../AlertDot', () => ({ + AlertDot: ({ className }: { className?: string }) => ( +
+ ), + AlertColor: { Bun: 'bg-accent-bun-default' }, +})); + +jest.mock('../feeds/FeedSettingsButton', () => ({ + FeedSettingsButton: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick: () => void; + }) => ( + + ), +})); + +jest.mock('../../lib/constants', () => ({ + ...jest.requireActual('../../lib/constants'), + webappUrl: 'https://app.daily.dev/', + settingsUrl: 'https://app.daily.dev/settings', +})); + +jest.mock('../../lib/feedSettings', () => ({ + getHasSeenTags: jest.fn(), + setHasSeenTags: jest.fn(), +})); + +const mockUseRouter = useRouter as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; +const mockUseActiveFeedNameContext = useActiveFeedNameContext as jest.Mock; +const mockUseSettingsContext = useSettingsContext as jest.Mock; +const mockUseActions = useActions as jest.Mock; +const mockUseFeedLayout = useFeedLayout as jest.Mock; +const mockUseViewSize = useViewSize as jest.Mock; +const mockUseShortcutsUser = useShortcutsUser as jest.Mock; +const mockUseCustomDefaultFeed = useCustomDefaultFeed as jest.Mock; +const mockGetHasSeenTags = getHasSeenTags as jest.Mock; +const mockSetHasSeenTags = setHasSeenTags as jest.Mock; + +const push = jest.fn(); +const completeAction = jest.fn(); + +const renderComponent = () => render(); + +describe('MyFeedHeading', () => { + beforeEach(() => { + push.mockReset(); + push.mockResolvedValue(true); + completeAction.mockReset(); + completeAction.mockResolvedValue(undefined); + mockGetHasSeenTags.mockReset(); + mockGetHasSeenTags.mockReturnValue(null); + mockSetHasSeenTags.mockReset(); + + mockUseRouter.mockReturnValue({ + push, + pathname: '/', + query: {}, + }); + mockUseAuthContext.mockReturnValue({ + user: { id: 'user-1' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.MyFeed, + }); + mockUseSettingsContext.mockReturnValue({ + toggleShowTopSites: jest.fn(), + }); + mockUseActions.mockReturnValue({ + completeAction, + checkHasCompleted: jest.fn().mockReturnValue(false), + isActionsFetched: true, + }); + mockUseFeedLayout.mockReturnValue({ + shouldUseListFeedLayout: false, + }); + mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); + mockUseShortcutsUser.mockReturnValue({ + isOldUserWithNoShortcuts: false, + showToggleShortcuts: false, + }); + mockUseCustomDefaultFeed.mockReturnValue({ + isCustomDefaultFeed: false, + defaultFeedId: 'user-1', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('routes the home custom default feed to its edit page', async () => { + mockUseCustomDefaultFeed.mockReturnValue({ + isCustomDefaultFeed: true, + defaultFeedId: 'feed-1', + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/feed-1/edit', + ); + }); + + it('routes the home For you feed to the user edit page with the tags tab open', async () => { + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); + + it('routes the For you feed to the user edit page with the tags tab open', async () => { + mockUseRouter.mockReturnValue({ + push, + pathname: '/my-feed', + query: {}, + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); + + it('routes custom feeds to their slug or id edit page', async () => { + mockUseRouter.mockReturnValue({ + push, + pathname: '/feeds/[slugOrId]', + query: { slugOrId: 'feed-2' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.Custom, + }); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/feed-2/edit', + ); + }); + + it('shows the tags reminder dot for the For you feed when tags were not seen yet', () => { + mockGetHasSeenTags.mockReturnValue(false); + + renderComponent(); + + expect(screen.getByTestId('alert-dot')).toBeInTheDocument(); + }); + + it('does not show the tags reminder dot for custom feeds', () => { + mockGetHasSeenTags.mockReturnValue(false); + mockUseRouter.mockReturnValue({ + push, + pathname: '/feeds/[slugOrId]', + query: { slugOrId: 'feed-2' }, + }); + mockUseActiveFeedNameContext.mockReturnValue({ + feedName: SharedFeedPage.Custom, + }); + + renderComponent(); + + expect(screen.queryByTestId('alert-dot')).not.toBeInTheDocument(); + }); + + it('marks tags as seen before navigating from the For you feed settings button', async () => { + mockGetHasSeenTags.mockReturnValue(false); + + renderComponent(); + + await userEvent.click( + screen.getByRole('button', { name: 'Feed settings' }), + ); + + expect(mockSetHasSeenTags).toHaveBeenCalledWith('user-1', true); + expect(completeAction).toHaveBeenCalledWith(ActionType.HasSeenTags); + expect(push).toHaveBeenCalledWith( + 'https://app.daily.dev/feeds/user-1/edit?dview=tags', + ); + }); +}); diff --git a/packages/shared/src/components/filters/MyFeedHeading.tsx b/packages/shared/src/components/filters/MyFeedHeading.tsx index 70f9a0d5cc9..333976ec477 100644 --- a/packages/shared/src/components/filters/MyFeedHeading.tsx +++ b/packages/shared/src/components/filters/MyFeedHeading.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/router'; import { FilterIcon, PlusIcon } from '../icons'; import { @@ -12,9 +12,13 @@ import { useActions, useFeedLayout, useViewSize, ViewSize } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { FeedSettingsButton } from '../feeds/FeedSettingsButton'; +import { AlertColor, AlertDot } from '../AlertDot'; +import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; import { useShortcutsUser } from '../../features/shortcuts/hooks/useShortcutsUser'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; +import { useAuthContext } from '../../contexts/AuthContext'; import { settingsUrl, webappUrl } from '../../lib/constants'; +import { getHasSeenTags, setHasSeenTags } from '../../lib/feedSettings'; import { SharedFeedPage } from '../utilities'; import { useActiveFeedNameContext } from '../../contexts'; @@ -26,7 +30,7 @@ function MyFeedHeading({ onOpenFeedFilters, }: MyFeedHeadingProps): ReactElement { const { push, pathname, query } = useRouter(); - const { completeAction } = useActions(); + const { completeAction, checkHasCompleted, isActionsFetched } = useActions(); const { toggleShowTopSites } = useSettingsContext(); const { isOldUserWithNoShortcuts, showToggleShortcuts } = useShortcutsUser(); const isMobile = useViewSize(ViewSize.MobileL); @@ -34,38 +38,90 @@ function MyFeedHeading({ const isLaptop = useViewSize(ViewSize.Laptop); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const { feedName } = useActiveFeedNameContext(); + const { user } = useAuthContext(); + const [hasSeenTagsState, setHasSeenTagsState] = useState( + null, + ); + + const hasSeenTagsAction = + isActionsFetched && checkHasCompleted(ActionType.HasSeenTags); const editFeedUrl = useMemo(() => { if (isCustomDefaultFeed && pathname === '/') { return `${webappUrl}feeds/${defaultFeedId}/edit`; } + if (feedName === SharedFeedPage.MyFeed && user?.id) { + return `${webappUrl}feeds/${user.id}/edit?dview=${FeedSettingsMenu.Tags}`; + } + if (feedName === SharedFeedPage.Custom) { return `${webappUrl}feeds/${query.slugOrId}/edit`; } return `${settingsUrl}/feed/general`; - }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query]); + }, [defaultFeedId, feedName, isCustomDefaultFeed, pathname, query, user?.id]); + + useEffect(() => { + if (!user?.id) { + setHasSeenTagsState(null); + return; + } + + if (hasSeenTagsAction) { + setHasSeenTags(user.id, true); + setHasSeenTagsState(true); + return; + } + + setHasSeenTagsState(getHasSeenTags(user.id)); + }, [hasSeenTagsAction, user?.id]); + + const shouldShowTagsReminder = + feedName === SharedFeedPage.MyFeed && hasSeenTagsState === false; const onClick = useCallback(() => { + if (shouldShowTagsReminder && user?.id) { + setHasSeenTags(user.id, true); + setHasSeenTagsState(true); + completeAction(ActionType.HasSeenTags).catch(() => null); + } + onOpenFeedFilters?.(); return push(editFeedUrl); - }, [editFeedUrl, onOpenFeedFilters, push]); + }, [ + completeAction, + editFeedUrl, + onOpenFeedFilters, + push, + shouldShowTagsReminder, + user?.id, + ]); return ( <> - } - iconPosition={ - shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined - } - > - {!isMobile ? 'Feed settings' : null} - +
+ } + iconPosition={ + shouldUseListFeedLayout + ? ButtonIconPosition.Right + : ButtonIconPosition.Left + } + > + {!isMobile ? 'Feed settings' : null} + + {shouldShowTagsReminder && ( + + )} +
{showToggleShortcuts && (
} + bottomSlot={
Starter feed ready
} + />, + ); + + const modalBody = document.querySelector('section'); + expect(modalBody).toHaveClass('overflow-y-auto', 'overflow-x-hidden'); + expect(modalBody).not.toHaveClass( + 'tablet:!overflow-x-visible', + 'tablet:!overflow-y-visible', + ); + + expect( + screen.getByRole('button', { name: 'Not interesting' }), + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Interesting' })).toBeVisible(); + expect( + screen.getByRole('img', { name: 'daily.dev source icon' }), + ).toBeVisible(); + expect(screen.getByText('Starter feed ready')).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index d58821bd584..6dc4d4f5875 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -31,6 +31,7 @@ import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; import { Loader } from '../../Loader'; +import LogoIcon from '../../../svg/LogoIcon'; import type { HotTake } from '../../../graphql/user/userHotTake'; const SWIPE_THRESHOLD = 80; @@ -54,9 +55,9 @@ const HOT_TAKE_CARD_HEIGHT = '28rem'; /** Title3 Γ— 3 lines (typo-title3 line-height 1.625rem in tailwind/typography.ts). */ const ONBOARDING_CARD_TITLE_MIN_HEIGHT = '4.875rem'; /** Fixed onboarding post card (source + 3-line title + 4:3 image + padding). */ -const ONBOARDING_POST_CARD_HEIGHT = '24rem'; +const ONBOARDING_POST_CARD_HEIGHT = 'clamp(19.5rem, 42dvh, 24rem)'; /** Swipe stack area: card height plus back-card vertical offset (8px). */ -const ONBOARDING_SWIPE_AREA_HEIGHT = '24.5rem'; +const ONBOARDING_SWIPE_AREA_HEIGHT = `calc(${ONBOARDING_POST_CARD_HEIGHT} + 0.5rem)`; const smoothstep01 = (t: number): number => { const x = Math.min(Math.max(t, 0), 1); @@ -453,6 +454,72 @@ const OnboardingCardBehindParticles = (): ReactElement => ( ); +const OnboardingSwipeHintButton = ({ + deltaX, + direction, + disabled, + onClick, +}: { + deltaX: number; + direction: 'left' | 'right'; + disabled: boolean; + onClick: () => void; +}): ReactElement => { + const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); + const isLeftDirection = direction === 'left'; + let visualStrength = 0; + if (isLeftDirection && deltaX < 0) { + visualStrength = swipeVisualIntensity; + } + if (!isLeftDirection && deltaX > 0) { + visualStrength = swipeVisualIntensity; + } + const accentColor = isLeftDirection + ? 'var(--theme-accent-bacon-default)' + : 'var(--theme-accent-avocado-default)'; + const isEmphasized = visualStrength > 0; + const restingClassName = isLeftDirection + ? 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-bacon-default enabled:hover:text-accent-bacon-default enabled:focus-visible:border-accent-bacon-default enabled:focus-visible:text-accent-bacon-default enabled:active:border-accent-bacon-default enabled:active:text-accent-bacon-default' + : 'border-border-subtlest-secondary text-text-secondary enabled:hover:border-accent-avocado-default enabled:hover:text-accent-avocado-default enabled:focus-visible:border-accent-avocado-default enabled:focus-visible:text-accent-avocado-default enabled:active:border-accent-avocado-default enabled:active:text-accent-avocado-default'; + + return ( + + ); +}; + const OnboardingSwipeHintIcons = ({ deltaX, disabled, @@ -464,81 +531,20 @@ const OnboardingSwipeHintIcons = ({ onNotInteresting: () => void; onInteresting: () => void; }): ReactElement => { - const swipeVisualIntensity = Math.min(Math.abs(deltaX) / SWIPE_THRESHOLD, 1); - const leftVisualStrength = deltaX < 0 ? swipeVisualIntensity : 0; - const rightVisualStrength = deltaX > 0 ? swipeVisualIntensity : 0; - - const leftAccentColor = 'var(--theme-accent-bacon-default)'; - const rightAccentColor = 'var(--theme-accent-avocado-default)'; - const leftSwipeEmphasized = leftVisualStrength > 0; - const rightSwipeEmphasized = rightVisualStrength > 0; - return (
- - + />
); }; @@ -1322,6 +1328,8 @@ const OnboardingPostCard = ({ dismissDurationMs: number; useInstantSwipeTransform?: boolean; }): ReactElement => { + const sourceName = card.source?.name || 'daily.dev'; + const sourceImage = card.source?.image; const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; let swipeDirection: 'left' | 'right' | null = null; if (isTop && Math.abs(swipeDelta) > 20) { @@ -1361,6 +1369,30 @@ const OnboardingPostCard = ({ } } + let sourceAvatar: ReactElement; + if (sourceImage) { + sourceAvatar = ( + {`${sourceName} + ); + } else if (sourceName === 'daily.dev') { + sourceAvatar = ( +
+ +
+ ); + } else { + sourceAvatar =
; + } + return (
- {swipeDirection && ( -
- {swipeDirection === 'right' ? 'INTERESTING' : 'NOT'} -
- )}
-
- {card.source?.image ? ( - {card.source.name - ) : ( -
- )} - - {card.source?.name || 'daily.dev'} - +
+
+ {sourceAvatar} + + {sourceName} + +
+ {swipeDirection ? ( +
+ {swipeDirection === 'right' ? 'INTERESTING' : 'UNINTERESTING'} +
+ ) : null}
-
- {card.image ? ( - {card.title + {card.tags && card.tags.length > 0 && ( +
+ {card.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ {card.summary ? ( + <> + + TLDR + + + {card.summary} + + ) : ( -
+ <> + + TLDR + + + No summary available for this post yet. + + )}
@@ -1458,9 +1522,7 @@ const OnboardingFeedEmptyState = ({ isRefetching: boolean; }): ReactElement => (
- {isRefetching ? ( - - ) : null} + {isRefetching ? : null} void; /** True while onboarding deck query is fetching (initial or retry). */ onboardingFeedRefetching?: boolean; + /** Renders onboarding swipe actions under the card or beside it on wider viewports. */ + onboardingActionLayout?: 'bottom' | 'sides'; } const HotAndColdModal = ({ @@ -1576,6 +1642,7 @@ const HotAndColdModal = ({ headerSlot, topSlot, bottomSlot, + onboardingContent, showHeader = true, showDefaultActions = true, showAddHotTakeButton = true, @@ -1586,6 +1653,7 @@ const HotAndColdModal = ({ onDismissedOnboardingCardsChange, onOnboardingFeedRetry, onboardingFeedRefetching = false, + onboardingActionLayout = 'bottom', className, ...props }: HotAndColdModalProps): ReactElement => { @@ -1636,7 +1704,9 @@ const HotAndColdModal = ({ setOnboardingIntroDelta(0); }, []); - const isOnboardingMode = !!onboardingCards; + const hasOnboardingCards = !!onboardingCards; + const hasOnboardingContent = onboardingContent !== undefined; + const isOnboardingMode = hasOnboardingCards || hasOnboardingContent; const availableOnboardingCards = useMemo( () => (onboardingCards ?? []).filter((card) => !dismissedCardIds.has(card.id)), @@ -1646,7 +1716,7 @@ const HotAndColdModal = ({ const nextOnboardingCard = availableOnboardingCards[1]; const isModalLoading = isOnboardingMode ? onboardingCardsLoading : isLoading; const isModalEmpty = isOnboardingMode - ? !isModalLoading && !currentOnboardingCard + ? !isModalLoading && !hasOnboardingContent && !currentOnboardingCard : isEmpty; const swipeAreaHeight = isOnboardingMode ? ONBOARDING_SWIPE_AREA_HEIGHT @@ -1679,7 +1749,7 @@ const HotAndColdModal = ({ useEffect(() => { if ( - !isOnboardingMode || + !hasOnboardingCards || isModalLoading || !currentOnboardingCard || onboardingIntroRepeatCancelledRef.current @@ -1753,7 +1823,34 @@ const HotAndColdModal = ({ setOnboardingIntroDelta(0); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- depend on card id only, not currentOnboardingCard reference - }, [isOnboardingMode, isModalLoading, currentOnboardingCard?.id]); + }, [hasOnboardingCards, isModalLoading, currentOnboardingCard?.id]); + + useEffect(() => { + if (hasOnboardingCards) { + return; + } + + abortOnboardingIntro(); + + if (flyTimerRef.current) { + clearTimeout(flyTimerRef.current); + flyTimerRef.current = null; + } + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + + animatingTakeIdRef.current = null; + setAnimatingTakeId(null); + setDismissDurationMs(DISMISS_ANIMATION_MS); + setIsAnimating(false); + setIsDragging(false); + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + }, [hasOnboardingCards, abortOnboardingIntro]); const startDismissAnimation = useCallback( ({ @@ -1800,7 +1897,7 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - if (isOnboardingMode && currentOnboardingCard) { + if (hasOnboardingCards && currentOnboardingCard) { updateDismissedCardIds((prev) => { const next = new Set(prev); next.add(currentOnboardingCard.id); @@ -1820,7 +1917,7 @@ const HotAndColdModal = ({ [ currentOnboardingCard, dismissCurrent, - isOnboardingMode, + hasOnboardingCards, onboardingCards, updateDismissedCardIds, ], @@ -1828,7 +1925,7 @@ const HotAndColdModal = ({ const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - const currentItemId = isOnboardingMode + const currentItemId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; @@ -1865,7 +1962,7 @@ const HotAndColdModal = ({ } onSwipeAction?.( direction, - isOnboardingMode ? { onboardingCardId: currentItemId } : undefined, + hasOnboardingCards ? { onboardingCardId: currentItemId } : undefined, ); let initialPush: number; @@ -1899,8 +1996,9 @@ const HotAndColdModal = ({ [ currentTake, currentOnboardingCard, - isOnboardingMode, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, toggleDownvote, toggleUpvote, @@ -1913,7 +2011,7 @@ const HotAndColdModal = ({ const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - const currentItemId = isOnboardingMode + const currentItemId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; @@ -1944,8 +2042,9 @@ const HotAndColdModal = ({ cancelHotTakeVote, currentTake, currentOnboardingCard, - isOnboardingMode, + hasOnboardingCards, isAnimating, + isOnboardingMode, startDismissAnimation, logEvent, onSwipeAction, @@ -1953,7 +2052,7 @@ const HotAndColdModal = ({ ], ); - const currentCardId = isOnboardingMode + const currentCardId = hasOnboardingCards ? currentOnboardingCard?.id : currentTake?.id; const isCurrentTakeAnimating = @@ -1962,11 +2061,11 @@ const HotAndColdModal = ({ isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; const combinedOnboardingSwipeX = - isOnboardingMode && !isDragging && !isCurrentTakeAnimating + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating ? cardSwipeDelta + onboardingIntroDelta : cardSwipeDelta; const onboardingIntroPlaying = - isOnboardingMode && + hasOnboardingCards && !isDragging && !isCurrentTakeAnimating && onboardingIntroDelta !== 0; @@ -2036,10 +2135,10 @@ const HotAndColdModal = ({
); + const showOnboardingSideActions = onboardingActionLayout === 'sides'; + const onboardingSwipeActions = showOnboardingSideActions ? ( +
+
+ handleDismiss('left', 'button')} + /> +
+
+ {cardSwipeArea} +
+
+ handleDismiss('right', 'button')} + /> +
+
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+
+ ) : ( + <> +
{cardSwipeArea}
+
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+ + ); return ( } {headerSlot} @@ -2151,99 +2294,101 @@ const HotAndColdModal = ({ )} - {!isModalLoading && !isModalEmpty && currentCardId && ( + {!isModalLoading && !isModalEmpty && isOnboardingMode && ( <> - {!isOnboardingMode && topSlot} - {isOnboardingMode ? ( -
+
+
{topSlot} - {cardSwipeArea} -
- handleDismiss('right', 'button')} - onNotInteresting={() => handleDismiss('left', 'button')} - /> -
+ {hasOnboardingContent + ? onboardingContent + : onboardingSwipeActions} {bottomSlot}
- ) : ( - <> - {cardSwipeArea} - {showDefaultActions && ( -
-
- )} - {bottomSlot} - {showAddHotTakeButton && user?.username && ( -
- -
- )} - - )} +
)} + + {!isModalLoading && + !isModalEmpty && + !isOnboardingMode && + currentCardId && ( + <> + {topSlot} + {cardSwipeArea} + {showDefaultActions && ( +
+
+ )} + {bottomSlot} + {showAddHotTakeButton && user?.username && ( +
+ +
+ )} + + )} ); diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 9f19d54c9ae..6d55aff3839 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; +import classNames from 'classnames'; import { Origin } from '../../lib/log'; -import type { FeedSettings } from '../../graphql/feedSettings'; import { TagSelection } from '../tags/TagSelection'; import useDebounceFn from '../../hooks/useDebounceFn'; import { useTagSearch } from '../../hooks/useTagSearch'; @@ -9,12 +9,12 @@ import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { SearchField } from '../fields/SearchField'; interface EditTagProps { - feedSettings: FeedSettings; headline?: string; + headlineClassName?: string; } export const EditTag = ({ - feedSettings, headline, + headlineClassName, }: EditTagProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); @@ -31,7 +31,12 @@ export const EditTag = ({ return ( <> -

+

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

void; session: FunnelSession; showCookieBanner?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime + stepComponentOverrides?: Partial>>; } const stepComponentMap = { @@ -79,9 +81,18 @@ const stepComponentMap = { [FunnelStepType.UploadCv]: FunnelUploadCv, } as const; -function FunnelStepComponent(props: Step) { - const { type } = props; - const Component = stepComponentMap[type]; +function FunnelStepComponent(props: { + stepComponentOverrides?: FunnelStepperProps['stepComponentOverrides']; + [key: string]: unknown; +}) { + const { stepComponentOverrides, type } = props; + const stepType = type as FunnelStepType; + const Component = + stepComponentOverrides?.[stepType] ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime + (stepComponentMap as Partial>>)[ + stepType + ]; if (!Component) { return null; @@ -96,7 +107,8 @@ export const FunnelStepper = ({ session, showCookieBanner, onComplete, -}: FunnelStepperProps): ReactElement => { + stepComponentOverrides, +}: FunnelStepperProps): ReactElement | null => { const steps = useMemo( () => funnel?.chapters?.flatMap((chapter) => chapter?.steps), [funnel?.chapters], @@ -123,7 +135,9 @@ export const FunnelStepper = ({ defaultOpen: showCookieBanner, trackFunnelEvent, }); - useEventListener(globalThis, 'scrollend', trackOnScroll, { passive: true }); + useEventListener(globalThis.window, 'scrollend', trackOnScroll, { + passive: true, + }); const shouldSkipRef = useRef>>({}); const currentNavigationRef = useRef({ step, position }); @@ -187,11 +201,12 @@ export const FunnelStepper = ({ ); const successCallback = useCallback( - (event?: PaddleEventData) => + (event: unknown) => onTransition({ type: FunnelStepTransitionType.Complete, details: { - subscribed: event?.data?.customer?.email, + subscribed: (event as PaddleEventData | undefined)?.data?.customer + ?.email, }, }), [onTransition], @@ -254,7 +269,7 @@ export const FunnelStepper = ({ !layout.isFullWidth && 'tablet:max-w-md laptopXL:max-w-lg', )} > - {layout.hasBanner && ( + {layout.hasBanner && funnel.parameters.banner && ( )}
); diff --git a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx index 831dfdc91a2..d0c3af8d5b8 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx @@ -42,7 +42,7 @@ function FunnelEditTagsComponent({ containerClassName="flex w-full flex-1 flex-col items-center laptop:justify-center overflow-hidden" >
- +
); diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 1b918eb10d9..89e5309a70b 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -39,6 +39,7 @@ export enum ActionType { FetchedSmartTitle = 'fetched_smart_title', EditTag = 'edit_tag', ContentTypes = 'content_types', + HasSeenTags = 'has_seen_tags', StreakTimezoneMismatch = 'streak_timezone_mismatch', SmartPrompt = 'smart_prompt', CheckedCoresRole = 'checked_cores_role', diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index f8d5ab1f86d..5fa84f62a93 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -151,7 +151,7 @@ export const sharedPostPreviewFeature = new Feature( false, ); -export const swipeOnboardingFeature = new Feature('swipe_onboarding', false); +export const swipeOnboardingFeature = new Feature('swipe_onboarding', true); export const featureUpvoteCountThreshold = new Feature<{ threshold: number; diff --git a/packages/shared/src/lib/feedSettings.ts b/packages/shared/src/lib/feedSettings.ts new file mode 100644 index 00000000000..98057213a1a --- /dev/null +++ b/packages/shared/src/lib/feedSettings.ts @@ -0,0 +1,25 @@ +import { generateStorageKey, StorageTopic } from './storage'; +import { storageWrapper } from './storageWrapper'; + +const hasSeenTagsStorageKey = 'hasSeenTags'; + +export const getHasSeenTagsStorageKey = (userId: string): string => + generateStorageKey(StorageTopic.Onboarding, hasSeenTagsStorageKey, userId); + +export const getHasSeenTags = (userId?: string | null): boolean | null => { + if (!userId) { + return null; + } + + const value = storageWrapper.getItem(getHasSeenTagsStorageKey(userId)); + + if (value === null) { + return null; + } + + return value === 'true'; +}; + +export const setHasSeenTags = (userId: string, hasSeenTags: boolean): void => { + storageWrapper.setItem(getHasSeenTagsStorageKey(userId), String(hasSeenTags)); +}; diff --git a/packages/webapp/.env b/packages/webapp/.env index 3f3cf591931..3b23f637971 100644 --- a/packages/webapp/.env +++ b/packages/webapp/.env @@ -17,4 +17,3 @@ NEXT_PUBLIC_ANDROID_APP="https://play.google.com/store/apps/details?id=dev.daily NEXT_PUBLIC_TURNSTILE_KEY=1x00000000000000000000AA -NEXT_PUBLIC_SWIPING_BACKEND_URL=http://localhost:8000 diff --git a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx new file mode 100644 index 00000000000..404c34105c2 --- /dev/null +++ b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx @@ -0,0 +1,425 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import HotAndColdModal from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal'; +import { useBookmarkPost } from '@dailydotdev/shared/src/hooks/useBookmarkPost'; +import useFeedSettings from '@dailydotdev/shared/src/hooks/useFeedSettings'; +import useTagAndSource from '@dailydotdev/shared/src/hooks/useTagAndSource'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { withIsActiveGuard } from '@dailydotdev/shared/src/features/onboarding/shared/withActiveGuard'; +import type { FunnelStepEditTags } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { FunnelStepTransitionType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { useAdaptiveSwipeDeck } from '../../hooks/useAdaptiveSwipeDeck'; +import { extractTags } from '../../lib/swipingBackendApi'; +import { SwipeOnboardingProgressHeader } from './SwipeOnboardingProgressHeader'; +import { + SWIPE_ONBOARDING_IMPROVE_MILESTONE, + SWIPE_ONBOARDING_MIN_TO_UNLOCK, + SWIPE_ONBOARDING_REFINE_TARGET, +} from '../../lib/swipeOnboardingGuidance'; + +const SWIPE_ONBOARDING_PROGRESS_MILESTONES: readonly number[] = [ + SWIPE_ONBOARDING_MIN_TO_UNLOCK, + SWIPE_ONBOARDING_IMPROVE_MILESTONE, + SWIPE_ONBOARDING_REFINE_TARGET, +]; + +const SWIPE_ONBOARDING_TAG_SEED_MAX = 25; +const SWIPE_ONBOARDING_LOADING_LABELS = [ + 'cooking', + 'optimizing', + 'thinking', + 'shuffling the good stuff', + 'bribing the feed gremlins', + 'warming up the swipe deck', +] as const; + +const swipeOnboardingModalShellClassName = + 'tablet:!h-[calc(100vh-2rem)] tablet:!max-h-[calc(100vh-2rem)] tablet:!w-[42rem] tablet:!max-w-[calc(100vw-2rem)] tablet:!overflow-hidden tablet:!rounded-[2rem] tablet:!border-border-subtlest-secondary tablet:shadow-[0_32px_120px_-48px_rgba(0,0,0,0.58)]'; + +const swipeOnboardingSurfaceClassName = + 'w-full overflow-hidden rounded-[2rem] border border-border-subtlest-secondary bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]'; + +const swipeOnboardingPanelClassName = + 'rounded-[1.5rem] border border-border-subtlest-tertiary bg-surface-float'; + +function useAnimatedLoadingLabel(isActive: boolean): string { + const [labelIndex, setLabelIndex] = useState(0); + const [dotCount, setDotCount] = useState(1); + + useEffect(() => { + if (!isActive) { + setLabelIndex(0); + setDotCount(1); + return undefined; + } + + const interval = window.setInterval(() => { + setDotCount((currentDotCount) => { + if (currentDotCount === 3) { + setLabelIndex( + (currentLabelIndex) => + (currentLabelIndex + 1) % SWIPE_ONBOARDING_LOADING_LABELS.length, + ); + return 1; + } + + return currentDotCount + 1; + }); + }, 550); + + return () => window.clearInterval(interval); + }, [isActive]); + + return `${SWIPE_ONBOARDING_LOADING_LABELS[labelIndex]}${'.'.repeat( + dotCount, + )}`; +} + +function SwipeOnboardingToolbar({ + onBack, +}: { + onBack: () => void; +}): ReactElement { + return ( +
+
+
+ ); +} + +function FunnelSwipeOnboardingStepComponent({ + parameters: { cta }, + onTransition, +}: FunnelStepEditTags): ReactElement { + const router = useRouter(); + const [swipesCount, setSwipesCount] = useState(0); + const [milestoneBurstKey, setMilestoneBurstKey] = useState(0); + const [promptText, setPromptText] = useState(''); + const [promptLoading, setPromptLoading] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); + const [isSwipeMode, setIsSwipeMode] = useState(false); + const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState< + Set + >(() => new Set()); + const prevSwipesForMilestoneRef = useRef(null); + const animatedPromptLoadingLabel = useAnimatedLoadingLabel(promptLoading); + const { feedSettings } = useFeedSettings(); + const { onFollowTags } = useTagAndSource({ + origin: Origin.Onboarding, + }); + const { toggleBookmark } = useBookmarkPost(); + const { + cards: adaptiveCards, + getBookmarkablePost, + isLoading: isAdaptiveLoading, + startDeck, + handleSwipe: handleAdaptiveSwipe, + retryFetch, + selectedTags: adaptiveSelectedTags, + } = useAdaptiveSwipeDeck(); + + useEffect(() => { + const prev = prevSwipesForMilestoneRef.current; + prevSwipesForMilestoneRef.current = swipesCount; + if (prev === null) { + return; + } + const crossedMilestone = SWIPE_ONBOARDING_PROGRESS_MILESTONES.find( + (milestone) => prev < milestone && swipesCount >= milestone, + ); + if (crossedMilestone !== undefined) { + setMilestoneBurstKey((currentKey) => currentKey + 1); + } + }, [swipesCount]); + + const handlePromptSubmit = useCallback(async () => { + if (promptLoading) { + return; + } + + setPromptLoading(true); + try { + let initialTags: string[] = []; + if (promptText.trim()) { + initialTags = await extractTags(promptText.trim()); + } + await startDeck({ prompt: promptText.trim(), initialTags }); + setIsSwipeMode(true); + } finally { + setPromptLoading(false); + } + }, [promptLoading, promptText, startDeck]); + + const handleSkipPrompt = useCallback(async () => { + if (promptLoading) { + return; + } + + setPromptLoading(true); + try { + await startDeck(); + setIsSwipeMode(true); + } finally { + setPromptLoading(false); + } + }, [promptLoading, startDeck]); + + const bookmarkRightSwipePost = useCallback( + (cardId: string) => { + const bookmarkPost = getBookmarkablePost(cardId); + if (!bookmarkPost) { + return; + } + + // Capture the current card payload before deck state changes. + toggleBookmark({ + post: bookmarkPost, + origin: Origin.Onboarding, + disableToast: true, + }).catch(() => null); + }, + [getBookmarkablePost, toggleBookmark], + ); + + const handleSwipeInteraction = useCallback( + ( + direction: 'left' | 'right' | 'skip', + meta?: { onboardingCardId?: string }, + ) => { + if (direction === 'left' || direction === 'right') { + setSwipesCount((currentValue) => currentValue + 1); + if (meta?.onboardingCardId) { + if (direction === 'right') { + bookmarkRightSwipePost(meta.onboardingCardId); + } + handleAdaptiveSwipe(direction, meta.onboardingCardId); + } + } + }, + [bookmarkRightSwipePost, handleAdaptiveSwipe], + ); + + const tagsFromSwipes = useMemo( + () => adaptiveSelectedTags.slice(0, SWIPE_ONBOARDING_TAG_SEED_MAX), + [adaptiveSelectedTags], + ); + + const handleComplete = useCallback(async () => { + if (isCompleting) { + return; + } + + setIsCompleting(true); + const currentTags = feedSettings?.includeTags ?? []; + const currentTagsSet = new Set(currentTags); + const tagsToFollow = tagsFromSwipes.filter( + (tag) => !currentTagsSet.has(tag), + ); + const finalTags = [...currentTags, ...tagsToFollow]; + + try { + if (tagsToFollow.length) { + await onFollowTags({ tags: tagsToFollow }); + } + } catch { + // Let the funnel continue even if persisting tags fails. + } finally { + setIsCompleting(false); + } + + onTransition({ + type: FunnelStepTransitionType.Complete, + details: { + tags: finalTags, + }, + }); + }, [ + feedSettings?.includeTags, + isCompleting, + onFollowTags, + onTransition, + tagsFromSwipes, + ]); + + const canContinue = swipesCount >= SWIPE_ONBOARDING_MIN_TO_UNLOCK; + + if (!isSwipeMode) { + return ( +
+
+
+
+
+ + Personalize your daily.dev feed + +
+

+ What do you want to read more about? +

+

+ Describe your interests and we'll turn that into a + swipe deck that feels closer to a real conversation than a + blank setup form. +

+
+
+
+