diff --git a/packages/shared/src/components/InteractionCounter.tsx b/packages/shared/src/components/InteractionCounter.tsx
index 84831e54f02..ed0c3e3e1e7 100644
--- a/packages/shared/src/components/InteractionCounter.tsx
+++ b/packages/shared/src/components/InteractionCounter.tsx
@@ -7,20 +7,23 @@ import { largeNumberFormat } from '../lib';
export type InteractionCounterProps = {
className?: string;
value: number | null;
+ /** Override the number formatter (defaults to `largeNumberFormat`). */
+ format?: (value: number | null) => string | null;
};
export default function InteractionCounter({
className,
value,
+ format = largeNumberFormat,
...props
}: InteractionCounterProps): ReactElement {
const [shownValue, setShownValue] = useState(value);
const [animate, setAnimate] = useState(false);
useEffect(() => {
- const formattedValue = largeNumberFormat(value);
- const formattedShownValue = largeNumberFormat(shownValue);
+ const formattedValue = format(value);
+ const formattedShownValue = format(shownValue);
if (formattedValue !== formattedShownValue) {
- if (value < shownValue) {
+ if ((value ?? 0) < (shownValue ?? 0)) {
setShownValue(value);
} else {
setAnimate(false);
@@ -37,9 +40,15 @@ export default function InteractionCounter({
);
if (shownValue === value) {
+ // Center the number within the fixed-height (h-5) box. Without this the text
+ // is top-aligned, so smaller type (e.g. typo-caption1 on mobile/tablet, whose
+ // 1rem line-height is shorter than h-5) sits visibly higher than the icon.
return (
-
- {largeNumberFormat(shownValue)}
+
+ {format(shownValue)}
);
}
@@ -49,8 +58,10 @@ export default function InteractionCounter({
setShownValue(value);
};
+ // leading-5 makes each rolling slice's line box fill its h-5 height so the
+ // digits stay centered during the roll (matches the resting state above).
const childClassName =
- 'h-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]';
+ 'h-5 leading-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]';
return (
@@ -60,7 +71,7 @@ export default function InteractionCounter({
animate ? '-translate-y-full opacity-0' : 'translate-y-0 opacity-100',
)}
>
- {largeNumberFormat(shownValue)}
+ {format(shownValue)}
- {largeNumberFormat(value)}
+ {format(value)}
);
diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx
index f5e64df8b7d..a3e97475eb7 100644
--- a/packages/shared/src/components/buttons/CardAction.tsx
+++ b/packages/shared/src/components/buttons/CardAction.tsx
@@ -46,6 +46,8 @@ type CardActionBaseProps = CardActionPassthroughProps & {
iconPressed?: IconElement;
label: string;
count?: number | null;
+ /** Override the counter formatter (defaults to `largeNumberFormat`). */
+ countFormat?: (value: number | null) => string | null;
color?: ColorName;
pressed?: boolean;
loading?: boolean;
@@ -64,6 +66,7 @@ function CardActionComponent(
iconPressed,
label,
count,
+ countFormat,
color,
pressed,
loading,
@@ -121,7 +124,9 @@ function CardActionComponent(
{showLabel && (
{label}
)}
- {showCount && }
+ {showCount && (
+
+ )}
)}
diff --git a/packages/shared/src/components/buttons/QuaternaryButton.tsx b/packages/shared/src/components/buttons/QuaternaryButton.tsx
index 84aa5c2c179..32a846d19ff 100644
--- a/packages/shared/src/components/buttons/QuaternaryButton.tsx
+++ b/packages/shared/src/components/buttons/QuaternaryButton.tsx
@@ -80,7 +80,9 @@ function QuaternaryButtonComponent(
htmlFor={id}
{...labelProps}
className={classNames(
- 'flex cursor-pointer items-center pl-1 font-bold typo-callout',
+ // Medium weight to match the button typography guideline (the
+ // Button base and CardAction both use font-medium).
+ 'flex cursor-pointer items-center pl-1 font-medium typo-callout',
{ readOnly: props.disabled },
labelClassName,
)}
diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx
index 8c9b93c65b1..74a10ce9d5e 100644
--- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx
+++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx
@@ -25,6 +25,7 @@ import { FeedCardGlassActions } from '../common/FeedCardGlassActions';
import { FeedbackGrid } from './feedback/FeedbackGrid';
import { ClickbaitShield } from '../common/ClickbaitShield';
import { useSmartTitle } from '../../../hooks/post/useSmartTitle';
+import { useFitFontSize } from '../../../hooks/useFitFontSize';
import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions';
import { usePostImage } from '../../../hooks/post/usePostImage';
import { useCardCover } from '../../../hooks/feed/useCardCover';
@@ -51,6 +52,12 @@ const IMAGE_COL_SPAN: Record = {
4: 'col-span-3',
};
+// The hero title shrinks (title1 → title3) to stay within three lines so the
+// summary below always has room, rather than spilling onto a fourth line and
+// pushing the TLDR off the card.
+const HERO_TITLE_SIZE_CLASSES = ['typo-title1', 'typo-title2', 'typo-title3'];
+const HERO_TITLE_MAX_LINES = 3;
+
const HighlightChip = ({
significance,
className,
@@ -148,6 +155,15 @@ export const ArticleFeaturedWideGridCard = forwardRef(
const { pinnedAt } = post;
const { showFeedback } = usePostFeedback({ post });
const { title } = useSmartTitle(post);
+ const {
+ ref: titleRef,
+ sizeClass: titleSizeClass,
+ isClamped: titleClamped,
+ } = useFitFontSize({
+ text: title,
+ sizeClasses: HERO_TITLE_SIZE_CLASSES,
+ maxLines: HERO_TITLE_MAX_LINES,
+ });
const isVideoType = isVideoPost(post);
const image = usePostImage(post);
const { overlay } = useCardCover({ post, onShare });
@@ -276,9 +292,11 @@ export const ArticleFeaturedWideGridCard = forwardRef(
showFeedback={false}
/>
{title}
diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx
index 83e2a65b5b2..88b6bbe21de 100644
--- a/packages/shared/src/components/cards/common/ActionButtons.tsx
+++ b/packages/shared/src/components/cards/common/ActionButtons.tsx
@@ -5,12 +5,13 @@ import type { Post } from '../../../graphql/posts';
import InteractionCounter from '../../InteractionCounter';
import { QuaternaryButton } from '../../buttons/QuaternaryButton';
import {
+ AnalyticsIcon,
DiscussIcon as CommentIcon,
LinkIcon,
DownvoteIcon,
} from '../../icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button';
-import { useFeedPreviewMode } from '../../../hooks';
+import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons';
import { IconSize } from '../../Icon';
@@ -21,6 +22,11 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel';
import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip';
import { useCardActions } from '../../../hooks/cards/useCardActions';
import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship';
+import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
+import {
+ formatImpressions,
+ getPostImpressions,
+} from '../../../lib/impressions';
import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2';
import ActionButtonsV2 from './ActionButtons.v2';
@@ -78,6 +84,14 @@ const ActionButtonsV1 = ({
}: ActionButtonsProps): ReactElement | null => {
const config = variantConfig[variant];
const isFeedPreview = useFeedPreviewMode();
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const { buttonSize, iconSize } = config;
+ // On mobile/tablet keep full-size icons but shrink the count so the icon
+ // reads as the primary affordance and the number as a subtle stat.
+ const counterClassName = classNames(
+ 'tabular-nums',
+ isLaptop ? variant === 'grid' && 'typo-footnote' : 'typo-caption1',
+ );
const { getUpvoteAnimation } = useBrandSponsorship();
const {
@@ -114,6 +128,8 @@ const ActionButtonsV1 = ({
};
}, [getUpvoteAnimation, post.tags]);
+ const onImpressionsClick = usePostImpressionsModal(post);
+
if (isFeedPreview) {
return null;
}
@@ -135,13 +151,16 @@ const ActionButtonsV1 = ({
href={post.commentsPermalink}
pressed={post.commented}
variant={ButtonVariant.Tertiary}
- size={config.buttonSize}
- icon={}
+ size={buttonSize}
+ icon={}
onClick={() => onCommentClick?.(post)}
>
{commentCount > 0 && (
)}
@@ -152,16 +171,16 @@ const ActionButtonsV1 = ({
}
+ icon={}
pressed={post.commented}
onClick={() => onCommentClick?.(post)}
- size={config.buttonSize}
+ size={buttonSize}
className="btn-tertiary-blueCheese"
>
{commentCount > 0 && (
}
>
{upvoteCount > 0 && (
)}
+ {commentButton}
{showDownvoteAction && (
+
}
pressed={isDownvoteActive}
onClick={onToggleDownvote}
variant={ButtonVariant.Tertiary}
- size={config.buttonSize}
+ size={buttonSize}
/>
)}
- {commentButton}
- {showAwardAction && (
-
+ {showAwardAction && isLaptop && (
+
)}
}
+ size={buttonSize}
+ icon={}
onClick={onCopyLink}
variant={ButtonVariant.Tertiary}
color={ButtonColor.Cabbage}
className={variant === 'list' ? 'pointer-events-auto' : undefined}
/>
+
+ }
+ onClick={onImpressionsClick}
+ variant={ButtonVariant.Tertiary}
+ color={ButtonColor.Cheese}
+ className={classNames(
+ 'btn-tertiary-cheese',
+ variant === 'list' && 'pointer-events-auto',
+ )}
+ >
+
+
+
);
diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx
index 9c5063f5fa2..940c222a681 100644
--- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx
+++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx
@@ -5,12 +5,13 @@ import type { Post } from '../../../graphql/posts';
import { CardAction } from '../../buttons/CardAction';
import { CardActionBar } from '../../buttons/CardActionBar';
import {
+ AnalyticsIcon,
DiscussIcon as CommentIcon,
LinkIcon,
DownvoteIcon,
} from '../../icons';
import { ButtonColor } from '../../buttons/ButtonV2';
-import { useFeedPreviewMode } from '../../../hooks';
+import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons/BookmarkButton.v2';
import { Tooltip } from '../../tooltip/Tooltip';
@@ -20,6 +21,11 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel';
import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip';
import { useCardActions } from '../../../hooks/cards/useCardActions';
import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship';
+import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
+import {
+ formatImpressions,
+ getPostImpressions,
+} from '../../../lib/impressions';
export type ActionButtonsVariant = 'grid' | 'list' | 'signal';
@@ -70,6 +76,8 @@ const ActionButtons = ({
}: ActionButtonsProps): ReactElement | null => {
const config = variantConfig[variant];
const isFeedPreview = useFeedPreviewMode();
+ // Awards are hidden in the feed below laptop (tablet + mobile).
+ const isLaptop = useViewSize(ViewSize.Laptop);
const { getUpvoteAnimation } = useBrandSponsorship();
const {
@@ -105,6 +113,8 @@ const ActionButtons = ({
};
}, [getUpvoteAnimation, post.tags]);
+ const onImpressionsClick = usePostImpressionsModal(post);
+
if (isFeedPreview) {
return null;
}
@@ -175,6 +185,7 @@ const ActionButtons = ({
buttonClassName="pointer-events-auto"
/>
+ {commentButton}
{showDownvoteAction && (
)}
- {commentButton}
- {showAwardAction && (
+ {showAwardAction && isLaptop && (
)}
+
+ }
+ label="Impressions"
+ count={getPostImpressions(post)}
+ countFormat={formatImpressions}
+ onClick={onImpressionsClick}
+ color={ButtonColor.Cheese}
+ buttonClassName={classNames(
+ variant === 'list' && 'pointer-events-auto',
+ )}
+ />
+
);
diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx
index fc1510c4d09..ae247cd24d3 100644
--- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx
+++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx
@@ -7,6 +7,7 @@ import InteractionCounter from '../../InteractionCounter';
import { QuaternaryButton } from '../../buttons/QuaternaryButton';
import { BookmarkButton } from '../../buttons/BookmarkButton';
import {
+ AnalyticsIcon,
DiscussIcon as CommentIcon,
DownvoteIcon,
LinkIcon,
@@ -16,6 +17,11 @@ import { IconSize } from '../../Icon';
import { Tooltip } from '../../tooltip/Tooltip';
import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode';
import { useCardActions } from '../../../hooks/cards/useCardActions';
+import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
+import {
+ formatImpressions,
+ getPostImpressions,
+} from '../../../lib/impressions';
// Full-bleed cover: drop side padding/bottom margin and round only the bottom
// corners so the image meets the card edges. Height/crop are untouched.
@@ -32,10 +38,14 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1';
// actions evenly across the pill: the icons keep equal gaps and a long counter
// (e.g. 900 upvotes / 900 comments) just grows its own button instead of
// clipping or shoving a neighbour off its mark.
-// `px-1` matches the 4px the h-10 pill leaves above/below its h-8 buttons, so
-// the padding is equal on all four sides.
+// The pill stays h-10 (its original height) so it reads as a comfortable bar,
+// while the buttons themselves are the smaller XSmall size.
+// Asymmetric `pl-1 pr-2.5`: the left edge holds the upvote icon while the right
+// edge holds the impressions number — a number reads tighter against the edge
+// than an icon, so it needs more padding to look optically balanced (per
+// jakub.kr "details that make interfaces feel better").
const pillClasses = classNames(
- 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-1',
+ 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden pl-1 pr-2.5',
'rounded-12 border border-border-subtlest-tertiary',
'text-text-primary backdrop-blur-xl backdrop-saturate-150',
'[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]',
@@ -49,9 +59,10 @@ const pillClasses = classNames(
const glassBackground =
'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--theme-blur-blur-bg)';
-// Keep the counter compact: monospaced digits so it never jitters, and the
-// `pr-1` breathing room mirrors the standard grid action bar.
-const countLabelClasses = '!pl-0.5 pr-1';
+// Keep the counter compact: monospaced digits so it never jitters, and a hair
+// of padding on each side so six actions (incl. impressions) still fit a narrow
+// card without the trailing icon clipping.
+const countLabelClasses = '!pl-0.5 pr-0.5';
const countClasses = 'tabular-nums typo-footnote';
// Dark glow behind the pill so it stays readable over busy cover images. Fixed
@@ -86,6 +97,7 @@ export function FeedCardGlassActions({
onBookmarkClick,
onCopyLinkClick,
});
+ const onImpressionsClick = usePostImpressionsModal(post);
if (isFeedPreview) {
return null;
@@ -93,6 +105,9 @@ export function FeedCardGlassActions({
const upvoteCount = post.numUpvotes ?? 0;
const commentCount = post.numComments ?? 0;
+ // Impressions = per-post views, shown on every card as a public stat (mock
+ // fallback until the feed returns real views — see getPostImpressions).
+ const impressions = getPostImpressions(post);
return (
<>
@@ -117,11 +132,11 @@ export function FeedCardGlassActions({
pressed={isUpvoteActive}
onClick={onToggleUpvote}
variant={ButtonVariant.Tertiary}
- size={ButtonSize.Small}
+ size={ButtonSize.XSmall}
icon={
}
>
@@ -140,12 +155,12 @@ export function FeedCardGlassActions({
icon={
}
pressed={post.commented}
onClick={() => onCommentClick?.(post)}
- size={ButtonSize.Small}
+ size={ButtonSize.XSmall}
className="btn-tertiary-blueCheese pointer-events-auto"
>
{commentCount > 0 && (
@@ -168,13 +183,13 @@ export function FeedCardGlassActions({
icon={
}
pressed={isDownvoteActive}
onClick={onToggleDownvote}
variant={ButtonVariant.Tertiary}
- size={ButtonSize.Small}
+ size={ButtonSize.XSmall}
/>
)}
@@ -184,22 +199,43 @@ export function FeedCardGlassActions({
buttonProps={{
id: `post-${post.id}-bookmark-btn`,
onClick: onToggleBookmark,
- size: ButtonSize.Small,
+ size: ButtonSize.XSmall,
className: 'btn-tertiary-bun pointer-events-auto',
}}
- iconSize={IconSize.XSmall}
+ iconSize={IconSize.Size16}
/>
}
+ size={ButtonSize.XSmall}
+ icon={}
onClick={onCopyLink}
variant={ButtonVariant.Tertiary}
color={ButtonColor.Cabbage}
className="pointer-events-auto"
/>
+
+ }
+ size={ButtonSize.XSmall}
+ variant={ButtonVariant.Tertiary}
+ color={ButtonColor.Cheese}
+ onClick={onImpressionsClick}
+ className="btn-tertiary-cheese pointer-events-auto"
+ >
+ {/* Always render the impression count (even 0) so the number is
+ visible to everyone — unlike upvotes/comments it is not hidden
+ at zero. */}
+
+
+
>
diff --git a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx
index 2f9ebceaf57..db6381eff0c 100644
--- a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx
+++ b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx
@@ -43,7 +43,9 @@ it('should render post metadata and engagement stats as passive metadata', () =>
expect(screen.getByText('Now')).toBeInTheDocument();
expect(screen.getByText('4m read time')).toBeInTheDocument();
- expect(screen.getByText('10 Impressions')).toBeInTheDocument();
+ // Impressions are shown only on the full post page/modal stats strip, not in
+ // the compact embed.
+ expect(screen.queryByText(/Impressions?$/)).not.toBeInTheDocument();
expect(screen.getByText('1.2K Upvotes')).toBeInTheDocument();
expect(screen.getByText('3 Comments')).toBeInTheDocument();
expect(screen.getByText('5 Reposts')).toBeInTheDocument();
diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx
index 199f5dfcce1..2934626328c 100644
--- a/packages/shared/src/components/modals/common.tsx
+++ b/packages/shared/src/components/modals/common.tsx
@@ -512,6 +512,13 @@ const ReaderPreviewLazyModal = dynamic(
),
);
+const PostImpressionsModal = dynamic(
+ () =>
+ import(
+ /* webpackChunkName: "postImpressionsModal" */ './post/PostImpressionsModal'
+ ),
+);
+
export const modals = {
[LazyModal.SquadMember]: SquadMemberModal,
[LazyModal.UpvotedPopup]: UpvotedPopupModal,
@@ -595,6 +602,7 @@ export const modals = {
[LazyModal.ReaderInstallPrompt]: ReaderInstallPromptModal,
[LazyModal.ReaderExtensionInstall]: ReaderExtensionInstallModal,
[LazyModal.ReaderPreview]: ReaderPreviewLazyModal,
+ [LazyModal.PostImpressions]: PostImpressionsModal,
};
type GetComponentProps = T extends
diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts
index dfda6a0d09c..f28b066f111 100644
--- a/packages/shared/src/components/modals/common/types.ts
+++ b/packages/shared/src/components/modals/common/types.ts
@@ -107,6 +107,7 @@ export enum LazyModal {
ReaderInstallPrompt = 'readerInstallPrompt',
ReaderExtensionInstall = 'readerExtensionInstall',
ReaderPreview = 'readerPreview',
+ PostImpressions = 'postImpressions',
}
export type ModalTabItem = {
diff --git a/packages/shared/src/components/modals/post/PostImpressionsModal.tsx b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx
new file mode 100644
index 00000000000..2233fbc9f4e
--- /dev/null
+++ b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx
@@ -0,0 +1,69 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import { Modal } from '../common/Modal';
+import type { LazyModalCommonProps } from '../common/Modal';
+import { ModalClose } from '../common/ModalClose';
+import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
+import {
+ Typography,
+ TypographyTag,
+ TypographyType,
+ TypographyColor,
+} from '../../typography/Typography';
+import { AnalyticsIcon } from '../../icons';
+import { IconSize } from '../../Icon';
+import { docs } from '../../../lib/constants';
+import { anchorDefaultRel } from '../../../lib/strings';
+
+/**
+ * X/Twitter-style "Views" explainer shown to non-authors when they tap the
+ * impressions stat — daily.dev's own modal styling, X-like messaging.
+ */
+function PostImpressionsModal(props: LazyModalCommonProps): ReactElement {
+ const { onRequestClose } = props;
+
+ return (
+
+
+
+
+
+ Impressions
+
+
+ The number of times this post was seen across daily.dev. To learn
+ more, visit our{' '}
+
+ docs
+
+ .
+
+
+
+
+ );
+}
+
+export default PostImpressionsModal;
diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx
index 7e0ca893eb6..41f76a63506 100644
--- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx
+++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx
@@ -8,18 +8,20 @@ import { Image } from '../image/Image';
import { useLazyModal } from '../../hooks/useLazyModal';
import { LazyModal } from '../modals/common/types';
import { useHasAccessToCores } from '../../hooks/useCoresFeature';
-import { canViewPostAnalytics } from '../../lib/user';
-import { useAuthContext } from '../../contexts/AuthContext';
import Link from '../utilities/Link';
-import { Button, ButtonSize } from '../buttons/Button';
-import { AnalyticsIcon } from '../icons';
import { webappUrl } from '../../lib/constants';
+import { getPostImpressions } from '../../lib/impressions';
const DEFAULT_REPOSTS_PER_PAGE = 20;
type PostUpvotesCommentsCountPost = Pick<
Post,
- 'analytics' | 'numAwards' | 'numComments' | 'numReposts' | 'numUpvotes'
+ | 'analytics'
+ | 'numAwards'
+ | 'numComments'
+ | 'numReposts'
+ | 'numUpvotes'
+ | 'views'
> &
Partial> & {
author?: Pick, 'id'>;
@@ -43,7 +45,6 @@ interface PostUpvotesCommentsCountProps {
type PostUpvotesCommentsCountContentProps = PostUpvotesCommentsCountProps & {
onRepostsClick?: () => unknown;
onAwardsClick?: () => unknown;
- showPostAnalytics?: boolean;
};
const PostUpvotesCommentsCountContent = ({
@@ -52,7 +53,6 @@ const PostUpvotesCommentsCountContent = ({
onCommentsClick,
onRepostsClick,
onAwardsClick,
- showPostAnalytics = false,
className,
compact = false,
passive = false,
@@ -95,11 +95,6 @@ const PostUpvotesCommentsCountContent = ({
)}
data-testid="statsBar"
>
- {!!post.analytics?.impressions && (
-
- {getText({ count: post.analytics.impressions, label: 'Impression' })}
-
- )}
{upvotes > 0 &&
renderText({
key: 'upvotes',
@@ -112,6 +107,34 @@ const PostUpvotesCommentsCountContent = ({
onClick: onCommentsClick,
children: getText({ count: comments, label: 'Comment' }),
})}
+ {/* Impressions sit right after comments and look like the other stats;
+ tapping them opens the analytics page (replacing the old blue "Post
+ analytics" button). Shown on the post page/modal strip (not the
+ compact embed). Uses the shared impressions helper so it stays visible
+ via the mock fallback until the API populates real `views`. */}
+ {!compact &&
+ post.id &&
+ (() => {
+ const impressions = getPostImpressions({
+ id: post.id,
+ views: post.views,
+ analytics: post.analytics,
+ });
+ const label = getText({ count: impressions, label: 'Impression' });
+ return !passive ? (
+
+
+ {label}
+
+
+ ) : (
+ {label}
+ );
+ })()}
{reposts > 0 &&
renderText({
key: 'reposts',
@@ -136,18 +159,6 @@ const PostUpvotesCommentsCountContent = ({
),
})}
- {showPostAnalytics && (
-
- }
- >
- Post analytics
-
-
- )}
);
};
@@ -160,7 +171,6 @@ const InteractivePostUpvotesCommentsCount = ({
compact,
}: PostUpvotesCommentsCountProps): ReactElement => {
const { openModal } = useLazyModal();
- const { user } = useAuthContext();
const awards = post.numAwards || 0;
const hasAccessToCores = useHasAccessToCores();
if (!post.id) {
@@ -213,7 +223,6 @@ const InteractivePostUpvotesCommentsCount = ({
}
: undefined
}
- showPostAnalytics={canViewPostAnalytics({ user, post })}
className={className}
compact={compact}
/>
diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts
index 84082859142..881e8c92440 100644
--- a/packages/shared/src/graphql/fragments.ts
+++ b/packages/shared/src/graphql/fragments.ts
@@ -383,6 +383,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql`
analytics {
impressions
}
+ views
numUpvotes
numComments
numAwards
diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts
index c5ca42d45ee..1d37a10f537 100644
--- a/packages/shared/src/graphql/posts.ts
+++ b/packages/shared/src/graphql/posts.ts
@@ -261,6 +261,9 @@ export interface Post {
numComments?: number;
numAwards?: number;
numReposts?: number;
+ // Per-post impressions, selected by the feed (FeedPostInfo) and post
+ // (SharedPostInfo) fragments.
+ views?: number;
author?: Author;
scout?: Scout;
read?: boolean;
diff --git a/packages/shared/src/hooks/post/usePostImpressionsModal.ts b/packages/shared/src/hooks/post/usePostImpressionsModal.ts
new file mode 100644
index 00000000000..e6ff89499b8
--- /dev/null
+++ b/packages/shared/src/hooks/post/usePostImpressionsModal.ts
@@ -0,0 +1,38 @@
+import type { MouseEvent } from 'react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/router';
+import type { Post } from '../../graphql/posts';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useLazyModal } from '../useLazyModal';
+import { LazyModal } from '../../components/modals/common/types';
+import { canViewPostAnalytics } from '../../lib/user';
+
+/**
+ * Click handler for the impressions stat:
+ * - the post owner (or a team member) can see real analytics, so they go to the
+ * post analytics page;
+ * - everyone else gets the X/Twitter-style explainer popup.
+ */
+export const usePostImpressionsModal = (
+ post: Pick,
+): ((event?: MouseEvent) => void) => {
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const { openModal } = useLazyModal();
+ const canViewAnalytics = canViewPostAnalytics({ user, post });
+
+ return useCallback(
+ (event?: MouseEvent) => {
+ event?.stopPropagation();
+ event?.preventDefault();
+
+ if (canViewAnalytics) {
+ router.push(`/posts/${post.id}/analytics`);
+ return;
+ }
+
+ openModal({ type: LazyModal.PostImpressions });
+ },
+ [canViewAnalytics, openModal, router, post.id],
+ );
+};
diff --git a/packages/shared/src/hooks/useFitFontSize.ts b/packages/shared/src/hooks/useFitFontSize.ts
new file mode 100644
index 00000000000..3e191ba9eca
--- /dev/null
+++ b/packages/shared/src/hooks/useFitFontSize.ts
@@ -0,0 +1,90 @@
+import type { RefCallback } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
+
+// Layout effects must run before paint to avoid a flash of the wrong size, but
+// `useLayoutEffect` warns during SSR — fall back to `useEffect` on the server.
+const useIsomorphicLayoutEffect =
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
+interface UseFitFontSizeProps {
+ /** Re-measures whenever this changes (pass the rendered text). */
+ text: string;
+ /** Font-size utility classes, largest first (e.g. typo-title1 → title3). */
+ sizeClasses: string[];
+ /** Maximum number of lines the text may occupy at the chosen size. */
+ maxLines: number;
+}
+
+interface UseFitFontSizeResult {
+ ref: RefCallback;
+ /** The largest size class at which the text fits within `maxLines`. */
+ sizeClass: string;
+ /**
+ * True once the smallest size is reached and the text still overflows — the
+ * caller should clamp (ellipsis) as a last resort. While stepping down we
+ * leave the text unclamped so `scrollHeight` reflects its true height.
+ */
+ isClamped: boolean;
+}
+
+/**
+ * Shrinks text to the largest font size at which it still fits within
+ * `maxLines`, measuring the rendered element rather than guessing from
+ * character count (the container width varies, so a count heuristic can't be
+ * trusted). Steps down one size per layout pass until it fits or bottoms out.
+ *
+ * The element is tracked via a callback ref (state, not `useRef`) so the
+ * measure/observe effects re-run whenever the node actually attaches — e.g.
+ * when the title only mounts after a sibling panel (feedback card) is dismissed.
+ */
+export function useFitFontSize({
+ text,
+ sizeClasses,
+ maxLines,
+}: UseFitFontSizeProps): UseFitFontSizeResult {
+ const [node, setNode] = useState(null);
+ const [index, setIndex] = useState(0);
+ const lastIndex = sizeClasses.length - 1;
+ const ref = useCallback>((el) => setNode(el), []);
+
+ // Start fresh from the largest size whenever the text changes.
+ useIsomorphicLayoutEffect(() => {
+ setIndex(0);
+ }, [text]);
+
+ // After each applied size (and once the node attaches), measure and step
+ // down once if it still overflows.
+ useIsomorphicLayoutEffect(() => {
+ if (!node || index >= lastIndex) {
+ return;
+ }
+ const lineHeight = parseFloat(getComputedStyle(node).lineHeight);
+ if (!lineHeight) {
+ return;
+ }
+ if (Math.round(node.scrollHeight / lineHeight) > maxLines) {
+ setIndex((current) => Math.min(current + 1, lastIndex));
+ }
+ }, [node, text, index, maxLines, lastIndex]);
+
+ // Re-fit from scratch when the available width changes (height changes from
+ // our own shrinking are ignored so we don't loop).
+ useIsomorphicLayoutEffect(() => {
+ const parent = node?.parentElement;
+ if (!parent || typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+ let lastWidth = parent.clientWidth;
+ const observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.contentRect.width ?? lastWidth;
+ if (Math.abs(width - lastWidth) > 1) {
+ lastWidth = width;
+ setIndex(0);
+ }
+ });
+ observer.observe(parent);
+ return () => observer.disconnect();
+ }, [node]);
+
+ return { ref, sizeClass: sizeClasses[index], isClamped: index >= lastIndex };
+}
diff --git a/packages/shared/src/lib/impressions.ts b/packages/shared/src/lib/impressions.ts
new file mode 100644
index 00000000000..036dc8392e7
--- /dev/null
+++ b/packages/shared/src/lib/impressions.ts
@@ -0,0 +1,58 @@
+import type { Post } from '../graphql/posts';
+
+/**
+ * X/Twitter-style compact number: a decimal only shows while the abbreviated
+ * value is < 10 (1.2K, 9.9K, 1.8M) and is dropped once it reaches double digits
+ * (12K, 137K, 45M) — so the decimal point isn't displayed at every magnitude.
+ * Applied at all resolutions.
+ */
+export const formatImpressions = (value: number | null): string => {
+ if (value == null || !Number.isFinite(value)) {
+ return '0';
+ }
+ if (Math.abs(value) < 1000) {
+ return String(Math.round(value));
+ }
+ const units = ['K', 'M', 'B', 'T'];
+ let scaled = value;
+ let unit = -1;
+ while (Math.abs(scaled) >= 1000 && unit < units.length - 1) {
+ scaled /= 1000;
+ unit += 1;
+ }
+ const compact =
+ Math.abs(scaled) < 10
+ ? (Math.round(scaled * 10) / 10).toFixed(1).replace(/\.0$/, '')
+ : String(Math.round(scaled));
+ return `${compact}${units[unit]}`;
+};
+
+/**
+ * MOCK FALLBACK — per-post impressions are only populated in some payloads
+ * (`analytics.impressions` on the post detail for authors/team, `views` once
+ * the feed exposes it). When a real value is present we always use it; when it
+ * is missing we derive a stable, realistic-looking number from the post id
+ * purely so the impressions UI can be reviewed on a preview build.
+ *
+ * @engineer Replace this fallback with the real impressions field once the API
+ * exposes it everywhere — keep only the real-value branch.
+ */
+export const getPostImpressions = (
+ post: Pick,
+): number => {
+ const real = post.analytics?.impressions ?? post.views;
+ if (typeof real === 'number' && real > 0) {
+ return real;
+ }
+
+ // Deterministic hash of the id → a stable number in ~100K–3M so each card
+ // shows a different realistic value (and it never changes between renders).
+ let hash = 0;
+ for (let i = 0; i < post.id.length; i += 1) {
+ hash = (hash * 131 + post.id.charCodeAt(i)) % 2_900_000;
+ }
+ // Final multiplicative mix so ids sharing a prefix (e.g. feed slugs) spread
+ // out instead of producing near-identical values.
+ hash = (hash * 1_000_003) % 2_900_000;
+ return 100_000 + hash;
+};
diff --git a/packages/shared/src/lib/numberFormat.ts b/packages/shared/src/lib/numberFormat.ts
index 1ed5599d89d..ce34cbce085 100644
--- a/packages/shared/src/lib/numberFormat.ts
+++ b/packages/shared/src/lib/numberFormat.ts
@@ -1,6 +1,6 @@
import type { PaddleProductLineItem } from '../graphql/paddle';
-export function largeNumberFormat(value: number): string | null {
+export function largeNumberFormat(value: number | null): string | null {
if (typeof value !== 'number') {
return null;
}