diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts index b48fe86869..0ede3e35c0 100644 --- a/packages/shared/src/graphql/feed.ts +++ b/packages/shared/src/graphql/feed.ts @@ -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 + } } } } @@ -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 = { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 132e1e7173..40042883a3 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -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); diff --git a/packages/webapp/__tests__/TagPage.tsx b/packages/webapp/__tests__/TagPage.tsx index 1fc2989922..26d00689db 100644 --- a/packages/webapp/__tests__/TagPage.tsx +++ b/packages/webapp/__tests__/TagPage.tsx @@ -173,6 +173,8 @@ const renderComponent = ( optOutReadingStreak: false, optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, + isGamificationEnabled: true, optOutCompanion: false, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.OldestFirst, @@ -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), @@ -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 () => { diff --git a/packages/webapp/components/tags/TagHubHeader.tsx b/packages/webapp/components/tags/TagHubHeader.tsx new file mode 100644 index 0000000000..0aba866ab1 --- /dev/null +++ b/packages/webapp/components/tags/TagHubHeader.tsx @@ -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 ( +
+ {sponsoredHero} +
+
+ + + # + + {title} + + + {dek.join(' ยท ')} + +
+
+ {!isLoggedIn && ( + + )} + {actions} +
+
+ {children} +
+ ); +} diff --git a/packages/webapp/components/tags/TagPostList.tsx b/packages/webapp/components/tags/TagPostList.tsx new file mode 100644 index 0000000000..0d5b0712a5 --- /dev/null +++ b/packages/webapp/components/tags/TagPostList.tsx @@ -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 ( +
+
+
+ {live && ( + + )} + + {title} + +
+ {seeAllHref && ( + + + See all + + + )} +
+
+ {usable.map((post, index) => ( + + ))} +
+
+ ); +} diff --git a/packages/webapp/components/tags/TagPostRow.tsx b/packages/webapp/components/tags/TagPostRow.tsx new file mode 100644 index 0000000000..f4326f478a --- /dev/null +++ b/packages/webapp/components/tags/TagPostRow.tsx @@ -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 ( +
+ {!!rank && ( + + {rank} + + )} +
+ + {post.title} + + {meta.length > 0 && ( + + {meta.join(' โ€ข ')} + + )} +
+ {post.image && ( + + )} + + + +
+ ); +} diff --git a/packages/webapp/components/tags/TagWidgets.tsx b/packages/webapp/components/tags/TagWidgets.tsx new file mode 100644 index 0000000000..0fc15cab12 --- /dev/null +++ b/packages/webapp/components/tags/TagWidgets.tsx @@ -0,0 +1,214 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { WidgetCard } from '@dailydotdev/shared/src/components/widgets/WidgetCard'; +import { RequestKey, StaleTime } from '@dailydotdev/shared/src/lib/query'; +import type { Connection } from '@dailydotdev/shared/src/graphql/common'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SOURCES_BY_TAG_QUERY } from '@dailydotdev/shared/src/graphql/sources'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { getTagPageLink } from '@dailydotdev/shared/src/lib/links'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { TagFaqItem } from './tagContent'; + +interface TagWidgetsProps { + title: string; + tag: string; + description?: string; + occurrences?: number; + contributorsCount?: number; + createdAt?: Date | string; + contributors: UserShortProfile[]; + relatedTags: { name?: string }[]; + roadmap?: ReactNode; + faqItems: TagFaqItem[]; +} + +const Stat = ({ value, label }: { value: string; label: string }) => ( + + {value} {label} + +); + +const PersonRow = ({ + image, + name, + permalink, + subtitle, +}: { + image: string; + name: string; + permalink: string; + subtitle?: string; +}): ReactElement => ( + + + {name} + + {name} + {subtitle && ( + + {subtitle} + + )} + + + +); + +const MiniLabel = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +/** + * "Everything about the topic" โ€” composed from native daily.dev widget cards: + * an About card (definition, key stats, related topics, learning path), a + * People & sources card, and an FAQ card. The supporting hub around the feed. + */ +export function TagWidgets({ + title, + tag, + description, + occurrences, + contributorsCount, + createdAt, + contributors, + relatedTags, + roadmap, + faqItems, +}: TagWidgetsProps): ReactElement { + const { data: sourcesData, isPending: sourcesPending } = useQuery({ + queryKey: [RequestKey.SourceByTag, null, tag], + queryFn: async () => + gqlClient.request<{ sourcesByTag: Connection }>( + SOURCES_BY_TAG_QUERY, + { tag, first: 5 }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const sources = + sourcesData?.sourcesByTag?.edges?.map((edge) => edge.node) ?? []; + const people = contributors.slice(0, 5); + const related = relatedTags + .map((item) => item.name) + .filter((name): name is string => !!name) + .slice(0, 12); + + const year = createdAt ? new Date(createdAt).getFullYear() : undefined; + const hasPeople = people.length > 0 || sources.length > 0 || sourcesPending; + + return ( +
+ +
+ {description && ( +

{description}

+ )} +
+ {!!occurrences && occurrences > 0 && ( + + )} + {!!contributorsCount && contributorsCount > 0 && ( + + )} + {related.length > 0 && ( + + )} + {!!year && !Number.isNaN(year) && ( + + )} +
+ {related.length > 0 && ( + + )} + {roadmap} +
+
+ + {hasPeople && ( + +
+ {people.length > 0 && ( +
+ Top contributors + {people.map((user) => ( + + ))} +
+ )} + {sources.length > 0 && ( +
+ Top sources + {sources.map((source) => ( + + ))} +
+ )} +
+
+ )} + + {faqItems.length > 0 && ( + +
+ {faqItems.map((item, index) => ( +
+ + {item.question} + + +

+ {item.answer} +

+
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/webapp/components/tags/tagContent.ts b/packages/webapp/components/tags/tagContent.ts new file mode 100644 index 0000000000..c8e8654820 --- /dev/null +++ b/packages/webapp/components/tags/tagContent.ts @@ -0,0 +1,79 @@ +import type { TagsData } from '@dailydotdev/shared/src/graphql/feedSettings'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; + +export interface TagFaqItem { + question: string; + answer: string; +} + +interface TagFaqInput { + title: string; + topContributors?: UserShortProfile[]; + recommendedTags?: TagsData['tags']; + roadmap?: string; +} + +const listToSentence = (items: string[]): string => { + if (items.length === 1) { + return items[0]; + } + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; +}; + +/** + * Builds a data-backed FAQ for a tag. Every answer is grounded in real data we + * already have (description, contributors, related tags, roadmap) โ€” nothing is + * fabricated, and entries are only included when there's a genuine answer. + * + * Used both for the visible FAQ section and the FAQPage JSON-LD, so the two + * never drift apart. This is the page's main Answer-Engine (AEO) surface: + * concise, quotable Q&A that LLMs and AI Overviews can lift directly. + */ +export function getTagFaqItems({ + title, + topContributors = [], + recommendedTags = [], + roadmap, +}: TagFaqInput): TagFaqItem[] { + const items: TagFaqItem[] = []; + + items.push({ + question: `How do I keep up with ${title}?`, + answer: `Follow ${title} on daily.dev to get the most upvoted posts, videos, and discussions about it in a single feed โ€” curated by millions of developers and updated every day.`, + }); + + const contributorNames = topContributors + .map((user) => user.name) + .filter((name): name is string => !!name) + .slice(0, 3); + if (contributorNames.length > 0) { + items.push({ + question: `Who are the top ${title} contributors?`, + answer: `${listToSentence(contributorNames)} ${ + contributorNames.length > 1 ? 'are' : 'is' + } among the most active developers writing about ${title} on daily.dev.`, + }); + } + + const relatedNames = recommendedTags + .map((tag) => tag.name) + .filter((name): name is string => !!name) + .slice(0, 4); + if (relatedNames.length >= 2) { + items.push({ + question: `What topics are related to ${title}?`, + answer: `Popular topics developers follow alongside ${title} include ${listToSentence( + relatedNames, + )}.`, + }); + } + + if (roadmap) { + items.push({ + question: `How can I learn ${title}?`, + answer: `Explore a structured ${title} learning roadmap to go from the fundamentals to advanced topics, then follow ${title} on daily.dev to keep building with fresh, community-curated content.`, + }); + } + + return items; +} diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 7d9d38504a..1d33fb07a0 100644 --- a/packages/webapp/pages/tags/[tag].tsx +++ b/packages/webapp/pages/tags/[tag].tsx @@ -56,7 +56,11 @@ import { GET_RECOMMENDED_TAGS_QUERY } from '@dailydotdev/shared/src/graphql/feed import { ReferralCampaignKey, useFeedLayout, + useViewSize, + ViewSize, } from '@dailydotdev/shared/src/hooks'; +import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; +import { AuthenticationBanner } from '@dailydotdev/shared/src/components/auth'; import { RecommendedTags } from '@dailydotdev/shared/src/components/RecommendedTags'; import { RelatedEntities } from '@dailydotdev/shared/src/components/RelatedEntities'; import type { Source } from '@dailydotdev/shared/src/graphql/sources'; @@ -67,7 +71,10 @@ import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts'; import HorizontalFeed from '@dailydotdev/shared/src/components/feeds/HorizontalFeed'; import { PostType } from '@dailydotdev/shared/src/graphql/posts'; import { useFeature } from '@dailydotdev/shared/src/components/GrowthBookProvider'; -import { feature } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + feature, + featureTagPageRedesign, +} from '@dailydotdev/shared/src/lib/featureManagement'; import { cloudinarySourceRoadmap } from '@dailydotdev/shared/src/lib/image'; import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; @@ -81,6 +88,11 @@ import { TOP_CREATORS_BY_TAG_QUERY } from '@dailydotdev/shared/src/graphql/users import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; import { SponsoredTagHero } from '@dailydotdev/shared/src/components/brand/SponsoredTagHero'; import { getPageSeoTitles } from '../../components/layouts/utils'; +import { TagHubHeader } from '../../components/tags/TagHubHeader'; +import { TagPostList } from '../../components/tags/TagPostList'; +import { TagWidgets } from '../../components/tags/TagWidgets'; +import type { TagFaqItem } from '../../components/tags/tagContent'; +import { getTagFaqItems } from '../../components/tags/tagContent'; import { getLayout } from '../../components/layouts/FeedLayout'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; import type { DynamicSeoProps } from '../../components/common'; @@ -215,10 +227,12 @@ const getTagPageJsonLd = ({ tag, initialData, topPosts, + faqItems, }: { tag: string; initialData: Keyword; topPosts: TopPost[]; + faqItems: TagFaqItem[]; }): string => { const encodedTag = encodeURIComponent(tag); const tagTitle = initialData.flags?.title || tag; @@ -237,7 +251,36 @@ const getTagPageJsonLd = ({ name: `${tagTitle} posts on daily.dev`, description: tagDescription, isPartOf: { '@type': 'WebSite', url: appOrigin }, + about: { '@id': `${tagUrl}#term` }, }, + { + '@type': 'DefinedTerm', + '@id': `${tagUrl}#term`, + name: tagTitle, + description: tagDescription, + url: tagUrl, + inDefinedTermSet: { + '@type': 'DefinedTermSet', + name: 'daily.dev tags', + url: `${appOrigin}/tags`, + }, + }, + ...(faqItems.length + ? [ + { + '@type': 'FAQPage', + '@id': `${tagUrl}#faq`, + mainEntity: faqItems.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + }, + ] + : []), ...(topPosts.length ? [ { @@ -355,8 +398,18 @@ const TagPage = ({ const { onFollowTags, onUnfollowTags, onBlockTags, onUnblockTags } = useTagAndSource({ origin: Origin.TagPage }); const title = initialData?.flags?.title || tag; + const faqItems: TagFaqItem[] = useMemo( + () => + getTagFaqItems({ + title, + topContributors, + recommendedTags, + roadmap: initialData?.flags?.roadmap, + }), + [title, initialData, topContributors, recommendedTags], + ); const jsonLd = initialData - ? getTagPageJsonLd({ tag, initialData, topPosts }) + ? getTagPageJsonLd({ tag, initialData, topPosts, faqItems }) : null; const { follow, unfollow } = useContentPreference({ showToastOnSuccess: false, @@ -409,123 +462,267 @@ const TagPage = ({ }, }; - return ( - - {jsonLd && ( - -