-
+ {onboardingFooterSlot ? (
+
+ {onboardingFooterSlot}
- )}
+ ) : null}
>
)}
+
+ {!isModalLoading &&
+ !isModalEmpty &&
+ !isOnboardingMode &&
+ currentCardId && (
+ <>
+ {topSlot}
+ {cardSwipeArea}
+ {showDefaultActions && (
+
+
+ )}
+ {bottomSlot}
+ {showAddHotTakeButton && user?.username && (
+
+
+ Add your own hot take
+
+
+ )}
+ >
+ )}
);
diff --git a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx
index c27f3403765..5ca3b95bd8a 100644
--- a/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx
+++ b/packages/shared/src/components/onboarding/PersonaSelector.spec.tsx
@@ -2,19 +2,6 @@ import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersonaSelector } from './PersonaSelector';
-import {
- broadcastPersonaSelection,
- broadcastRecommendRequest,
-} from './onboardingPopBus';
-
-jest.mock('./onboardingPopBus', () => {
- const actual = jest.requireActual('./onboardingPopBus');
- return {
- ...actual,
- broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection),
- broadcastRecommendRequest: jest.fn(actual.broadcastRecommendRequest),
- };
-});
const mockOnFollowTags = jest.fn().mockResolvedValue({ successful: true });
const mockOnUnfollowTags = jest.fn().mockResolvedValue({ successful: true });
@@ -67,7 +54,7 @@ describe('PersonaSelector', () => {
expect(screen.getByText('Backend')).toBeInTheDocument();
});
- it('follows tags and broadcasts pop + recommend on click', async () => {
+ it('follows tags on click', async () => {
renderComponent();
fireEvent.click(await screen.findByText('Frontend'));
await waitFor(() =>
@@ -76,8 +63,6 @@ describe('PersonaSelector', () => {
requireLogin: true,
}),
);
- expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']);
- expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']);
});
it('allows multi-select without unfollowing previous persona', async () => {
diff --git a/packages/shared/src/components/onboarding/PersonaSelector.tsx b/packages/shared/src/components/onboarding/PersonaSelector.tsx
index 49d838436d4..2c42078b37c 100644
--- a/packages/shared/src/components/onboarding/PersonaSelector.tsx
+++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx
@@ -13,24 +13,33 @@ import { RequestKey, StaleTime, generateQueryKey } from '../../lib/query';
import { Button, ButtonColor } from '../buttons/Button';
import { ButtonVariant } from '../buttons/common';
import { ElementPlaceholder } from '../ElementPlaceholder';
-import {
- broadcastPersonaSelection,
- broadcastRecommendRequest,
-} from './onboardingPopBus';
export const MAX_PERSONAS = 3;
+const personaButtonClassName =
+ 'w-full !justify-start text-left tablet:w-auto tablet:!justify-center';
+
+export type PersonaSelectorMode = 'follow' | 'seed';
+
interface PersonaSelectorProps {
className?: string;
feedId?: string;
+ mode?: PersonaSelectorMode;
+ initialActiveIds?: string[];
+ onSelectionChange?: (selected: GQLPersona[]) => void;
}
export function PersonaSelector({
className,
feedId,
+ initialActiveIds = [],
+ mode = 'follow',
+ onSelectionChange,
}: PersonaSelectorProps): ReactElement | null {
const { logEvent } = useLogContext();
- const [activeIds, setActiveIds] = useState
>(new Set());
+ const [activeIds, setActiveIds] = useState>(
+ () => new Set(initialActiveIds),
+ );
const { onFollowTags, onUnfollowTags } = useTagAndSource({
origin: Origin.OnboardingPersona,
feedId,
@@ -56,6 +65,13 @@ export function PersonaSelector({
staleTime: StaleTime.OneHour,
});
+ const emitSelection = (nextActiveIds: Set) => {
+ if (!onSelectionChange || !personas) {
+ return;
+ }
+ onSelectionChange(personas.filter((p) => nextActiveIds.has(p.id)));
+ };
+
const handleClick = async (persona: GQLPersona) => {
const isActive = activeIds.has(persona.id);
const isAtCap = !isActive && activeIds.size >= MAX_PERSONAS;
@@ -75,21 +91,25 @@ export function PersonaSelector({
});
if (isActive) {
- await onUnfollowTags({ tags: persona.tags });
+ if (mode === 'follow') {
+ await onUnfollowTags({ tags: persona.tags });
+ }
setActiveIds((prev) => {
const next = new Set(prev);
next.delete(persona.id);
+ emitSelection(next);
return next;
});
return;
}
- broadcastPersonaSelection(persona.tags);
- await onFollowTags({ tags: persona.tags, requireLogin: true });
- broadcastRecommendRequest(persona.tags);
+ if (mode === 'follow') {
+ await onFollowTags({ tags: persona.tags, requireLogin: true });
+ }
setActiveIds((prev) => {
const next = new Set(prev);
next.add(persona.id);
+ emitSelection(next);
return next;
});
};
@@ -106,7 +126,7 @@ export function PersonaSelector({
aria-label="Pick a role to follow related tags"
aria-busy={isPending}
className={classNames(
- 'flex w-full max-w-4xl flex-wrap justify-center gap-3',
+ 'flex w-full max-w-4xl flex-col gap-2 tablet:flex-row tablet:flex-wrap tablet:justify-center tablet:gap-3',
className,
)}
>
@@ -115,7 +135,7 @@ export function PersonaSelector({
))}
{!isPending &&
@@ -124,10 +144,10 @@ export function PersonaSelector({
const isDisabled = !isActive && isAtCap;
const buttonContent = (
<>
-
+
{persona.emoji}
- {persona.title}
+ {persona.title}
>
);
@@ -136,6 +156,7 @@ export function PersonaSelector({
handleClick(persona)}
@@ -148,6 +169,7 @@ export function PersonaSelector({
return (
handleClick(persona)}
diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx
index 637c2734018..82582f6396b 100644
--- a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx
+++ b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx
@@ -62,6 +62,7 @@ const alwaysDarkSteps = [
FunnelStepType.OrganicSignup,
FunnelStepType.BrowserExtension,
];
+const tallTopGradientSteps = [FunnelStepType.EditTags];
export const FunnelStepBackground = ({
children,
@@ -90,6 +91,7 @@ export const FunnelStepBackground = ({
const shouldShowBg =
!isPricingV2 && !hiddenBgSteps.some((type) => type === step.type);
+ const shouldUseTallTopGradient = tallTopGradientSteps.includes(step.type);
const needInvertedColors =
(isStepForcedTo.dark && isLightMode) ||
@@ -107,7 +109,9 @@ export const FunnelStepBackground = ({
void;
session: FunnelSession;
showCookieBanner?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime
+ stepComponentOverrides?: Partial
>>;
}
const stepComponentMap = {
@@ -79,9 +81,18 @@ const stepComponentMap = {
[FunnelStepType.UploadCv]: FunnelUploadCv,
} as const;
-function FunnelStepComponent(props: Step) {
- const { type } = props;
- const Component = stepComponentMap[type];
+function FunnelStepComponent(props: {
+ stepComponentOverrides?: FunnelStepperProps['stepComponentOverrides'];
+ [key: string]: unknown;
+}) {
+ const { stepComponentOverrides, type } = props;
+ const stepType = type as FunnelStepType;
+ const Component =
+ stepComponentOverrides?.[stepType] ??
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime
+ (stepComponentMap as Partial>>)[
+ stepType
+ ];
if (!Component) {
return null;
@@ -96,7 +107,8 @@ export const FunnelStepper = ({
session,
showCookieBanner,
onComplete,
-}: FunnelStepperProps): ReactElement => {
+ stepComponentOverrides,
+}: FunnelStepperProps): ReactElement | null => {
const steps = useMemo(
() => funnel?.chapters?.flatMap((chapter) => chapter?.steps),
[funnel?.chapters],
@@ -123,7 +135,9 @@ export const FunnelStepper = ({
defaultOpen: showCookieBanner,
trackFunnelEvent,
});
- useEventListener(globalThis, 'scrollend', trackOnScroll, { passive: true });
+ useEventListener(globalThis.window, 'scrollend', trackOnScroll, {
+ passive: true,
+ });
const shouldSkipRef = useRef>>({});
const currentNavigationRef = useRef({ step, position });
@@ -187,11 +201,12 @@ export const FunnelStepper = ({
);
const successCallback = useCallback(
- (event?: PaddleEventData) =>
+ (event: unknown) =>
onTransition({
type: FunnelStepTransitionType.Complete,
details: {
- subscribed: event?.data?.customer?.email,
+ subscribed: (event as PaddleEventData | undefined)?.data?.customer
+ ?.email,
},
}),
[onTransition],
@@ -254,7 +269,7 @@ export const FunnelStepper = ({
!layout.isFullWidth && 'tablet:max-w-md laptopXL:max-w-lg',
)}
>
- {layout.hasBanner && (
+ {layout.hasBanner && funnel.parameters.banner && (
)}
);
diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts
index 8415f0da445..b05c823f0a6 100644
--- a/packages/shared/src/graphql/actions.ts
+++ b/packages/shared/src/graphql/actions.ts
@@ -40,6 +40,7 @@ export enum ActionType {
FetchedSmartTitle = 'fetched_smart_title',
EditTag = 'edit_tag',
ContentTypes = 'content_types',
+ HasSeenTags = 'has_seen_tags',
StreakTimezoneMismatch = 'streak_timezone_mismatch',
CheckedCoresRole = 'checked_cores_role',
CompletedOnboarding = 'completed_onboarding',
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 11a2946d5a6..4e1c5240bdb 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -201,6 +201,18 @@ export const featureCompanionDemoWidget = new Feature(
false,
);
+export const swipeOnboardingFeature = new Feature('swipe_onboarding', true);
+
+export const featureUpvoteCountThreshold = new Feature<{
+ threshold: number;
+ belowThresholdLabel: string;
+ newWindowHours: number;
+}>('upvote_count_threshold', {
+ threshold: 0,
+ belowThresholdLabel: '',
+ newWindowHours: 24,
+});
+
export const featureFeedTagChips = new Feature('feed_tag_chips', false);
export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false);
diff --git a/packages/shared/src/lib/feedSettings.ts b/packages/shared/src/lib/feedSettings.ts
new file mode 100644
index 00000000000..98057213a1a
--- /dev/null
+++ b/packages/shared/src/lib/feedSettings.ts
@@ -0,0 +1,25 @@
+import { generateStorageKey, StorageTopic } from './storage';
+import { storageWrapper } from './storageWrapper';
+
+const hasSeenTagsStorageKey = 'hasSeenTags';
+
+export const getHasSeenTagsStorageKey = (userId: string): string =>
+ generateStorageKey(StorageTopic.Onboarding, hasSeenTagsStorageKey, userId);
+
+export const getHasSeenTags = (userId?: string | null): boolean | null => {
+ if (!userId) {
+ return null;
+ }
+
+ const value = storageWrapper.getItem(getHasSeenTagsStorageKey(userId));
+
+ if (value === null) {
+ return null;
+ }
+
+ return value === 'true';
+};
+
+export const setHasSeenTags = (userId: string, hasSeenTags: boolean): void => {
+ storageWrapper.setItem(getHasSeenTagsStorageKey(userId), String(hasSeenTags));
+};
diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css
index 5480fade045..c0d54034400 100644
--- a/packages/shared/src/styles/base.css
+++ b/packages/shared/src/styles/base.css
@@ -895,6 +895,12 @@ meter::-webkit-meter-bar {
background: radial-gradient(94% 48.83% at 50% 0%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-onion-default) 75.12%, var(--theme-accent-onion-baseline) 100%);
}
+ &-onboarding-tall {
+ background:
+ radial-gradient(120% 30% at 100% 0%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-cabbage-baseline) 100%),
+ radial-gradient(120% 30% at 0% 0%, var(--theme-accent-onion-default) 0%, var(--theme-accent-onion-baseline) 100%);
+ }
+
&-hourglass {
background: radial-gradient(192.5% 100% at 50% 100%, var(--theme-accent-cabbage-default) 0%, var(--theme-accent-cabbage-baseline) 50%),
radial-gradient(192.5% 100% at 50% 0%, var(--theme-accent-onion-default) 0%, var(--theme-accent-onion-baseline) 50%);
diff --git a/packages/shared/src/styles/components.css b/packages/shared/src/styles/components.css
index 2f12a1a584b..36c0fc6bbf7 100644
--- a/packages/shared/src/styles/components.css
+++ b/packages/shared/src/styles/components.css
@@ -1,3 +1,4 @@
@import './components/buttons.css';
@import './components/buttons-v2.css';
@import './components/radix.css';
+@import './components/swipe-onboarding.css';
diff --git a/packages/shared/src/styles/components/swipe-onboarding.css b/packages/shared/src/styles/components/swipe-onboarding.css
new file mode 100644
index 00000000000..07a85c6199a
--- /dev/null
+++ b/packages/shared/src/styles/components/swipe-onboarding.css
@@ -0,0 +1,152 @@
+/* Swipe onboarding animations.
+ * Keep durations aligned with:
+ * - SWIPE_ONBOARDING_TRANSITION_MS in FunnelSwipeOnboardingStep.tsx
+ * - PROGRESS_LABEL_TRANSITION_MS in SwipeOnboardingProgressHeader.tsx */
+
+@keyframes swipeOnboardingCompleteIn {
+ from {
+ opacity: 0;
+ transform: translateY(0.875rem) scale(0.985);
+ filter: blur(0.25rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: blur(0);
+ }
+}
+
+.swipe-onboarding-complete-enter {
+ animation: swipeOnboardingCompleteIn 0.42s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@keyframes swipeOnboardingCompleteGradient {
+ 0% {
+ background-position: 0% 50%;
+ }
+
+ 50% {
+ background-position: 100% 50%;
+ }
+
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+.swipe-onboarding-complete-gradient {
+ animation: swipeOnboardingCompleteGradient 8s ease-in-out infinite;
+ background-size: 200% 100%;
+}
+
+@media (max-width: 655px) {
+ .swipe-onboarding-complete-gradient {
+ display: inline-block;
+ line-height: 1.15;
+ overflow: visible;
+ padding: 0.15em 0.1em 0.35em;
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
+ }
+}
+
+@keyframes swipeOnboardingScreenEnter {
+ from {
+ opacity: 0;
+ transform: translateY(0.75rem) scale(0.985);
+ filter: blur(0.25rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: blur(0);
+ }
+}
+
+@keyframes swipeOnboardingScreenExit {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: blur(0);
+ }
+
+ to {
+ opacity: 0;
+ transform: translateY(-0.75rem) scale(0.985);
+ filter: blur(0.25rem);
+ }
+}
+
+.swipe-onboarding-screen-enter {
+ animation: swipeOnboardingScreenEnter 0.34s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.swipe-onboarding-screen-exit {
+ pointer-events: none;
+ animation: swipeOnboardingScreenExit var(--swipe-onboarding-transition-ms, 260ms)
+ cubic-bezier(0.4, 0, 1, 1) both;
+}
+
+@keyframes swipePersonaFloatingEmoji {
+ 0% {
+ transform: translate3d(0, 0, 0) rotateZ(0deg) rotateY(0deg) scale(0.35);
+ opacity: 0;
+ filter: blur(0.25rem);
+ }
+
+ 18% {
+ transform: translate3d(0, 0, 0) rotateZ(0deg) rotateY(0deg) scale(1);
+ opacity: 1;
+ filter: blur(0);
+ }
+
+ 100% {
+ transform: translate3d(var(--emoji-tx), var(--emoji-ty), 0) rotateZ(var(--emoji-rz))
+ rotateY(var(--emoji-ry)) scale(1.35);
+ opacity: 0;
+ filter: blur(0.125rem);
+ }
+}
+
+.swipe-persona-floating-emoji {
+ animation: swipePersonaFloatingEmoji 3.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+ text-shadow: 0 1rem 3rem rgb(0 0 0 / 0.45);
+ transform-style: preserve-3d;
+ will-change: transform, opacity, filter;
+}
+
+.swipe-onboarding-progress-label {
+ transition: opacity 340ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .swipe-onboarding-complete-enter {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ filter: none;
+ }
+
+ .swipe-onboarding-complete-gradient {
+ animation: none;
+ }
+
+ .swipe-onboarding-screen-enter,
+ .swipe-onboarding-screen-exit {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ filter: none;
+ }
+
+ .swipe-persona-floating-emoji {
+ animation: none;
+ opacity: 0;
+ }
+
+ .swipe-onboarding-progress-label {
+ transition: none;
+ }
+}
diff --git a/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx
new file mode 100644
index 00000000000..3ae938cdb7e
--- /dev/null
+++ b/packages/webapp/components/onboarding/FunnelSwipeOnboardingStep.tsx
@@ -0,0 +1,506 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import classNames from 'classnames';
+import { useRouter } from 'next/router';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import Logo, { LogoPosition } from '@dailydotdev/shared/src/components/Logo';
+import { ArrowIcon } from '@dailydotdev/shared/src/components/icons';
+import HotAndColdModal from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal';
+import { useBookmarkPost } from '@dailydotdev/shared/src/hooks/useBookmarkPost';
+import useFeedSettings from '@dailydotdev/shared/src/hooks/useFeedSettings';
+import useTagAndSource from '@dailydotdev/shared/src/hooks/useTagAndSource';
+import { Origin } from '@dailydotdev/shared/src/lib/log';
+import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
+import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings';
+import { withIsActiveGuard } from '@dailydotdev/shared/src/features/onboarding/shared/withActiveGuard';
+import type { FunnelStepEditTags } from '@dailydotdev/shared/src/features/onboarding/types/funnel';
+import { FunnelStepTransitionType } from '@dailydotdev/shared/src/features/onboarding/types/funnel';
+import { useAdaptiveSwipeDeck } from '../../hooks/useAdaptiveSwipeDeck';
+import { buildSwipePrompt } from '../../lib/buildSwipePrompt';
+import { SwipeOnboardingProgressHeader } from './SwipeOnboardingProgressHeader';
+import {
+ SwipePersonaIntro,
+ SwipePersonaIntroHeading,
+} from './SwipePersonaIntro';
+import {
+ SWIPE_ONBOARDING_MIN_TO_UNLOCK,
+ SWIPE_ONBOARDING_REFINE_TARGET,
+} from '../../lib/swipeOnboardingGuidance';
+import { recommendOnboardingTags } from '../../lib/swipingBackendApi';
+
+const SWIPE_ONBOARDING_TAG_SEED_MAX = 25;
+const SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT = 10;
+const SWIPE_ONBOARDING_LOADING_LABELS = [
+ 'cooking',
+ 'optimizing',
+ 'thinking',
+ 'shuffling the good stuff',
+ 'bribing the feed gremlins',
+ 'warming up the swipe deck',
+] as const;
+
+/** Keep in sync with --swipe-onboarding-transition-ms in swipe-onboarding.css */
+const SWIPE_ONBOARDING_TRANSITION_MS = 260;
+
+const swipeOnboardingModalShellClassName =
+ '!h-dvh !max-h-dvh !w-full !max-w-none !border-transparent !bg-transparent !shadow-none tablet:!mx-0 tablet:!h-dvh tablet:!max-h-dvh tablet:!w-full tablet:!max-w-none tablet:!overflow-hidden tablet:!rounded-none tablet:!border-transparent tablet:!bg-transparent tablet:!shadow-none';
+
+const waitMs = (ms: number): Promise =>
+ new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+
+function SwipeOnboardingLogo(): ReactElement {
+ return (
+
+
+
+ );
+}
+
+function SwipeOnboardingTopBar({
+ onBack,
+}: {
+ onBack?: () => void;
+}): ReactElement {
+ return (
+
+ {onBack ? (
+ }
+ size={ButtonSize.Medium}
+ type="button"
+ variant={ButtonVariant.Tertiary}
+ onClick={onBack}
+ >
+ Back
+
+ ) : null}
+
+
+ );
+}
+
+function SwipeOnboardingBackButton({
+ onBack,
+}: {
+ onBack: () => void;
+}): ReactElement {
+ return (
+
+ }
+ size={ButtonSize.Medium}
+ type="button"
+ variant={ButtonVariant.Tertiary}
+ onClick={onBack}
+ >
+ Back
+
+
+ );
+}
+
+function useAnimatedLoadingLabel(isActive: boolean): string {
+ const [labelIndex, setLabelIndex] = useState(0);
+ const [dotCount, setDotCount] = useState(1);
+
+ useEffect(() => {
+ if (!isActive) {
+ setLabelIndex(0);
+ setDotCount(1);
+ return undefined;
+ }
+
+ const interval = window.setInterval(() => {
+ setDotCount((currentDotCount) => {
+ if (currentDotCount === 3) {
+ setLabelIndex(
+ (currentLabelIndex) =>
+ (currentLabelIndex + 1) % SWIPE_ONBOARDING_LOADING_LABELS.length,
+ );
+ return 1;
+ }
+
+ return currentDotCount + 1;
+ });
+ }, 550);
+
+ return () => window.clearInterval(interval);
+ }, [isActive]);
+
+ return `${SWIPE_ONBOARDING_LOADING_LABELS[labelIndex]}${'.'.repeat(
+ dotCount,
+ )}`;
+}
+
+function SwipeOnboardingStarterFeedReady({
+ cta,
+ isCompleting,
+ onComplete,
+}: {
+ cta?: string;
+ isCompleting: boolean;
+ onComplete: () => void;
+}): ReactElement {
+ return (
+ <>
+
+
+ We have enough signal to build your first pass. You can keep refining
+ it after this.
+
+
+ {cta || 'Next'}
+
+
+
+
+ {cta || 'Next'}
+
+
+ >
+ );
+}
+
+function SwipeOnboardingCompleteView({
+ cta,
+ isCompleting,
+ onComplete,
+}: {
+ cta?: string;
+ isCompleting: boolean;
+ onComplete: () => void;
+}): ReactElement {
+ return (
+
+ );
+}
+
+function FunnelSwipeOnboardingStepComponent({
+ parameters: { cta },
+ onTransition,
+}: FunnelStepEditTags): ReactElement {
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const [swipesCount, setSwipesCount] = useState(0);
+ const [selectedPersonas, setSelectedPersonas] = useState([]);
+ const [promptLoading, setPromptLoading] = useState(false);
+ const [isCompleting, setIsCompleting] = useState(false);
+ const [isSwipeMode, setIsSwipeMode] = useState(false);
+ const [isIntroExiting, setIsIntroExiting] = useState(false);
+ const [dismissedOnboardingCardIds, setDismissedOnboardingCardIds] = useState<
+ Set
+ >(() => new Set());
+ const animatedPromptLoadingLabel = useAnimatedLoadingLabel(promptLoading);
+ const { feedSettings } = useFeedSettings();
+ const { onFollowTags } = useTagAndSource({
+ origin: Origin.Onboarding,
+ });
+ const { toggleBookmark } = useBookmarkPost();
+ const {
+ cards: adaptiveCards,
+ getBookmarkablePost,
+ isLoading: isAdaptiveLoading,
+ startDeck,
+ handleSwipe: handleAdaptiveSwipe,
+ retryFetch,
+ selectedTags: adaptiveSelectedTags,
+ } = useAdaptiveSwipeDeck();
+
+ const handleStartSwipe = useCallback(async () => {
+ if (promptLoading) {
+ return;
+ }
+
+ setPromptLoading(true);
+ try {
+ const personaTags = Array.from(
+ new Set(selectedPersonas.flatMap((persona) => persona.tags)),
+ );
+ const recommendedTags = await recommendOnboardingTags(
+ personaTags,
+ SWIPE_ONBOARDING_RECOMMENDED_TAGS_COUNT,
+ ).catch(() => []);
+ const initialTags = Array.from(
+ new Set([...personaTags, ...recommendedTags]),
+ );
+ const prompt = buildSwipePrompt({
+ personas: selectedPersonas,
+ experienceLevel: user?.experienceLevel,
+ });
+ await startDeck({ prompt, initialTags });
+ setIsIntroExiting(true);
+ await waitMs(SWIPE_ONBOARDING_TRANSITION_MS);
+ setIsSwipeMode(true);
+ } finally {
+ setPromptLoading(false);
+ }
+ }, [promptLoading, selectedPersonas, startDeck, user?.experienceLevel]);
+
+ const bookmarkRightSwipePost = useCallback(
+ (cardId: string) => {
+ const bookmarkPost = getBookmarkablePost(cardId);
+ if (!bookmarkPost) {
+ return;
+ }
+
+ // Capture the current card payload before deck state changes.
+ toggleBookmark({
+ post: bookmarkPost,
+ origin: Origin.Onboarding,
+ disableToast: true,
+ }).catch(() => null);
+ },
+ [getBookmarkablePost, toggleBookmark],
+ );
+
+ const handleSwipeInteraction = useCallback(
+ (
+ direction: 'left' | 'right' | 'skip',
+ meta?: { onboardingCardId?: string },
+ ) => {
+ if (direction !== 'left' && direction !== 'right') {
+ return;
+ }
+ if (direction === 'right') {
+ setSwipesCount((currentValue) => currentValue + 1);
+ }
+ if (meta?.onboardingCardId) {
+ if (direction === 'right') {
+ bookmarkRightSwipePost(meta.onboardingCardId);
+ }
+ handleAdaptiveSwipe(direction, meta.onboardingCardId);
+ }
+ },
+ [bookmarkRightSwipePost, handleAdaptiveSwipe],
+ );
+
+ const tagsFromSwipes = useMemo(
+ () => adaptiveSelectedTags.slice(0, SWIPE_ONBOARDING_TAG_SEED_MAX),
+ [adaptiveSelectedTags],
+ );
+
+ const handleComplete = useCallback(async () => {
+ if (isCompleting) {
+ return;
+ }
+
+ setIsCompleting(true);
+ const currentTags = feedSettings?.includeTags ?? [];
+ const currentTagsSet = new Set(currentTags);
+ const tagsToFollow = tagsFromSwipes.filter(
+ (tag) => !currentTagsSet.has(tag),
+ );
+ const finalTags = [...currentTags, ...tagsToFollow];
+
+ try {
+ if (tagsToFollow.length) {
+ await onFollowTags({ tags: tagsToFollow });
+ }
+ } catch {
+ // Let the funnel continue even if persisting tags fails.
+ } finally {
+ setIsCompleting(false);
+ }
+
+ onTransition({
+ type: FunnelStepTransitionType.Complete,
+ details: {
+ tags: finalTags,
+ },
+ });
+ }, [
+ feedSettings?.includeTags,
+ isCompleting,
+ onFollowTags,
+ onTransition,
+ tagsFromSwipes,
+ ]);
+
+ const canContinue = swipesCount >= SWIPE_ONBOARDING_MIN_TO_UNLOCK;
+ const isRefineComplete = swipesCount >= SWIPE_ONBOARDING_REFINE_TARGET;
+
+ if (!isSwipeMode) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ const handleBackFromSwipe = (): void => {
+ setIsIntroExiting(false);
+ setIsSwipeMode(false);
+ };
+
+ const handleCompleteClick = (): void => {
+ handleComplete().catch(() => null);
+ };
+
+ const bottomSlot = isRefineComplete ? null : (
+ <>
+
+ {canContinue ? (
+
+ ) : null}
+
+
+
+ {canContinue ? (
+
+ ) : null}
+
+ >
+ );
+
+ return (
+
+
+
+
+
+
+
+ >
+ )
+ }
+ shouldCloseOnOverlayClick={false}
+ dismissedOnboardingCardIds={dismissedOnboardingCardIds}
+ onDismissedOnboardingCardsChange={setDismissedOnboardingCardIds}
+ onboardingActionLayout="sides"
+ onboardingCards={adaptiveCards}
+ onboardingCardsLoading={isAdaptiveLoading}
+ onboardingFeedRefetching={isAdaptiveLoading}
+ onboardingContent={
+ isRefineComplete ? (
+
+ ) : undefined
+ }
+ onOnboardingFeedRetry={() => {
+ retryFetch();
+ }}
+ onSwipeAction={(direction, meta) => {
+ handleSwipeInteraction(direction, meta);
+ }}
+ topSlot={
+ isRefineComplete ? undefined : (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+ }
+ progressSlot={
+ isRefineComplete ? undefined : (
+
+
+
+ )
+ }
+ bottomSlot={bottomSlot}
+ onRequestClose={() => {
+ router.back();
+ }}
+ />
+ );
+}
+
+export const FunnelSwipeOnboardingStep = withIsActiveGuard(
+ FunnelSwipeOnboardingStepComponent,
+);
diff --git a/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx
new file mode 100644
index 00000000000..1f3ae5bb870
--- /dev/null
+++ b/packages/webapp/components/onboarding/SwipeOnboardingProgressHeader.tsx
@@ -0,0 +1,147 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ getSwipeOnboardingBarProgress,
+ SWIPE_ONBOARDING_REFINE_TARGET,
+} from '../../lib/swipeOnboardingGuidance';
+
+/** Keep in sync with .swipe-onboarding-progress-label in swipe-onboarding.css */
+const PROGRESS_LABEL_TRANSITION_MS = 340;
+
+export type SwipeOnboardingProgressHeaderProps = {
+ /** Swipe count and/or tag selections — same scale as onboarding swipes (0–40+). */
+ progressCount: number;
+};
+
+function getProgressGradientStyle(fillPercent: number): CSSProperties {
+ const gradientProgress = Math.max(fillPercent, 0.01);
+
+ return {
+ backgroundImage: `linear-gradient(
+ 90deg,
+ var(--theme-accent-cabbage-default) 0%,
+ var(--theme-accent-avocado-default) ${gradientProgress * 0.22}%,
+ var(--theme-accent-cheese-default) ${gradientProgress * 0.44}%,
+ var(--theme-accent-cabbage-default) ${gradientProgress * 0.66}%,
+ var(--theme-accent-avocado-default) ${gradientProgress * 0.88}%,
+ var(--theme-accent-cheese-default) ${gradientProgress}%,
+ var(--theme-text-quaternary) ${gradientProgress}%,
+ var(--theme-text-quaternary) 100%
+ )`,
+ };
+}
+
+function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const updatePreference = (): void => {
+ setPrefersReducedMotion(mediaQuery.matches);
+ };
+
+ updatePreference();
+ mediaQuery.addEventListener('change', updatePreference);
+
+ return () => mediaQuery.removeEventListener('change', updatePreference);
+ }, []);
+
+ return prefersReducedMotion;
+}
+
+function useAnimatedProgressPercent(
+ targetPercent: number,
+ prefersReducedMotion: boolean,
+): number {
+ const [displayPercent, setDisplayPercent] = useState(targetPercent);
+ const displayPercentRef = useRef(displayPercent);
+ displayPercentRef.current = displayPercent;
+
+ useEffect(() => {
+ if (prefersReducedMotion) {
+ setDisplayPercent(targetPercent);
+ return undefined;
+ }
+
+ const startPercent = displayPercentRef.current;
+ if (startPercent === targetPercent) {
+ return undefined;
+ }
+
+ let animationFrame = 0;
+ const startTime = performance.now();
+
+ const tick = (now: number): void => {
+ const elapsed = now - startTime;
+ const progress = Math.min(elapsed / PROGRESS_LABEL_TRANSITION_MS, 1);
+ const eased = 1 - (1 - progress) ** 3;
+ const nextPercent = Math.round(
+ startPercent + (targetPercent - startPercent) * eased,
+ );
+
+ setDisplayPercent(nextPercent);
+
+ if (progress < 1) {
+ animationFrame = window.requestAnimationFrame(tick);
+ }
+ };
+
+ animationFrame = window.requestAnimationFrame(tick);
+
+ return () => window.cancelAnimationFrame(animationFrame);
+ }, [prefersReducedMotion, targetPercent]);
+
+ return displayPercent;
+}
+
+const progressLabelClassName =
+ 'col-start-1 row-start-1 inline-block max-w-full whitespace-nowrap bg-clip-text text-[min(2rem,7vw)] font-black leading-[1.05] tracking-[-0.05em] text-transparent tablet:text-[2.25rem]';
+
+export function SwipeOnboardingProgressHeader({
+ progressCount,
+}: SwipeOnboardingProgressHeaderProps): ReactElement {
+ const progress = getSwipeOnboardingBarProgress(progressCount);
+ const progressValue = Math.min(progressCount, SWIPE_ONBOARDING_REFINE_TARGET);
+ const targetPercent = Math.round(progress);
+ const isComplete = progressValue >= SWIPE_ONBOARDING_REFINE_TARGET;
+ const prefersReducedMotion = usePrefersReducedMotion();
+ const displayPercent = useAnimatedProgressPercent(
+ targetPercent,
+ prefersReducedMotion,
+ );
+ const progressAnnouncement = isComplete
+ ? '100% complete'
+ : `${displayPercent}% to complete`;
+
+ return (
+
+
{progressAnnouncement}
+
+ {displayPercent}% to complete
+
+
+ 100% complete
+
+
+ );
+}
diff --git a/packages/webapp/components/onboarding/SwipePersonaIntro.tsx b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx
new file mode 100644
index 00000000000..837cf4e2e49
--- /dev/null
+++ b/packages/webapp/components/onboarding/SwipePersonaIntro.tsx
@@ -0,0 +1,267 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import {
+ MAX_PERSONAS,
+ PersonaSelector,
+} from '@dailydotdev/shared/src/components/onboarding/PersonaSelector';
+import { MagicIcon } from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { RootPortal } from '@dailydotdev/shared/src/components/tooltips/Portal';
+import type { GQLPersona } from '@dailydotdev/shared/src/graphql/feedSettings';
+
+interface SwipePersonaIntroProps {
+ initialSelectedPersonas?: GQLPersona[];
+ onSelectionChange: (selected: GQLPersona[]) => void;
+ onStart: () => void;
+ loading: boolean;
+ loadingLabel?: string;
+}
+
+const surfaceClassName =
+ 'w-full overflow-hidden rounded-[2rem] bg-background-default shadow-[0_24px_90px_-48px_rgba(0,0,0,0.58)]';
+
+const panelClassName = 'overflow-hidden';
+
+const floatingEmojiPlacements = [
+ {
+ className: 'left-3 top-[18%]',
+ tx: '4rem',
+ ty: '-3rem',
+ rz: '-18deg',
+ ry: '32deg',
+ },
+ {
+ className: 'right-3 top-[24%]',
+ tx: '-4.5rem',
+ ty: '-2rem',
+ rz: '18deg',
+ ry: '-34deg',
+ },
+ {
+ className: 'bottom-[14%] left-6',
+ tx: '5rem',
+ ty: '-4.5rem',
+ rz: '12deg',
+ ry: '28deg',
+ },
+ {
+ className: 'bottom-[18%] right-6',
+ tx: '-5rem',
+ ty: '-4rem',
+ rz: '-12deg',
+ ry: '-28deg',
+ },
+] as const;
+
+type FloatingEmoji = {
+ id: string;
+ emoji: string;
+ placementIndex: number;
+};
+
+type SwipePersonaIntroHeadingVariant = 'persona' | 'swipe';
+
+const swipePersonaIntroHeadingCopy: Record<
+ SwipePersonaIntroHeadingVariant,
+ {
+ eyebrow: string;
+ title: string;
+ description: ReactElement;
+ }
+> = {
+ persona: {
+ eyebrow: 'Personalize your daily.dev feed',
+ title: 'What kind of dev are you?',
+ description: (
+ <>
+ Pick up to 3 roles.
+
+ We'll line up posts you'll actually want to read.
+ >
+ ),
+ },
+ swipe: {
+ eyebrow: 'Teach us what you like',
+ title: 'Swipe to tune your feed',
+ description: (
+ <>
+ Swipe right → on posts you want more of.
+
+ Swipe left ← on posts that are not for you.
+ >
+ ),
+ },
+};
+
+export function SwipePersonaIntroHeading({
+ variant = 'persona',
+}: {
+ variant?: SwipePersonaIntroHeadingVariant;
+}): ReactElement {
+ const copy = swipePersonaIntroHeadingCopy[variant];
+
+ return (
+
+
+
+ {copy.eyebrow}
+
+
+ {copy.title}
+
+
+ {copy.description}
+
+
+ );
+}
+
+export function SwipePersonaIntro({
+ initialSelectedPersonas = [],
+ onSelectionChange,
+ onStart,
+ loading,
+ loadingLabel,
+}: SwipePersonaIntroProps): ReactElement {
+ const [selectedCount, setSelectedCount] = useState(
+ initialSelectedPersonas.length,
+ );
+ const [floatingEmojis, setFloatingEmojis] = useState([]);
+ const selectedPersonasRef = useRef>(
+ new Set(initialSelectedPersonas.map((persona) => persona.id)),
+ );
+ const floatingEmojiCountRef = useRef(0);
+ const cleanupTimersRef = useRef([]);
+ const shouldShowActions = selectedCount >= MAX_PERSONAS;
+ const handleSelectionChange = useCallback(
+ (selected: GQLPersona[]) => {
+ const previousIds = selectedPersonasRef.current;
+ const addedPersona = selected.find(
+ (persona) => !previousIds.has(persona.id),
+ );
+ selectedPersonasRef.current = new Set(
+ selected.map((persona) => persona.id),
+ );
+
+ if (addedPersona) {
+ const count = floatingEmojiCountRef.current;
+ floatingEmojiCountRef.current += 1;
+ const floatingEmoji: FloatingEmoji = {
+ id: `${addedPersona.id}-${count}`,
+ emoji: addedPersona.emoji,
+ placementIndex: count % floatingEmojiPlacements.length,
+ };
+
+ setFloatingEmojis((current) => [...current, floatingEmoji]);
+ const timer = window.setTimeout(() => {
+ setFloatingEmojis((current) =>
+ current.filter((item) => item.id !== floatingEmoji.id),
+ );
+ }, 3200);
+ cleanupTimersRef.current.push(timer);
+ }
+
+ setSelectedCount(selected.length);
+ onSelectionChange(selected);
+ },
+ [onSelectionChange],
+ );
+
+ useEffect(() => {
+ const timersRef = cleanupTimersRef;
+ return () => {
+ timersRef.current.forEach((timer) => window.clearTimeout(timer));
+ };
+ }, []);
+
+ const renderNextButton = (): ReactElement => (
+
+ {loading ? loadingLabel ?? 'Loading…' : 'Next →'}
+
+ );
+
+ return (
+
+ {floatingEmojis.length > 0 && (
+
+ {floatingEmojis.map((item) => {
+ const placement = floatingEmojiPlacements[item.placementIndex];
+
+ return (
+
+ {item.emoji}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
persona.id,
+ )}
+ mode="seed"
+ onSelectionChange={handleSelectionChange}
+ />
+
+ {shouldShowActions ? (
+ <>
+
+
+
+ {renderNextButton()}
+
+
+
+
+ {renderNextButton()}
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/packages/webapp/hooks/useAdaptiveSwipeDeck.ts b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts
new file mode 100644
index 00000000000..ff7a63aa5b1
--- /dev/null
+++ b/packages/webapp/hooks/useAdaptiveSwipeDeck.ts
@@ -0,0 +1,349 @@
+import { useCallback, useRef, useState } from 'react';
+import type { OnboardingSwipeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal';
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import { PostType } from '@dailydotdev/shared/src/graphql/posts';
+import type { PostSummary } from '../lib/swipingBackendApi';
+import { discoverPosts } from '../lib/swipingBackendApi';
+import { fetchSwipeOnboardingPopularDeck } from '../lib/swipeOnboardingPopularDeck';
+
+// Scoring constants — ported from PostSwiper.tsx
+const LIKE_SCORE = 1.5;
+const DISLIKE_SCORE = -1;
+const ADD_THRESHOLD = 3;
+const SATURATE_THRESHOLD = 4.5;
+const REMOVE_THRESHOLD = -5;
+const IGNORE_AFTER = 10;
+const SATURATE_AFTER = 5;
+const PREFETCH_AFTER_SWIPES = 3;
+const BATCH_SIZE = 8;
+
+function toSwipeCard(post: PostSummary): OnboardingSwipeCard {
+ return {
+ id: post.postId,
+ summary: post.summary,
+ title: post.title,
+ image: null,
+ tags: post.tags,
+ source: {
+ name: 'daily.dev',
+ image: null,
+ },
+ };
+}
+
+function toBookmarkablePost(post: PostSummary): Post {
+ return {
+ id: post.postId,
+ title: post.title,
+ summary: post.summary,
+ permalink: post.url,
+ commentsPermalink: post.url,
+ image: '',
+ tags: post.tags,
+ bookmarked: false,
+ type: PostType.Article,
+ };
+}
+
+function postToSummary(post: Post): PostSummary {
+ const title = post.title ?? '';
+ return {
+ postId: post.id,
+ title,
+ summary: post.summary ?? title,
+ tags: post.tags ?? [],
+ url: post.permalink ?? post.commentsPermalink ?? '',
+ sourceId: post.source?.id ?? '',
+ };
+}
+
+interface StartDeckOptions {
+ prompt?: string;
+ initialTags?: string[];
+}
+
+interface AdaptiveSwipeDeck {
+ cards: OnboardingSwipeCard[];
+ getBookmarkablePost: (cardId: string) => Post | undefined;
+ isLoading: boolean;
+ startDeck: (options?: StartDeckOptions) => Promise;
+ handleSwipe: (direction: 'left' | 'right', cardId: string) => void;
+ retryFetch: () => Promise;
+ selectedTags: string[];
+}
+
+export function useAdaptiveSwipeDeck(): AdaptiveSwipeDeck {
+ const [cards, setCards] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
+
+ // Refs for mutable state that persists across batches
+ const tagScoresRef = useRef>({});
+ const tagSeenCountRef = useRef>({});
+ const seenIdsRef = useRef>(new Set());
+ const likedTitlesRef = useRef([]);
+ const startDeckOptionsRef = useRef(undefined);
+ const prefetchedRef = useRef(null);
+ const swipesInBatchRef = useRef(0);
+ const prefetchTriggeredRef = useRef(false);
+ const selectedTagsRef = useRef([]);
+ // Keep a PostSummary lookup so we can access tags/title on swipe
+ const postLookupRef = useRef