From eee57bc3346f2669e2a5f691a5b446d2df6333f1 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 5 Apr 2026 19:19:42 +0300 Subject: [PATCH 1/3] feat: add swipe-based onboarding flow with hot takes cards Introduce a standalone onboarding swipe experience gated by feature flag with URL preview override, reusing Hot Takes swipe behavior while layering onboarding-specific progress, card content, and guidance UI. Made-with: Cursor --- packages/shared/src/components/MainLayout.tsx | 40 +- .../modals/hotTakes/HotAndColdModal.tsx | 465 ++++++++++++++---- packages/shared/src/lib/constants.ts | 1 + packages/shared/src/lib/featureManagement.ts | 2 + packages/webapp/pages/_app.tsx | 34 +- packages/webapp/pages/onboarding/swipe.tsx | 289 +++++++++++ 6 files changed, 729 insertions(+), 102 deletions(-) create mode 100644 packages/webapp/pages/onboarding/swipe.tsx 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..20571d95088 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'; @@ -35,6 +41,7 @@ const SKIP_DISMISS_FLY_DISTANCE = 600; const SKIP_DRAG_ELASTICITY_FACTOR = 0.3; const COLD_ACCENT_COLOR = '#123a88'; const HOT_TAKE_CARD_HEIGHT = '28rem'; +const ONBOARDING_CARD_HEIGHT = '24rem'; const getElasticDelta = (delta: number): number => { const absoluteDelta = Math.abs(delta); @@ -794,6 +801,146 @@ const HotTakeCard = ({ ); }; +const OnboardingPostCard = ({ + card, + isTop, + offset, + swipeDelta, + skipDeltaY = 0, + isDismissAnimating, + isDragging, + dismissDurationMs, +}: { + card: OnboardingSwipeCard; + isTop: boolean; + offset: number; + swipeDelta: number; + skipDeltaY?: number; + isDismissAnimating: boolean; + isDragging: boolean; + dismissDurationMs: number; +}): 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) { + transition = 'none'; + } else { + transition = 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)'; + } + } + + return ( +
event.preventDefault()} + style={{ + 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 INTERESTING'} +
+ )} + {card.image ? ( + {card.title + ) : ( +
+ )} +
+
+ {card.source?.image ? ( + {card.source.name + ) : ( +
+ )} + + {card.source?.name || 'daily.dev'} + +
+ + {card.title || 'Popular developer story'} + +
+
+ ); +}; + const EmptyState = ({ onClose, username, @@ -834,10 +981,45 @@ const EmptyState = ({
); +type SwipeActionDirection = 'left' | 'right' | 'skip'; + +export interface OnboardingSwipeCard { + id: string; + title?: string; + image?: string | null; + 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) => void; + onboardingCards?: OnboardingSwipeCard[]; + onboardingCardsLoading?: boolean; +} + const HotAndColdModal = ({ onRequestClose, + title = 'Hot Takes', + headerSlot, + topSlot, + bottomSlot, + showHeader = true, + showDefaultActions = true, + showAddHotTakeButton = true, + onSwipeAction, + onboardingCards, + onboardingCardsLoading = false, ...props -}: ModalProps): ReactElement => { +}: HotAndColdModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = useDiscoverHotTakes(); const { toggleUpvote, toggleDownvote, cancelHotTakeVote } = useVoteHotTake(); @@ -855,6 +1037,25 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); + const [dismissedCardIds, setDismissedCardIds] = useState>( + () => new Set(), + ); + + 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 cardHeight = isOnboardingMode + ? ONBOARDING_CARD_HEIGHT + : HOT_TAKE_CARD_HEIGHT; useEffect(() => { animatingTakeIdRef.current = animatingTakeId; @@ -926,17 +1127,29 @@ const HotAndColdModal = ({ swipeDeltaYRef.current = 0; animatingTakeIdRef.current = null; setAnimatingTakeId(null); - dismissCurrent(); + if (isOnboardingMode && currentOnboardingCard) { + setDismissedCardIds((prev) => { + const next = new Set(prev); + next.add(currentOnboardingCard.id); + return next; + }); + } else { + dismissCurrent(); + } setIsAnimating(false); dismissTimerRef.current = null; }, durationMs); }, - [dismissCurrent], + [currentOnboardingCard, dismissCurrent, isOnboardingMode], ); const handleDismiss = useCallback( (direction: 'left' | 'right', source: 'swipe' | 'button' = 'swipe') => { - if (!currentTake || isAnimating) { + const currentItemId = isOnboardingMode + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } @@ -948,21 +1161,24 @@ 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); let initialPush: number; let flyDistance: number; @@ -986,7 +1202,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 +1210,40 @@ const HotAndColdModal = ({ }, [ currentTake, + currentOnboardingCard, + isOnboardingMode, isAnimating, startDismissAnimation, toggleDownvote, toggleUpvote, logEvent, + onSwipeAction, swipeDelta, ], ); const handleSkip = useCallback( (source: 'swipe' | 'button' = 'button') => { - if (!currentTake || isAnimating) { + const currentItemId = isOnboardingMode + ? currentOnboardingCard?.id + : currentTake?.id; + + if (!currentItemId || isAnimating) { return; } 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,14 +1252,20 @@ const HotAndColdModal = ({ [ cancelHotTakeVote, currentTake, + currentOnboardingCard, + isOnboardingMode, isAnimating, startDismissAnimation, logEvent, + onSwipeAction, ], ); + 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; @@ -1089,9 +1321,10 @@ const HotAndColdModal = ({ return ( - + {showHeader && } - {isLoading && ( + {headerSlot} + {isModalLoading && (
)} - {!isLoading && isEmpty && ( + {!isModalLoading && isModalEmpty && ( )} - {!isLoading && !isEmpty && currentTake && ( + {!isModalLoading && !isModalEmpty && currentCardId && ( <> + {topSlot}
- {nextTake && ( - + {isOnboardingMode ? ( + <> + {nextOnboardingCard && ( + + )} + {currentOnboardingCard && ( + + )} + + ) : ( + <> + {nextTake && ( + + )} + {currentTake && ( + + )} + )} -
-
-
+ {showDefaultActions && ( +
+
+ )} + + {bottomSlot} - {user?.username && ( + {showAddHotTakeButton && user?.username && (
+ )} +
+ } + onRequestClose={() => { + onComplete().catch(() => null); + }} + /> +
+ ); +} + +function Page(): ReactElement { + return ( + + + + ); +} + +Page.layoutProps = { seo }; + +export default withFeaturesBoundary(Page); From 9baf48811aa2f970640f8cc0a00e179b423b0b7b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 6 Apr 2026 16:15:25 +0300 Subject: [PATCH 2/3] feat(onboarding): swipe flow progress, tags mode, and popular deck - Add SwipeOnboardingProgressHeader with tiered copy, typing headline, milestone FX fixes\n- Tags mode: shared progress, tag-weighted bar, floating CTA, no feed preview in EditTag\n- Paginated mostUpvoted deck with machine-first + relaxed fallback; recycle deck; onboarding empty/retry\n- HotAndColdModal: 4s intro repeat, loading/empty copy for onboarding vs hot takes\n- Guidance helpers + tests; eligible-post filter tests Made-with: Cursor --- .../modals/hotTakes/HotAndColdModal.tsx | 1107 ++++++++++++++--- .../src/components/onboarding/EditTag.tsx | 39 - .../onboarding/steps/FunnelEditTags.tsx | 8 +- .../SwipeOnboardingProgressHeader.tsx | 392 ++++++ .../lib/swipeOnboardingEligiblePosts.spec.ts | 89 ++ .../lib/swipeOnboardingEligiblePosts.ts | 40 + .../lib/swipeOnboardingGuidance.spec.ts | 159 +++ .../webapp/lib/swipeOnboardingGuidance.ts | 141 +++ .../webapp/lib/swipeOnboardingPopularDeck.ts | 77 ++ packages/webapp/pages/onboarding/swipe.tsx | 420 +++++-- 10 files changed, 2139 insertions(+), 333 deletions(-) create mode 100644 packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx create mode 100644 packages/webapp/lib/swipeOnboardingEligiblePosts.spec.ts create mode 100644 packages/webapp/lib/swipeOnboardingEligiblePosts.ts create mode 100644 packages/webapp/lib/swipeOnboardingGuidance.spec.ts create mode 100644 packages/webapp/lib/swipeOnboardingGuidance.ts create mode 100644 packages/webapp/lib/swipeOnboardingPopularDeck.ts diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 20571d95088..95f70400403 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -18,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, @@ -28,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; @@ -41,7 +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'; -const ONBOARDING_CARD_HEIGHT = '24rem'; +/** 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); @@ -810,6 +1310,7 @@ const OnboardingPostCard = ({ isDismissAnimating, isDragging, dismissDurationMs, + useInstantSwipeTransform = false, }: { card: OnboardingSwipeCard; isTop: boolean; @@ -819,6 +1320,7 @@ const OnboardingPostCard = ({ isDismissAnimating: boolean; isDragging: boolean; dismissDurationMs: number; + useInstantSwipeTransform?: boolean; }): ReactElement => { const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; let swipeDirection: 'left' | 'right' | null = null; @@ -852,7 +1354,7 @@ const OnboardingPostCard = ({ 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) { + } else if (isDragging || useInstantSwipeTransform) { transition = 'none'; } else { transition = 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)'; @@ -862,15 +1364,15 @@ const OnboardingPostCard = ({ 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, @@ -889,26 +1391,16 @@ const OnboardingPostCard = ({ className={classNames( 'z-20 absolute left-1/2 top-3 -translate-x-1/2 rounded-10 px-3 py-1 font-bold text-white typo-callout', swipeDirection === 'right' - ? 'bg-accent-cabbage-default' + ? 'bg-accent-avocado-default' : 'bg-accent-bacon-default', )} style={{ opacity: swipeIntensity }} > - {swipeDirection === 'right' ? 'INTERESTING' : 'NOT INTERESTING'} + {swipeDirection === 'right' ? 'INTERESTING' : 'NOT'}
)} - {card.image ? ( - {card.title - ) : ( -
- )} -
-
+
+
{card.source?.image ? ( {card.source.name
- - {card.title || 'Popular developer story'} - + + {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, @@ -983,10 +1532,15 @@ 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; @@ -1001,9 +1555,19 @@ interface HotAndColdModalProps extends ModalProps { showHeader?: boolean; showDefaultActions?: boolean; showAddHotTakeButton?: boolean; - onSwipeAction?: (direction: SwipeActionDirection) => void; + 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 = ({ @@ -1018,6 +1582,11 @@ const HotAndColdModal = ({ onSwipeAction, onboardingCards, onboardingCardsLoading = false, + dismissedOnboardingCardIds, + onDismissedOnboardingCardsChange, + onOnboardingFeedRetry, + onboardingFeedRefetching = false, + className, ...props }: HotAndColdModalProps): ReactElement => { const { currentTake, nextTake, isEmpty, isLoading, dismissCurrent } = @@ -1037,9 +1606,35 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); - const [dismissedCardIds, setDismissedCardIds] = useState>( - () => new Set(), + 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( @@ -1053,8 +1648,8 @@ const HotAndColdModal = ({ const isModalEmpty = isOnboardingMode ? !isModalLoading && !currentOnboardingCard : isEmpty; - const cardHeight = isOnboardingMode - ? ONBOARDING_CARD_HEIGHT + const swipeAreaHeight = isOnboardingMode + ? ONBOARDING_SWIPE_AREA_HEIGHT : HOT_TAKE_CARD_HEIGHT; useEffect(() => { @@ -1082,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, @@ -1128,9 +1801,13 @@ const HotAndColdModal = ({ animatingTakeIdRef.current = null; setAnimatingTakeId(null); if (isOnboardingMode && currentOnboardingCard) { - setDismissedCardIds((prev) => { + 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 { @@ -1140,7 +1817,13 @@ const HotAndColdModal = ({ dismissTimerRef.current = null; }, durationMs); }, - [currentOnboardingCard, dismissCurrent, isOnboardingMode], + [ + currentOnboardingCard, + dismissCurrent, + isOnboardingMode, + onboardingCards, + updateDismissedCardIds, + ], ); const handleDismiss = useCallback( @@ -1153,6 +1836,8 @@ const HotAndColdModal = ({ return; } + abortOnboardingIntro(); + const isButtonSource = source === 'button'; const durationMs = isButtonSource ? BUTTON_DISMISS_ANIMATION_MS @@ -1178,7 +1863,10 @@ const HotAndColdModal = ({ }); } } - onSwipeAction?.(direction); + onSwipeAction?.( + direction, + isOnboardingMode ? { onboardingCardId: currentItemId } : undefined, + ); let initialPush: number; let flyDistance: number; @@ -1219,6 +1907,7 @@ const HotAndColdModal = ({ logEvent, onSwipeAction, swipeDelta, + abortOnboardingIntro, ], ); @@ -1232,6 +1921,8 @@ const HotAndColdModal = ({ return; } + abortOnboardingIntro(); + logEvent({ event_name: LogEvent.SkipHotTake, target_id: currentItemId, @@ -1258,6 +1949,7 @@ const HotAndColdModal = ({ startDismissAnimation, logEvent, onSwipeAction, + abortOnboardingIntro, ], ); @@ -1269,6 +1961,15 @@ const HotAndColdModal = ({ 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); @@ -1285,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); @@ -1300,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 @@ -1319,159 +2032,215 @@ const HotAndColdModal = ({ touchEventOptions: { passive: false }, }); + const cardSwipeArea = ( +
+ {isOnboardingMode ? ( + <> + {nextOnboardingCard && ( + + )} + {currentOnboardingCard && ( + + )} + + + ) : ( + <> + {nextTake && ( + + )} + {currentTake && ( + + )} + + )} +
+ ); + return ( - + {showHeader && } - + {headerSlot} {isModalLoading && ( -
+
+ {isOnboardingMode ? : null} - Loading hot takes... + {isOnboardingMode ? 'Loading stories…' : 'Loading hot takes...'}
)} - {!isModalLoading && isModalEmpty && ( + {!isModalLoading && isModalEmpty && isOnboardingMode && ( + + )} + + {!isModalLoading && isModalEmpty && !isOnboardingMode && ( )} {!isModalLoading && !isModalEmpty && currentCardId && ( <> - {topSlot} -
- {isOnboardingMode ? ( - <> - {nextOnboardingCard && ( - - )} - {currentOnboardingCard && ( - + {topSlot} + {cardSwipeArea} +
+ handleDismiss('right', 'button')} + onNotInteresting={() => handleDismiss('left', 'button')} + /> +
+ {bottomSlot} +
+ ) : ( + <> + {cardSwipeArea} + {showDefaultActions && ( +
+
- - {showDefaultActions && ( -
-
- )} - - {bottomSlot} - - {showAddHotTakeButton && user?.username && ( -
- -
+
+ )} + {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/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/onboarding/swipe.tsx b/packages/webapp/pages/onboarding/swipe.tsx index 95f29a604e6..0773348810b 100644 --- a/packages/webapp/pages/onboarding/swipe.tsx +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -1,17 +1,29 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useEffect, useMemo, useRef } 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 { OnboardingHeader } from '@dailydotdev/shared/src/components/onboarding'; +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'; @@ -22,26 +34,24 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { - DownvoteIcon, - UpvoteIcon, -} from '@dailydotdev/shared/src/components/icons'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +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 Logo, { LogoPosition } from '@dailydotdev/shared/src/components/Logo'; -import { - Typography, - TypographyColor, - TypographyType, -} from '@dailydotdev/shared/src/components/typography/Typography'; import { useQuery } from '@tanstack/react-query'; -import { MOST_UPVOTED_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed'; -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; 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'); @@ -51,33 +61,57 @@ const seo: NextSeoProps = { ...defaultSeo, }; const swipeOnboardingPreviewQueryKey = 'swipeOnboardingPreview'; -const MIN_SWIPES_TO_CONTINUE = 10; +const MIN_SWIPES_TO_CONTINUE = SWIPE_ONBOARDING_MIN_TO_UNLOCK; -interface PopularFeedQueryData { - page?: { - edges?: Array<{ - node: Post; - }>; - }; -} +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] = React.useState(0); - const { data: cardsData, isPending: isCardsPending } = - useQuery({ - queryKey: ['onboarding-swipe-popular-cards'], - queryFn: () => - gqlClient.request(MOST_UPVOTED_FEED_QUERY, { - first: 80, - period: 30, - }), - enabled: isLoggedIn, - staleTime: 1000 * 60 * 2, - }); + 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, @@ -118,9 +152,50 @@ function SwipeOnboardingPage(): ReactElement { await redirectToApp(router); }, [completeStep, router]); - const onSwipeAction = useCallback(() => { - setSwipesCount((value) => value + 1); - }, []); + 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( () => ({ @@ -142,18 +217,75 @@ function SwipeOnboardingPage(): ReactElement { ); const onboardingCards = useMemo( () => - (cardsData?.page?.edges ?? []).map(({ node }) => ({ + deckPosts.map((node) => ({ id: node.id, title: node.title, image: node.image, + tags: node.tags, source: { name: node.source?.name, image: node.source?.image, }, })), - [cardsData?.page?.edges], + [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
; } @@ -182,96 +314,146 @@ function SwipeOnboardingPage(): ReactElement { ); } - const progress = Math.min((swipesCount / 40) * 100, 100); - const progressPercent = Math.round(progress); - const canContinue = swipesCount >= MIN_SWIPES_TO_CONTINUE; + const canContinue = + onboardingUiMode === 'swipe' + ? swipesCount >= MIN_SWIPES_TO_CONTINUE + : swipesCount >= MIN_SWIPES_TO_CONTINUE || + selectedTagCount >= MIN_SWIPES_TO_CONTINUE; + + /** Placeholder uses `h-10` to match `ButtonSize.Medium` so the footer height stays fixed. */ + const bottomContinueSlot = ( +
+ {canContinue ? ( + + ) : ( +
+ )} +
+ ); return ( -
- { - if (direction === 'left' || direction === 'right') { - onSwipeAction(); - } - }} - onboardingCards={onboardingCards} - onboardingCardsLoading={isCardsPending} - topSlot={ -
-
- - Build your feed - - + {onboardingUiMode === 'swipe' ? ( + { + refetchSwipeDeck().catch(() => null); + }} + onSwipeAction={(direction, meta) => { + handleSwipeInteraction(direction, meta); + }} + onboardingCards={onboardingCards} + onboardingCardsLoading={isCardsPending} + headerSlot={ +
+
-
- - Feed personalization - - { + setOnboardingUiMode('tags'); + }} > - {progressPercent}% - + Use tags instead +
-
-
+ } + bottomSlot={bottomContinueSlot} + onRequestClose={() => { + onComplete().catch(() => null); + }} + /> + ) : ( + { + onComplete().catch(() => null); + }} + size={ModalSize.Small} + > + +
+
- - {swipesCount} swipes - -
- } - bottomSlot={ -
-
-
- - - Not interesting - -
-
- - - Interesting - -
-
- {canContinue && ( - )} -
- } - onRequestClose={() => { - onComplete().catch(() => null); - }} - /> +
+ +
+
+ {isFeedSettingsLoading || !feedSettings ? ( +
+ +
+ ) : ( + + )} +
+
+ {bottomContinueSlot} +
+
+ + + )}
); } From b783ad4a553c3d9fc23991886e219ce40b1a5868 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 6 Apr 2026 18:00:36 +0300 Subject: [PATCH 3/3] feat(onboarding): polish swipe onboarding footer and hint controls - Hide tags modal footer and bottom slot until continue unlocks (no empty chrome). - Onboarding hint buttons: secondary default border/icon, size-14 + Large icons, wider gap. Made-with: Cursor --- .../modals/hotTakes/HotAndColdModal.tsx | 14 +++---- packages/webapp/pages/onboarding/swipe.tsx | 39 +++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 95f70400403..d58821bd584 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -474,17 +474,17 @@ const OnboardingSwipeHintIcons = ({ const rightSwipeEmphasized = rightVisualStrength > 0; return ( -
+
); diff --git a/packages/webapp/pages/onboarding/swipe.tsx b/packages/webapp/pages/onboarding/swipe.tsx index 0773348810b..e4a56d23050 100644 --- a/packages/webapp/pages/onboarding/swipe.tsx +++ b/packages/webapp/pages/onboarding/swipe.tsx @@ -320,26 +320,21 @@ function SwipeOnboardingPage(): ReactElement { : swipesCount >= MIN_SWIPES_TO_CONTINUE || selectedTagCount >= MIN_SWIPES_TO_CONTINUE; - /** Placeholder uses `h-10` to match `ButtonSize.Medium` so the footer height stays fixed. */ - const bottomContinueSlot = ( + const bottomContinueSlot = canContinue ? (
- {canContinue ? ( - - ) : ( -
- )} +
- ); + ) : null; return (
@@ -447,9 +442,11 @@ function SwipeOnboardingPage(): ReactElement { /> )}
-
- {bottomContinueSlot} -
+ {canContinue ? ( +
+ {bottomContinueSlot} +
+ ) : null}