Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/post/BasePostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements';
import type { BasePostContentProps } from './common';
import { PostHeaderActions } from './PostHeaderActions';
import { ButtonSize } from '../buttons/common';
import { ReadNext } from '../../features/postPageOnboarding/ReadNext';

const Custom404 = dynamic(
() => import(/* webpackChunkName: "custom404" */ '../Custom404'),
Expand Down Expand Up @@ -70,6 +71,7 @@ export function BasePostContent({
shouldOnboardAuthor={shouldOnboardAuthor}
/>
)}
{isPostPage && <ReadNext post={post} />}
</>
);
}
79 changes: 49 additions & 30 deletions packages/shared/src/components/post/PostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ 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 { AnonSourceStrip } from '../../features/postPageOnboarding/AnonSourceStrip';

type PostContentRawProps = Omit<PostContentProps, 'post'> & { post: Post };

Expand Down Expand Up @@ -98,6 +100,10 @@ export function PostContentRaw({
: undefined,
},
);
// Anonymous "build your feed" experience — only on the full post page.
const { isEnabled: isAnonExperience } = useAnonPostOnboarding();
const anonExperienceActive = isAnonExperience && !!isPostPage;

const handleImageClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (onReaderInstallGateClick(event)) {
return;
Expand Down Expand Up @@ -170,15 +176,24 @@ export function PostContentRaw({
post={post}
>
<div className={isCompactModalSpacing ? 'my-4' : 'my-6'}>
<div className="mb-3 flex items-center">
<PostSourceInfo
className="min-w-0 flex-1"
{anonExperienceActive ? (
<AnonSourceStrip
className="mb-3"
post={post}
onClose={onClose}
onClose={onClose as () => void}
onReadArticle={onReadArticle}
hideSubscribeAction={hideSubscribeAction}
/>
</div>
) : (
<div className="mb-3 flex items-center">
<PostSourceInfo
className="min-w-0 flex-1"
post={post}
onClose={onClose}
onReadArticle={onReadArticle}
hideSubscribeAction={hideSubscribeAction}
/>
</div>
)}
<h1
className="break-words font-bold typo-large-title"
data-testid="post-modal-title"
Expand All @@ -187,7 +202,9 @@ export function PostContentRaw({
{title}
</ArticleLink>
</h1>
{post.clickbaitTitleDetected && <PostClickbaitShield post={post} />}
{post.clickbaitTitleDetected && !anonExperienceActive && (
<PostClickbaitShield post={post} />
)}
</div>
{isVideoType && (
<YoutubeVideo
Expand Down Expand Up @@ -284,29 +301,31 @@ export function PostContentRaw({
);

return (
<PostContentContainer
hasNavigation={hasNavigation}
className={containerClass}
aria-live={subject === ToastSubject.PostContent ? 'polite' : 'off'}
navigationProps={
position === 'fixed'
? {
...navigationProps,
isBannerVisible: !!isBannerVisible,
className: {
...className?.fixedNavigation,
container: classNames(
className?.fixedNavigation?.container,
isPostPage && 'tablet:max-w-[calc(100%-4rem)]',
),
},
}
: undefined
}
>
{postMainColumn}
{postWidgetsColumn}
</PostContentContainer>
<>
<PostContentContainer
hasNavigation={hasNavigation}
className={containerClass}
aria-live={subject === ToastSubject.PostContent ? 'polite' : 'off'}
navigationProps={
position === 'fixed'
? {
...navigationProps,
isBannerVisible: !!isBannerVisible,
className: {
...className?.fixedNavigation,
container: classNames(
className?.fixedNavigation?.container,
isPostPage && 'tablet:max-w-[calc(100%-4rem)]',
),
},
}
: undefined
}
>
{postMainColumn}
{postWidgetsColumn}
</PostContentContainer>
</>
);
}

Expand Down
21 changes: 20 additions & 1 deletion packages/shared/src/components/post/PostWidgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ 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 { PostSignupWidget } from './PostSignupWidget';
import { useAnonPostOnboarding } from '../../features/postPageOnboarding/useAnonPostOnboarding';
import { BuildFeedConversionCard } from '../../features/postPageOnboarding/BuildFeedConversionCard';

const UserEntityCard = dynamic(
/* webpackChunkName: "userEntityCard" */ () =>
Expand Down Expand Up @@ -53,10 +55,27 @@ 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) {
// 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 (
<PageWidgets className={className}>
<div className="sticky top-16">
<BuildFeedConversionCard post={post} />
</div>
<FooterLinks />
</PageWidgets>
);
}

const creator = post.author || post.scout;
let sourceCard = null;

Expand Down
Original file line number Diff line number Diff line change
@@ -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 => (
<div
className={classNames(
'flex items-center justify-between gap-3 rounded-12 border border-border-subtlest-tertiary p-2 pl-3',
className,
)}
>
<SourceStrip source={post?.source as SourceTooltip} />
<PostHeaderActions
post={post}
onReadArticle={onReadArticle}
onClose={onClose}
contextMenuId="anon-post-header-actions"
buttonSize={ButtonSize.Small}
className="shrink-0"
/>
</div>
);
Original file line number Diff line number Diff line change
@@ -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 (
<AuthOptions
ignoreMessages
formRef={null as unknown as MutableRefObject<HTMLFormElement>}
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
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { Post } from '../../graphql/posts';
import { capitalize } from '../../lib/strings';
import {
Button,
ButtonColor,
ButtonSize,
ButtonVariant,
} from '../../components/buttons/Button';
import {
Typography,
TypographyColor,
TypographyType,
} from '../../components/typography/Typography';
import { useAnonFeedTags } from './useAnonFeedTags';
import { BuildFeedAuthOptions } from './BuildFeedAuthOptions';

interface BuildFeedConversionCardProps {
post: Post;
}

const MAX_CHIPS = 6;

/**
* 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,
}: BuildFeedConversionCardProps): ReactElement => {
const { chips, selectedTags, toggleTag } = useAnonFeedTags({
postTags: post?.tags ?? [],
enabled: true,
});

return (
<aside className="flex flex-col gap-5 rounded-16 border border-border-subtlest-tertiary p-5">
<div className="flex flex-col gap-2">
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Tertiary}
className="font-mono uppercase tracking-wider"
>
daily.dev
</Typography>
<Typography bold type={TypographyType.Title3}>
Keep up, without the noise
</Typography>
<Typography
type={TypographyType.Callout}
color={TypographyColor.Secondary}
>
The best of the dev web — articles, tools, and discussions — in one
feed you actually control.
</Typography>
</div>

{chips.length > 0 && (
<div className="flex flex-col gap-2">
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Tertiary}
>
Shape it around this article
</Typography>
<div className="flex flex-wrap gap-1.5">
{chips.slice(0, MAX_CHIPS).map((tag) => {
if (selectedTags.includes(tag)) {
return (
<Button
key={tag}
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Primary}
color={ButtonColor.Cabbage}
onClick={() => toggleTag(tag)}
>
{capitalize(tag)}
</Button>
);
}
return (
<Button
key={tag}
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Float}
onClick={() => toggleTag(tag)}
>
{capitalize(tag)}
</Button>
);
})}
</div>
</div>
)}

<BuildFeedAuthOptions tags={selectedTags} origin="sidebar" />
</aside>
);
};
Loading
Loading