+
+ 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/common.ts b/packages/shared/src/features/postPageOnboarding/common.ts
new file mode 100644
index 0000000000..4158eae533
--- /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/useAnonFeedTags.ts b/packages/shared/src/features/postPageOnboarding/useAnonFeedTags.ts
new file mode 100644
index 0000000000..e1af4b8e57
--- /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 0000000000..b869fcb893
--- /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 0000000000..5f4cd98c19
--- /dev/null
+++ b/packages/shared/src/features/postPageOnboarding/useBuildFeedSignup.ts
@@ -0,0 +1,98 @@
+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';
+import type { AuthProps } from '../../components/auth/common';
+
+export type BuildFeedSignupOrigin = 'sidebar' | 'feed';
+
+interface UseBuildFeedSignup {
+ /** 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[],
+ origin: BuildFeedSignupOrigin,
+ ) => (props: Partial) => void;
+}
+
+/**
+ * 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 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, tags }),
+ });
+ },
+ [logEvent],
+ );
+
+ const triggerSignup = useCallback(
+ (tags: string[], origin: BuildFeedSignupOrigin) => {
+ logStart(tags, origin);
+ showLogin({
+ trigger: AuthTriggers.PostPage,
+ options: { onRegistrationSuccess: () => applyFollowedTags(tags) },
+ });
+ },
+ [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 { applyFollowedTags, triggerSignup, getAuthStateHandler };
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 132e1e7173..e18408347a 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
@@ -177,6 +178,13 @@ 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.
+// 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);
export const featureGenericReferralPopupV2 = new Feature(
diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx
index fce81b9431..e02d33e942 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 && }