From 22755fd08f14d08107a8328213babf4e90a9f3b6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 13:16:27 +0300 Subject: [PATCH 1/8] feat: anonymous post-page build-your-feed experience Adds an off-by-default (post_build_feed) conversion experience for logged-out post-page visitors, scoped strictly to anonymous users: - Build-your-feed sidebar widget with social proof, no-password topic following seeded from the article's tags, and a live in-page feed taste that assembles as you scroll - Single timed conversion prompt fired at the scroll value moment, on exit intent, or by intercepting the first "read original" click (bounce) - Reframed CTAs around building a feed; followed topics carried into signup - Unhide "Happening Now" highlights for anonymous readers - Consolidate the redundant post-page auth banners when the experience is on Falls back to existing behavior when the flag is off. --- .../highlight/HighlightPostSidebarWidget.tsx | 8 +- .../src/components/post/PostContent.tsx | 95 +++++++++--- .../src/components/post/PostWidgets.tsx | 4 +- .../AnonConversionPrompt.tsx | 127 ++++++++++++++++ .../BuildYourFeedWidget.tsx | 141 ++++++++++++++++++ .../postPageOnboarding/FeedTastePreview.tsx | 88 +++++++++++ .../src/features/postPageOnboarding/common.ts | 5 + .../useAnonConversionPrompt.ts | 67 +++++++++ .../postPageOnboarding/useAnonFeedTags.ts | 80 ++++++++++ .../useAnonPostOnboarding.ts | 30 ++++ .../postPageOnboarding/useBuildFeedSignup.ts | 70 +++++++++ .../useScrollValueMoment.ts | 77 ++++++++++ packages/shared/src/lib/featureManagement.ts | 6 + packages/webapp/pages/posts/[id]/index.tsx | 10 +- 14 files changed, 777 insertions(+), 31 deletions(-) create mode 100644 packages/shared/src/features/postPageOnboarding/AnonConversionPrompt.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/BuildYourFeedWidget.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/common.ts create mode 100644 packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts create mode 100644 packages/shared/src/features/postPageOnboarding/useAnonFeedTags.ts create mode 100644 packages/shared/src/features/postPageOnboarding/useAnonPostOnboarding.ts create mode 100644 packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts create mode 100644 packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx index 8717308efd0..d9f5c96f08c 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -33,16 +33,18 @@ const prefersReducedMotion = (): boolean => { }; export const HighlightPostSidebarWidget = (): ReactElement | null => { - const { user } = useAuthContext(); + const { isAuthReady } = useAuthContext(); const { logEvent } = useLogContext(); + // Shown to logged-in AND anonymous readers: the live dev pulse is the + // strongest "this place is alive" hook for a first-time visitor. const { value: isEnabled } = useConditionalFeature({ feature: featurePostPageHighlights, - shouldEvaluate: !!user, + shouldEvaluate: isAuthReady, }); const { data } = useQuery({ ...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }), - enabled: isEnabled && !!user, + enabled: isEnabled, refetchInterval: ONE_MINUTE, }); diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 17e4a1f73a5..d7145711246 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -28,6 +28,9 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; +import { useAnonPostOnboarding } from '../../features/postPageOnboarding/useAnonPostOnboarding'; +import { useAnonConversionPrompt } from '../../features/postPageOnboarding/useAnonConversionPrompt'; +import { AnonConversionPrompt } from '../../features/postPageOnboarding/AnonConversionPrompt'; type PostContentRawProps = Omit & { post: Post }; @@ -98,12 +101,47 @@ export function PostContentRaw({ : undefined, }, ); + // Anonymous "build your feed" experience — only on the full post page. + const { isEnabled: isAnonExperience } = useAnonPostOnboarding(); + const anonExperienceActive = isAnonExperience && !!isPostPage; + const { + isOpen: isConversionOpen, + reason: conversionReason, + openPrompt, + closePrompt, + } = useAnonConversionPrompt({ enabled: anonExperienceActive }); + + // Turn the bounce into the conversion: the click to read the original + // article is peak intent. Intercept it once for anonymous readers to offer + // a personalized feed instead of silently sending them off-site. + const interceptAnonRead = ( + event: React.MouseEvent, + ): boolean => { + if (!anonExperienceActive) { + return false; + } + if (openPrompt('read_intent')) { + event.preventDefault(); + return true; + } + return false; + }; + const handleImageClick = (event: React.MouseEvent) => { + if (interceptAnonRead(event)) { + return; + } if (onReaderInstallGateClick(event)) { return; } onReadArticle(); }; + const handleTitleClick = (event: React.MouseEvent) => { + if (interceptAnonRead(event)) { + return; + } + onReadArticle(); + }; const onSendViewPost = useViewPost(); const showCodeSnippets = useFeature(feature.showCodeSnippets); const { title } = useSmartTitle(post); @@ -183,7 +221,7 @@ export function PostContentRaw({ className="break-words font-bold typo-large-title" data-testid="post-modal-title" > - + {title} @@ -284,29 +322,38 @@ export function PostContentRaw({ ); return ( - - {postMainColumn} - {postWidgetsColumn} - + <> + + {postMainColumn} + {postWidgetsColumn} + + {anonExperienceActive && isConversionOpen && ( + + )} + ); } diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 27bf7aa79c4..a997a97fd23 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -16,8 +16,8 @@ import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; -import { PostSignupWidget } from './PostSignupWidget'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; +import { BuildYourFeedWidget } from '../../features/postPageOnboarding/BuildYourFeedWidget'; const UserEntityCard = dynamic( /* webpackChunkName: "userEntityCard" */ () => @@ -83,7 +83,7 @@ export function PostWidgets({ return ( - + {sourceCard} {creator && ( void; +} + +const COPY: Record< + BuildFeedSignupOrigin, + { title: string; body: string; cta: string } +> = { + value_moment: { + title: 'Liked this read?', + body: 'Get one tuned to you every morning — no noise, just what matters.', + cta: 'Build my feed', + }, + exit_intent: { + title: 'Before you go', + body: "We'll line up more like this so it's waiting when you're back.", + cta: 'Save my feed', + }, + read_intent: { + title: "We'll keep this for you", + body: 'Save this article and get a feed of more like it, tuned to you.', + cta: 'Build my feed', + }, + sidebar: { + title: 'Build your personalized feed', + body: 'Get tech news, tools, and discussions that actually matter.', + cta: 'Build my feed', + }, +}; + +/** + * The single, timed conversion surface for anonymous readers. Controlled by + * useAnonConversionPrompt — it appears once at the value moment / exit intent + * / intercepted read click, framed around the feed (not "sign up"), and + * carries the topics they've been following into the signup. + */ +export const AnonConversionPrompt = ({ + post, + reason, + onClose, +}: AnonConversionPromptProps): ReactElement | null => { + const { triggerSignup } = useBuildFeedSignup(); + const { selectedTags } = useAnonFeedTags({ + postTags: post?.tags ?? [], + enabled: true, + }); + + if (!reason) { + return null; + } + + const copy = COPY[reason]; + const topics = selectedTags.slice(0, 3); + + return ( +
+ + + {copy.title} + + + {copy.body} + + {topics.length > 0 && ( + + Starting with {topics.join(', ')} + {selectedTags.length > topics.length ? ' and more' : ''}. + + )} + +
+ ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/BuildYourFeedWidget.tsx b/packages/shared/src/features/postPageOnboarding/BuildYourFeedWidget.tsx new file mode 100644 index 00000000000..c0807f668f9 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/BuildYourFeedWidget.tsx @@ -0,0 +1,141 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Post } from '../../graphql/posts'; +import type { Tag } from '../../graphql/feedSettings'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { AuthTriggers } from '../../lib/auth'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { ClickableText } from '../../components/buttons/ClickableText'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../components/typography/Typography'; +import { TagElement } from '../../components/tags/TagElement'; +import { highlightsTitleGradientClassName } from '../../components/cards/highlight/common'; +import { PostSignupWidget } from '../../components/post/PostSignupWidget'; +import { useAnonPostOnboarding } from './useAnonPostOnboarding'; +import { useAnonFeedTags } from './useAnonFeedTags'; +import { useBuildFeedSignup } from './useBuildFeedSignup'; +import { FeedTastePreview } from './FeedTastePreview'; + +interface BuildYourFeedWidgetProps { + post: Post; +} + +const MAX_CHIPS = 8; + +/** + * Anonymous post-page sidebar. Replaces the plain signup widget with a + * personalized "build your feed" surface: social proof, no-password topic + * following (seeded from the article's tags), a live taste of the resulting + * feed, and a single benefit-framed CTA. Falls back to the existing + * PostSignupWidget when the experiment is off so the slot is never empty. + */ +export const BuildYourFeedWidget = ({ + post, +}: BuildYourFeedWidgetProps): ReactElement | null => { + const { isEnabled } = useAnonPostOnboarding(); + const { showLogin } = useAuthContext(); + const { triggerSignup } = useBuildFeedSignup(); + const { chips, selectedTags, previewTags, toggleTag } = useAnonFeedTags({ + postTags: post?.tags ?? [], + enabled: isEnabled, + }); + + if (!isEnabled) { + // Experiment off → keep the existing behavior untouched. + return ; + } + + const upvotes = post?.numUpvotes ?? 0; + const comments = post?.numComments ?? 0; + const proofParts = [ + upvotes > 0 && `${upvotes} upvote${upvotes === 1 ? '' : 's'}`, + comments > 0 && `${comments} discussing`, + ].filter(Boolean); + + return ( +
+
+ + Build your personalized feed + + + Join millions of developers on daily.dev. + {proofParts.length > 0 && ` ${proofParts.join(' · ')} on this post.`} + +
+ +
+ + Following {selectedTags.length || 'these'} topic + {selectedTags.length === 1 ? '' : 's'} — tap to tune your feed + +
+ {chips.slice(0, MAX_CHIPS).map((tag) => ( + + clicked.name && toggleTag(clicked.name) + } + /> + ))} +
+
+ + + +
+ + + Already a member?{' '} + + showLogin({ + trigger: AuthTriggers.PostPage, + options: { isLogin: true }, + }) + } + > + Log in + + +
+
+ ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx b/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx new file mode 100644 index 00000000000..610b401c5d2 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx @@ -0,0 +1,88 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import SimilarPosts from '../../components/widgets/SimilarPosts'; +import { gqlClient } from '../../graphql/common'; +import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; + +const PREVIEW_SIZE = 6; +const INITIAL_VISIBLE = 2; + +interface FeedTastePreviewProps { + tags: string[]; + currentPostId?: string; + title?: string; +} + +/** + * A live taste of the feed the visitor would get, seeded by the tags they're + * following. It starts with a couple of posts and reveals more as the reader + * scrolls — so the act of reading visibly assembles a feed around them. The + * query is anonymous-safe (loggedIn defaults to false server-side). + */ +export const FeedTastePreview = ({ + tags, + currentPostId, + title = 'Your feed is forming', +}: FeedTastePreviewProps): ReactElement | null => { + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE); + + const { data, isPending } = useQuery({ + queryKey: ['anonFeedTaste', tags], + queryFn: () => + gqlClient.request(FEED_BY_TAGS_QUERY, { + tags, + first: PREVIEW_SIZE, + }), + enabled: tags.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const posts = useMemo(() => { + const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; + return nodes.filter((post) => post.id !== currentPostId); + }, [data, currentPostId]); + + // Reveal more posts as the reader scrolls — the feed "builds" around them. + const revealRef = useRef(visibleCount); + revealRef.current = visibleCount; + useEffect(() => { + if (typeof window === 'undefined' || posts.length <= INITIAL_VISIBLE) { + return undefined; + } + const onScroll = () => { + const { scrollY, innerHeight } = window; + const docHeight = document.documentElement.scrollHeight; + const progress = + docHeight <= innerHeight ? 1 : (scrollY + innerHeight) / docHeight; + const next = Math.min( + posts.length, + INITIAL_VISIBLE + + Math.floor(progress * (posts.length - INITIAL_VISIBLE) * 1.5), + ); + if (next > revealRef.current) { + setVisibleCount(next); + } + }; + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + return () => window.removeEventListener('scroll', onScroll); + }, [posts.length]); + + if (tags.length === 0) { + return null; + } + + if (!isPending && posts.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/common.ts b/packages/shared/src/features/postPageOnboarding/common.ts new file mode 100644 index 00000000000..4158eae533b --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/common.ts @@ -0,0 +1,5 @@ +// Local persistence keys for the anonymous post-page "build your feed" +// experience. Kept here (not in the shared PersistentContextKeys enum) so the +// feature is fully self-contained. +export const ANON_FEED_TAGS_KEY = 'anon_feed_tags'; +export const ANON_CONVERT_PROMPT_SEEN_KEY = 'anon_convert_prompt_seen'; diff --git a/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts b/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts new file mode 100644 index 00000000000..346e5b479d8 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts @@ -0,0 +1,67 @@ +import { useCallback, useState } from 'react'; +import usePersistentContext from '../../hooks/usePersistentContext'; +import { ANON_CONVERT_PROMPT_SEEN_KEY } from './common'; +import { useScrollValueMoment } from './useScrollValueMoment'; +import type { BuildFeedSignupOrigin } from './useBuildFeedSignup'; + +interface UseAnonConversionPromptProps { + enabled: boolean; +} + +interface UseAnonConversionPrompt { + isOpen: boolean; + reason: BuildFeedSignupOrigin | null; + /** Open the prompt; returns true if it actually opened (intercepted). */ + openPrompt: (reason: BuildFeedSignupOrigin) => boolean; + closePrompt: () => void; +} + +/** + * Orchestrates the single anonymous conversion surface. It opens at most once + * (per device) at whichever high-intent moment comes first — scroll value + * moment, exit intent, or an intercepted "read original" click — and never + * nags again once dismissed. + */ +export const useAnonConversionPrompt = ({ + enabled, +}: UseAnonConversionPromptProps): UseAnonConversionPrompt => { + const [seen, setSeen] = usePersistentContext( + ANON_CONVERT_PROMPT_SEEN_KEY, + false, + ); + const [state, setState] = useState<{ + isOpen: boolean; + reason: BuildFeedSignupOrigin | null; + }>({ isOpen: false, reason: null }); + + const canShow = enabled && !seen; + + const openPrompt = useCallback( + (reason: BuildFeedSignupOrigin): boolean => { + if (!canShow || state.isOpen) { + return false; + } + setState({ isOpen: true, reason }); + return true; + }, + [canShow, state.isOpen], + ); + + const closePrompt = useCallback(() => { + setState({ isOpen: false, reason: null }); + setSeen(true); + }, [setSeen]); + + useScrollValueMoment({ + enabled: canShow, + onValueMoment: () => openPrompt('value_moment'), + onExitIntent: () => openPrompt('exit_intent'), + }); + + return { + isOpen: state.isOpen, + reason: state.reason, + openPrompt, + closePrompt, + }; +}; diff --git a/packages/shared/src/features/postPageOnboarding/useAnonFeedTags.ts b/packages/shared/src/features/postPageOnboarding/useAnonFeedTags.ts new file mode 100644 index 00000000000..e1af4b8e572 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useAnonFeedTags.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import usePersistentContext from '../../hooks/usePersistentContext'; +import { ANON_FEED_TAGS_KEY } from './common'; + +const MAX_SEED_TAGS = 5; + +interface UseAnonFeedTagsProps { + /** Tags of the post currently being read — the personalization seed. */ + postTags: string[]; + /** Only seed/persist when the experience is active. */ + enabled: boolean; +} + +interface UseAnonFeedTags { + /** Tag chips to render: this post's tags plus anything followed earlier. */ + chips: string[]; + /** The tags the visitor is currently following (accumulates across posts). */ + selectedTags: string[]; + /** Tags to drive the live feed taste — falls back to the post's tags. */ + previewTags: string[]; + isReady: boolean; + toggleTag: (tag: string) => void; +} + +const dedupe = (tags: string[]): string[] => Array.from(new Set(tags)); + +/** + * Progressive, no-password personalization. The article's own tags are the + * single strongest signal we have about an anonymous reader, so we pre-select + * them and let the visitor refine — all stored locally (IndexedDB) with no + * account. The selection accumulates across posts so the feed they're shown + * keeps getting more "theirs" the more they read. + */ +export const useAnonFeedTags = ({ + postTags, + enabled, +}: UseAnonFeedTagsProps): UseAnonFeedTags => { + const cleanPostTags = useMemo(() => dedupe(postTags ?? []), [postTags]); + const [stored, setStored, isFetched] = + usePersistentContext(ANON_FEED_TAGS_KEY); + const hasSeeded = useRef(false); + + // Seed once from the current article's tags for a brand-new visitor. + useEffect(() => { + if (!enabled || !isFetched || hasSeeded.current) { + return; + } + hasSeeded.current = true; + if (!stored && cleanPostTags.length > 0) { + setStored(cleanPostTags.slice(0, MAX_SEED_TAGS)); + } + }, [enabled, isFetched, stored, cleanPostTags, setStored]); + + const selectedTags = useMemo(() => stored ?? [], [stored]); + + const toggleTag = useCallback( + (tag: string) => { + const next = selectedTags.includes(tag) + ? selectedTags.filter((item) => item !== tag) + : [...selectedTags, tag]; + setStored(next); + }, + [selectedTags, setStored], + ); + + const chips = useMemo( + () => dedupe([...cleanPostTags, ...selectedTags]), + [cleanPostTags, selectedTags], + ); + + const previewTags = selectedTags.length > 0 ? selectedTags : cleanPostTags; + + return { + chips, + selectedTags, + previewTags, + isReady: isFetched, + toggleTag, + }; +}; diff --git a/packages/shared/src/features/postPageOnboarding/useAnonPostOnboarding.ts b/packages/shared/src/features/postPageOnboarding/useAnonPostOnboarding.ts new file mode 100644 index 00000000000..b869fcb893f --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useAnonPostOnboarding.ts @@ -0,0 +1,30 @@ +import { useAuthContext } from '../../contexts/AuthContext'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featurePostBuildFeed } from '../../lib/featureManagement'; + +interface UseAnonPostOnboarding { + /** True only for logged-out visitors once auth is resolved. */ + isAnonymous: boolean; + /** True when the anonymous build-feed experience should render. */ + isEnabled: boolean; +} + +/** + * Central gate for the anonymous post-page "build your feed" experience. + * Every piece of the experience (sidebar widget, feed taste, conversion + * prompt, banner consolidation) reads from this single hook so the behavior + * is consistent and evaluated once per surface. + */ +export const useAnonPostOnboarding = (): UseAnonPostOnboarding => { + const { user, isAuthReady } = useAuthContext(); + const isAnonymous = isAuthReady && !user; + const { value: isFlagEnabled } = useConditionalFeature({ + feature: featurePostBuildFeed, + shouldEvaluate: isAnonymous, + }); + + return { + isAnonymous, + isEnabled: isAnonymous && isFlagEnabled, + }; +}; diff --git a/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts b/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts new file mode 100644 index 00000000000..d3c8391115e --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { gqlClient } from '../../graphql/common'; +import { ADD_FILTERS_TO_FEED_MUTATION } from '../../graphql/feedSettings'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent } from '../../lib/log'; +import { RequestKey } from '../../lib/query'; + +export type BuildFeedSignupOrigin = + | 'sidebar' + | 'value_moment' + | 'exit_intent' + | 'read_intent'; + +interface UseBuildFeedSignup { + triggerSignup: ( + tags: string[], + trackingOrigin: BuildFeedSignupOrigin, + ) => void; +} + +/** + * Opens the signup flow framed as "build my feed" and applies the tags the + * visitor followed (no-password, stored locally) the moment their account is + * created — so the very first feed they see is already personalized to what + * they were reading. This is the payoff of the progressive personalization. + */ +export const useBuildFeedSignup = (): UseBuildFeedSignup => { + const { showLogin } = useAuthContext(); + const { logEvent } = useLogContext(); + const queryClient = useQueryClient(); + + const triggerSignup = useCallback( + (tags: string[], trackingOrigin: BuildFeedSignupOrigin) => { + logEvent({ + event_name: LogEvent.Click, + extra: JSON.stringify({ origin: trackingOrigin, tags }), + }); + + showLogin({ + trigger: AuthTriggers.PostPage, + options: { + onRegistrationSuccess: () => { + if (!tags.length) { + return; + } + gqlClient + .request(ADD_FILTERS_TO_FEED_MUTATION, { + filters: { includeTags: tags }, + }) + .then(() => + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey.includes(RequestKey.FeedSettings), + }), + ) + .catch(() => { + // Onboarding will let them pick tags anyway; never block signup. + }); + }, + }, + }); + }, + [showLogin, logEvent, queryClient], + ); + + return { triggerSignup }; +}; diff --git a/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts b/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts new file mode 100644 index 00000000000..ab5882cadc8 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; + +interface UseScrollValueMomentProps { + enabled: boolean; + /** Fraction of the page scrolled that counts as "got value" (0-1). */ + threshold?: number; + /** Fired once when the reader reaches the value moment. */ + onValueMoment: () => void; + /** Fired once when the pointer leaves through the top of the viewport. */ + onExitIntent?: () => void; +} + +const DEFAULT_THRESHOLD = 0.55; + +/** + * Detects the two highest-intent moments on the post page for an anonymous + * reader and fires each at most once: + * - the "value moment": they've scrolled far enough to have gotten value; + * - "exit intent": the cursor flies up toward the tab bar / back button. + * We ask for the account on this momentum rather than on arrival. + */ +export const useScrollValueMoment = ({ + enabled, + threshold = DEFAULT_THRESHOLD, + onValueMoment, + onExitIntent, +}: UseScrollValueMomentProps): void => { + const valueFired = useRef(false); + const exitFired = useRef(false); + const onValueMomentRef = useRef(onValueMoment); + const onExitIntentRef = useRef(onExitIntent); + + onValueMomentRef.current = onValueMoment; + onExitIntentRef.current = onExitIntent; + + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return undefined; + } + + const checkScroll = () => { + if (valueFired.current) { + return; + } + const { scrollY, innerHeight } = window; + const docHeight = document.documentElement.scrollHeight; + if (docHeight <= innerHeight) { + return; + } + const progress = (scrollY + innerHeight) / docHeight; + if (progress >= threshold) { + valueFired.current = true; + onValueMomentRef.current(); + } + }; + + const handleMouseOut = (event: MouseEvent) => { + if (exitFired.current || !onExitIntentRef.current) { + return; + } + // Pointer left the document through the top edge. + if (!event.relatedTarget && event.clientY <= 8) { + exitFired.current = true; + onExitIntentRef.current(); + } + }; + + window.addEventListener('scroll', checkScroll, { passive: true }); + document.addEventListener('mouseout', handleMouseOut); + checkScroll(); + + return () => { + window.removeEventListener('scroll', checkScroll); + document.removeEventListener('mouseout', handleMouseOut); + }; + }, [enabled, threshold]); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 132e1e71734..bf54cc7aeb1 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -177,6 +177,12 @@ export const featureOnboardingPersonas = new Feature( export const featurePostSignupWidget = new Feature('post_signup_widget', false); +// Anonymous post-page "build your feed" conversion experience. When enabled, +// it replaces the simple signup widget with a personalized feed-building +// surface and consolidates the redundant auth banners into a single timed +// prompt. Off by default; opt-in per A/B experiment. +export const featurePostBuildFeed = new Feature('post_build_feed', false); + export const featureReaderModal = new Feature('reader_modal_v2', false); export const featureGenericReferralPopupV2 = new Feature( diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index fce81b9431d..e02d33e942c 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -48,6 +48,7 @@ import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; +import { useAnonPostOnboarding } from '@dailydotdev/shared/src/features/postPageOnboarding/useAnonPostOnboarding'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -186,6 +187,11 @@ export const PostPage = ({ const router = useRouter(); const isFallback = false; const { shouldShowAuthBanner } = useOnboardingActions(); + // When the "build your feed" experience is on, it owns the single conversion + // surface (sidebar widget + one timed prompt), so suppress the redundant + // post-page auth banners. + const { isEnabled: isBuildFeedExperience } = useAnonPostOnboarding(); + const showAuthBanner = shouldShowAuthBanner && !isBuildFeedExperience; const isLaptop = useViewSize(ViewSize.Laptop); const { post, isError, isLoading } = usePostById({ id, @@ -278,7 +284,7 @@ export const PostPage = ({ backToSquad={!!router?.query?.squad} shouldOnboardAuthor={!!router.query?.author} origin={Origin.ArticlePage} - isBannerVisible={shouldShowAuthBanner && !isLaptop} + isBannerVisible={showAuthBanner && !isLaptop} className={{ container: containerClass, fixedNavigation: { container: 'flex laptop:hidden' }, @@ -288,7 +294,7 @@ export const PostPage = ({ }, }} /> - {shouldShowAuthBanner && isLaptop && } + {showAuthBanner && isLaptop && } From 0e808e0ca53ed2aaa337ccbd4313eb6ab0b7b8ec Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 13:26:27 +0300 Subject: [PATCH 2/8] chore: default post_build_feed and post_page_highlights on for preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary — revert both to false before merge. Lets the anonymous build-your-feed experience render on the preview without GrowthBook setup. --- packages/shared/src/lib/featureManagement.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index bf54cc7aeb1..e18408347a6 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -34,7 +34,8 @@ export const latestFeedVersion = new Feature('latest_feed_version', 2); export const customFeedVersion = new Feature('custom_feed_version', 2); export const featurePostPageHighlights = new Feature( 'post_page_highlights', - false, + // TODO: revert to false — temporarily on to preview the anonymous experience. + true, ); // @ts-expect-error stale feature without default @@ -180,8 +181,9 @@ export const featurePostSignupWidget = new Feature('post_signup_widget', false); // Anonymous post-page "build your feed" conversion experience. When enabled, // it replaces the simple signup widget with a personalized feed-building // surface and consolidates the redundant auth banners into a single timed -// prompt. Off by default; opt-in per A/B experiment. -export const featurePostBuildFeed = new Feature('post_build_feed', false); +// prompt. +// TODO: revert to false — temporarily on to preview the anonymous experience. +export const featurePostBuildFeed = new Feature('post_build_feed', true); export const featureReaderModal = new Feature('reader_modal_v2', false); From f03cb1a0b526cbab06b1190680dac83350dadf14 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 14:07:13 +0300 Subject: [PATCH 3/8] feat: rework anonymous post page from review feedback - Right column becomes one cohesive conversion card: relevant hero copy, real-time engagement pulse + recent upvoter avatars, no-password topic chips (static, no layout shift), feed taste, single CTA, Happening Now - Add endless personalized "Keep reading" feed below the comments - Remove sidebar noise for anon: source/author/tools cards, ShareBar ("recommend this post"), Best discussions, and the clickbait shield - Demote the promo to the last sidebar slot - Bottom prompt is leave-intent only (exit intent + read-click); no more mid-scroll interruption - Drop the obsolete BuildYourFeedWidget --- .../src/components/post/BasePostContent.tsx | 2 + .../src/components/post/PostContent.tsx | 4 +- .../src/components/post/PostWidgets.tsx | 22 ++- ...Widget.tsx => BuildFeedConversionCard.tsx} | 85 +++++----- .../ContinueReadingFeed.tsx | 147 ++++++++++++++++++ .../postPageOnboarding/FeedTastePreview.tsx | 51 ++---- .../features/postPageOnboarding/LivePulse.tsx | 101 ++++++++++++ .../useAnonConversionPrompt.ts | 3 +- .../useScrollValueMoment.ts | 6 +- 9 files changed, 332 insertions(+), 89 deletions(-) rename packages/shared/src/features/postPageOnboarding/{BuildYourFeedWidget.tsx => BuildFeedConversionCard.tsx} (62%) create mode 100644 packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/LivePulse.tsx diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 0e1d81a904b..ba5e6afca96 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; +import { ContinueReadingFeed } from '../../features/postPageOnboarding/ContinueReadingFeed'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -70,6 +71,7 @@ export function BasePostContent({ shouldOnboardAuthor={shouldOnboardAuthor} /> )} + {isPostPage && } ); } diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index d7145711246..69f6a8c8e41 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -225,7 +225,9 @@ export function PostContentRaw({ {title}
- {post.clickbaitTitleDetected && } + {post.clickbaitTitleDetected && !anonExperienceActive && ( + + )} {isVideoType && ( @@ -53,10 +55,26 @@ export function PostWidgets({ origin, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); + const { isEnabled: isAnonExperience } = useAnonPostOnboarding(); const { source } = post; const cardClasses = 'w-full bg-transparent'; + // Anonymous "build your feed" experience: the whole right column becomes a + // single cohesive conversion card, with the promo demoted to the last slot. + if (isAnonExperience) { + return ( + + + + + + ); + } + const creator = post.author || post.scout; let sourceCard = null; @@ -83,7 +101,7 @@ export function PostWidgets({ return ( - + {sourceCard} {creator && ( { + if (chips.length === 0) { + return 'Get a daily feed of the best dev content — handpicked for you, no noise.'; + } + const primary = capitalize(chips[0]); + const list = chips.slice(0, 3).map(capitalize).join(', '); + return `Loving this ${primary} read? Get a daily feed of ${list} and more — the best dev content, picked for you.`; +}; /** - * Anonymous post-page sidebar. Replaces the plain signup widget with a - * personalized "build your feed" surface: social proof, no-password topic - * following (seeded from the article's tags), a live taste of the resulting - * feed, and a single benefit-framed CTA. Falls back to the existing - * PostSignupWidget when the experiment is off so the slot is never empty. + * The single anonymous conversion surface in the right column. The whole + * panel is one cohesive, tinted card: a benefit-led hero made relevant by the + * article's own topics, a real-time activity pulse, no-password topic + * following, a taste of the resulting feed, and the live dev pulse — all + * driving one CTA. */ -export const BuildYourFeedWidget = ({ +export const BuildFeedConversionCard = ({ post, -}: BuildYourFeedWidgetProps): ReactElement | null => { - const { isEnabled } = useAnonPostOnboarding(); +}: BuildFeedConversionCardProps): ReactElement => { const { showLogin } = useAuthContext(); const { triggerSignup } = useBuildFeedSignup(); const { chips, selectedTags, previewTags, toggleTag } = useAnonFeedTags({ postTags: post?.tags ?? [], - enabled: isEnabled, + enabled: true, }); - if (!isEnabled) { - // Experiment off → keep the existing behavior untouched. - return ; - } - - const upvotes = post?.numUpvotes ?? 0; - const comments = post?.numComments ?? 0; - const proofParts = [ - upvotes > 0 && `${upvotes} upvote${upvotes === 1 ? '' : 's'}`, - comments > 0 && `${comments} discussing`, - ].filter(Boolean); - return ( -
-
+
+
- Join millions of developers on daily.dev. - {proofParts.length > 0 && ` ${proofParts.join(' · ')} on this post.`} + {buildSubcopy(chips)} +
- Following {selectedTags.length || 'these'} topic - {selectedTags.length === 1 ? '' : 's'} — tap to tune your feed + Your topics · tap to tune
{chips.slice(0, MAX_CHIPS).map((tag) => ( @@ -100,20 +97,14 @@ export const BuildYourFeedWidget = ({
- -
+ +
+ + +
); }; diff --git a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx new file mode 100644 index 00000000000..4bb714aec9a --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx @@ -0,0 +1,147 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import classNames from 'classnames'; +import type { Post } from '../../graphql/posts'; +import { gqlClient } from '../../graphql/common'; +import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; +import { capitalize } from '../../lib/strings'; +import { LazyImage } from '../../components/LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; +import { CardLink } from '../../components/cards/common/Card'; +import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../components/typography/Typography'; +import InfiniteScrolling, { + checkFetchMore, +} from '../../components/containers/InfiniteScrolling'; +import { useAnonPostOnboarding } from './useAnonPostOnboarding'; + +const PAGE_SIZE = 10; + +interface ContinueReadingFeedProps { + post: Post; +} + +const ContinueReadingItem = ({ post }: { post: Post }): ReactElement => ( +
+ + +
+ + {post.source?.name} + +
+ {post.title} +
+ +
+
+); + +const ItemPlaceholder = (): ReactElement => ( +
+ +
+ + + +
+
+); + +/** + * An endless, personalized feed below the comments so an anonymous reader who + * finishes the article and discussion keeps scrolling into more relevant dev + * content instead of hitting a dead end. Seeded by the article's tags and + * anonymous-safe. + */ +export const ContinueReadingFeed = ({ + post, +}: ContinueReadingFeedProps): ReactElement | null => { + const { isEnabled } = useAnonPostOnboarding(); + const tags = useMemo(() => post?.tags ?? [], [post?.tags]); + + const query = useInfiniteQuery({ + queryKey: ['continueReading', post?.id, tags], + queryFn: ({ pageParam }) => + gqlClient.request(FEED_BY_TAGS_QUERY, { + tags, + first: PAGE_SIZE, + after: pageParam, + }), + initialPageParam: '', + getNextPageParam: (lastPage) => + lastPage.page.pageInfo.hasNextPage + ? lastPage.page.pageInfo.endCursor + : undefined, + enabled: isEnabled && tags.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const posts = useMemo(() => { + const nodes = + query.data?.pages.flatMap((page) => + page.page.edges.map((edge) => edge.node), + ) ?? []; + return nodes.filter((item) => item.id !== post?.id); + }, [query.data, post?.id]); + + if (!isEnabled || tags.length === 0) { + return null; + } + + if (!query.isLoading && posts.length === 0) { + return null; + } + + const primary = tags[0] ? capitalize(tags[0]) : 'dev'; + + return ( +
+ + Keep reading + + + More {primary} stories developers are reading right now + + } + > + {query.isLoading ? ( + <> + + + + + ) : ( + posts.map((item) => ) + )} + +
+ ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx b/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx index 610b401c5d2..6dceb8a98f1 100644 --- a/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx +++ b/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx @@ -1,38 +1,35 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import SimilarPosts from '../../components/widgets/SimilarPosts'; import { gqlClient } from '../../graphql/common'; import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; -const PREVIEW_SIZE = 6; -const INITIAL_VISIBLE = 2; - interface FeedTastePreviewProps { tags: string[]; currentPostId?: string; title?: string; + maxItems?: number; } /** - * A live taste of the feed the visitor would get, seeded by the tags they're - * following. It starts with a couple of posts and reveals more as the reader - * scrolls — so the act of reading visibly assembles a feed around them. The - * query is anonymous-safe (loggedIn defaults to false server-side). + * A compact, static taste of the feed the visitor would get, seeded by the + * tags they're following. Fixed count (no scroll-reveal) so it never shifts + * the sticky sidebar — the endless "keep reading" experience lives in the + * main column instead. */ export const FeedTastePreview = ({ tags, currentPostId, title = 'Your feed is forming', + maxItems = 3, }: FeedTastePreviewProps): ReactElement | null => { - const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE); - const { data, isPending } = useQuery({ queryKey: ['anonFeedTaste', tags], queryFn: () => gqlClient.request(FEED_BY_TAGS_QUERY, { tags, - first: PREVIEW_SIZE, + first: maxItems + 1, }), enabled: tags.length > 0, staleTime: 5 * 60 * 1000, @@ -40,34 +37,8 @@ export const FeedTastePreview = ({ const posts = useMemo(() => { const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; - return nodes.filter((post) => post.id !== currentPostId); - }, [data, currentPostId]); - - // Reveal more posts as the reader scrolls — the feed "builds" around them. - const revealRef = useRef(visibleCount); - revealRef.current = visibleCount; - useEffect(() => { - if (typeof window === 'undefined' || posts.length <= INITIAL_VISIBLE) { - return undefined; - } - const onScroll = () => { - const { scrollY, innerHeight } = window; - const docHeight = document.documentElement.scrollHeight; - const progress = - docHeight <= innerHeight ? 1 : (scrollY + innerHeight) / docHeight; - const next = Math.min( - posts.length, - INITIAL_VISIBLE + - Math.floor(progress * (posts.length - INITIAL_VISIBLE) * 1.5), - ); - if (next > revealRef.current) { - setVisibleCount(next); - } - }; - window.addEventListener('scroll', onScroll, { passive: true }); - onScroll(); - return () => window.removeEventListener('scroll', onScroll); - }, [posts.length]); + return nodes.filter((post) => post.id !== currentPostId).slice(0, maxItems); + }, [data, currentPostId, maxItems]); if (tags.length === 0) { return null; @@ -80,7 +51,7 @@ export const FeedTastePreview = ({ return ( diff --git a/packages/shared/src/features/postPageOnboarding/LivePulse.tsx b/packages/shared/src/features/postPageOnboarding/LivePulse.tsx new file mode 100644 index 00000000000..9b5ee01f6da --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/LivePulse.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import useSubscription from '../../hooks/useSubscription'; +import { gqlClient } from '../../graphql/common'; +import type { Post, PostsEngaged } from '../../graphql/posts'; +import { + POSTS_ENGAGED_SUBSCRIPTION, + POST_UPVOTES_BY_ID_QUERY, +} from '../../graphql/posts'; +import type { UserImageProps } from '../../components/ProfilePicture'; +import { + ProfileImageSize, + ProfilePicture, +} from '../../components/ProfilePicture'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../components/typography/Typography'; + +interface UpvotesData { + upvotes: { + edges: Array<{ node: { user: UserImageProps } }>; + }; +} + +const AVATAR_LIMIT = 5; + +/** + * Makes the article feel alive with REAL data: upvote/comment counts tick up + * in real time via the posts-engaged subscription (no-ops gracefully when the + * socket isn't connected), alongside the faces of developers who recently + * upvoted. No fabricated "reading now" numbers. + */ +export const LivePulse = ({ post }: { post: Post }): ReactElement | null => { + const [counts, setCounts] = useState({ + upvotes: post?.numUpvotes ?? 0, + comments: post?.numComments ?? 0, + }); + + useSubscription( + () => ({ query: POSTS_ENGAGED_SUBSCRIPTION }), + { + next: (data: PostsEngaged) => { + if (data?.postsEngaged?.id === post?.id) { + setCounts({ + upvotes: data.postsEngaged.numUpvotes, + comments: data.postsEngaged.numComments, + }); + } + }, + }, + [post?.id], + ); + + const { data } = useQuery({ + queryKey: ['livePulseUpvoters', post?.id], + queryFn: () => + gqlClient.request(POST_UPVOTES_BY_ID_QUERY, { + id: post.id, + first: AVATAR_LIMIT, + }), + enabled: !!post?.id && (post?.numUpvotes ?? 0) > 0, + staleTime: 5 * 60 * 1000, + }); + + const avatars = + data?.upvotes?.edges?.map((edge) => edge.node.user).filter(Boolean) ?? []; + + if (counts.upvotes === 0 && counts.comments === 0) { + return null; + } + + return ( +
+ + + + + {avatars.length > 0 && ( +
+ {avatars.map((user) => ( + + ))} +
+ )} + + {counts.upvotes} upvotes · {counts.comments} discussing + +
+ ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts b/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts index 346e5b479d8..b6845ffeb19 100644 --- a/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts +++ b/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts @@ -52,9 +52,10 @@ export const useAnonConversionPrompt = ({ setSeen(true); }, [setSeen]); + // Leave-intent only: the bottom prompt never interrupts mid-read. The + // persistent right-side card carries the value-moment conversion instead. useScrollValueMoment({ enabled: canShow, - onValueMoment: () => openPrompt('value_moment'), onExitIntent: () => openPrompt('exit_intent'), }); diff --git a/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts b/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts index ab5882cadc8..f84ceb4d3b0 100644 --- a/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts +++ b/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts @@ -4,8 +4,8 @@ interface UseScrollValueMomentProps { enabled: boolean; /** Fraction of the page scrolled that counts as "got value" (0-1). */ threshold?: number; - /** Fired once when the reader reaches the value moment. */ - onValueMoment: () => void; + /** Fired once when the reader reaches the value moment (optional). */ + onValueMoment?: () => void; /** Fired once when the pointer leaves through the top of the viewport. */ onExitIntent?: () => void; } @@ -39,7 +39,7 @@ export const useScrollValueMoment = ({ } const checkScroll = () => { - if (valueFired.current) { + if (valueFired.current || !onValueMomentRef.current) { return; } const { scrollY, innerHeight } = window; From 64dc4bbc07caeb85f9b5c4f6e81d3e20acf95368 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 14:34:26 +0300 Subject: [PATCH 4/8] feat: polish anonymous post page conversion (round 3) - Redesign the right-side card as a focused, standout product hero on the brand gradient: topic-relevant headline, explicit benefit bullets, live pulse, topic tuning, and inline one-tap signup (Google/GitHub/email) - Keep "Happening Now" as its own sidebar item below the card - Rebuild the "Keep reading" feed: richer cards, reliable "Load more" pagination, and a polished conversion banner woven into the feed - Replace the exit-intent strip with the in-flow feed banner (proven inline-auth design, improved messaging); remove read-click interception - Carry followed topics into signup via inline AuthOptions onSuccessfulRegistration - Remove dead files (exit prompt, scroll hook, sidebar feed taste, old widget) --- .../src/components/post/PostContent.tsx | 42 +---- .../src/components/post/PostWidgets.tsx | 1 + .../AnonConversionPrompt.tsx | 127 -------------- .../BuildFeedAuthOptions.tsx | 50 ++++++ .../BuildFeedConversionCard.tsx | 166 ++++++++---------- .../ContinueReadingFeed.tsx | 92 ++++++---- .../FeedConversionBanner.tsx | 60 +++++++ .../postPageOnboarding/FeedTastePreview.tsx | 59 ------- .../useAnonConversionPrompt.ts | 68 ------- .../postPageOnboarding/useBuildFeedSignup.ts | 102 +++++++---- .../useScrollValueMoment.ts | 77 -------- 11 files changed, 305 insertions(+), 539 deletions(-) delete mode 100644 packages/shared/src/features/postPageOnboarding/AnonConversionPrompt.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/BuildFeedAuthOptions.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts delete mode 100644 packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 69f6a8c8e41..5c1cc0f89e7 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -29,8 +29,6 @@ import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; import { useAnonPostOnboarding } from '../../features/postPageOnboarding/useAnonPostOnboarding'; -import { useAnonConversionPrompt } from '../../features/postPageOnboarding/useAnonConversionPrompt'; -import { AnonConversionPrompt } from '../../features/postPageOnboarding/AnonConversionPrompt'; type PostContentRawProps = Omit & { post: Post }; @@ -104,44 +102,13 @@ export function PostContentRaw({ // Anonymous "build your feed" experience — only on the full post page. const { isEnabled: isAnonExperience } = useAnonPostOnboarding(); const anonExperienceActive = isAnonExperience && !!isPostPage; - const { - isOpen: isConversionOpen, - reason: conversionReason, - openPrompt, - closePrompt, - } = useAnonConversionPrompt({ enabled: anonExperienceActive }); - - // Turn the bounce into the conversion: the click to read the original - // article is peak intent. Intercept it once for anonymous readers to offer - // a personalized feed instead of silently sending them off-site. - const interceptAnonRead = ( - event: React.MouseEvent, - ): boolean => { - if (!anonExperienceActive) { - return false; - } - if (openPrompt('read_intent')) { - event.preventDefault(); - return true; - } - return false; - }; const handleImageClick = (event: React.MouseEvent) => { - if (interceptAnonRead(event)) { - return; - } if (onReaderInstallGateClick(event)) { return; } onReadArticle(); }; - const handleTitleClick = (event: React.MouseEvent) => { - if (interceptAnonRead(event)) { - return; - } - onReadArticle(); - }; const onSendViewPost = useViewPost(); const showCodeSnippets = useFeature(feature.showCodeSnippets); const { title } = useSmartTitle(post); @@ -221,7 +188,7 @@ export function PostContentRaw({ className="break-words font-bold typo-large-title" data-testid="post-modal-title" > - + {title} @@ -348,13 +315,6 @@ export function PostContentRaw({ {postMainColumn} {postWidgetsColumn} - {anonExperienceActive && isConversionOpen && ( - - )} ); } diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index b80a48a90c0..59a8979cc44 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -66,6 +66,7 @@ export function PostWidgets({ return ( + void; -} - -const COPY: Record< - BuildFeedSignupOrigin, - { title: string; body: string; cta: string } -> = { - value_moment: { - title: 'Liked this read?', - body: 'Get one tuned to you every morning — no noise, just what matters.', - cta: 'Build my feed', - }, - exit_intent: { - title: 'Before you go', - body: "We'll line up more like this so it's waiting when you're back.", - cta: 'Save my feed', - }, - read_intent: { - title: "We'll keep this for you", - body: 'Save this article and get a feed of more like it, tuned to you.', - cta: 'Build my feed', - }, - sidebar: { - title: 'Build your personalized feed', - body: 'Get tech news, tools, and discussions that actually matter.', - cta: 'Build my feed', - }, -}; - -/** - * The single, timed conversion surface for anonymous readers. Controlled by - * useAnonConversionPrompt — it appears once at the value moment / exit intent - * / intercepted read click, framed around the feed (not "sign up"), and - * carries the topics they've been following into the signup. - */ -export const AnonConversionPrompt = ({ - post, - reason, - onClose, -}: AnonConversionPromptProps): ReactElement | null => { - const { triggerSignup } = useBuildFeedSignup(); - const { selectedTags } = useAnonFeedTags({ - postTags: post?.tags ?? [], - enabled: true, - }); - - if (!reason) { - return null; - } - - const copy = COPY[reason]; - const topics = selectedTags.slice(0, 3); - - return ( -
- - - {copy.title} - - - {copy.body} - - {topics.length > 0 && ( - - Starting with {topics.join(', ')} - {selectedTags.length > topics.length ? ' and more' : ''}. - - )} - -
- ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/BuildFeedAuthOptions.tsx b/packages/shared/src/features/postPageOnboarding/BuildFeedAuthOptions.tsx new file mode 100644 index 00000000000..73e836d26a5 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/BuildFeedAuthOptions.tsx @@ -0,0 +1,50 @@ +import type { MutableRefObject, ReactElement } from 'react'; +import React from 'react'; +import AuthOptions from '../../components/auth/AuthOptions'; +import { AuthDisplay } from '../../components/auth/common'; +import { AuthTriggers } from '../../lib/auth'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/Button'; +import { useBuildFeedSignup } from './useBuildFeedSignup'; +import type { BuildFeedSignupOrigin } from './useBuildFeedSignup'; + +interface BuildFeedAuthOptionsProps { + tags: string[]; + origin: BuildFeedSignupOrigin; + className?: string; + hideLoginLink?: boolean; +} + +/** + * Inline signup (Google / GitHub / email) reused across the anonymous + * build-feed surfaces. One-tap social signs up in place; the email path + * escalates to the modal. Either way the followed topics are applied to the + * new feed. + */ +export const BuildFeedAuthOptions = ({ + tags, + origin, + className, + hideLoginLink = false, +}: BuildFeedAuthOptionsProps): ReactElement => { + const { getAuthStateHandler, applyFollowedTags } = useBuildFeedSignup(); + + return ( + } + trigger={AuthTriggers.PostPage} + simplified + defaultDisplay={AuthDisplay.OnboardingSignup} + forceDefaultDisplay + className={{ onboardingSignup: className ?? '!gap-3' }} + onAuthStateUpdate={getAuthStateHandler(tags, origin)} + onSuccessfulRegistration={() => applyFollowedTags(tags)} + onboardingSignupButton={{ + variant: ButtonVariant.Primary, + size: ButtonSize.Medium, + }} + hideLoginLink={hideLoginLink} + compact + /> + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx index ceb90d95975..bde36eaaced 100644 --- a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx +++ b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx @@ -1,28 +1,23 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { Post } from '../../graphql/posts'; import type { Tag } from '../../graphql/feedSettings'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { AuthTriggers } from '../../lib/auth'; import { capitalize } from '../../lib/strings'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/Button'; -import { ClickableText } from '../../components/buttons/ClickableText'; +import { VIcon } from '../../components/icons'; +import { IconSize } from '../../components/Icon'; import { Typography, TypographyColor, + TypographyTag, TypographyType, } from '../../components/typography/Typography'; import { TagElement } from '../../components/tags/TagElement'; -import { highlightsTitleGradientClassName } from '../../components/cards/highlight/common'; -import { HighlightPostSidebarWidget } from '../../components/cards/highlight/HighlightPostSidebarWidget'; +import { onboardingGradientClasses } from '../../components/onboarding/common'; +import { authGradientBg } from '../../components/marketing/banners/common'; import { useAnonFeedTags } from './useAnonFeedTags'; -import { useBuildFeedSignup } from './useBuildFeedSignup'; import { LivePulse } from './LivePulse'; -import { FeedTastePreview } from './FeedTastePreview'; +import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; interface BuildFeedConversionCardProps { post: Post; @@ -30,112 +25,91 @@ interface BuildFeedConversionCardProps { const MAX_CHIPS = 6; -const buildSubcopy = (chips: string[]): string => { - if (chips.length === 0) { - return 'Get a daily feed of the best dev content — handpicked for you, no noise.'; - } - const primary = capitalize(chips[0]); - const list = chips.slice(0, 3).map(capitalize).join(', '); - return `Loving this ${primary} read? Get a daily feed of ${list} and more — the best dev content, picked for you.`; -}; +const Benefit = ({ children }: { children: string }): ReactElement => ( +
  • + + + {children} + +
  • +); /** - * The single anonymous conversion surface in the right column. The whole - * panel is one cohesive, tinted card: a benefit-led hero made relevant by the - * article's own topics, a real-time activity pulse, no-password topic - * following, a taste of the resulting feed, and the live dev pulse — all - * driving one CTA. + * The standout anonymous conversion surface. A focused product hero on the + * brand gradient: a headline made relevant by the article's topic, three + * explicit benefits, a real-time activity pulse, no-password topic tuning, + * and inline one-tap signup — engineered for the aha moment. */ export const BuildFeedConversionCard = ({ post, }: BuildFeedConversionCardProps): ReactElement => { - const { showLogin } = useAuthContext(); - const { triggerSignup } = useBuildFeedSignup(); - const { chips, selectedTags, previewTags, toggleTag } = useAnonFeedTags({ + const { chips, selectedTags, toggleTag } = useAnonFeedTags({ postTags: post?.tags ?? [], enabled: true, }); + const primaryTopic = chips[0] ? capitalize(chips[0]) : null; + const topicList = chips.slice(0, 3).map(capitalize).join(', '); + return ( -
    -
    +
    +
    - Build your personalized feed - - - {buildSubcopy(chips)} - - -
    - -
    - - Your topics · tap to tune + Personalized for you -
    - {chips.slice(0, MAX_CHIPS).map((tag) => ( - - clicked.name && toggleTag(clicked.name) - } - /> - ))} -
    -
    - -
    - - Already a member?{' '} - - showLogin({ - trigger: AuthTriggers.PostPage, - options: { isLogin: true }, - }) - } - > - Log in - + {primaryTopic + ? `Your daily feed of ${primaryTopic}` + : 'Your personalized dev feed'} +
      + + {topicList + ? `The best of ${topicList} — every morning` + : 'The best dev content — every morning'} + + Real discussions with developers who get it + Save, organize & never lose a great read +
    +
    -
    - - +
    +
    + + Tune your topics + +
    + {chips.slice(0, MAX_CHIPS).map((tag) => ( + + clicked.name && toggleTag(clicked.name) + } + /> + ))} +
    +
    +
    ); diff --git a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx index 4bb714aec9a..c5c925427ae 100644 --- a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx +++ b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; +import React, { Fragment, useMemo } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; -import classNames from 'classnames'; import type { Post } from '../../graphql/posts'; import { gqlClient } from '../../graphql/common'; import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; @@ -11,29 +10,35 @@ import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { CardLink } from '../../components/cards/common/Card'; import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { Loader } from '../../components/Loader'; import { Typography, TypographyColor, TypographyType, } from '../../components/typography/Typography'; -import InfiniteScrolling, { - checkFetchMore, -} from '../../components/containers/InfiniteScrolling'; import { useAnonPostOnboarding } from './useAnonPostOnboarding'; +import { useAnonFeedTags } from './useAnonFeedTags'; +import { FeedConversionBanner } from './FeedConversionBanner'; -const PAGE_SIZE = 10; +const PAGE_SIZE = 7; +const BANNER_AFTER = 3; interface ContinueReadingFeedProps { post: Post; } const ContinueReadingItem = ({ post }: { post: Post }): ReactElement => ( -
    +
    @@ -44,9 +49,9 @@ const ContinueReadingItem = ({ post }: { post: Post }): ReactElement => ( > {post.source?.name} -
    +

    {post.title} -

    + ( ); const ItemPlaceholder = (): ReactElement => ( -
    - +
    +
    - +
    ); /** - * An endless, personalized feed below the comments so an anonymous reader who - * finishes the article and discussion keeps scrolling into more relevant dev - * content instead of hitting a dead end. Seeded by the article's tags and - * anonymous-safe. + * A personalized "Keep reading" feed below the comments so an anonymous reader + * who finishes the article keeps discovering relevant dev content. Loads in + * pages via an explicit "Load more" button (cheaper than auto infinite scroll) + * and weaves a conversion banner into the flow. */ export const ContinueReadingFeed = ({ post, }: ContinueReadingFeedProps): ReactElement | null => { const { isEnabled } = useAnonPostOnboarding(); - const tags = useMemo(() => post?.tags ?? [], [post?.tags]); + const { previewTags, selectedTags } = useAnonFeedTags({ + postTags: post?.tags ?? [], + enabled: isEnabled, + }); const query = useInfiniteQuery({ - queryKey: ['continueReading', post?.id, tags], + queryKey: ['continueReading', post?.id, previewTags], queryFn: ({ pageParam }) => gqlClient.request(FEED_BY_TAGS_QUERY, { - tags, + tags: previewTags, first: PAGE_SIZE, - after: pageParam, + after: pageParam || undefined, }), initialPageParam: '', getNextPageParam: (lastPage) => lastPage.page.pageInfo.hasNextPage ? lastPage.page.pageInfo.endCursor : undefined, - enabled: isEnabled && tags.length > 0, + enabled: isEnabled && previewTags.length > 0, staleTime: 5 * 60 * 1000, }); @@ -104,7 +112,7 @@ export const ContinueReadingFeed = ({ return nodes.filter((item) => item.id !== post?.id); }, [query.data, post?.id]); - if (!isEnabled || tags.length === 0) { + if (!isEnabled || previewTags.length === 0) { return null; } @@ -112,26 +120,22 @@ export const ContinueReadingFeed = ({ return null; } - const primary = tags[0] ? capitalize(tags[0]) : 'dev'; + const primary = previewTags[0] ? capitalize(previewTags[0]) : 'dev'; return ( -
    +
    Keep reading More {primary} stories developers are reading right now - } - > + +
    {query.isLoading ? ( <> @@ -139,9 +143,29 @@ export const ContinueReadingFeed = ({ ) : ( - posts.map((item) => ) + posts.map((item, index) => ( + + + {index === BANNER_AFTER - 1 && ( + + )} + + )) )} - +
    + + {query.hasNextPage && ( + + )}
    ); }; diff --git a/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx b/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx new file mode 100644 index 00000000000..e91269739b1 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { capitalize } from '../../lib/strings'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import { onboardingGradientClasses } from '../../components/onboarding/common'; +import { authGradientBg } from '../../components/marketing/banners/common'; +import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; + +interface FeedConversionBannerProps { + tags: string[]; +} + +/** + * A polished conversion banner woven into the "Keep reading" feed — the + * proven side-by-side headline + inline signup, with messaging made relevant + * by the reader's topics. Converts in the flow of content instead of as an + * interruptive bottom strip. + */ +export const FeedConversionBanner = ({ + tags, +}: FeedConversionBannerProps): ReactElement => { + const topicList = tags.slice(0, 3).map(capitalize).join(', '); + + return ( +
    +
    + + Stop scrolling random feeds + + + {topicList + ? `Get a personalized stream of ${topicList} and the best dev content — built around what you actually read.` + : 'Get a personalized stream of the best dev content — built around what you actually read.'} + +
    +
    + +
    +
    + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx b/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx deleted file mode 100644 index 6dceb8a98f1..00000000000 --- a/packages/shared/src/features/postPageOnboarding/FeedTastePreview.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import SimilarPosts from '../../components/widgets/SimilarPosts'; -import { gqlClient } from '../../graphql/common'; -import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; - -interface FeedTastePreviewProps { - tags: string[]; - currentPostId?: string; - title?: string; - maxItems?: number; -} - -/** - * A compact, static taste of the feed the visitor would get, seeded by the - * tags they're following. Fixed count (no scroll-reveal) so it never shifts - * the sticky sidebar — the endless "keep reading" experience lives in the - * main column instead. - */ -export const FeedTastePreview = ({ - tags, - currentPostId, - title = 'Your feed is forming', - maxItems = 3, -}: FeedTastePreviewProps): ReactElement | null => { - const { data, isPending } = useQuery({ - queryKey: ['anonFeedTaste', tags], - queryFn: () => - gqlClient.request(FEED_BY_TAGS_QUERY, { - tags, - first: maxItems + 1, - }), - enabled: tags.length > 0, - staleTime: 5 * 60 * 1000, - }); - - const posts = useMemo(() => { - const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; - return nodes.filter((post) => post.id !== currentPostId).slice(0, maxItems); - }, [data, currentPostId, maxItems]); - - if (tags.length === 0) { - return null; - } - - if (!isPending && posts.length === 0) { - return null; - } - - return ( - - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts b/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts deleted file mode 100644 index b6845ffeb19..00000000000 --- a/packages/shared/src/features/postPageOnboarding/useAnonConversionPrompt.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useCallback, useState } from 'react'; -import usePersistentContext from '../../hooks/usePersistentContext'; -import { ANON_CONVERT_PROMPT_SEEN_KEY } from './common'; -import { useScrollValueMoment } from './useScrollValueMoment'; -import type { BuildFeedSignupOrigin } from './useBuildFeedSignup'; - -interface UseAnonConversionPromptProps { - enabled: boolean; -} - -interface UseAnonConversionPrompt { - isOpen: boolean; - reason: BuildFeedSignupOrigin | null; - /** Open the prompt; returns true if it actually opened (intercepted). */ - openPrompt: (reason: BuildFeedSignupOrigin) => boolean; - closePrompt: () => void; -} - -/** - * Orchestrates the single anonymous conversion surface. It opens at most once - * (per device) at whichever high-intent moment comes first — scroll value - * moment, exit intent, or an intercepted "read original" click — and never - * nags again once dismissed. - */ -export const useAnonConversionPrompt = ({ - enabled, -}: UseAnonConversionPromptProps): UseAnonConversionPrompt => { - const [seen, setSeen] = usePersistentContext( - ANON_CONVERT_PROMPT_SEEN_KEY, - false, - ); - const [state, setState] = useState<{ - isOpen: boolean; - reason: BuildFeedSignupOrigin | null; - }>({ isOpen: false, reason: null }); - - const canShow = enabled && !seen; - - const openPrompt = useCallback( - (reason: BuildFeedSignupOrigin): boolean => { - if (!canShow || state.isOpen) { - return false; - } - setState({ isOpen: true, reason }); - return true; - }, - [canShow, state.isOpen], - ); - - const closePrompt = useCallback(() => { - setState({ isOpen: false, reason: null }); - setSeen(true); - }, [setSeen]); - - // Leave-intent only: the bottom prompt never interrupts mid-read. The - // persistent right-side card carries the value-moment conversion instead. - useScrollValueMoment({ - enabled: canShow, - onExitIntent: () => openPrompt('exit_intent'), - }); - - return { - isOpen: state.isOpen, - reason: state.reason, - openPrompt, - closePrompt, - }; -}; diff --git a/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts b/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts index d3c8391115e..5f4cd98c198 100644 --- a/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts +++ b/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts @@ -7,64 +7,92 @@ import { ADD_FILTERS_TO_FEED_MUTATION } from '../../graphql/feedSettings'; import { AuthTriggers } from '../../lib/auth'; import { LogEvent } from '../../lib/log'; import { RequestKey } from '../../lib/query'; +import type { AuthProps } from '../../components/auth/common'; -export type BuildFeedSignupOrigin = - | 'sidebar' - | 'value_moment' - | 'exit_intent' - | 'read_intent'; +export type BuildFeedSignupOrigin = 'sidebar' | 'feed'; interface UseBuildFeedSignup { - triggerSignup: ( + /** Apply the followed topics to the new account's feed (best effort). */ + applyFollowedTags: (tags: string[]) => void; + /** Open the auth modal from a button, carrying the followed topics. */ + triggerSignup: (tags: string[], origin: BuildFeedSignupOrigin) => void; + /** Handler for inline AuthOptions' email-continue path (escalates to modal). */ + getAuthStateHandler: ( tags: string[], - trackingOrigin: BuildFeedSignupOrigin, - ) => void; + origin: BuildFeedSignupOrigin, + ) => (props: Partial) => void; } /** - * Opens the signup flow framed as "build my feed" and applies the tags the - * visitor followed (no-password, stored locally) the moment their account is - * created — so the very first feed they see is already personalized to what - * they were reading. This is the payoff of the progressive personalization. + * Signup wiring for the anonymous build-feed surfaces. The tags the visitor + * followed (no-password, stored locally) are applied to their feed the moment + * the account is created — whether they sign up via an inline social button + * (onSuccessfulRegistration) or via the email→modal path (onRegistrationSuccess). */ export const useBuildFeedSignup = (): UseBuildFeedSignup => { const { showLogin } = useAuthContext(); const { logEvent } = useLogContext(); const queryClient = useQueryClient(); - const triggerSignup = useCallback( - (tags: string[], trackingOrigin: BuildFeedSignupOrigin) => { + const applyFollowedTags = useCallback( + (tags: string[]) => { + if (!tags?.length) { + return; + } + gqlClient + .request(ADD_FILTERS_TO_FEED_MUTATION, { + filters: { includeTags: tags }, + }) + .then(() => + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey.includes(RequestKey.FeedSettings), + }), + ) + .catch(() => { + // Onboarding lets them pick topics anyway; never block signup. + }); + }, + [queryClient], + ); + + const logStart = useCallback( + (tags: string[], origin: BuildFeedSignupOrigin) => { logEvent({ event_name: LogEvent.Click, - extra: JSON.stringify({ origin: trackingOrigin, tags }), + extra: JSON.stringify({ origin, tags }), }); + }, + [logEvent], + ); + const triggerSignup = useCallback( + (tags: string[], origin: BuildFeedSignupOrigin) => { + logStart(tags, origin); showLogin({ trigger: AuthTriggers.PostPage, - options: { - onRegistrationSuccess: () => { - if (!tags.length) { - return; - } - gqlClient - .request(ADD_FILTERS_TO_FEED_MUTATION, { - filters: { includeTags: tags }, - }) - .then(() => - queryClient.invalidateQueries({ - predicate: (query) => - query.queryKey.includes(RequestKey.FeedSettings), - }), - ) - .catch(() => { - // Onboarding will let them pick tags anyway; never block signup. - }); - }, - }, + options: { onRegistrationSuccess: () => applyFollowedTags(tags) }, }); }, - [showLogin, logEvent, queryClient], + [showLogin, logStart, applyFollowedTags], + ); + + const getAuthStateHandler = useCallback( + (tags: string[], origin: BuildFeedSignupOrigin) => + (props: Partial) => { + logStart(tags, origin); + showLogin({ + trigger: AuthTriggers.PostPage, + options: { + isLogin: !!props.isLoginFlow, + defaultDisplay: props.defaultDisplay, + formValues: props.email ? { email: props.email } : undefined, + onRegistrationSuccess: () => applyFollowedTags(tags), + }, + }); + }, + [showLogin, logStart, applyFollowedTags], ); - return { triggerSignup }; + return { applyFollowedTags, triggerSignup, getAuthStateHandler }; }; diff --git a/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts b/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts deleted file mode 100644 index f84ceb4d3b0..00000000000 --- a/packages/shared/src/features/postPageOnboarding/useScrollValueMoment.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect, useRef } from 'react'; - -interface UseScrollValueMomentProps { - enabled: boolean; - /** Fraction of the page scrolled that counts as "got value" (0-1). */ - threshold?: number; - /** Fired once when the reader reaches the value moment (optional). */ - onValueMoment?: () => void; - /** Fired once when the pointer leaves through the top of the viewport. */ - onExitIntent?: () => void; -} - -const DEFAULT_THRESHOLD = 0.55; - -/** - * Detects the two highest-intent moments on the post page for an anonymous - * reader and fires each at most once: - * - the "value moment": they've scrolled far enough to have gotten value; - * - "exit intent": the cursor flies up toward the tab bar / back button. - * We ask for the account on this momentum rather than on arrival. - */ -export const useScrollValueMoment = ({ - enabled, - threshold = DEFAULT_THRESHOLD, - onValueMoment, - onExitIntent, -}: UseScrollValueMomentProps): void => { - const valueFired = useRef(false); - const exitFired = useRef(false); - const onValueMomentRef = useRef(onValueMoment); - const onExitIntentRef = useRef(onExitIntent); - - onValueMomentRef.current = onValueMoment; - onExitIntentRef.current = onExitIntent; - - useEffect(() => { - if (!enabled || typeof window === 'undefined') { - return undefined; - } - - const checkScroll = () => { - if (valueFired.current || !onValueMomentRef.current) { - return; - } - const { scrollY, innerHeight } = window; - const docHeight = document.documentElement.scrollHeight; - if (docHeight <= innerHeight) { - return; - } - const progress = (scrollY + innerHeight) / docHeight; - if (progress >= threshold) { - valueFired.current = true; - onValueMomentRef.current(); - } - }; - - const handleMouseOut = (event: MouseEvent) => { - if (exitFired.current || !onExitIntentRef.current) { - return; - } - // Pointer left the document through the top edge. - if (!event.relatedTarget && event.clientY <= 8) { - exitFired.current = true; - onExitIntentRef.current(); - } - }; - - window.addEventListener('scroll', checkScroll, { passive: true }); - document.addEventListener('mouseout', handleMouseOut); - checkScroll(); - - return () => { - window.removeEventListener('scroll', checkScroll); - document.removeEventListener('mouseout', handleMouseOut); - }; - }, [enabled, threshold]); -}; From c53b96af547f7aa8934c91cef084cec5fd75bd02 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 14:50:37 +0300 Subject: [PATCH 5/8] feat: make anonymous post page stand out (round 4) - Conversion card is now a living product demo: animated gradient frame, a "building feed for " typewriter line, real posts streaming in staggered to assemble a feed, real-time pulse, topic tuning, inline signup - In-flow feed banner gets the animated gradient frame + bolder headline - "Keep reading" becomes a visual 2-col image-card discovery grid with hover, conversion banner woven across the row, and Load more - Add useTypewriter + AnimatedFeedPreview motion primitives --- .../AnimatedFeedPreview.tsx | 94 ++++++++++++++ .../BuildFeedConversionCard.tsx | 117 ++++++++++-------- .../ContinueReadingFeed.tsx | 78 +++++++----- .../FeedConversionBanner.tsx | 71 ++++++----- .../postPageOnboarding/useTypewriter.ts | 69 +++++++++++ 5 files changed, 313 insertions(+), 116 deletions(-) create mode 100644 packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/useTypewriter.ts diff --git a/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx b/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx new file mode 100644 index 00000000000..fcc0ccf240c --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; +import { LazyImage } from '../../components/LazyImage'; +import { UpvoteIcon } from '../../components/icons'; +import { IconSize } from '../../components/Icon'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; + +interface AnimatedFeedPreviewProps { + tags: string[]; + currentPostId?: string; + max?: number; +} + +/** + * A live, staggered preview of the feed the visitor would get — real posts + * matching their topics streaming in one after another, so they literally + * watch a feed assemble itself around what they're reading. This is the aha. + */ +export const AnimatedFeedPreview = ({ + tags, + currentPostId, + max = 3, +}: AnimatedFeedPreviewProps): ReactElement | null => { + const { data, isPending } = useQuery({ + queryKey: ['anonFeedTaste', tags], + queryFn: () => + gqlClient.request(FEED_BY_TAGS_QUERY, { + tags, + first: max + 1, + }), + enabled: tags.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const posts = useMemo(() => { + const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; + return nodes.filter((post) => post.id !== currentPostId).slice(0, max); + }, [data, currentPostId, max]); + + if (tags.length === 0) { + return null; + } + + if (isPending) { + return ( +
    + {Array.from({ length: max }).map((_, index) => ( +
    + + +
    + ))} +
    + ); + } + + if (posts.length === 0) { + return null; + } + + return ( +
    + {posts.map((post, index) => ( + + + + {post.title} + + + + {post.numUpvotes ?? 0} + + + ))} +
    + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx index bde36eaaced..7c478303bf4 100644 --- a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx +++ b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx @@ -1,11 +1,8 @@ import type { ReactElement } from 'react'; import React from 'react'; -import classNames from 'classnames'; import type { Post } from '../../graphql/posts'; import type { Tag } from '../../graphql/feedSettings'; import { capitalize } from '../../lib/strings'; -import { VIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; import { Typography, TypographyColor, @@ -14,9 +11,10 @@ import { } from '../../components/typography/Typography'; import { TagElement } from '../../components/tags/TagElement'; import { onboardingGradientClasses } from '../../components/onboarding/common'; -import { authGradientBg } from '../../components/marketing/banners/common'; import { useAnonFeedTags } from './useAnonFeedTags'; +import { useTypewriter } from './useTypewriter'; import { LivePulse } from './LivePulse'; +import { AnimatedFeedPreview } from './AnimatedFeedPreview'; import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; interface BuildFeedConversionCardProps { @@ -25,23 +23,19 @@ interface BuildFeedConversionCardProps { const MAX_CHIPS = 6; -const Benefit = ({ children }: { children: string }): ReactElement => ( -
  • - - - {children} - -
  • -); +const borderGradient: React.CSSProperties = { + backgroundImage: + 'linear-gradient(90deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default), var(--theme-accent-water-default), var(--theme-accent-cabbage-default))', + backgroundSize: '200% 100%', + animation: 'bf-border-shift 6s linear infinite', +}; /** - * The standout anonymous conversion surface. A focused product hero on the - * brand gradient: a headline made relevant by the article's topic, three - * explicit benefits, a real-time activity pulse, no-password topic tuning, - * and inline one-tap signup — engineered for the aha moment. + * The standout anonymous conversion surface. Rather than listing features, it + * *shows* the product working: an animated gradient frame, a live "building + * your feed" line that types out the reader's own topics, real posts streaming + * in to form a feed before their eyes, a real-time activity pulse, and inline + * one-tap signup. Built to land the aha moment. */ export const BuildFeedConversionCard = ({ post, @@ -51,43 +45,59 @@ export const BuildFeedConversionCard = ({ enabled: true, }); - const primaryTopic = chips[0] ? capitalize(chips[0]) : null; - const topicList = chips.slice(0, 3).map(capitalize).join(', '); + const typewriterWords = + chips.length > 0 ? chips.map(capitalize) : ['your stack', 'dev news']; + const typed = useTypewriter(typewriterWords); return ( -
    -
    - - Personalized for you - - - {primaryTopic - ? `Your daily feed of ${primaryTopic}` - : 'Your personalized dev feed'} - -
      - - {topicList - ? `The best of ${topicList} — every morning` - : 'The best dev content — every morning'} - - Real discussions with developers who get it - Save, organize & never lose a great read -
    - -
    +
    + +
    +
    + + Built for developers like you + + + Your personalized dev feed + +
    + {'> '} + building feed for  + {typed} + +
    + +
    + +
    + + Streaming now + + +
    -
    +
    diff --git a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx index c5c925427ae..ec1ad0c18e3 100644 --- a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx +++ b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx @@ -15,7 +15,6 @@ import { ButtonSize, ButtonVariant, } from '../../components/buttons/Button'; -import { Loader } from '../../components/Loader'; import { Typography, TypographyColor, @@ -25,58 +24,68 @@ import { useAnonPostOnboarding } from './useAnonPostOnboarding'; import { useAnonFeedTags } from './useAnonFeedTags'; import { FeedConversionBanner } from './FeedConversionBanner'; -const PAGE_SIZE = 7; -const BANNER_AFTER = 3; +const PAGE_SIZE = 8; +const BANNER_AFTER = 2; interface ContinueReadingFeedProps { post: Post; } -const ContinueReadingItem = ({ post }: { post: Post }): ReactElement => ( -
    +const FeedCard = ({ post }: { post: Post }): ReactElement => ( +
    -
    - - {post.source?.name} - -

    +
    +
    + + + {post.source?.name} + +
    +

    {post.title}

    ); -const ItemPlaceholder = (): ReactElement => ( -
    - -
    +const CardPlaceholder = (): ReactElement => ( +
    + +
    -
    +
    ); /** - * A personalized "Keep reading" feed below the comments so an anonymous reader - * who finishes the article keeps discovering relevant dev content. Loads in - * pages via an explicit "Load more" button (cheaper than auto infinite scroll) - * and weaves a conversion banner into the flow. + * A visual, card-based "Keep reading" feed below the comments — turns the end + * of the article into an addictive discovery grid of relevant dev content, + * with a conversion banner woven in and an explicit "Load more" for + * performance (no runaway auto-fetching). */ export const ContinueReadingFeed = ({ post, @@ -135,19 +144,22 @@ export const ContinueReadingFeed = ({ More {primary} stories developers are reading right now -
    +
    {query.isLoading ? ( <> - - - + + + + ) : ( posts.map((item, index) => ( - + {index === BANNER_AFTER - 1 && ( - +
    + +
    )}
    )) @@ -158,12 +170,12 @@ export const ContinueReadingFeed = ({ )}
    diff --git a/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx b/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx index e91269739b1..60419261b35 100644 --- a/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx +++ b/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx @@ -16,11 +16,17 @@ interface FeedConversionBannerProps { tags: string[]; } +const borderGradient: React.CSSProperties = { + backgroundImage: + 'linear-gradient(90deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default), var(--theme-accent-water-default), var(--theme-accent-cabbage-default))', + backgroundSize: '200% 100%', + animation: 'bf-border-shift 6s linear infinite', +}; + /** - * A polished conversion banner woven into the "Keep reading" feed — the - * proven side-by-side headline + inline signup, with messaging made relevant - * by the reader's topics. Converts in the flow of content instead of as an - * interruptive bottom strip. + * A bold conversion banner woven into the "Keep reading" feed — animated + * gradient frame, big headline, and inline one-tap signup, with messaging made + * relevant by the reader's topics. Converts in the flow of discovery. */ export const FeedConversionBanner = ({ tags, @@ -28,32 +34,37 @@ export const FeedConversionBanner = ({ const topicList = tags.slice(0, 3).map(capitalize).join(', '); return ( -
    -
    - - Stop scrolling random feeds - - - {topicList - ? `Get a personalized stream of ${topicList} and the best dev content — built around what you actually read.` - : 'Get a personalized stream of the best dev content — built around what you actually read.'} - -
    -
    - +
    + +
    +
    + + Stop scrolling random feeds + + + {topicList + ? `Your own stream of ${topicList} and the best dev content — built around what you actually read. Free, forever.` + : 'Your own stream of the best dev content — built around what you actually read. Free, forever.'} + +
    +
    + +
    ); diff --git a/packages/shared/src/features/postPageOnboarding/useTypewriter.ts b/packages/shared/src/features/postPageOnboarding/useTypewriter.ts new file mode 100644 index 00000000000..5c3deffd298 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/useTypewriter.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseTypewriterOptions { + typeMs?: number; + deleteMs?: number; + holdMs?: number; +} + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + !!window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** + * Cycles through words with a type/delete effect — the personal, "alive" + * touch in the conversion hero (it types out the visitor's own topics). + * Falls back to the first word instantly when reduced motion is preferred. + */ +export const useTypewriter = ( + words: string[], + { typeMs = 85, deleteMs = 35, holdMs = 1500 }: UseTypewriterOptions = {}, +): string => { + const [text, setText] = useState(''); + const [wordIndex, setWordIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const reduced = useRef(false); + + useEffect(() => { + reduced.current = prefersReducedMotion(); + }, []); + + useEffect(() => { + if (!words.length) { + return undefined; + } + if (reduced.current) { + setText(words[0]); + return undefined; + } + + const current = words[wordIndex % words.length]; + + if (!isDeleting && text === current) { + const timeout = setTimeout(() => setIsDeleting(true), holdMs); + return () => clearTimeout(timeout); + } + + if (isDeleting && text === '') { + setIsDeleting(false); + setWordIndex((index) => (index + 1) % words.length); + return undefined; + } + + const timeout = setTimeout( + () => { + setText((prev) => + isDeleting + ? current.slice(0, prev.length - 1) + : current.slice(0, prev.length + 1), + ); + }, + isDeleting ? deleteMs : typeMs, + ); + + return () => clearTimeout(timeout); + }, [text, isDeleting, wordIndex, words, typeMs, deleteMs, holdMs]); + + return text; +}; From 8685b6ee929d33e1f3c2a15ac9c840719c503a1f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 15:03:29 +0300 Subject: [PATCH 6/8] feat: reader-style source strip + clean right panel + reliable feed (round 5) - Replace the source line with a reader-style horizontal strip (source name + Read post button + three-dots), reusing the "Read inside daily.dev" SourceStrip + PostHeaderActions - Rebuild the right panel from scratch: a clean, compact card focused only on customize (small topic chips) + sign up (inline auth); no oversized/cut UI - Move the promo into the feed; reserve "Happening Now" for signed-in users - Keep-reading feed always renders via a realistic mock fallback (so it shows even when the tag feed is empty); promo relocated to the bottom - Remove now-unused motion files (LivePulse, AnimatedFeedPreview, useTypewriter) --- .../src/components/post/PostContent.tsx | 22 ++- .../src/components/post/PostWidgets.tsx | 8 +- .../AnimatedFeedPreview.tsx | 94 ----------- .../postPageOnboarding/AnonSourceStrip.tsx | 45 +++++ .../BuildFeedConversionCard.tsx | 156 ++++++++---------- .../ContinueReadingFeed.tsx | 85 ++++++++-- .../features/postPageOnboarding/LivePulse.tsx | 101 ------------ .../postPageOnboarding/useTypewriter.ts | 69 -------- 8 files changed, 204 insertions(+), 376 deletions(-) delete mode 100644 packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/AnonSourceStrip.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/LivePulse.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/useTypewriter.ts diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 5c1cc0f89e7..0161bffc05d 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -29,6 +29,7 @@ import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; import { useAnonPostOnboarding } from '../../features/postPageOnboarding/useAnonPostOnboarding'; +import { AnonSourceStrip } from '../../features/postPageOnboarding/AnonSourceStrip'; type PostContentRawProps = Omit & { post: Post }; @@ -175,15 +176,24 @@ export function PostContentRaw({ post={post} >
    -
    - void} onReadArticle={onReadArticle} - hideSubscribeAction={hideSubscribeAction} /> -
    + ) : ( +
    + +
    + )}

    - - ); diff --git a/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx b/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx deleted file mode 100644 index fcc0ccf240c..00000000000 --- a/packages/shared/src/features/postPageOnboarding/AnimatedFeedPreview.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { gqlClient } from '../../graphql/common'; -import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; -import { LazyImage } from '../../components/LazyImage'; -import { UpvoteIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { ElementPlaceholder } from '../../components/ElementPlaceholder'; - -interface AnimatedFeedPreviewProps { - tags: string[]; - currentPostId?: string; - max?: number; -} - -/** - * A live, staggered preview of the feed the visitor would get — real posts - * matching their topics streaming in one after another, so they literally - * watch a feed assemble itself around what they're reading. This is the aha. - */ -export const AnimatedFeedPreview = ({ - tags, - currentPostId, - max = 3, -}: AnimatedFeedPreviewProps): ReactElement | null => { - const { data, isPending } = useQuery({ - queryKey: ['anonFeedTaste', tags], - queryFn: () => - gqlClient.request(FEED_BY_TAGS_QUERY, { - tags, - first: max + 1, - }), - enabled: tags.length > 0, - staleTime: 5 * 60 * 1000, - }); - - const posts = useMemo(() => { - const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; - return nodes.filter((post) => post.id !== currentPostId).slice(0, max); - }, [data, currentPostId, max]); - - if (tags.length === 0) { - return null; - } - - if (isPending) { - return ( -
    - {Array.from({ length: max }).map((_, index) => ( -
    - - -
    - ))} -
    - ); - } - - if (posts.length === 0) { - return null; - } - - return ( -
    - {posts.map((post, index) => ( - - - - {post.title} - - - - {post.numUpvotes ?? 0} - - - ))} -
    - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/AnonSourceStrip.tsx b/packages/shared/src/features/postPageOnboarding/AnonSourceStrip.tsx new file mode 100644 index 00000000000..68e21c3c1a3 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/AnonSourceStrip.tsx @@ -0,0 +1,45 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../graphql/posts'; +import type { SourceTooltip } from '../../graphql/sources'; +import { SourceStrip } from '../../components/post/reader/SourceStrip'; +import { PostHeaderActions } from '../../components/post/PostHeaderActions'; +import { ButtonSize } from '../../components/buttons/Button'; + +interface AnonSourceStripProps { + post: Post; + onReadArticle?: () => void; + onClose?: () => void; + className?: string; +} + +/** + * The post-page source area for anonymous readers, styled after the "Read + * inside daily.dev" reader: a single horizontal strip with the source + * (avatar + name), the "Read post" button, and the three-dots menu. Cleaner + * and more action-oriented than the legacy inline source line. + */ +export const AnonSourceStrip = ({ + post, + onReadArticle, + onClose, + className, +}: AnonSourceStripProps): ReactElement => ( +
    + + +
    +); diff --git a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx index 7c478303bf4..072ea89d73e 100644 --- a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx +++ b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx @@ -1,41 +1,33 @@ import type { ReactElement } from 'react'; import React from 'react'; import type { Post } from '../../graphql/posts'; -import type { Tag } from '../../graphql/feedSettings'; import { capitalize } from '../../lib/strings'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; import { Typography, TypographyColor, TypographyTag, TypographyType, } from '../../components/typography/Typography'; -import { TagElement } from '../../components/tags/TagElement'; import { onboardingGradientClasses } from '../../components/onboarding/common'; import { useAnonFeedTags } from './useAnonFeedTags'; -import { useTypewriter } from './useTypewriter'; -import { LivePulse } from './LivePulse'; -import { AnimatedFeedPreview } from './AnimatedFeedPreview'; import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; interface BuildFeedConversionCardProps { post: Post; } -const MAX_CHIPS = 6; - -const borderGradient: React.CSSProperties = { - backgroundImage: - 'linear-gradient(90deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default), var(--theme-accent-water-default), var(--theme-accent-cabbage-default))', - backgroundSize: '200% 100%', - animation: 'bf-border-shift 6s linear infinite', -}; +const MAX_CHIPS = 8; /** - * The standout anonymous conversion surface. Rather than listing features, it - * *shows* the product working: an animated gradient frame, a live "building - * your feed" line that types out the reader's own topics, real posts streaming - * in to form a feed before their eyes, a real-time activity pulse, and inline - * one-tap signup. Built to land the aha moment. + * The anonymous right panel — focused on exactly two jobs: customize (pick + * topics, pre-seeded from the article) and sign up (inline one-tap). Clean, + * compact, and explicit, with nothing else competing for attention. */ export const BuildFeedConversionCard = ({ post, @@ -45,83 +37,67 @@ export const BuildFeedConversionCard = ({ enabled: true, }); - const typewriterWords = - chips.length > 0 ? chips.map(capitalize) : ['your stack', 'dev news']; - const typed = useTypewriter(typewriterWords); - return ( -
    - -
    -
    - - Built for developers like you - - - Your personalized dev feed - -
    - {'> '} - building feed for  - {typed} - -
    - -
    +
    +
    + + Build your personalized feed + + + Pick your topics and get a daily feed of the dev content that matters. + Free, forever. + +
    -
    - - Streaming now - - -
    - -
    - - Tune your topics - -
    - {chips.slice(0, MAX_CHIPS).map((tag) => ( - + + Your topics + +
    + {chips.slice(0, MAX_CHIPS).map((tag) => { + const isSelected = selectedTags.includes(tag); + if (isSelected) { + return ( + + ); + } + return ( +
    + type="button" + size={ButtonSize.XSmall} + variant={ButtonVariant.Float} + onClick={() => toggleTag(tag)} + > + {capitalize(tag)} + + ); + })}
    - -
    + +
    ); }; diff --git a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx index ec1ad0c18e3..09fc62c4126 100644 --- a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx +++ b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx @@ -5,11 +5,13 @@ import type { Post } from '../../graphql/posts'; import { gqlClient } from '../../graphql/common'; import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; import { capitalize } from '../../lib/strings'; -import { LazyImage } from '../../components/LazyImage'; +import { webappUrl } from '../../lib/constants'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; +import { LazyImage } from '../../components/LazyImage'; import { CardLink } from '../../components/cards/common/Card'; import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { PostSidebarAdWidget } from '../../components/post/PostSidebarAdWidget'; import { Button, ButtonSize, @@ -31,6 +33,61 @@ interface ContinueReadingFeedProps { post: Post; } +// Realistic placeholder content so the discovery feed always looks alive in +// previews / low-coverage topics, even before the real feed query resolves. +const MOCK_POSTS = [ + { + title: 'Why I stopped using useEffect for data fetching', + source: 'Josh W. Comeau', + upvotes: 842, + comments: 96, + }, + { + title: 'The 2025 State of JavaScript results are in', + source: 'State of JS', + upvotes: 1290, + comments: 214, + }, + { + title: 'Building a fully type-safe API layer with tRPC', + source: 'tRPC Blog', + upvotes: 537, + comments: 48, + }, + { + title: 'How Rust is quietly taking over backend infrastructure', + source: 'The Pragmatic Engineer', + upvotes: 1631, + comments: 305, + }, + { + title: 'CSS :has() is a game changer — here is why', + source: 'web.dev', + upvotes: 724, + comments: 61, + }, + { + title: 'Stop over-engineering your React state', + source: 'Kent C. Dodds', + upvotes: 988, + comments: 132, + }, +].map( + (item, index) => + ({ + id: `mock-${index}`, + title: item.title, + commentsPermalink: webappUrl, + image: cloudinaryPostImageCoverPlaceholder, + numUpvotes: item.upvotes, + numComments: item.comments, + source: { + name: item.source, + image: cloudinaryPostImageCoverPlaceholder, + }, + } as unknown as Post), +); + const FeedCard = ({ post }: { post: Post }): ReactElement => (
    @@ -83,9 +140,10 @@ const CardPlaceholder = (): ReactElement => ( /** * A visual, card-based "Keep reading" feed below the comments — turns the end - * of the article into an addictive discovery grid of relevant dev content, - * with a conversion banner woven in and an explicit "Load more" for - * performance (no runaway auto-fetching). + * of the article into a discovery grid of relevant dev content with a + * conversion banner woven in, the relocated promo at the bottom, and an + * explicit "Load more". Falls back to realistic mock posts so it always + * renders something engaging. */ export const ContinueReadingFeed = ({ post, @@ -113,7 +171,7 @@ export const ContinueReadingFeed = ({ staleTime: 5 * 60 * 1000, }); - const posts = useMemo(() => { + const realPosts = useMemo(() => { const nodes = query.data?.pages.flatMap((page) => page.page.edges.map((edge) => edge.node), @@ -121,14 +179,12 @@ export const ContinueReadingFeed = ({ return nodes.filter((item) => item.id !== post?.id); }, [query.data, post?.id]); - if (!isEnabled || previewTags.length === 0) { - return null; - } - - if (!query.isLoading && posts.length === 0) { + if (!isEnabled) { return null; } + const isLoadingReal = query.isLoading && previewTags.length > 0; + const posts = realPosts.length > 0 ? realPosts : MOCK_POSTS; const primary = previewTags[0] ? capitalize(previewTags[0]) : 'dev'; return ( @@ -145,7 +201,7 @@ export const ContinueReadingFeed = ({
    - {query.isLoading ? ( + {isLoadingReal ? ( <> @@ -178,6 +234,13 @@ export const ContinueReadingFeed = ({ Load more articles )} + +
    + +
    ); }; diff --git a/packages/shared/src/features/postPageOnboarding/LivePulse.tsx b/packages/shared/src/features/postPageOnboarding/LivePulse.tsx deleted file mode 100644 index 9b5ee01f6da..00000000000 --- a/packages/shared/src/features/postPageOnboarding/LivePulse.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import useSubscription from '../../hooks/useSubscription'; -import { gqlClient } from '../../graphql/common'; -import type { Post, PostsEngaged } from '../../graphql/posts'; -import { - POSTS_ENGAGED_SUBSCRIPTION, - POST_UPVOTES_BY_ID_QUERY, -} from '../../graphql/posts'; -import type { UserImageProps } from '../../components/ProfilePicture'; -import { - ProfileImageSize, - ProfilePicture, -} from '../../components/ProfilePicture'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../components/typography/Typography'; - -interface UpvotesData { - upvotes: { - edges: Array<{ node: { user: UserImageProps } }>; - }; -} - -const AVATAR_LIMIT = 5; - -/** - * Makes the article feel alive with REAL data: upvote/comment counts tick up - * in real time via the posts-engaged subscription (no-ops gracefully when the - * socket isn't connected), alongside the faces of developers who recently - * upvoted. No fabricated "reading now" numbers. - */ -export const LivePulse = ({ post }: { post: Post }): ReactElement | null => { - const [counts, setCounts] = useState({ - upvotes: post?.numUpvotes ?? 0, - comments: post?.numComments ?? 0, - }); - - useSubscription( - () => ({ query: POSTS_ENGAGED_SUBSCRIPTION }), - { - next: (data: PostsEngaged) => { - if (data?.postsEngaged?.id === post?.id) { - setCounts({ - upvotes: data.postsEngaged.numUpvotes, - comments: data.postsEngaged.numComments, - }); - } - }, - }, - [post?.id], - ); - - const { data } = useQuery({ - queryKey: ['livePulseUpvoters', post?.id], - queryFn: () => - gqlClient.request(POST_UPVOTES_BY_ID_QUERY, { - id: post.id, - first: AVATAR_LIMIT, - }), - enabled: !!post?.id && (post?.numUpvotes ?? 0) > 0, - staleTime: 5 * 60 * 1000, - }); - - const avatars = - data?.upvotes?.edges?.map((edge) => edge.node.user).filter(Boolean) ?? []; - - if (counts.upvotes === 0 && counts.comments === 0) { - return null; - } - - return ( -
    - - - - - {avatars.length > 0 && ( -
    - {avatars.map((user) => ( - - ))} -
    - )} - - {counts.upvotes} upvotes · {counts.comments} discussing - -
    - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/useTypewriter.ts b/packages/shared/src/features/postPageOnboarding/useTypewriter.ts deleted file mode 100644 index 5c3deffd298..00000000000 --- a/packages/shared/src/features/postPageOnboarding/useTypewriter.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -interface UseTypewriterOptions { - typeMs?: number; - deleteMs?: number; - holdMs?: number; -} - -const prefersReducedMotion = (): boolean => - typeof window !== 'undefined' && - !!window.matchMedia && - window.matchMedia('(prefers-reduced-motion: reduce)').matches; - -/** - * Cycles through words with a type/delete effect — the personal, "alive" - * touch in the conversion hero (it types out the visitor's own topics). - * Falls back to the first word instantly when reduced motion is preferred. - */ -export const useTypewriter = ( - words: string[], - { typeMs = 85, deleteMs = 35, holdMs = 1500 }: UseTypewriterOptions = {}, -): string => { - const [text, setText] = useState(''); - const [wordIndex, setWordIndex] = useState(0); - const [isDeleting, setIsDeleting] = useState(false); - const reduced = useRef(false); - - useEffect(() => { - reduced.current = prefersReducedMotion(); - }, []); - - useEffect(() => { - if (!words.length) { - return undefined; - } - if (reduced.current) { - setText(words[0]); - return undefined; - } - - const current = words[wordIndex % words.length]; - - if (!isDeleting && text === current) { - const timeout = setTimeout(() => setIsDeleting(true), holdMs); - return () => clearTimeout(timeout); - } - - if (isDeleting && text === '') { - setIsDeleting(false); - setWordIndex((index) => (index + 1) % words.length); - return undefined; - } - - const timeout = setTimeout( - () => { - setText((prev) => - isDeleting - ? current.slice(0, prev.length - 1) - : current.slice(0, prev.length + 1), - ); - }, - isDeleting ? deleteMs : typeMs, - ); - - return () => clearTimeout(timeout); - }, [text, isDeleting, wordIndex, words, typeMs, deleteMs, holdMs]); - - return text; -}; From 85269000d79bad9a82a19baa5b6658eae3d05e87 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 16:13:39 +0300 Subject: [PATCH 7/8] feat: immersive discovery universe on anonymous post page (round 6) Turn the area below the article into a full discovery experience that sells the whole platform, not a few boxes: - Hero invite band ("Your dev world is waiting") with inline signup - Netflix-style content rows (Trending in , Hottest discussions) as horizontal carousels of rich cards - A "locked" row that blurs behind an unlock-your-feed CTA to tease volume - Honest social-proof band (stars, community avatars, millions of developers, what you get) and a final conversion banner - Right rail becomes a sticky sign-up + customize card with the promo beneath - Themed mock content so rows always look full; real tag feed powers the trending row when available - Replace ContinueReadingFeed with DiscoveryUniverse --- .../src/components/post/BasePostContent.tsx | 4 +- .../src/components/post/PostWidgets.tsx | 14 +- .../ContinueReadingFeed.tsx | 246 ------------------ .../postPageOnboarding/DiscoveryRow.tsx | 137 ++++++++++ .../postPageOnboarding/DiscoveryUniverse.tsx | 154 +++++++++++ .../postPageOnboarding/SocialProofBand.tsx | 93 +++++++ .../features/postPageOnboarding/mockFeed.ts | 147 +++++++++++ 7 files changed, 543 insertions(+), 252 deletions(-) delete mode 100644 packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/mockFeed.ts diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index ba5e6afca96..5f603da89d1 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,7 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; -import { ContinueReadingFeed } from '../../features/postPageOnboarding/ContinueReadingFeed'; +import { DiscoveryUniverse } from '../../features/postPageOnboarding/DiscoveryUniverse'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -71,7 +71,7 @@ export function BasePostContent({ shouldOnboardAuthor={shouldOnboardAuthor} /> )} - {isPostPage && } + {isPostPage && } ); } diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 4006ab2326c..0141dded338 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -63,12 +63,18 @@ export function PostWidgets({ // Anonymous "build your feed" experience: the whole right column becomes a // single cohesive conversion card, with the promo demoted to the last slot. if (isAnonExperience) { - // Right panel does exactly one job for anonymous readers: sign up + - // customize. The promo moves into the feed; "Happening Now" is reserved - // for signed-in users. + // Right rail for anonymous readers: a sticky sign-up + customize card + // (always in view as they explore the discovery experience below), with + // the promo beneath it. "Happening Now" is reserved for signed-in users. return ( - +
    + + +
    ); diff --git a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx b/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx deleted file mode 100644 index 09fc62c4126..00000000000 --- a/packages/shared/src/features/postPageOnboarding/ContinueReadingFeed.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { Fragment, useMemo } from 'react'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import type { Post } from '../../graphql/posts'; -import { gqlClient } from '../../graphql/common'; -import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; -import { capitalize } from '../../lib/strings'; -import { webappUrl } from '../../lib/constants'; -import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; -import { LazyImage } from '../../components/LazyImage'; -import { CardLink } from '../../components/cards/common/Card'; -import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; -import { ElementPlaceholder } from '../../components/ElementPlaceholder'; -import { PostSidebarAdWidget } from '../../components/post/PostSidebarAdWidget'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../components/typography/Typography'; -import { useAnonPostOnboarding } from './useAnonPostOnboarding'; -import { useAnonFeedTags } from './useAnonFeedTags'; -import { FeedConversionBanner } from './FeedConversionBanner'; - -const PAGE_SIZE = 8; -const BANNER_AFTER = 2; - -interface ContinueReadingFeedProps { - post: Post; -} - -// Realistic placeholder content so the discovery feed always looks alive in -// previews / low-coverage topics, even before the real feed query resolves. -const MOCK_POSTS = [ - { - title: 'Why I stopped using useEffect for data fetching', - source: 'Josh W. Comeau', - upvotes: 842, - comments: 96, - }, - { - title: 'The 2025 State of JavaScript results are in', - source: 'State of JS', - upvotes: 1290, - comments: 214, - }, - { - title: 'Building a fully type-safe API layer with tRPC', - source: 'tRPC Blog', - upvotes: 537, - comments: 48, - }, - { - title: 'How Rust is quietly taking over backend infrastructure', - source: 'The Pragmatic Engineer', - upvotes: 1631, - comments: 305, - }, - { - title: 'CSS :has() is a game changer — here is why', - source: 'web.dev', - upvotes: 724, - comments: 61, - }, - { - title: 'Stop over-engineering your React state', - source: 'Kent C. Dodds', - upvotes: 988, - comments: 132, - }, -].map( - (item, index) => - ({ - id: `mock-${index}`, - title: item.title, - commentsPermalink: webappUrl, - image: cloudinaryPostImageCoverPlaceholder, - numUpvotes: item.upvotes, - numComments: item.comments, - source: { - name: item.source, - image: cloudinaryPostImageCoverPlaceholder, - }, - } as unknown as Post), -); - -const FeedCard = ({ post }: { post: Post }): ReactElement => ( -
    - - -
    -
    - - - {post.source?.name} - -
    -

    - {post.title} -

    - -
    -
    -); - -const CardPlaceholder = (): ReactElement => ( -
    - -
    - - - -
    -
    -); - -/** - * A visual, card-based "Keep reading" feed below the comments — turns the end - * of the article into a discovery grid of relevant dev content with a - * conversion banner woven in, the relocated promo at the bottom, and an - * explicit "Load more". Falls back to realistic mock posts so it always - * renders something engaging. - */ -export const ContinueReadingFeed = ({ - post, -}: ContinueReadingFeedProps): ReactElement | null => { - const { isEnabled } = useAnonPostOnboarding(); - const { previewTags, selectedTags } = useAnonFeedTags({ - postTags: post?.tags ?? [], - enabled: isEnabled, - }); - - const query = useInfiniteQuery({ - queryKey: ['continueReading', post?.id, previewTags], - queryFn: ({ pageParam }) => - gqlClient.request(FEED_BY_TAGS_QUERY, { - tags: previewTags, - first: PAGE_SIZE, - after: pageParam || undefined, - }), - initialPageParam: '', - getNextPageParam: (lastPage) => - lastPage.page.pageInfo.hasNextPage - ? lastPage.page.pageInfo.endCursor - : undefined, - enabled: isEnabled && previewTags.length > 0, - staleTime: 5 * 60 * 1000, - }); - - const realPosts = useMemo(() => { - const nodes = - query.data?.pages.flatMap((page) => - page.page.edges.map((edge) => edge.node), - ) ?? []; - return nodes.filter((item) => item.id !== post?.id); - }, [query.data, post?.id]); - - if (!isEnabled) { - return null; - } - - const isLoadingReal = query.isLoading && previewTags.length > 0; - const posts = realPosts.length > 0 ? realPosts : MOCK_POSTS; - const primary = previewTags[0] ? capitalize(previewTags[0]) : 'dev'; - - return ( -
    - - Keep reading - - - More {primary} stories developers are reading right now - - -
    - {isLoadingReal ? ( - <> - - - - - - ) : ( - posts.map((item, index) => ( - - - {index === BANNER_AFTER - 1 && ( -
    - -
    - )} -
    - )) - )} -
    - - {query.hasNextPage && ( - - )} - -
    - -
    -
    - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx b/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx new file mode 100644 index 00000000000..8d7fc01aa10 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx @@ -0,0 +1,137 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../graphql/posts'; +import { LazyImage } from '../../components/LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; +import { CardLink } from '../../components/cards/common/Card'; +import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; +import { LockIcon } from '../../components/icons'; +import { IconSize } from '../../components/Icon'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../components/typography/Typography'; + +interface DiscoveryRowProps { + title: string; + icon?: ReactNode; + subtitle?: string; + posts: Post[]; + locked?: boolean; + onUnlock?: () => void; +} + +const RowCard = ({ post }: { post: Post }): ReactElement => ( +
    + + +
    +
    + + + {post.source?.name} + +
    +

    + {post.title} +

    + +
    +
    +); + +/** + * A Netflix-style titled carousel of posts. The `locked` variant blurs the + * content behind a gradient veil and an "unlock your feed" CTA — turning the + * sheer volume of available content into a reason to sign up. + */ +export const DiscoveryRow = ({ + title, + icon, + subtitle, + posts, + locked = false, + onUnlock, +}: DiscoveryRowProps): ReactElement => ( +
    +
    + {icon} +
    + + {title} + + {subtitle && ( + + {subtitle} + + )} +
    +
    +
    +
    + {posts.map((post) => ( + + ))} +
    + {locked && ( +
    +
    + + + + + Your full feed is locked + + + Sign up free to unlock endless personalized dev content — tools, + news, and discussions, every day. + + +
    +
    + )} +
    +
    +); diff --git a/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx b/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx new file mode 100644 index 00000000000..adea05154d1 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx @@ -0,0 +1,154 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import type { Post } from '../../graphql/posts'; +import { gqlClient } from '../../graphql/common'; +import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; +import { capitalize } from '../../lib/strings'; +import { HotIcon, StarIcon, TrendingIcon } from '../../components/icons'; +import { IconSize } from '../../components/Icon'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import { onboardingGradientClasses } from '../../components/onboarding/common'; +import { authGradientBg } from '../../components/marketing/banners/common'; +import { useAnonPostOnboarding } from './useAnonPostOnboarding'; +import { useAnonFeedTags } from './useAnonFeedTags'; +import { useBuildFeedSignup } from './useBuildFeedSignup'; +import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; +import { DiscoveryRow } from './DiscoveryRow'; +import { SocialProofBand } from './SocialProofBand'; +import { FeedConversionBanner } from './FeedConversionBanner'; +import { mockDiscussions, mockTools, mockTrending } from './mockFeed'; + +interface DiscoveryUniverseProps { + post: Post; +} + +const REAL_ROW_SIZE = 10; + +/** + * The anonymous post page's discovery experience — the doorway to "a whole + * world built for you". Below the article it opens into an immersive band: + * a hero invite, Netflix-style content rows (trending, discussions), a locked + * row that teases the volume behind signup, social proof, and a final CTA. + */ +export const DiscoveryUniverse = ({ + post, +}: DiscoveryUniverseProps): ReactElement | null => { + const { isEnabled } = useAnonPostOnboarding(); + const { previewTags, selectedTags } = useAnonFeedTags({ + postTags: post?.tags ?? [], + enabled: isEnabled, + }); + const { triggerSignup } = useBuildFeedSignup(); + + const { data } = useQuery({ + queryKey: ['discoveryUniverse', post?.id, previewTags], + queryFn: () => + gqlClient.request(FEED_BY_TAGS_QUERY, { + tags: previewTags, + first: REAL_ROW_SIZE, + }), + enabled: isEnabled && previewTags.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const realPosts = useMemo(() => { + const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; + return nodes.filter((item) => item.id !== post?.id); + }, [data, post?.id]); + + if (!isEnabled) { + return null; + } + + const topic = previewTags[0] ? capitalize(previewTags[0]) : 'your stack'; + const trendingRow = realPosts.length >= 4 ? realPosts : mockTrending; + + return ( +
    +
    + + You just read one post + + + Your dev world is waiting + + + One feed with the best of {topic} and your whole stack — news, tools, + and the discussions that matter, curated daily and built around you. + Free, forever. + +
    + +
    +
    + + + } + posts={trendingRow} + /> + + + } + posts={mockDiscussions} + /> + + + } + posts={mockTools} + locked + onUnlock={() => triggerSignup(selectedTags, 'feed')} + /> + + + + +
    + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx b/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx new file mode 100644 index 00000000000..1a37475eb97 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx @@ -0,0 +1,93 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { StarIcon, HashtagIcon, DiscussIcon } from '../../components/icons'; +import { IconSize } from '../../components/Icon'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; + +const Avatars = (): ReactElement => ( +
    + {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
    +); + +const Stat = ({ + icon, + label, +}: { + icon: ReactNode; + label: string; +}): ReactElement => ( +
    + {icon} + + {label} + +
    +); + +/** + * Social-proof band — the "you're joining something big and trusted" beat, + * G2/Trustpilot style but honest: star visual, a community avatar pile, the + * "millions of developers" signal, and what the product gives. No fabricated + * counts or reviews. + */ +export const SocialProofBand = (): ReactElement => ( +
    + +
    + {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
    + + Loved by millions of developers + + + daily.dev is the home where developers stay ahead — the best news, tools, + and discussions across the dev world, in one feed built around you. + +
    + + } + label="Every major dev source, in one place" + /> + + } + label="A real community, not a comment void" + /> + } + label="A feed that learns what you love" + /> +
    +
    +); diff --git a/packages/shared/src/features/postPageOnboarding/mockFeed.ts b/packages/shared/src/features/postPageOnboarding/mockFeed.ts new file mode 100644 index 00000000000..979a516ae6f --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/mockFeed.ts @@ -0,0 +1,147 @@ +import type { Post } from '../../graphql/posts'; +import { webappUrl } from '../../lib/constants'; +import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; + +interface MockSeed { + title: string; + source: string; + upvotes: number; + comments: number; +} + +const toPost = (item: MockSeed, id: string): Post => + ({ + id, + title: item.title, + commentsPermalink: webappUrl, + permalink: webappUrl, + image: cloudinaryPostImageCoverPlaceholder, + numUpvotes: item.upvotes, + numComments: item.comments, + source: { + name: item.source, + image: cloudinaryPostImageCoverPlaceholder, + }, + } as unknown as Post); + +const build = (seeds: MockSeed[], prefix: string): Post[] => + seeds.map((seed, index) => toPost(seed, `${prefix}-${index}`)); + +/** + * Realistic placeholder content so the discovery experience always looks full + * and alive — used as a fallback when the live tag feed is empty, and to + * populate themed rows we don't yet have a dedicated query for. + */ +export const mockTrending = build( + [ + { + title: 'Why I stopped using useEffect for data fetching', + source: 'Josh W. Comeau', + upvotes: 842, + comments: 96, + }, + { + title: 'The 2025 State of JavaScript results are in', + source: 'State of JS', + upvotes: 1290, + comments: 214, + }, + { + title: 'Building a fully type-safe API layer with tRPC', + source: 'tRPC Blog', + upvotes: 537, + comments: 48, + }, + { + title: 'How Rust is quietly taking over backend infrastructure', + source: 'The Pragmatic Engineer', + upvotes: 1631, + comments: 305, + }, + { + title: 'CSS :has() is a game changer — here is why', + source: 'web.dev', + upvotes: 724, + comments: 61, + }, + { + title: 'Stop over-engineering your React state', + source: 'Kent C. Dodds', + upvotes: 988, + comments: 132, + }, + ], + 'trend', +); + +export const mockDiscussions = build( + [ + { + title: 'Is TypeScript still worth it in 2025? An honest take', + source: 'Hacker News', + upvotes: 2104, + comments: 612, + }, + { + title: 'We migrated 2M lines from JS to TS. Here is what broke', + source: 'Engineering Blog', + upvotes: 1456, + comments: 388, + }, + { + title: 'Why your team keeps shipping bugs (and how to stop)', + source: 'LeadDev', + upvotes: 903, + comments: 277, + }, + { + title: 'The case against microservices for small teams', + source: 'InfoQ', + upvotes: 1188, + comments: 341, + }, + { + title: 'AI pair programming: hype vs. what actually helps', + source: 'Stack Overflow Blog', + upvotes: 1675, + comments: 459, + }, + ], + 'disc', +); + +export const mockTools = build( + [ + { + title: 'Biome: the all-in-one toolchain replacing ESLint + Prettier', + source: 'Biome', + upvotes: 1320, + comments: 142, + }, + { + title: 'Bun 1.2 is fast — benchmarks that surprised me', + source: 'Bun', + upvotes: 1899, + comments: 233, + }, + { + title: 'Zod 4 is here: smaller, faster schema validation', + source: 'Total TypeScript', + upvotes: 1044, + comments: 88, + }, + { + title: 'The VS Code extensions every developer should install', + source: 'Visual Studio Code', + upvotes: 2210, + comments: 401, + }, + { + title: 'Drizzle vs Prisma: which ORM in 2025?', + source: 'Drizzle ORM', + upvotes: 967, + comments: 176, + }, + ], + 'tool', +); From 767e7df5f8479f2d649209c63eca1935769419c7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 16:41:24 +0300 Subject: [PATCH 8/8] feat: substance-first anonymous post page (round 7) Drop the conversion-funnel theater (locked rows, fake social proof, mock carousels, animated banners) in favor of a calm, developer-grade approach: - Right rail is one quiet, honest invite: what daily.dev is in a line, the article's real topics to shape a feed, one low-key inline signup. No ads. - "Read next" shows REAL related posts (FURTHER_READING_QUERY) as a clean editorial column; renders nothing when there's no real content (no mock). - Keep the reader-style source strip. - Remove DiscoveryUniverse/Row, SocialProofBand, mockFeed, FeedConversionBanner. --- .../src/components/post/BasePostContent.tsx | 4 +- .../src/components/post/PostWidgets.tsx | 12 +- .../BuildFeedConversionCard.tsx | 93 ++++----- .../postPageOnboarding/DiscoveryRow.tsx | 137 ------------- .../postPageOnboarding/DiscoveryUniverse.tsx | 154 --------------- .../FeedConversionBanner.tsx | 71 ------- .../features/postPageOnboarding/ReadNext.tsx | 181 ++++++++++++++++++ .../postPageOnboarding/SocialProofBand.tsx | 93 --------- .../features/postPageOnboarding/mockFeed.ts | 147 -------------- 9 files changed, 234 insertions(+), 658 deletions(-) delete mode 100644 packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx create mode 100644 packages/shared/src/features/postPageOnboarding/ReadNext.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx delete mode 100644 packages/shared/src/features/postPageOnboarding/mockFeed.ts diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 5f603da89d1..3b971c022d4 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,7 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; -import { DiscoveryUniverse } from '../../features/postPageOnboarding/DiscoveryUniverse'; +import { ReadNext } from '../../features/postPageOnboarding/ReadNext'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -71,7 +71,7 @@ export function BasePostContent({ shouldOnboardAuthor={shouldOnboardAuthor} /> )} - {isPostPage && } + {isPostPage && } ); } diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 0141dded338..bb3059f5f08 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -63,17 +63,13 @@ export function PostWidgets({ // Anonymous "build your feed" experience: the whole right column becomes a // single cohesive conversion card, with the promo demoted to the last slot. if (isAnonExperience) { - // Right rail for anonymous readers: a sticky sign-up + customize card - // (always in view as they explore the discovery experience below), with - // the promo beneath it. "Happening Now" is reserved for signed-in users. + // Right rail for anonymous readers: a single calm, sticky invite. No ads + // or promos competing for a first-time reader's trust; "Happening Now" is + // reserved for signed-in users. return ( -
    +
    -
    diff --git a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx index 072ea89d73e..52e5e34a941 100644 --- a/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx +++ b/packages/shared/src/features/postPageOnboarding/BuildFeedConversionCard.tsx @@ -11,10 +11,8 @@ import { import { Typography, TypographyColor, - TypographyTag, TypographyType, } from '../../components/typography/Typography'; -import { onboardingGradientClasses } from '../../components/onboarding/common'; import { useAnonFeedTags } from './useAnonFeedTags'; import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; @@ -22,12 +20,13 @@ interface BuildFeedConversionCardProps { post: Post; } -const MAX_CHIPS = 8; +const MAX_CHIPS = 6; /** - * The anonymous right panel — focused on exactly two jobs: customize (pick - * topics, pre-seeded from the article) and sign up (inline one-tap). Clean, - * compact, and explicit, with nothing else competing for attention. + * The anonymous right rail — deliberately calm and substance-first. No FOMO, + * no fake proof, no animation: an honest one-line description of what + * daily.dev is, the article's real topics presented as a quiet way to shape a + * feed, and a single low-key signup. The value does the work, not the pitch. */ export const BuildFeedConversionCard = ({ post, @@ -38,66 +37,68 @@ export const BuildFeedConversionCard = ({ }); return ( -
    -
    +
    +
    -
    - - Your topics - -
    - {chips.slice(0, MAX_CHIPS).map((tag) => { - const isSelected = selectedTags.includes(tag); - if (isSelected) { + {chips.length > 0 && ( +
    + + Shape it around this article + +
    + {chips.slice(0, MAX_CHIPS).map((tag) => { + if (selectedTags.includes(tag)) { + return ( + + ); + } return ( ); - } - return ( - - ); - })} + })} +
    -
    + )} -
    + ); }; diff --git a/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx b/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx deleted file mode 100644 index 8d7fc01aa10..00000000000 --- a/packages/shared/src/features/postPageOnboarding/DiscoveryRow.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import type { Post } from '../../graphql/posts'; -import { LazyImage } from '../../components/LazyImage'; -import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; -import { CardLink } from '../../components/cards/common/Card'; -import { PostEngagementCounts } from '../../components/cards/SimilarPosts/PostEngagementCounts'; -import { LockIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../components/typography/Typography'; - -interface DiscoveryRowProps { - title: string; - icon?: ReactNode; - subtitle?: string; - posts: Post[]; - locked?: boolean; - onUnlock?: () => void; -} - -const RowCard = ({ post }: { post: Post }): ReactElement => ( -
    - - -
    -
    - - - {post.source?.name} - -
    -

    - {post.title} -

    - -
    -
    -); - -/** - * A Netflix-style titled carousel of posts. The `locked` variant blurs the - * content behind a gradient veil and an "unlock your feed" CTA — turning the - * sheer volume of available content into a reason to sign up. - */ -export const DiscoveryRow = ({ - title, - icon, - subtitle, - posts, - locked = false, - onUnlock, -}: DiscoveryRowProps): ReactElement => ( -
    -
    - {icon} -
    - - {title} - - {subtitle && ( - - {subtitle} - - )} -
    -
    -
    -
    - {posts.map((post) => ( - - ))} -
    - {locked && ( -
    -
    - - - - - Your full feed is locked - - - Sign up free to unlock endless personalized dev content — tools, - news, and discussions, every day. - - -
    -
    - )} -
    -
    -); diff --git a/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx b/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx deleted file mode 100644 index adea05154d1..00000000000 --- a/packages/shared/src/features/postPageOnboarding/DiscoveryUniverse.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; -import classNames from 'classnames'; -import { useQuery } from '@tanstack/react-query'; -import type { Post } from '../../graphql/posts'; -import { gqlClient } from '../../graphql/common'; -import { FEED_BY_TAGS_QUERY, type FeedData } from '../../graphql/feed'; -import { capitalize } from '../../lib/strings'; -import { HotIcon, StarIcon, TrendingIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { onboardingGradientClasses } from '../../components/onboarding/common'; -import { authGradientBg } from '../../components/marketing/banners/common'; -import { useAnonPostOnboarding } from './useAnonPostOnboarding'; -import { useAnonFeedTags } from './useAnonFeedTags'; -import { useBuildFeedSignup } from './useBuildFeedSignup'; -import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; -import { DiscoveryRow } from './DiscoveryRow'; -import { SocialProofBand } from './SocialProofBand'; -import { FeedConversionBanner } from './FeedConversionBanner'; -import { mockDiscussions, mockTools, mockTrending } from './mockFeed'; - -interface DiscoveryUniverseProps { - post: Post; -} - -const REAL_ROW_SIZE = 10; - -/** - * The anonymous post page's discovery experience — the doorway to "a whole - * world built for you". Below the article it opens into an immersive band: - * a hero invite, Netflix-style content rows (trending, discussions), a locked - * row that teases the volume behind signup, social proof, and a final CTA. - */ -export const DiscoveryUniverse = ({ - post, -}: DiscoveryUniverseProps): ReactElement | null => { - const { isEnabled } = useAnonPostOnboarding(); - const { previewTags, selectedTags } = useAnonFeedTags({ - postTags: post?.tags ?? [], - enabled: isEnabled, - }); - const { triggerSignup } = useBuildFeedSignup(); - - const { data } = useQuery({ - queryKey: ['discoveryUniverse', post?.id, previewTags], - queryFn: () => - gqlClient.request(FEED_BY_TAGS_QUERY, { - tags: previewTags, - first: REAL_ROW_SIZE, - }), - enabled: isEnabled && previewTags.length > 0, - staleTime: 5 * 60 * 1000, - }); - - const realPosts = useMemo(() => { - const nodes = data?.page?.edges?.map((edge) => edge.node) ?? []; - return nodes.filter((item) => item.id !== post?.id); - }, [data, post?.id]); - - if (!isEnabled) { - return null; - } - - const topic = previewTags[0] ? capitalize(previewTags[0]) : 'your stack'; - const trendingRow = realPosts.length >= 4 ? realPosts : mockTrending; - - return ( -
    -
    - - You just read one post - - - Your dev world is waiting - - - One feed with the best of {topic} and your whole stack — news, tools, - and the discussions that matter, curated daily and built around you. - Free, forever. - -
    - -
    -
    - - - } - posts={trendingRow} - /> - - - } - posts={mockDiscussions} - /> - - - } - posts={mockTools} - locked - onUnlock={() => triggerSignup(selectedTags, 'feed')} - /> - - - - -
    - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx b/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx deleted file mode 100644 index 60419261b35..00000000000 --- a/packages/shared/src/features/postPageOnboarding/FeedConversionBanner.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { capitalize } from '../../lib/strings'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; -import { onboardingGradientClasses } from '../../components/onboarding/common'; -import { authGradientBg } from '../../components/marketing/banners/common'; -import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; - -interface FeedConversionBannerProps { - tags: string[]; -} - -const borderGradient: React.CSSProperties = { - backgroundImage: - 'linear-gradient(90deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default), var(--theme-accent-water-default), var(--theme-accent-cabbage-default))', - backgroundSize: '200% 100%', - animation: 'bf-border-shift 6s linear infinite', -}; - -/** - * A bold conversion banner woven into the "Keep reading" feed — animated - * gradient frame, big headline, and inline one-tap signup, with messaging made - * relevant by the reader's topics. Converts in the flow of discovery. - */ -export const FeedConversionBanner = ({ - tags, -}: FeedConversionBannerProps): ReactElement => { - const topicList = tags.slice(0, 3).map(capitalize).join(', '); - - return ( -
    - -
    -
    - - Stop scrolling random feeds - - - {topicList - ? `Your own stream of ${topicList} and the best dev content — built around what you actually read. Free, forever.` - : 'Your own stream of the best dev content — built around what you actually read. Free, forever.'} - -
    -
    - -
    -
    -
    - ); -}; diff --git a/packages/shared/src/features/postPageOnboarding/ReadNext.tsx b/packages/shared/src/features/postPageOnboarding/ReadNext.tsx new file mode 100644 index 00000000000..2ba00986616 --- /dev/null +++ b/packages/shared/src/features/postPageOnboarding/ReadNext.tsx @@ -0,0 +1,181 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Post } from '../../graphql/posts'; +import { gqlClient } from '../../graphql/common'; +import { + FURTHER_READING_QUERY, + type FurtherReadingData, +} from '../../graphql/furtherReading'; +import { capitalize } from '../../lib/strings'; +import { CardLink } from '../../components/cards/common/Card'; +import { ElementPlaceholder } from '../../components/ElementPlaceholder'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import { useAnonPostOnboarding } from './useAnonPostOnboarding'; +import { useAnonFeedTags } from './useAnonFeedTags'; +import { BuildFeedAuthOptions } from './BuildFeedAuthOptions'; + +interface ReadNextProps { + post: Post; +} + +const MAX_ITEMS = 7; + +const dedupeById = (posts: Post[], excludeId?: string): Post[] => { + const seen = new Set(); + return posts.filter((item) => { + if (!item?.id || item.id === excludeId || seen.has(item.id)) { + return false; + } + seen.add(item.id); + return true; + }); +}; + +const ReadNextItem = ({ + post, + index, +}: { + post: Post; + index: number; +}): ReactElement => { + const meta = [ + post.source?.name, + post.readTime ? `${post.readTime} min read` : null, + ] + .filter(Boolean) + .join(' · '); + + return ( +
    + + + {(index + 1).toString().padStart(2, '0')} + +
    +

    + {post.title} +

    + {meta && ( + + {meta} + + )} +
    +
    + ); +}; + +/** + * "Read next" — real related posts (similar / trending / discussed) for the + * current article, presented as a calm editorial column. No images-as-bait, no + * locked rows, no fabricated content: if there's nothing real to show, it + * renders nothing. The invitation at the end is honest and low-key. + */ +export const ReadNext = ({ post }: ReadNextProps): ReactElement | null => { + const { isEnabled } = useAnonPostOnboarding(); + const { selectedTags } = useAnonFeedTags({ + postTags: post?.tags ?? [], + enabled: isEnabled, + }); + const tags = post?.tags ?? []; + + const { data, isLoading } = useQuery({ + queryKey: ['readNext', post?.id], + queryFn: () => + gqlClient.request(FURTHER_READING_QUERY, { + loggedIn: false, + post: post.id, + trendingFirst: 3, + similarFirst: MAX_ITEMS, + discussedFirst: 4, + withDiscussedPosts: true, + tags, + }), + enabled: isEnabled && !!post?.id && tags.length > 0, + staleTime: 5 * 60 * 1000, + }); + + const posts = useMemo(() => { + const merged = [ + ...(data?.similarPosts ?? []), + ...(data?.trendingPosts ?? []), + ...(data?.discussedPosts ?? []), + ]; + return dedupeById(merged, post?.id).slice(0, MAX_ITEMS); + }, [data, post?.id]); + + if (!isEnabled || tags.length === 0) { + return null; + } + + if (!isLoading && posts.length === 0) { + return null; + } + + const topic = tags[0] ? capitalize(tags[0]) : null; + + return ( +
    + + Read next + + + {topic + ? `More on ${topic}, from what the developer community is reading.` + : 'More from what the developer community is reading.'} + + +
    + {isLoading ? ( + Array.from({ length: 4 }).map((_, index) => ( +
    + + +
    + )) + ) : ( + <> + {posts.map((item, index) => ( + + ))} + + )} +
    + +
    + + This is a glimpse of what a daily.dev feed gives you every day — the + best of {topic ?? 'your stack'}, curated and tuned to you. Make one + that's yours. + +
    + +
    +
    +
    + ); +}; diff --git a/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx b/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx deleted file mode 100644 index 1a37475eb97..00000000000 --- a/packages/shared/src/features/postPageOnboarding/SocialProofBand.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import { StarIcon, HashtagIcon, DiscussIcon } from '../../components/icons'; -import { IconSize } from '../../components/Icon'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../../components/typography/Typography'; - -const Avatars = (): ReactElement => ( -
    - {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
    -); - -const Stat = ({ - icon, - label, -}: { - icon: ReactNode; - label: string; -}): ReactElement => ( -
    - {icon} - - {label} - -
    -); - -/** - * Social-proof band — the "you're joining something big and trusted" beat, - * G2/Trustpilot style but honest: star visual, a community avatar pile, the - * "millions of developers" signal, and what the product gives. No fabricated - * counts or reviews. - */ -export const SocialProofBand = (): ReactElement => ( -
    - -
    - {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
    - - Loved by millions of developers - - - daily.dev is the home where developers stay ahead — the best news, tools, - and discussions across the dev world, in one feed built around you. - -
    - - } - label="Every major dev source, in one place" - /> - - } - label="A real community, not a comment void" - /> - } - label="A feed that learns what you love" - /> -
    -
    -); diff --git a/packages/shared/src/features/postPageOnboarding/mockFeed.ts b/packages/shared/src/features/postPageOnboarding/mockFeed.ts deleted file mode 100644 index 979a516ae6f..00000000000 --- a/packages/shared/src/features/postPageOnboarding/mockFeed.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Post } from '../../graphql/posts'; -import { webappUrl } from '../../lib/constants'; -import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; - -interface MockSeed { - title: string; - source: string; - upvotes: number; - comments: number; -} - -const toPost = (item: MockSeed, id: string): Post => - ({ - id, - title: item.title, - commentsPermalink: webappUrl, - permalink: webappUrl, - image: cloudinaryPostImageCoverPlaceholder, - numUpvotes: item.upvotes, - numComments: item.comments, - source: { - name: item.source, - image: cloudinaryPostImageCoverPlaceholder, - }, - } as unknown as Post); - -const build = (seeds: MockSeed[], prefix: string): Post[] => - seeds.map((seed, index) => toPost(seed, `${prefix}-${index}`)); - -/** - * Realistic placeholder content so the discovery experience always looks full - * and alive — used as a fallback when the live tag feed is empty, and to - * populate themed rows we don't yet have a dedicated query for. - */ -export const mockTrending = build( - [ - { - title: 'Why I stopped using useEffect for data fetching', - source: 'Josh W. Comeau', - upvotes: 842, - comments: 96, - }, - { - title: 'The 2025 State of JavaScript results are in', - source: 'State of JS', - upvotes: 1290, - comments: 214, - }, - { - title: 'Building a fully type-safe API layer with tRPC', - source: 'tRPC Blog', - upvotes: 537, - comments: 48, - }, - { - title: 'How Rust is quietly taking over backend infrastructure', - source: 'The Pragmatic Engineer', - upvotes: 1631, - comments: 305, - }, - { - title: 'CSS :has() is a game changer — here is why', - source: 'web.dev', - upvotes: 724, - comments: 61, - }, - { - title: 'Stop over-engineering your React state', - source: 'Kent C. Dodds', - upvotes: 988, - comments: 132, - }, - ], - 'trend', -); - -export const mockDiscussions = build( - [ - { - title: 'Is TypeScript still worth it in 2025? An honest take', - source: 'Hacker News', - upvotes: 2104, - comments: 612, - }, - { - title: 'We migrated 2M lines from JS to TS. Here is what broke', - source: 'Engineering Blog', - upvotes: 1456, - comments: 388, - }, - { - title: 'Why your team keeps shipping bugs (and how to stop)', - source: 'LeadDev', - upvotes: 903, - comments: 277, - }, - { - title: 'The case against microservices for small teams', - source: 'InfoQ', - upvotes: 1188, - comments: 341, - }, - { - title: 'AI pair programming: hype vs. what actually helps', - source: 'Stack Overflow Blog', - upvotes: 1675, - comments: 459, - }, - ], - 'disc', -); - -export const mockTools = build( - [ - { - title: 'Biome: the all-in-one toolchain replacing ESLint + Prettier', - source: 'Biome', - upvotes: 1320, - comments: 142, - }, - { - title: 'Bun 1.2 is fast — benchmarks that surprised me', - source: 'Bun', - upvotes: 1899, - comments: 233, - }, - { - title: 'Zod 4 is here: smaller, faster schema validation', - source: 'Total TypeScript', - upvotes: 1044, - comments: 88, - }, - { - title: 'The VS Code extensions every developer should install', - source: 'Visual Studio Code', - upvotes: 2210, - comments: 401, - }, - { - title: 'Drizzle vs Prisma: which ORM in 2025?', - source: 'Drizzle ORM', - upvotes: 967, - comments: 176, - }, - ], - 'tool', -);