diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 4af27936ce0..6e1ba91063a 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -17,7 +17,7 @@ import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; import { SharedFeedPage } from './utilities'; -import { isTesting, onboardingUrl } from '../lib/constants'; +import { isTesting, onboardingUrl, swipeOnboardingUrl } from '../lib/constants'; import { useBanner } from '../hooks/useBanner'; import { useGrowthBookContext } from './GrowthBookProvider'; import { @@ -33,6 +33,8 @@ 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( () => @@ -47,6 +49,7 @@ const Sidebar = dynamic(() => (mod) => mod.Sidebar, ), ); +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; export interface MainLayoutProps extends Omit, @@ -94,6 +97,20 @@ function MainLayoutComponent({ const isLaptopXL = useViewSize(ViewSize.LaptopXL); const { screenCenteredOnMobileLayout } = useFeedLayout(); 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 = + swipeOnboardingPreviewQuery === '1' || + swipeOnboardingPreviewQuery === 'true' || + (Array.isArray(swipeOnboardingPreviewQuery) && + (swipeOnboardingPreviewQuery.includes('1') || + swipeOnboardingPreviewQuery.includes('true'))); useNotificationParams(); useEffect(() => { @@ -111,8 +128,6 @@ function MainLayoutComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNotificationsReady, unreadCount, hasLoggedImpression]); - const isPageReady = - (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; const isPageApplicableForOnboarding = !page || feeds.includes(page) || isCustomFeed; const shouldRedirectOnboarding = @@ -126,7 +141,11 @@ function MainLayoutComponent({ const entries = Object.entries(router.query); if (entries.length === 0) { - router.push(onboardingUrl); + router.push( + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? swipeOnboardingUrl + : onboardingUrl, + ); return; } @@ -136,8 +155,17 @@ function MainLayoutComponent({ params.append(key, value as string); }); - router.push(`${onboardingUrl}?${params.toString()}`); - }, [shouldRedirectOnboarding, router]); + const destination = + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? swipeOnboardingUrl + : onboardingUrl; + router.push(`${destination}?${params.toString()}`); + }, [ + isSwipeOnboardingEnabled, + isSwipeOnboardingPreviewForced, + shouldRedirectOnboarding, + router, + ]); const ignoredUtmMediumForLogin = ['slack']; const utmSource = router?.query?.utm_source; diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 1f5a6b3a5cc..d58821bd584 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -1,5 +1,11 @@ -import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useSwipeable } from 'react-swipeable'; import classNames from 'classnames'; import type { ModalProps } from '../common/Modal'; @@ -12,7 +18,9 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { LogEvent, Origin } from '../../../lib/log'; import { webappUrl } from '../../../lib/constants'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { IconSize } from '../../Icon'; import { HotIcon } from '../../icons/Hot'; +import { MiniCloseIcon, VIcon } from '../../icons'; import { Typography, TypographyType, @@ -22,9 +30,17 @@ import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; +import { Loader } from '../../Loader'; import type { HotTake } from '../../../graphql/user/userHotTake'; const SWIPE_THRESHOLD = 80; +const ONBOARDING_INTRO_INTERESTING_OFFSET = 56; +const ONBOARDING_INTRO_NOT_OFFSET = -56; +const ONBOARDING_INTRO_PHASE_MS = 160; +const ONBOARDING_INTRO_PAUSE_MS = 55; +const ONBOARDING_INTRO_START_DELAY_MS = 120; +/** Time from the start of one intro play to the start of the next (~4s; hint loop until user interacts). */ +const ONBOARDING_INTRO_REPEAT_INTERVAL_MS = 4000; const DISMISS_ANIMATION_MS = 340; const BUTTON_DISMISS_ANIMATION_MS = 620; const DISMISS_FLY_DISTANCE = 760; @@ -35,6 +51,497 @@ const SKIP_DISMISS_FLY_DISTANCE = 600; const SKIP_DRAG_ELASTICITY_FACTOR = 0.3; const COLD_ACCENT_COLOR = '#123a88'; const HOT_TAKE_CARD_HEIGHT = '28rem'; +/** Title3 × 3 lines (typo-title3 line-height 1.625rem in tailwind/typography.ts). */ +const ONBOARDING_CARD_TITLE_MIN_HEIGHT = '4.875rem'; +/** Fixed onboarding post card (source + 3-line title + 4:3 image + padding). */ +const ONBOARDING_POST_CARD_HEIGHT = '24rem'; +/** Swipe stack area: card height plus back-card vertical offset (8px). */ +const ONBOARDING_SWIPE_AREA_HEIGHT = '24.5rem'; + +const smoothstep01 = (t: number): number => { + const x = Math.min(Math.max(t, 0), 1); + return x * x * (3 - 2 * x); +}; + +const pauseMs = (ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(() => { + resolve(); + }, ms); + }); + +const runOnboardingIntroAnimation = ({ + signal, + onUpdate, +}: { + signal: { aborted: boolean }; + onUpdate: (value: number) => void; +}): Promise => { + const segment = (from: number, to: number, durationMs: number) => + new Promise((resolve) => { + const startTime = performance.now(); + const tick = (now: number) => { + if (signal.aborted) { + resolve(); + return; + } + const elapsed = now - startTime; + const t = durationMs <= 0 ? 1 : Math.min(elapsed / durationMs, 1); + const eased = smoothstep01(t); + onUpdate(from + (to - from) * eased); + if (t < 1) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + + return (async () => { + await segment( + 0, + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment( + ONBOARDING_INTRO_INTERESTING_OFFSET, + ONBOARDING_INTRO_NOT_OFFSET, + ONBOARDING_INTRO_PHASE_MS, + ); + if (signal.aborted) { + return; + } + await pauseMs(ONBOARDING_INTRO_PAUSE_MS); + if (signal.aborted) { + return; + } + await segment(ONBOARDING_INTRO_NOT_OFFSET, 0, ONBOARDING_INTRO_PHASE_MS); + })(); +}; + +const ONBOARDING_BEHIND_PARTICLES_CSS = ` + @keyframes onboardingBehindParticle { + 0% { transform: translate3d(0, 0, 0) scale(1); opacity: 0; } + 10% { opacity: 0.9; } + 35% { transform: translate3d(var(--obp-tx), var(--obp-ty), 0) scale(1.08); opacity: 0.65; } + 65% { transform: translate3d(calc(var(--obp-ex) * 0.55), 3.5rem, 0) scale(0.65); opacity: 0.3; } + 100% { transform: translate3d(var(--obp-ex), 8rem, 0) scale(0.15); opacity: 0; } + } + @keyframes onboardingMagicSpark { + 0%, 100% { transform: translate3d(0, 0, 0) scale(0.75); opacity: 0.45; filter: blur(0); } + 20% { transform: translate3d(var(--oms-x1), var(--oms-y1), 0) scale(1.35); opacity: 1; filter: blur(0); } + 45% { transform: translate3d(var(--oms-x2), var(--oms-y2), 0) scale(1); opacity: 0.65; filter: blur(0); } + 70% { transform: translate3d(var(--oms-x3), var(--oms-y3), 0) scale(1.2); opacity: 0.9; filter: blur(0); } + } + @keyframes onboardingAuraDrift { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.35; } + 33% { transform: translate3d(0.35rem, -0.5rem, 0) scale(1.06); opacity: 0.55; } + 66% { transform: translate3d(-0.25rem, 0.35rem, 0) scale(0.96); opacity: 0.42; } + } +`; + +const ONBOARDING_BEHIND_PARTICLE_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + tx: string; + ty: string; + ex: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '6%', + top: '72%', + size: '0.25rem', + tx: '1.5rem', + ty: '-2.25rem', + ex: '0.5rem', + duration: 4.6, + delay: 0, + className: 'bg-accent-avocado-default/40', + }, + { + left: '92%', + top: '68%', + size: '0.1875rem', + tx: '-1.25rem', + ty: '-2rem', + ex: '-0.75rem', + duration: 4.1, + delay: 0.35, + className: 'bg-accent-bacon-default/35', + }, + { + left: '18%', + top: '88%', + size: '0.3125rem', + tx: '0.75rem', + ty: '-1.25rem', + ex: '1rem', + duration: 3.7, + delay: 0.7, + className: 'bg-text-tertiary/50', + }, + { + left: '78%', + top: '82%', + size: '0.25rem', + tx: '-0.5rem', + ty: '-1.75rem', + ex: '-1.25rem', + duration: 4.3, + delay: 0.2, + className: 'bg-accent-avocado-default/30', + }, + { + left: '44%', + top: '92%', + size: '0.1875rem', + tx: '1rem', + ty: '-0.75rem', + ex: '0.25rem', + duration: 3.4, + delay: 1.1, + className: 'bg-text-tertiary/40', + }, + { + left: '52%', + top: '78%', + size: '0.25rem', + tx: '-1.5rem', + ty: '-2.5rem', + ex: '-0.25rem', + duration: 4.8, + delay: 0.55, + className: 'bg-accent-bacon-default/25', + }, + { + left: '28%', + top: '58%', + size: '0.1875rem', + tx: '2rem', + ty: '0.25rem', + ex: '0.75rem', + duration: 5.1, + delay: 0.9, + className: 'bg-accent-avocado-default/25', + }, + { + left: '66%', + top: '52%', + size: '0.25rem', + tx: '-1.75rem', + ty: '0.5rem', + ex: '-1rem', + duration: 4.4, + delay: 1.4, + className: 'bg-text-tertiary/35', + }, +]; + +const ONBOARDING_MAGIC_SPARK_SPECS: ReadonlyArray<{ + left: string; + top: string; + size: string; + x1: string; + y1: string; + x2: string; + y2: string; + x3: string; + y3: string; + duration: number; + delay: number; + className: string; +}> = [ + { + left: '12%', + top: '38%', + size: '0.1875rem', + x1: '0.4rem', + y1: '-1.25rem', + x2: '-0.35rem', + y2: '-2rem', + x3: '0.5rem', + y3: '-1rem', + duration: 2.8, + delay: 0, + className: 'bg-accent-avocado-default/90', + }, + { + left: '84%', + top: '42%', + size: '0.15625rem', + x1: '-0.45rem', + y1: '-1rem', + x2: '0.3rem', + y2: '-1.75rem', + x3: '-0.2rem', + y3: '-0.5rem', + duration: 3.2, + delay: 0.4, + className: 'bg-accent-bacon-default/85', + }, + { + left: '48%', + top: '28%', + size: '0.125rem', + x1: '0.25rem', + y1: '-0.75rem', + x2: '0.5rem', + y2: '-1.5rem', + x3: '0.1rem', + y3: '-1.1rem', + duration: 2.4, + delay: 0.8, + className: 'bg-accent-cabbage-default/80', + }, + { + left: '22%', + top: '48%', + size: '0.15625rem', + x1: '0.6rem', + y1: '0.25rem', + x2: '0.2rem', + y2: '-0.5rem', + x3: '0.75rem', + y3: '-0.25rem', + duration: 3.6, + delay: 0.15, + className: 'bg-accent-avocado-default/70', + }, + { + left: '72%', + top: '36%', + size: '0.1875rem', + x1: '-0.5rem', + y1: '-0.5rem', + x2: '-0.75rem', + y2: '-1.25rem', + x3: '-0.35rem', + y3: '-1.75rem', + duration: 2.9, + delay: 1.1, + className: 'bg-accent-bacon-default/75', + }, + { + left: '56%', + top: '44%', + size: '0.125rem', + x1: '-0.2rem', + y1: '-1.5rem', + x2: '0.4rem', + y2: '-2.25rem', + x3: '0.15rem', + y3: '-1.75rem', + duration: 3.4, + delay: 0.55, + className: 'bg-text-tertiary/80', + }, + { + left: '36%', + top: '32%', + size: '0.15625rem', + x1: '0.35rem', + y1: '-0.35rem', + x2: '-0.15rem', + y2: '-1rem', + x3: '0.45rem', + y3: '-1.4rem', + duration: 2.6, + delay: 1.3, + className: 'bg-accent-avocado-default/80', + }, + { + left: '64%', + top: '50%', + size: '0.125rem', + x1: '-0.3rem', + y1: '-1.1rem', + x2: '0.25rem', + y2: '-1.8rem', + x3: '-0.5rem', + y3: '-1.2rem', + duration: 3, + delay: 0.25, + className: 'bg-accent-cheese-default/75', + }, +]; + +const OnboardingCardBehindParticles = (): ReactElement => ( + <> + {/* eslint-disable-next-line react/no-unknown-property -- style tag for scoped keyframes */} + +
+
+
+
+ {ONBOARDING_MAGIC_SPARK_SPECS.map((spark) => ( + + ))} + {ONBOARDING_BEHIND_PARTICLE_SPECS.map((particle) => ( + + ))} +
+ +); + +const OnboardingSwipeHintIcons = ({ + deltaX, + disabled, + onNotInteresting, + onInteresting, +}: { + deltaX: number; + disabled: boolean; + 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 ( +
+ + +
+ ); +}; const getElasticDelta = (delta: number): number => { const absoluteDelta = Math.abs(delta); @@ -794,6 +1301,195 @@ 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 isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; + let swipeDirection: 'left' | 'right' | null = null; + if (isTop && Math.abs(swipeDelta) > 20) { + swipeDirection = swipeDelta > 0 ? 'right' : 'left'; + } + const swipeIntensity = isTop + ? Math.min(Math.abs(swipeDelta) / SWIPE_THRESHOLD, 1) + : 0; + const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; + const translateX = isTop ? swipeDelta : 0; + const stackScale = isTop ? 1 : 1 - offset * 0.05; + const translateY = isTop ? 0 : offset * 8; + const dismissDistance = isSkipAnimating + ? SKIP_DISMISS_FLY_DISTANCE + : DISMISS_FLY_DISTANCE; + const dismissProgress = + isTop && isDismissAnimating + ? Math.min( + Math.abs(isSkipAnimating ? skipDeltaY : swipeDelta) / dismissDistance, + 1, + ) + : 0; + const scale = isTop ? 1 - dismissProgress * 0.06 : stackScale; + const dismissLift = isTop ? dismissProgress * -22 : 0; + const translateYWithOutro = + translateY + dismissLift + (isTop ? skipDeltaY : 0); + + let transition = + 'transform 0.3s ease, border-color 0.2s ease, box-shadow 0.2s ease'; + if (isTop) { + if (isDismissAnimating) { + transition = `transform ${dismissDurationMs}ms cubic-bezier(0.16, 0.86, 0.22, 1), opacity ${dismissDurationMs}ms ease-out, filter ${dismissDurationMs}ms ease-out`; + } else if (isDragging || useInstantSwipeTransform) { + transition = 'none'; + } else { + transition = 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)'; + } + } + + return ( +
event.preventDefault()} + style={{ + height: ONBOARDING_POST_CARD_HEIGHT, + transform: `translateX(${translateX}px) translateY(${translateYWithOutro}px) rotate(${rotation}deg) scale(${scale})`, + zIndex: 10 - offset, + transition, + opacity: isTop ? 1 - dismissProgress * 0.75 : 1, + filter: + isTop && isDismissAnimating + ? `blur(${dismissProgress * 1.8}px)` + : undefined, + boxShadow: isTop + ? '0 1.25rem 2.75rem -0.75rem rgba(0, 0, 0, 0.45)' + : '0 0.75rem 1.75rem -0.75rem rgba(0, 0, 0, 0.32)', + }} + > + {swipeDirection && ( +
+ {swipeDirection === 'right' ? 'INTERESTING' : 'NOT'} +
+ )} +
+
+ {card.source?.image ? ( + {card.source.name + ) : ( +
+ )} + + {card.source?.name || 'daily.dev'} + +
+
+ + {card.title || 'Popular developer story'} + +
+
+ {card.image ? ( + {card.title + ) : ( +
+ )} +
+
+
+ ); +}; + +const OnboardingFeedEmptyState = ({ + onRetry, + isRefetching, +}: { + onRetry?: () => void; + isRefetching: boolean; +}): ReactElement => ( +
+ {isRefetching ? ( + + ) : null} + + Couldn't load stories + + + Check your connection and try again. + + {onRetry ? ( + + ) : null} +
+); + const EmptyState = ({ onClose, username, @@ -834,10 +1530,65 @@ const EmptyState = ({
); +type SwipeActionDirection = 'left' | 'right' | 'skip'; + +export type OnboardingSwipeActionMeta = { + onboardingCardId?: string; +}; + +export interface OnboardingSwipeCard { + id: string; + title?: string; + image?: string | null; + tags?: string[]; + source?: { + name?: string | null; + image?: string | null; + } | null; +} + +interface HotAndColdModalProps extends ModalProps { + title?: string; + headerSlot?: ReactNode; + topSlot?: ReactNode; + bottomSlot?: ReactNode; + 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; +} + const HotAndColdModal = ({ onRequestClose, + title = 'Hot Takes', + headerSlot, + topSlot, + bottomSlot, + showHeader = true, + showDefaultActions = true, + showAddHotTakeButton = true, + onSwipeAction, + onboardingCards, + onboardingCardsLoading = false, + dismissedOnboardingCardIds, + onDismissedOnboardingCardsChange, + onOnboardingFeedRetry, + onboardingFeedRefetching = false, + className, ...props -}: ModalProps): ReactElement => { +}: HotAndColdModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = useDiscoverHotTakes(); const { toggleUpvote, toggleDownvote, cancelHotTakeVote } = useVoteHotTake(); @@ -855,6 +1606,51 @@ 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 isOnboardingMode = !!onboardingCards; + 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 && !currentOnboardingCard + : isEmpty; + const swipeAreaHeight = isOnboardingMode + ? ONBOARDING_SWIPE_AREA_HEIGHT + : HOT_TAKE_CARD_HEIGHT; useEffect(() => { animatingTakeIdRef.current = animatingTakeId; @@ -881,6 +1677,84 @@ const HotAndColdModal = ({ }; }, []); + useEffect(() => { + if ( + !isOnboardingMode || + 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 + }, [isOnboardingMode, isModalLoading, currentOnboardingCard?.id]); + const startDismissAnimation = useCallback( ({ takeId, @@ -926,20 +1800,44 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - dismissCurrent(); + if (isOnboardingMode && 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, + isOnboardingMode, + onboardingCards, + updateDismissedCardIds, + ], ); const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - if (!currentTake || isAnimating) { + const currentItemId = isOnboardingMode + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + const isButtonSource = source === 'button'; const durationMs = isButtonSource ? BUTTON_DISMISS_ANIMATION_MS @@ -948,21 +1846,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, + isOnboardingMode ? { onboardingCardId: currentItemId } : undefined, + ); let initialPush: number; let flyDistance: number; @@ -986,7 +1890,7 @@ const HotAndColdModal = ({ setSwipeDelta(initialPush); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs, flyDelayMs: isButtonSource ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSwipeDelta(flyDistance), @@ -994,30 +1898,43 @@ const HotAndColdModal = ({ }, [ currentTake, + currentOnboardingCard, + isOnboardingMode, isAnimating, startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, + onSwipeAction, swipeDelta, + abortOnboardingIntro, ], ); const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - if (!currentTake || isAnimating) { + const currentItemId = isOnboardingMode + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } + abortOnboardingIntro(); + logEvent({ event_name: LogEvent.SkipHotTake, - target_id: currentTake.id, + target_id: currentItemId, }); - cancelHotTakeVote({ id: currentTake.id }); + if (!isOnboardingMode && currentTake) { + cancelHotTakeVote({ id: currentTake.id }); + } + onSwipeAction?.('skip'); startDismissAnimation({ - takeId: currentTake.id, + takeId: currentItemId, durationMs: SKIP_DISMISS_ANIMATION_MS, flyDelayMs: source === 'button' ? BUTTON_FLY_KICK_DELAY_MS : 0, onFly: () => setSkipDelta(-SKIP_DISMISS_FLY_DISTANCE), @@ -1026,17 +1943,33 @@ const HotAndColdModal = ({ [ cancelHotTakeVote, currentTake, + currentOnboardingCard, + isOnboardingMode, isAnimating, startDismissAnimation, logEvent, + onSwipeAction, + abortOnboardingIntro, ], ); + const currentCardId = isOnboardingMode + ? currentOnboardingCard?.id + : currentTake?.id; const isCurrentTakeAnimating = - !!currentTake && isAnimating && animatingTakeId === currentTake.id; + !!currentCardId && isAnimating && animatingTakeId === currentCardId; const cardSwipeDelta = isAnimating && !isCurrentTakeAnimating ? 0 : swipeDelta; const cardSkipDelta = isAnimating && !isCurrentTakeAnimating ? 0 : skipDelta; + const combinedOnboardingSwipeX = + isOnboardingMode && !isDragging && !isCurrentTakeAnimating + ? cardSwipeDelta + onboardingIntroDelta + : cardSwipeDelta; + const onboardingIntroPlaying = + isOnboardingMode && + !isDragging && + !isCurrentTakeAnimating && + onboardingIntroDelta !== 0; const handleSwiped = (direction: 'left' | 'right') => { setIsDragging(false); @@ -1053,9 +1986,14 @@ const HotAndColdModal = ({ const handlers = useSwipeable({ onSwiping: (e) => { if (!isAnimating) { + abortOnboardingIntro(); setIsDragging(true); setSwipeDelta(e.deltaX); - if (e.deltaY < 0 && Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + if ( + !isOnboardingMode && + e.deltaY < 0 && + Math.abs(e.deltaY) > Math.abs(e.deltaX) + ) { setSkipDelta(getElasticDelta(e.deltaY)); } else { setSkipDelta(0); @@ -1068,6 +2006,13 @@ const HotAndColdModal = ({ onSwipedRight: () => handleSwiped('right'), onSwipedUp: () => { setIsDragging(false); + if (isOnboardingMode) { + setSwipeDelta(0); + swipeDeltaRef.current = 0; + setSkipDelta(0); + swipeDeltaYRef.current = 0; + return; + } if ( swipeDeltaYRef.current < 0 && Math.abs(swipeDeltaYRef.current) > SWIPE_THRESHOLD @@ -1087,114 +2032,215 @@ const HotAndColdModal = ({ touchEventOptions: { passive: false }, }); + const cardSwipeArea = ( +
+ {isOnboardingMode ? ( + <> + {nextOnboardingCard && ( + + )} + {currentOnboardingCard && ( + + )} + + + ) : ( + <> + {nextTake && ( + + )} + {currentTake && ( + + )} + + )} +
+ ); + return ( - - - - {isLoading && ( -
+ + {showHeader && } + + {headerSlot} + {isModalLoading && ( +
+ {isOnboardingMode ? : null} - Loading hot takes... + {isOnboardingMode ? 'Loading stories…' : 'Loading hot takes...'}
)} - {!isLoading && isEmpty && ( + {!isModalLoading && isModalEmpty && isOnboardingMode && ( + + )} + + {!isModalLoading && isModalEmpty && !isOnboardingMode && ( )} - {!isLoading && !isEmpty && currentTake && ( + {!isModalLoading && !isModalEmpty && currentCardId && ( <> -
- {nextTake && ( - - )} - -
- -
-
- - {user?.username && ( -
- + {!isOnboardingMode && topSlot} + {isOnboardingMode ? ( +
+ {topSlot} + {cardSwipeArea} +
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+ {bottomSlot}
+ ) : ( + <> + {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 b74099cf4e7..9f19d54c9ae 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -1,36 +1,22 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; -import { FeedPreviewControls } from '../feeds'; -import { REQUIRED_TAGS_THRESHOLD } from './common'; import { Origin } from '../../lib/log'; -import Feed from '../Feed'; -import { OtherFeedPage, RequestKey } from '../../lib/query'; -import { PREVIEW_FEED_QUERY } from '../../graphql/feed'; import type { FeedSettings } from '../../graphql/feedSettings'; import { TagSelection } from '../tags/TagSelection'; -import { FeedLayoutProvider } from '../../contexts/FeedContext'; import useDebounceFn from '../../hooks/useDebounceFn'; import { useTagSearch } from '../../hooks/useTagSearch'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { SearchField } from '../fields/SearchField'; -import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; interface EditTagProps { feedSettings: FeedSettings; - userId: string; headline?: string; - requiredTags?: number; } export const EditTag = ({ feedSettings, - userId, headline, - requiredTags = REQUIRED_TAGS_THRESHOLD, }: EditTagProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); - const [isPreviewVisible, setPreviewVisible] = useState(false); - const tagsCount = feedSettings?.includeTags?.length || 0; - const isPreviewEnabled = tagsCount >= requiredTags; const [searchQuery, setSearchQuery] = useState(''); const [onSearch] = useDebounceFn((value?: string) => { @@ -63,31 +49,6 @@ export const EditTag = ({ searchQuery={searchQuery} searchTags={searchTags} /> - - {isPreviewEnabled && isPreviewVisible && ( - -

- Change your tag selection until you're happy with your feed - preview. -

- -
- )} ); }; diff --git a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx index 684064910d6..831dfdc91a2 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx @@ -14,7 +14,7 @@ function FunnelEditTagsComponent({ onTransition, }: FunnelStepEditTags): ReactElement | null { const { feedSettings } = useFeedSettings(); - const { user, trackingId } = useAuthContext(); + const { user } = useAuthContext(); const handleComplete = () => { onTransition({ type: FunnelStepTransitionType.Complete, @@ -42,11 +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/lib/constants.ts b/packages/shared/src/lib/constants.ts index 2b5c1da02db..f151b8b3e6d 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -54,6 +54,7 @@ export const isChrome = (): boolean => export const webappUrl = process.env.NEXT_PUBLIC_WEBAPP_URL as string; export const onboardingUrl = `${webappUrl}onboarding`; +export const swipeOnboardingUrl = `${webappUrl}onboarding/swipe`; export const plusUrl = `${webappUrl}plus`; export const managePlusUrl = 'https://r.daily.dev/billing'; export const plusDetailsUrl = 'https://r.daily.dev/plus-onboarding'; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 2649b7a121f..f8d5ab1f86d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -151,6 +151,8 @@ export const sharedPostPreviewFeature = new Feature( false, ); +export const swipeOnboardingFeature = new Feature('swipe_onboarding', false); + export const featureUpvoteCountThreshold = new Feature<{ threshold: number; belowThresholdLabel: string; diff --git a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx new file mode 100644 index 00000000000..64ff996b483 --- /dev/null +++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx @@ -0,0 +1,392 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + getSwipeOnboardingBarProgress, + getSwipeOnboardingGuidanceMessage, + getSwipeOnboardingHeadline, + type SwipeOnboardingProgressCopyVariant, +} from '../../lib/swipeOnboardingGuidance'; + +/** Typing speed; full headline refresh when swipe tier copy changes. */ +const SWIPE_HEADLINE_TYPING_MS_PER_CHAR = 12; +/** + * Stable min height = 3 × typo-title2 line-height (1.875rem) so headline changes do not + * shift the progress bar. + */ +const SWIPE_HEADLINE_BLOCK_MIN_HEIGHT_CLASS = 'min-h-[5.625rem]'; + +function SwipeOnboardingTypingHeadline({ + line1, + line2, +}: { + line1: string; + line2?: string; +}): ReactElement { + const fullText = useMemo( + () => (line2 !== undefined ? `${line1}\n${line2}` : line1), + [line1, line2], + ); + const [visibleCount, setVisibleCount] = useState(0); + + useEffect(() => { + setVisibleCount(0); + const len = fullText.length; + if (len === 0) { + return undefined; + } + let i = 0; + const id = window.setInterval(() => { + i += 1; + setVisibleCount(Math.min(i, len)); + if (i >= len) { + window.clearInterval(id); + } + }, SWIPE_HEADLINE_TYPING_MS_PER_CHAR); + return () => window.clearInterval(id); + }, [fullText]); + + const slice = fullText.slice(0, visibleCount); + const parts = slice.split('\n'); + const shownLine1 = parts[0] ?? ''; + const shownLine2 = parts.length > 1 ? parts.slice(1).join('\n') : undefined; + + return ( +
+ + {shownLine1} + {shownLine2 !== undefined ? ( + <> +
+ {shownLine2} + + ) : null} +
+
+ ); +} + +const SWIPE_PROGRESS_MILESTONE_SPARK_OFFSETS: ReadonlyArray<{ + tx: string; + ty: string; +}> = [ + { tx: '0rem', ty: '-1.5rem' }, + { tx: '0.6875rem', ty: '-1.25rem' }, + { tx: '-0.6875rem', ty: '-1.25rem' }, + { tx: '1.125rem', ty: '-0.5rem' }, + { tx: '-1.125rem', ty: '-0.5rem' }, + { tx: '1.375rem', ty: '0rem' }, + { tx: '-1.375rem', ty: '0rem' }, + { tx: '0.9375rem', ty: '0.5625rem' }, + { tx: '-0.9375rem', ty: '0.5625rem' }, + { tx: '0.5rem', ty: '1.0625rem' }, + { tx: '-0.5rem', ty: '1.0625rem' }, + { tx: '0rem', ty: '1.1875rem' }, + { tx: '1rem', ty: '-1rem' }, + { tx: '-1rem', ty: '-1rem' }, + { tx: '1.1875rem', ty: '0.75rem' }, + { tx: '-1.1875rem', ty: '0.75rem' }, +]; + +export type SwipeOnboardingProgressHeaderProps = { + /** Swipe count and/or tag selections — same scale as onboarding swipes (0–40+). */ + progressCount: number; + milestoneBurstKey: number; + copyVariant?: SwipeOnboardingProgressCopyVariant; +}; + +export function SwipeOnboardingProgressHeader({ + progressCount, + milestoneBurstKey, + copyVariant = 'swipe', +}: SwipeOnboardingProgressHeaderProps): ReactElement { + const progress = getSwipeOnboardingBarProgress(progressCount); + const { line1: headlineLine1, line2: headlineLine2 } = + getSwipeOnboardingHeadline(progressCount, copyVariant); + + return ( +
+ {/* eslint-disable-next-line react/no-unknown-property -- scoped keyframes for progress bar */} + + +
+
+
+
+
+ {milestoneBurstKey > 0 && progress > 0 && ( +
+ )} +
+
+
+
+
+
0 ? undefined : 0, + }} + /> +
+ {milestoneBurstKey > 0 && progress > 0 && ( +
+
+
+ {SWIPE_PROGRESS_MILESTONE_SPARK_OFFSETS.map( + (sp, sparkIndex) => ( + + ), + )} +
+
+ )} +
+ + {getSwipeOnboardingGuidanceMessage(progressCount, copyVariant)} + +
+
+ ); +} diff --git a/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts new file mode 100644 index 00000000000..5ed289ab8ff --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts @@ -0,0 +1,89 @@ +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..89422ed4efc --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.spec.ts @@ -0,0 +1,159 @@ +import { + getSwipeOnboardingBarProgress, + getSwipeOnboardingGuidanceMessage, + getSwipeOnboardingHeadline, +} from './swipeOnboardingGuidance'; + +describe('getSwipeOnboardingGuidanceMessage', () => { + it('returns starter tier with remaining count for 0 to 9 (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(0)).toBe( + 'Swipe 10 posts to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(1)).toBe( + 'Swipe 9 more posts to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(9)).toBe( + 'Swipe 1 more post to get started.', + ); + }); + + it('returns improve tier with remaining until 20 (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(10)).toBe( + 'Swipe 10 more posts.', + ); + expect(getSwipeOnboardingGuidanceMessage(15)).toBe( + 'Swipe 5 more posts.', + ); + expect(getSwipeOnboardingGuidanceMessage(19)).toBe( + 'Swipe 1 more post.', + ); + }); + + it('returns refine tier with remaining until 40 (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(20)).toBe( + 'Swipe 20 more posts.', + ); + expect(getSwipeOnboardingGuidanceMessage(30)).toBe( + 'Swipe 10 more posts.', + ); + expect(getSwipeOnboardingGuidanceMessage(39)).toBe( + 'Swipe 1 more post.', + ); + }); + + it('returns fine-tune copy for 40 or more (swipe)', () => { + expect(getSwipeOnboardingGuidanceMessage(40)).toBe('Swipe for fine-tune.'); + expect(getSwipeOnboardingGuidanceMessage(100)).toBe('Swipe for fine-tune.'); + }); + + it('returns tag variant with remaining counts per tier', () => { + expect(getSwipeOnboardingGuidanceMessage(0, 'tags')).toBe( + 'Pick 10 tags to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(3, 'tags')).toBe( + 'Pick 7 more tags to get started.', + ); + expect(getSwipeOnboardingGuidanceMessage(10, 'tags')).toBe( + 'Pick 10 more tags.', + ); + expect(getSwipeOnboardingGuidanceMessage(19, 'tags')).toBe( + 'Pick 1 more tag.', + ); + expect(getSwipeOnboardingGuidanceMessage(25, 'tags')).toBe( + 'Pick 15 more tags.', + ); + expect(getSwipeOnboardingGuidanceMessage(40, 'tags')).toBe( + 'Add tags for fine-tune.', + ); + }); +}); + +describe('getSwipeOnboardingHeadline', () => { + it('returns fixed two-line starter for 0–9 (swipe)', () => { + expect(getSwipeOnboardingHeadline(0)).toEqual({ + line1: 'Tune your feed.', + line2: 'Swipe on at least 10 posts to get started.', + }); + expect(getSwipeOnboardingHeadline(5)).toEqual({ + line1: 'Tune your feed.', + line2: 'Swipe on at least 10 posts to get started.', + }); + expect(getSwipeOnboardingHeadline(9)).toEqual({ + line1: 'Tune your feed.', + line2: 'Swipe on at least 10 posts to get started.', + }); + }); + + it('returns fixed improve-tier headline for 10–19 (swipe)', () => { + expect(getSwipeOnboardingHeadline(10)).toEqual({ + line1: 'Keep going, 10 more and it gets better.', + }); + expect(getSwipeOnboardingHeadline(15)).toEqual({ + line1: 'Keep going, 10 more and it gets better.', + }); + expect(getSwipeOnboardingHeadline(19)).toEqual({ + line1: 'Keep going, 10 more and it gets better.', + }); + }); + + it('returns fixed refine-tier headline for 20–39 (swipe)', () => { + expect(getSwipeOnboardingHeadline(20)).toEqual({ + line1: "You're getting there, 20 more to dial it in.", + }); + expect(getSwipeOnboardingHeadline(30)).toEqual({ + line1: "You're getting there, 20 more to dial it in.", + }); + expect(getSwipeOnboardingHeadline(39)).toEqual({ + line1: "You're getting there, 20 more to dial it in.", + }); + }); + + it('returns completion headline at 40 and above (swipe)', () => { + expect(getSwipeOnboardingHeadline(40)).toEqual({ + line1: "You're all set! Keep swiping to fine-tune.", + }); + }); + + it('uses fixed tag wording per tier when variant is tags', () => { + expect(getSwipeOnboardingHeadline(0, 'tags')).toEqual({ + line1: 'Tune your feed.', + line2: 'Pick at least 10 tags to get started.', + }); + expect(getSwipeOnboardingHeadline(7, 'tags')).toEqual({ + line1: 'Tune your feed.', + line2: 'Pick at least 10 tags to get started.', + }); + expect(getSwipeOnboardingHeadline(12, 'tags')).toEqual({ + line1: 'Keep going, 10 more and it gets better.', + }); + expect(getSwipeOnboardingHeadline(35, 'tags')).toEqual({ + line1: "You're getting there, 20 more to dial it in.", + }); + expect(getSwipeOnboardingHeadline(40, 'tags')).toEqual({ + line1: "You're all set! Keep adding tags to fine-tune.", + }); + }); +}); + +describe('getSwipeOnboardingBarProgress', () => { + it('fills 0 to 100% for swipes 0 to 9, then shows 25% at swipe 10 (first quarter)', () => { + expect(getSwipeOnboardingBarProgress(0)).toBe(0); + expect(getSwipeOnboardingBarProgress(5)).toBe(50); + expect(getSwipeOnboardingBarProgress(9)).toBe(90); + expect(getSwipeOnboardingBarProgress(10)).toBe(25); + }); + + it('advances each 25% segment over the next 10 swipes through 40', () => { + expect(getSwipeOnboardingBarProgress(15)).toBe(37.5); + expect(getSwipeOnboardingBarProgress(19)).toBe(47.5); + expect(getSwipeOnboardingBarProgress(20)).toBe(50); + expect(getSwipeOnboardingBarProgress(30)).toBe(75); + expect(getSwipeOnboardingBarProgress(39)).toBe(97.5); + expect(getSwipeOnboardingBarProgress(40)).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..882b4b9a55f --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingGuidance.ts @@ -0,0 +1,141 @@ +/** Minimum swipes before “Continue” unlocks (tier 0 ends at this minus 1). */ +export const SWIPE_ONBOARDING_MIN_TO_UNLOCK = 10; + +/** Second staircase (refine tier starts at this count). */ +export const SWIPE_ONBOARDING_IMPROVE_MILESTONE = 20; + +/** Swipe count at which the bar is full and “all set” copy starts. */ +export const SWIPE_ONBOARDING_REFINE_TARGET = 40; + +/** Number of equal segments on the onboarding progress bar (25% each). */ +export const SWIPE_ONBOARDING_BAR_STAGE_COUNT = 4; + +const swipesPerBarStage = + SWIPE_ONBOARDING_REFINE_TARGET / SWIPE_ONBOARDING_BAR_STAGE_COUNT; + +if (!Number.isInteger(swipesPerBarStage)) { + throw new Error( + 'SWIPE_ONBOARDING_REFINE_TARGET must divide evenly by SWIPE_ONBOARDING_BAR_STAGE_COUNT', + ); +} + +const barSegmentPercent = 100 / SWIPE_ONBOARDING_BAR_STAGE_COUNT; + +/** + * Progress bar fill (0 to 100). First {@link SWIPE_ONBOARDING_MIN_TO_UNLOCK} steps + * animate the full bar from 0% to 100%; after that the bar uses four 25% segments (10 steps + * each) up to {@link SWIPE_ONBOARDING_REFINE_TARGET}. + */ +export function getSwipeOnboardingBarProgress(progressCount: number): number { + const n = Math.max(0, progressCount); + + if (n >= SWIPE_ONBOARDING_REFINE_TARGET) { + return 100; + } + + if (n < SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return (n / SWIPE_ONBOARDING_MIN_TO_UNLOCK) * 100; + } + + const stageIndex = Math.floor(n / swipesPerBarStage); + const positionInStage = n % swipesPerBarStage; + + return ( + stageIndex * barSegmentPercent + + (positionInStage / swipesPerBarStage) * barSegmentPercent + ); +} + +export type SwipeOnboardingHeadline = { + line1: string; + line2?: string; +}; + +/** Same progress tiers as swipes; copy refers to tags instead. */ +export type SwipeOnboardingProgressCopyVariant = 'swipe' | 'tags'; + +function unitWord( + count: number, + singular: string, + plural: string, +): string { + return count === 1 ? singular : plural; +} + +/** + * Main title above the progress bar (one or two lines). + * Copy is fixed per tier only — counts update in {@link getSwipeOnboardingGuidanceMessage}, not here. + */ +export function getSwipeOnboardingHeadline( + progressCount: number, + variant: SwipeOnboardingProgressCopyVariant = 'swipe', +): SwipeOnboardingHeadline { + const n = Math.max(0, progressCount); + + if (n < SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + return { + line1: 'Tune your feed.', + line2: + variant === 'tags' + ? 'Pick at least 10 tags to get started.' + : 'Swipe on at least 10 posts to get started.', + }; + } + + if (n >= SWIPE_ONBOARDING_REFINE_TARGET) { + return { + line1: + variant === 'tags' + ? "You're all set! Keep adding tags to fine-tune." + : "You're all set! Keep swiping to fine-tune.", + }; + } + + if (n < SWIPE_ONBOARDING_IMPROVE_MILESTONE) { + return { line1: 'Keep going, 10 more and it gets better.' }; + } + + return { line1: "You're getting there, 20 more to dial it in." }; +} + +/** + * Action-based copy for onboarding progress (swipes or tag picks use the same thresholds). + */ +export function getSwipeOnboardingGuidanceMessage( + progressCount: number, + variant: SwipeOnboardingProgressCopyVariant = 'swipe', +): string { + const n = Math.max(0, progressCount); + + if (n >= SWIPE_ONBOARDING_REFINE_TARGET) { + return variant === 'tags' ? 'Add tags for fine-tune.' : 'Swipe for fine-tune.'; + } + + if (n >= SWIPE_ONBOARDING_IMPROVE_MILESTONE) { + const remaining = SWIPE_ONBOARDING_REFINE_TARGET - n; + return variant === 'tags' + ? `Pick ${remaining} more ${unitWord(remaining, 'tag', 'tags')}.` + : `Swipe ${remaining} more ${unitWord(remaining, 'post', 'posts')}.`; + } + + if (n >= SWIPE_ONBOARDING_MIN_TO_UNLOCK) { + const remaining = SWIPE_ONBOARDING_IMPROVE_MILESTONE - n; + return variant === 'tags' + ? `Pick ${remaining} more ${unitWord(remaining, 'tag', 'tags')}.` + : `Swipe ${remaining} more ${unitWord(remaining, 'post', 'posts')}.`; + } + + if (variant === 'tags') { + if (n === 0) { + return 'Pick 10 tags to get started.'; + } + const remaining = SWIPE_ONBOARDING_MIN_TO_UNLOCK - n; + return `Pick ${remaining} more ${unitWord(remaining, 'tag', 'tags')} to get started.`; + } + + if (n === 0) { + return 'Swipe 10 posts to get started.'; + } + const remaining = SWIPE_ONBOARDING_MIN_TO_UNLOCK - n; + return `Swipe ${remaining} more ${unitWord(remaining, 'post', 'posts')} to get started.`; +} diff --git a/packages/webapp/lib/swipeOnboardingPopularDeck.ts b/packages/webapp/lib/swipeOnboardingPopularDeck.ts new file mode 100644 index 00000000000..c99c87689ef --- /dev/null +++ b/packages/webapp/lib/swipeOnboardingPopularDeck.ts @@ -0,0 +1,77 @@ +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; +}; + +/** + * 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 seenIds = new Set(); + const strictPosts: Post[] = []; + const relaxedPosts: Post[] = []; + + let cursor: string | undefined; + let pages = 0; + + while (pages < MAX_PAGES) { + 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 ?? []; + + for (const { node } of edges) { + if (seenIds.has(node.id)) { + continue; + } + seenIds.add(node.id); + if (isSwipeOnboardingEligiblePost(node)) { + strictPosts.push(node); + } else if (isSwipeOnboardingRelaxedEligiblePost(node)) { + relaxedPosts.push(node); + } + } + + pages += 1; + + const hasNext = conn?.pageInfo?.hasNextPage === true; + const endCursor = conn?.pageInfo?.endCursor ?? undefined; + + if (strictPosts.length >= TARGET_STRICT_COUNT || !hasNext || !endCursor) { + break; + } + cursor = endCursor; + } + + if (strictPosts.length >= STRICT_MIN_BEFORE_RELAXED) { + return strictPosts.slice(0, MAX_DECK_POSTS); + } + + const merged = [...strictPosts, ...relaxedPosts]; + return merged.slice(0, MAX_DECK_POSTS); +} diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 7fa921b35be..971778867a8 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -46,6 +46,8 @@ import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structu import { fromCDN } from '@dailydotdev/shared/src/lib'; import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { useCheckCoresRole } from '@dailydotdev/shared/src/hooks/useCheckCoresRole'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { swipeOnboardingFeature } from '@dailydotdev/shared/src/lib/featureManagement'; import { messageHandlerExists, postWebKitMessage, @@ -89,6 +91,7 @@ const onboardingExcludedPaths = [ const hotAndColdModalQueryKey = 'openModal'; const hotAndColdModalQueryValue = 'hottakes'; const hotAndColdModalLegacyQueryValue = 'hotAndCold'; +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; const isOnboardingExcludedPath = (pathname: string): boolean => onboardingExcludedPaths.some((path) => pathname.startsWith(path)); @@ -141,6 +144,12 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { const { unreadCount } = useNotificationContext(); const unreadText = getUnreadText(unreadCount); const { user, trackingId, isFunnel } = useAuthContext(); + const { + value: isSwipeOnboardingEnabled, + isLoading: isSwipeOnboardingLoading, + } = useConditionalFeature({ + feature: swipeOnboardingFeature, + }); const { showBanner, onAcceptCookies, onOpenBanner, onHideBanner } = useCookieBanner(); useWebVitals(); @@ -160,6 +169,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) { @@ -196,12 +213,25 @@ function InternalApp({ Component, pageProps, router }: AppProps): ReactElement { if ( !isFunnel && isOnboardingActionsReady && + (!isSwipeOnboardingLoading || isSwipeOnboardingPreviewForced) && !isOnboardingComplete && !isOnboardingExcludedPath(router.pathname) ) { - router.replace('/onboarding'); + const destination = + isSwipeOnboardingEnabled || isSwipeOnboardingPreviewForced + ? '/onboarding/swipe' + : '/onboarding'; + router.replace(destination); } - }, [isFunnel, isOnboardingActionsReady, router, isOnboardingComplete]); + }, [ + isFunnel, + isOnboardingActionsReady, + isOnboardingComplete, + isSwipeOnboardingEnabled, + isSwipeOnboardingLoading, + isSwipeOnboardingPreviewForced, + router, + ]); useEffect(() => { const id = user?.id || trackingId; diff --git a/packages/webapp/pages/onboarding/swipe.tsx b/packages/webapp/pages/onboarding/swipe.tsx new file mode 100644 index 00000000000..e4a56d23050 --- /dev/null +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -0,0 +1,468 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import type { NextSeoProps } from 'next-seo'; +import AuthOptions from '@dailydotdev/shared/src/components/auth/AuthOptions'; +import { AuthDisplay } from '@dailydotdev/shared/src/components/auth/common'; +import type { AuthOptionsProps } from '@dailydotdev/shared/src/components/auth/common'; +import { + EditTag, + OnboardingHeader, +} from '@dailydotdev/shared/src/components/onboarding'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalSize } from '@dailydotdev/shared/src/components/modals/common/types'; +import { + FooterLinks, + withFeaturesBoundary, +} from '@dailydotdev/shared/src/components'; +import { ErrorBoundary } from '@dailydotdev/shared/src/components/ErrorBoundary'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { redirectToApp } from '@dailydotdev/shared/src/features/onboarding/lib/utils'; +import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; +import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; +import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; +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 type { OnboardingSwipeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal'; +import { useQuery } from '@tanstack/react-query'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import useFeedSettings from '@dailydotdev/shared/src/hooks/useFeedSettings'; +import useTagAndSource from '@dailydotdev/shared/src/hooks/useTagAndSource'; +import { swipeOnboardingFeature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { getPageSeoTitles } from '../../components/layouts/utils'; +import { SwipeOnboardingProgressHeader } from '../../components/onboarding/SwipeOnboardingProgressHeader'; +import { fetchSwipeOnboardingPopularDeck } from '../../lib/swipeOnboardingPopularDeck'; +import { + SWIPE_ONBOARDING_IMPROVE_MILESTONE, + SWIPE_ONBOARDING_MIN_TO_UNLOCK, + SWIPE_ONBOARDING_REFINE_TARGET, +} from '../../lib/swipeOnboardingGuidance'; +import { defaultOpenGraph, defaultSeo } from '../../next-seo'; + +const seoTitles = getPageSeoTitles('Swipe onboarding'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, + ...defaultSeo, +}; +const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; +const MIN_SWIPES_TO_CONTINUE = SWIPE_ONBOARDING_MIN_TO_UNLOCK; + +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; + +/** + * Test-only: clears legacy followed tags when opening "Use tags instead" (unfollows + * `includeTags` once per tags visit). Set toggle to `false` before shipping. You can + * also set `NEXT_PUBLIC_SWIPE_ONBOARDING_TEST_CLEAR_TAGS=true` in `.env.local` instead + * of using the toggle. + */ +const SWIPE_ONBOARDING_TEST_CLEAR_PRESELECTED_TAGS_TOGGLE = true; +const shouldClearPreselectedTagsForSwipeOnboardingTest = + SWIPE_ONBOARDING_TEST_CLEAR_PRESELECTED_TAGS_TOGGLE || + process.env.NEXT_PUBLIC_SWIPE_ONBOARDING_TEST_CLEAR_TAGS === 'true'; + +function SwipeOnboardingPage(): ReactElement { + const router = useRouter(); + const formRef = useRef(null as unknown as HTMLFormElement); + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { completeStep } = useOnboardingActions(); + const [swipesCount, setSwipesCount] = useState(0); + const [milestoneBurstKey, setMilestoneBurstKey] = useState(0); + const [onboardingUiMode, setOnboardingUiMode] = useState<'swipe' | 'tags'>( + 'swipe', + ); + const [rightSwipedPostIds, setRightSwipedPostIds] = useState>( + () => new Set(), + ); + const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState< + Set + >(() => new Set()); + const swipeOnboardingTestTagsClearDoneForSessionRef = useRef(false); + const prevSwipesForMilestoneRef = useRef(null); + + const { + data: deckPosts = [], + isPending: isCardsPending, + refetch: refetchSwipeDeck, + isFetching: isSwipeDeckFetching, + } = useQuery({ + queryKey: ['onboarding-swipe-popular-cards'], + queryFn: fetchSwipeOnboardingPopularDeck, + enabled: isLoggedIn, + staleTime: 1000 * 60 * 2, + }); + const { + value: isSwipeOnboardingEnabled, + isLoading: isSwipeOnboardingLoading, + } = useConditionalFeature({ + feature: swipeOnboardingFeature, + }); + const swipeOnboardingPreviewQuery = + router.query[swipeOnboardingPreviewQueryKey]; + const isSwipeOnboardingPreviewForced = + router.isReady && + (swipeOnboardingPreviewQuery === '1' || + swipeOnboardingPreviewQuery === 'true' || + (Array.isArray(swipeOnboardingPreviewQuery) && + (swipeOnboardingPreviewQuery.includes('1') || + swipeOnboardingPreviewQuery.includes('true')))); + + useEffect(() => { + if ( + !router.isReady || + isSwipeOnboardingLoading || + isSwipeOnboardingEnabled || + isSwipeOnboardingPreviewForced + ) { + return; + } + router.replace('/onboarding').catch(() => null); + }, [ + isSwipeOnboardingEnabled, + isSwipeOnboardingLoading, + isSwipeOnboardingPreviewForced, + router, + ]); + + const onComplete = useCallback(async () => { + completeStep(ActionType.CompletedOnboarding); + completeStep(ActionType.EditTag); + completeStep(ActionType.ContentTypes); + await redirectToApp(router); + }, [completeStep, router]); + + const { feedSettings, isLoading: isFeedSettingsLoading } = useFeedSettings({ + enabled: isLoggedIn, + }); + const { onFollowTags, onUnfollowTags } = useTagAndSource({ + origin: Origin.Onboarding, + }); + + const selectedTagCount = feedSettings?.includeTags?.length ?? 0; + const onboardingProgressCount = + onboardingUiMode === 'tags' ? selectedTagCount : swipesCount; + + useEffect(() => { + const prev = prevSwipesForMilestoneRef.current; + prevSwipesForMilestoneRef.current = onboardingProgressCount; + if (prev === null) { + return; + } + const crossedMilestone = SWIPE_ONBOARDING_PROGRESS_MILESTONES.find( + (m) => prev < m && onboardingProgressCount >= m, + ); + if (crossedMilestone !== undefined) { + setMilestoneBurstKey((k) => k + 1); + } + }, [onboardingProgressCount]); + + const handleSwipeInteraction = useCallback( + ( + direction: 'left' | 'right' | 'skip', + meta?: { onboardingCardId?: string }, + ) => { + if (direction === 'left' || direction === 'right') { + setSwipesCount((value) => value + 1); + if (direction === 'right' && meta?.onboardingCardId) { + const cardId = meta.onboardingCardId; + setRightSwipedPostIds((prev) => { + const next = new Set(prev); + next.add(cardId); + return next; + }); + } + } + }, + [], + ); + + const authOptionProps: AuthOptionsProps = useMemo( + () => ({ + simplified: true, + forceDefaultDisplay: true, + trigger: AuthTriggers.Onboarding, + formRef, + defaultDisplay: AuthDisplay.OnboardingSignup, + className: { + container: classNames('w-full rounded-none tablet:max-w-[30rem]'), + onboardingSignup: '!gap-5 !pb-5 tablet:gap-8 tablet:pb-8', + }, + onboardingSignupButton: { + size: ButtonSize.Medium, + variant: ButtonVariant.Primary, + }, + }), + [], + ); + const onboardingCards = useMemo( + () => + deckPosts.map((node) => ({ + id: node.id, + title: node.title, + image: node.image, + tags: node.tags, + source: { + name: node.source?.name, + image: node.source?.image, + }, + })), + [deckPosts], + ); + + useEffect(() => { + if (onboardingUiMode !== 'tags') { + swipeOnboardingTestTagsClearDoneForSessionRef.current = false; + return; + } + if (!shouldClearPreselectedTagsForSwipeOnboardingTest) { + return; + } + if (swipeOnboardingTestTagsClearDoneForSessionRef.current) { + return; + } + const existing = feedSettings?.includeTags ?? []; + if (existing.length === 0) { + swipeOnboardingTestTagsClearDoneForSessionRef.current = true; + return; + } + swipeOnboardingTestTagsClearDoneForSessionRef.current = true; + onUnfollowTags({ tags: [...existing] }).catch(() => null); + }, [onboardingUiMode, feedSettings?.includeTags, onUnfollowTags]); + + const tagsFromRightSwipes = useMemo(() => { + const seen = new Set(); + const ordered: string[] = []; + onboardingCards + .filter((card) => rightSwipedPostIds.has(card.id)) + .forEach((card) => { + (card.tags ?? []).forEach((tag) => { + if ( + !seen.has(tag) && + ordered.length < SWIPE_ONBOARDING_TAG_SEED_MAX + ) { + seen.add(tag); + ordered.push(tag); + } + }); + }); + return ordered; + }, [onboardingCards, rightSwipedPostIds]); + + useEffect(() => { + if (onboardingUiMode !== 'tags') { + return; + } + const included = new Set(feedSettings?.includeTags ?? []); + const toFollow = tagsFromRightSwipes.filter((t) => !included.has(t)); + if (toFollow.length === 0) { + return; + } + onFollowTags({ tags: toFollow }).catch(() => null); + }, [ + onboardingUiMode, + tagsFromRightSwipes, + feedSettings?.includeTags, + onFollowTags, + ]); + + if (!isAuthReady) { + return
; + } + + if (!router.isReady) { + return
; + } + + if ( + !isSwipeOnboardingLoading && + !isSwipeOnboardingEnabled && + !isSwipeOnboardingPreviewForced + ) { + return
; + } + + if (!isLoggedIn) { + return ( +
+ +
+ +
+ +
+ ); + } + + const canContinue = + onboardingUiMode === 'swipe' + ? swipesCount >= MIN_SWIPES_TO_CONTINUE + : swipesCount >= MIN_SWIPES_TO_CONTINUE || + selectedTagCount >= MIN_SWIPES_TO_CONTINUE; + + const bottomContinueSlot = canContinue ? ( +
+ +
+ ) : null; + + return ( +
+ {onboardingUiMode === 'swipe' ? ( + { + refetchSwipeDeck().catch(() => null); + }} + onSwipeAction={(direction, meta) => { + handleSwipeInteraction(direction, meta); + }} + onboardingCards={onboardingCards} + onboardingCardsLoading={isCardsPending} + headerSlot={ +
+ +
+ } + topSlot={ + + } + bottomSlot={bottomContinueSlot} + onRequestClose={() => { + onComplete().catch(() => null); + }} + /> + ) : ( + { + onComplete().catch(() => null); + }} + size={ModalSize.Small} + > + +
+ +
+ +
+
+ {isFeedSettingsLoading || !feedSettings ? ( +
+ +
+ ) : ( + + )} +
+ {canContinue ? ( +
+ {bottomContinueSlot} +
+ ) : null} +
+
+
+ )} +
+ ); +} + +function Page(): ReactElement { + return ( + + + + ); +} + +Page.layoutProps = { seo }; + +export default withFeaturesBoundary(Page);