Skip to content
Open
28 changes: 28 additions & 0 deletions packages/shared/src/graphql/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,20 @@ export const TAG_TOP_POSTS_QUERY = gql`
id
title
slug
image
permalink
commentsPermalink
readTime
numUpvotes
numComments
createdAt
source {
id
name
image
permalink
handle
}
}
}
}
Expand All @@ -493,6 +507,20 @@ export type TopPost = {
id: string;
title?: string;
slug?: string;
image?: string;
permalink?: string;
commentsPermalink?: string;
readTime?: number;
numUpvotes?: number;
numComments?: number;
createdAt?: string;
source?: {
id: string;
name: string;
image: string;
permalink: string;
handle?: string;
};
};

export type TopPostsData = {
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,7 @@ export const featurePostHighlightCards = new Feature(
'post_highlight_cards',
false,
);

// Tag page + tags directory redesign. Default ON for review/testing; the
// new-user (anonymous) conversion layer renders on top of the existing layout.
export const featureTagPageRedesign = new Feature('tag_page_redesign', true);
18 changes: 13 additions & 5 deletions packages/webapp/__tests__/TagPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ const renderComponent = (
optOutReadingStreak: false,
optOutLevelSystem: false,
optOutQuestSystem: false,
optOutAchievements: false,
isGamificationEnabled: true,
optOutCompanion: false,
autoDismissNotifications: true,
sortCommentsBy: SortCommentsBy.OldestFirst,
Expand All @@ -191,6 +193,8 @@ const renderComponent = (
toggleOptOutReadingStreak: jest.fn().mockResolvedValue(undefined),
toggleOptOutLevelSystem: jest.fn().mockResolvedValue(undefined),
toggleOptOutQuestSystem: jest.fn().mockResolvedValue(undefined),
toggleOptOutAchievements: jest.fn().mockResolvedValue(undefined),
toggleAllGamification: jest.fn().mockResolvedValue(undefined),
toggleOptOutCompanion: jest.fn().mockResolvedValue(undefined),
toggleAutoDismissNotifications: jest.fn().mockResolvedValue(undefined),
toggleShowFeedbackButton: jest.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -310,11 +314,15 @@ it('should show login popup when logged-out on follow click', async () => {
it('should render top contributors section from static props', async () => {
renderComponent(undefined, defaultUser, initialDataObj, [topContributor]);

expect(await screen.findByText('👥 Top contributors')).toBeInTheDocument();
expect(screen.getByText('Ido').closest('a')).toHaveAttribute(
'href',
'/idoshamun',
);
// Contributors render in the "People & sources" widget.
expect(await screen.findByText('Top contributors')).toBeInTheDocument();
// The contributor name appears both as a widget link and in the FAQ prose;
// assert specifically against the linked occurrence.
const idoLink = screen
.getAllByText('Ido')
.map((el) => el.closest('a'))
.find((el): el is HTMLAnchorElement => !!el);
expect(idoLink).toHaveAttribute('href', '/idoshamun');
});

it('should show login popup when logged-out on block click', async () => {
Expand Down
86 changes: 86 additions & 0 deletions packages/webapp/components/tags/TagHubHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '@dailydotdev/shared/src/components/typography/Typography';
import {
Button,
ButtonSize,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/Button';
import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat';

interface TagHubHeaderProps {
title: string;
isLoggedIn: boolean;
actions: ReactNode;
sponsoredHero?: ReactNode;
onGetFeed: () => void;
occurrences?: number;
contributorsCount?: number;
/** sr-only SEO links, kept in the DOM for crawlers. */
children?: ReactNode;
}

/**
* Tag hub header in the native briefing-home style: a bold title, a one-line
* stat "dek", the primary action, and a short standfirst — closed by a rule.
*/
export function TagHubHeader({
title,
isLoggedIn,
actions,
sponsoredHero,
onGetFeed,
occurrences,
contributorsCount,
children,
}: TagHubHeaderProps): ReactElement {
const dek: string[] = [];
if (occurrences && occurrences > 0) {
dek.push(`${largeNumberFormat(occurrences) ?? occurrences} posts`);
}
dek.push('Updated daily');
if (contributorsCount && contributorsCount > 0) {
dek.push(`${contributorsCount} contributors`);
}

return (
<header className="mx-4 flex flex-col gap-3 border-b border-border-subtlest-tertiary pb-4">
{sponsoredHero}
<div className="flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<Typography tag={TypographyTag.H1} type={TypographyType.Title2} bold>
<span aria-hidden className="text-text-quaternary">
#
</span>
{title}
</Typography>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
{dek.join(' · ')}
</Typography>
</div>
<div className="flex shrink-0 items-center gap-3">
{!isLoggedIn && (
<Button
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
onClick={onGetFeed}
aria-label={`Get my ${title} feed`}
>
Get my {title} feed
</Button>
)}
{actions}
</div>
</div>
{children}
</header>
);
}
68 changes: 68 additions & 0 deletions packages/webapp/components/tags/TagPostList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { ReactElement } from 'react';
import React from 'react';
import {
Typography,
TypographyTag,
TypographyType,
} from '@dailydotdev/shared/src/components/typography/Typography';
import Link from '@dailydotdev/shared/src/components/utilities/Link';
import type { TopPost } from '@dailydotdev/shared/src/graphql/feed';
import { TagPostRow } from './TagPostRow';

interface TagPostListProps {
title: string;
posts: TopPost[];
ranked?: boolean;
live?: boolean;
seeAllHref?: string;
limit?: number;
}

/**
* A titled section of native post rows — the scannable, discussion-style core
* of the tag hub. Optionally numbered (a ranked board) and/or marked "live".
*/
export function TagPostList({
title,
posts,
ranked = false,
live = false,
seeAllHref,
limit = 6,
}: TagPostListProps): ReactElement | null {
const usable = posts.filter((post) => !!post.title).slice(0, limit);
if (usable.length < 3) {
return null;
}

return (
<section className="mx-4 flex scroll-mt-16 flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{live && (
<span className="size-2 animate-scale-down-pulse rounded-full bg-accent-ketchup-default" />
)}
<Typography tag={TypographyTag.H2} type={TypographyType.Title3} bold>
{title}
</Typography>
</div>
{seeAllHref && (
<Link href={seeAllHref} passHref prefetch={false}>
<a className="text-text-tertiary typo-footnote hover:text-text-primary">
See all
</a>
</Link>
)}
</div>
<div className="flex flex-col gap-2">
{usable.map((post, index) => (
<TagPostRow
key={post.id}
post={post}
rank={ranked ? index + 1 : undefined}
/>
))}
</div>
</section>
);
}
76 changes: 76 additions & 0 deletions packages/webapp/components/tags/TagPostRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ReactElement } from 'react';
import React from 'react';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '@dailydotdev/shared/src/components/typography/Typography';
import { CardLink } from '@dailydotdev/shared/src/components/cards/common/Card';
import Link from '@dailydotdev/shared/src/components/utilities/Link';
import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings';
import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat';
import type { TopPost } from '@dailydotdev/shared/src/graphql/feed';

interface TagPostRowProps {
post: TopPost;
rank?: number;
}

const fmt = (value?: number): string | undefined =>
value && value > 0 ? `${largeNumberFormat(value) ?? value}` : undefined;

/**
* Native daily.dev list row for a post — mirrors the briefing list item: a
* bordered rounded-16 row with an optional rank, a clamped title, a single
* metadata line, a thumbnail, and a full-row CardLink. This is the building
* block that turns the tag page into a scannable, discussion-style hub.
*/
export function TagPostRow({ post, rank }: TagPostRowProps): ReactElement {
const meta = [
post.source?.name,
fmt(post.numUpvotes) && `${fmt(post.numUpvotes)} upvotes`,
fmt(post.numComments) && `${fmt(post.numComments)} comments`,
post.readTime ? `${post.readTime}m read` : undefined,
].filter(Boolean);

return (
<article className="relative flex w-full items-center gap-3 rounded-16 border border-border-subtlest-tertiary p-3 transition-colors hover:border-border-subtlest-secondary tablet:gap-4">
{!!rank && (
<span className="w-5 shrink-0 text-center font-bold tabular-nums text-text-quaternary typo-title3">
{rank}
</span>
)}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Typography
tag={TypographyTag.H3}
type={TypographyType.Callout}
bold
className="line-clamp-2"
>
{post.title}
</Typography>
{meta.length > 0 && (
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
truncate
>
{meta.join(' • ')}
</Typography>
)}
</div>
{post.image && (
<img
src={post.image}
alt=""
aria-hidden
className="hidden h-14 w-20 shrink-0 rounded-12 object-cover mobileXL:block"
/>
)}
<Link href={`/posts/${post.slug || post.id}`} passHref prefetch={false}>
<CardLink title={post.title} rel={anchorDefaultRel} />
</Link>
</article>
);
}
Loading
Loading