From 32b8aee40a569b4274ce197c0a568adad6074c91 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 1 Jun 2026 00:08:26 +0300 Subject: [PATCH 1/4] feat: redesign tag landing page Co-authored-by: Cursor --- .../src/components/tags/TagBestOfShowcase.tsx | 58 +++ .../src/components/tags/TagJoinStrip.tsx | 62 +++ .../src/components/tags/TagPageHero.tsx | 175 +++++++ .../src/components/tags/TagPeopleSources.tsx | 184 ++++++++ .../src/components/tags/TagSectionNav.tsx | 48 ++ packages/shared/src/lib/featureManagement.ts | 2 + packages/webapp/__tests__/TagPage.tsx | 6 +- packages/webapp/pages/tags/[tag].tsx | 428 +++++++++++++++++- 8 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/components/tags/TagBestOfShowcase.tsx create mode 100644 packages/shared/src/components/tags/TagJoinStrip.tsx create mode 100644 packages/shared/src/components/tags/TagPageHero.tsx create mode 100644 packages/shared/src/components/tags/TagPeopleSources.tsx create mode 100644 packages/shared/src/components/tags/TagSectionNav.tsx diff --git a/packages/shared/src/components/tags/TagBestOfShowcase.tsx b/packages/shared/src/components/tags/TagBestOfShowcase.tsx new file mode 100644 index 0000000000..396beba07a --- /dev/null +++ b/packages/shared/src/components/tags/TagBestOfShowcase.tsx @@ -0,0 +1,58 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { SparkleIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +interface TagBestOfShowcaseProps { + tag: string; + topPosts: ReactNode; + mostUpvoted: ReactNode; + bestDiscussed: ReactNode; + className?: string; +} + +export const TagBestOfShowcase = ({ + tag, + topPosts, + mostUpvoted, + bestDiscussed, + className, +}: TagBestOfShowcaseProps): ReactElement => ( +
+
+ + + Editor scan + + + Best of #{tag} + + + Start with the strongest posts, then compare what the community upvoted + and debated. + +
+
+
{topPosts}
+
{mostUpvoted}
+
{bestDiscussed}
+
+
+); diff --git a/packages/shared/src/components/tags/TagJoinStrip.tsx b/packages/shared/src/components/tags/TagJoinStrip.tsx new file mode 100644 index 0000000000..0eaf1e4c95 --- /dev/null +++ b/packages/shared/src/components/tags/TagJoinStrip.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { IconSize } from '../Icon'; +import { DailyIcon, SparkleIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +interface TagJoinStripProps { + tag: string; + onJoin: () => void; + className?: string; +} + +export const TagJoinStrip = ({ + tag, + onJoin, + className, +}: TagJoinStripProps): ReactElement => ( +
+
+
+
+ + + +
+ + Make #{tag} your daily brief + + + Follow the topic, train your feed, and join the developer + conversation around it. + +
+
+ +
+
+); diff --git a/packages/shared/src/components/tags/TagPageHero.tsx b/packages/shared/src/components/tags/TagPageHero.tsx new file mode 100644 index 0000000000..4ef1bf9a3b --- /dev/null +++ b/packages/shared/src/components/tags/TagPageHero.tsx @@ -0,0 +1,175 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { IconSize } from '../Icon'; +import { BlockIcon, HashtagIcon, PlusIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +export type TagStatus = 'followed' | 'blocked' | 'unfollowed'; + +export interface TagPageStat { + label: string; + value: string; + caption: string; +} + +interface TagStatsBarProps { + stats: TagPageStat[]; +} + +const TagStatsBar = ({ stats }: TagStatsBarProps): ReactElement => ( +
+ {stats.map((stat) => ( +
+
{stat.label}
+
+ {stat.value} +
+

+ {stat.caption} +

+
+ ))} +
+); + +interface TagPageHeroProps { + tag: string; + title: string; + description?: string; + stats: TagPageStat[]; + tagStatus: TagStatus; + followButtonProps: ButtonProps<'button'>; + blockButtonProps: ButtonProps<'button'>; + optionsMenu: ReactNode; + sponsoredHero?: ReactNode; + recommendedTags?: ReactNode; + roadmap?: ReactNode; + crawlLinks?: ReactNode; + className?: string; +} + +export const TagPageHero = ({ + tag, + title, + description, + stats, + tagStatus, + followButtonProps, + blockButtonProps, + optionsMenu, + sponsoredHero, + recommendedTags, + roadmap, + crawlLinks, + className, +}: TagPageHeroProps): ReactElement => ( +
+
+
+
+ +
+ {sponsoredHero} +
+
+
+ + + Developer topic + + + Updated daily + +
+
+ + + {title} + +
+ + {description || + `Follow #${tag} to turn this topic into a personalized daily.dev feed.`} + +
+ +
+ {tagStatus !== 'blocked' && ( + + )} + + {tagStatus === 'blocked' + ? 'This topic is blocked from your feed.' + : 'Build a personal feed from this topic. Free, no card.'} + +
+ {tagStatus !== 'followed' && ( + + )} + {optionsMenu} +
+
+
+ + {stats.length > 0 && } + {recommendedTags} + {roadmap} + {crawlLinks} +
+
+); diff --git a/packages/shared/src/components/tags/TagPeopleSources.tsx b/packages/shared/src/components/tags/TagPeopleSources.tsx new file mode 100644 index 0000000000..c474575277 --- /dev/null +++ b/packages/shared/src/components/tags/TagPeopleSources.tsx @@ -0,0 +1,184 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ElementPlaceholder } from '../ElementPlaceholder'; +import { SourceIcon, UserIcon } from '../icons'; +import { IconSize } from '../Icon'; +import Link from '../utilities/Link'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +export interface TagAuthorityEntity { + id?: string; + image: string; + imageAlt: string; + name: string; + permalink: string; + label: string; +} + +interface EntityRailProps { + id: string; + title: string; + description: string; + items?: TagAuthorityEntity[]; + isLoading: boolean; + type: 'sources' | 'contributors'; +} + +const EntityRail = ({ + id, + title, + description, + items, + isLoading, + type, +}: EntityRailProps): ReactElement | null => { + if (isLoading && (!items || items.length === 0)) { + return ( +
+ +
+ + +
+
+ ); + } + + if (!items || items.length === 0) { + return null; + } + + const Icon = type === 'sources' ? SourceIcon : UserIcon; + + return ( +
+
+ + + {title} + + + {description} + +
+
+ {items.map((item) => ( + + +
+
+ {item.imageAlt} +
+ + {item.name} + + + {item.label} + +
+
+
+ + ))} +
+
+ ); +}; + +interface TagPeopleSourcesProps { + tag: string; + sources?: TagAuthorityEntity[]; + contributors?: TagAuthorityEntity[]; + isSourcesLoading: boolean; + isContributorsLoading: boolean; + className?: string; +} + +export const TagPeopleSources = ({ + tag, + sources, + contributors, + isSourcesLoading, + isContributorsLoading, + className, +}: TagPeopleSourcesProps): ReactElement | null => { + const hasSources = !!sources?.length || isSourcesLoading; + const hasContributors = !!contributors?.length || isContributorsLoading; + + if (!hasSources && !hasContributors) { + return null; + } + + return ( +
+
+ + Community signal + + + Who shapes #{tag} + + + Follow the people and publications that keep this topic useful. + +
+ + +
+ ); +}; diff --git a/packages/shared/src/components/tags/TagSectionNav.tsx b/packages/shared/src/components/tags/TagSectionNav.tsx new file mode 100644 index 0000000000..6aa87c05d8 --- /dev/null +++ b/packages/shared/src/components/tags/TagSectionNav.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +export interface TagSectionNavItem { + href: string; + label: string; + isVisible?: boolean; +} + +interface TagSectionNavProps { + items: TagSectionNavItem[]; + className?: string; +} + +export const TagSectionNav = ({ + items, + className, +}: TagSectionNavProps): ReactElement | null => { + const visibleItems = items.filter((item) => item.isVisible !== false); + + if (visibleItems.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 132e1e7173..8b7b460810 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -37,6 +37,8 @@ export const featurePostPageHighlights = new Feature( false, ); +export const featureTagPageRedesign = new Feature('tag_page_redesign', true); + // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ title: string; diff --git a/packages/webapp/__tests__/TagPage.tsx b/packages/webapp/__tests__/TagPage.tsx index 1fc2989922..ee6e02e113 100644 --- a/packages/webapp/__tests__/TagPage.tsx +++ b/packages/webapp/__tests__/TagPage.tsx @@ -173,7 +173,9 @@ const renderComponent = ( optOutReadingStreak: false, optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, optOutCompanion: false, + isGamificationEnabled: true, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.OldestFirst, showFeedbackButton: true, @@ -191,7 +193,9 @@ const renderComponent = ( toggleOptOutReadingStreak: jest.fn().mockResolvedValue(undefined), toggleOptOutLevelSystem: jest.fn().mockResolvedValue(undefined), toggleOptOutQuestSystem: jest.fn().mockResolvedValue(undefined), + toggleOptOutAchievements: jest.fn().mockResolvedValue(undefined), toggleOptOutCompanion: jest.fn().mockResolvedValue(undefined), + toggleAllGamification: jest.fn().mockResolvedValue(undefined), toggleAutoDismissNotifications: jest.fn().mockResolvedValue(undefined), toggleShowFeedbackButton: jest.fn().mockResolvedValue(undefined), updateCustomLinks: jest.fn().mockResolvedValue(undefined), @@ -310,7 +314,7 @@ 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(await screen.findByText('Top contributors')).toBeInTheDocument(); expect(screen.getByText('Ido').closest('a')).toHaveAttribute( 'href', '/idoshamun', diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 7d9d38504a..454c34709d 100644 --- a/packages/webapp/pages/tags/[tag].tsx +++ b/packages/webapp/pages/tags/[tag].tsx @@ -67,7 +67,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'; @@ -80,6 +83,18 @@ import { ContentPreferenceType } from '@dailydotdev/shared/src/graphql/contentPr 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 { TagBestOfShowcase } from '@dailydotdev/shared/src/components/tags/TagBestOfShowcase'; +import { TagJoinStrip } from '@dailydotdev/shared/src/components/tags/TagJoinStrip'; +import { + TagPageHero, + type TagPageStat, +} from '@dailydotdev/shared/src/components/tags/TagPageHero'; +import { + TagPeopleSources, + type TagAuthorityEntity, +} from '@dailydotdev/shared/src/components/tags/TagPeopleSources'; +import { TagSectionNav } from '@dailydotdev/shared/src/components/tags/TagSectionNav'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { getLayout } from '../../components/layouts/FeedLayout'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -211,6 +226,123 @@ const TagTopContributors = ({ ); }; +const getTagPageStats = ({ + initialData, + topPostsCount, + recommendedTagsCount, + topContributorsCount, +}: { + initialData: Keyword | null; + topPostsCount: number; + recommendedTagsCount: number; + topContributorsCount: number; +}): TagPageStat[] => { + const stats: TagPageStat[] = []; + + if (initialData?.occurrences) { + stats.push({ + label: 'Indexed posts', + value: largeNumberFormat(initialData.occurrences) ?? '0', + caption: 'articles and discussions', + }); + } + + if (topPostsCount > 0) { + stats.push({ + label: 'Top picks', + value: topPostsCount.toString(), + caption: 'ready to scan', + }); + } + + if (recommendedTagsCount > 0) { + stats.push({ + label: 'Related tags', + value: recommendedTagsCount.toString(), + caption: 'nearby topics', + }); + } + + if (topContributorsCount > 0) { + stats.push({ + label: 'Contributors', + value: topContributorsCount.toString(), + caption: 'developers to follow', + }); + } + + return stats; +}; + +const TagPeopleSourcesSection = ({ + tag, + initialUsers = [], +}: { + tag: string; + initialUsers?: UserShortProfile[]; +}): ReactElement | null => { + const { data: topSources, isPending: isSourcesPending } = useQuery({ + queryKey: [RequestKey.SourceByTag, 'redesign', tag], + + queryFn: async () => + await gqlClient.request<{ sourcesByTag: Connection }>( + SOURCES_BY_TAG_QUERY, + { + tag, + first: 6, + }, + ), + + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + const { data: topContributors, isPending: isContributorsPending } = useQuery({ + queryKey: [RequestKey.TopCreatorsByTag, 'redesign', tag], + + queryFn: async () => + await gqlClient.request<{ topCreatorsByTag: UserShortProfile[] }>( + TOP_CREATORS_BY_TAG_QUERY, + { + tag, + limit: 6, + }, + ), + + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const sources: TagAuthorityEntity[] | undefined = + topSources?.sourcesByTag?.edges?.map((edge) => ({ + id: edge.node.id, + image: edge.node.image, + imageAlt: `${edge.node.name} logo`, + name: edge.node.name, + permalink: edge.node.permalink, + label: 'Source', + })); + const users = topContributors?.topCreatorsByTag ?? initialUsers; + const contributors: TagAuthorityEntity[] = users.map((user) => ({ + id: user.id, + image: user.image, + imageAlt: `${user.name} avatar`, + name: user.name, + permalink: user.permalink, + label: 'Contributor', + })); + + return ( + + ); +}; + const getTagPageJsonLd = ({ tag, initialData, @@ -288,6 +420,7 @@ const TagPage = ({ }: TagPageProps): ReactElement => { const { push } = useRouter(); const showRoadmap = useFeature(feature.showRoadmap); + const isTagPageRedesign = useFeature(featureTagPageRedesign); const { user, showLogin } = useContext(AuthContext); const mostUpvotedQueryVariables = useMemo( () => ({ @@ -377,6 +510,22 @@ const TagPage = ({ return 'unfollowed'; }, [feedSettings, tag]); + const tagPageStats = useMemo( + () => + getTagPageStats({ + initialData, + topPostsCount: topPosts.length, + recommendedTagsCount: recommendedTags.length, + topContributorsCount: topContributors.length, + }), + [ + initialData, + topContributors.length, + topPosts.length, + recommendedTags.length, + ], + ); + const followButtonProps: ButtonProps<'button'> = { size: ButtonSize.Small, icon: tagStatus === 'followed' ? : , @@ -409,6 +558,283 @@ const TagPage = ({ }, }; + if (isTagPageRedesign) { + return ( + + {jsonLd && ( + +