diff --git a/packages/shared/src/components/tags/TagBestOfShowcase.tsx b/packages/shared/src/components/tags/TagBestOfShowcase.tsx new file mode 100644 index 0000000000..1967920e98 --- /dev/null +++ b/packages/shared/src/components/tags/TagBestOfShowcase.tsx @@ -0,0 +1,126 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { DiscussIcon, TrendingIcon, UpvoteIcon } 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; +} + +type LensId = 'top' | 'upvoted' | 'discussed'; + +interface Lens { + id: LensId; + label: string; + description: string; + icon: ReactNode; +} + +const lenses: Lens[] = [ + { + id: 'top', + label: 'Top posts', + description: 'The broadest signal โ€” start here to get oriented fast.', + icon: , + }, + { + id: 'upvoted', + label: 'Most upvoted', + description: 'What the community decided was worth saving.', + icon: , + }, + { + id: 'discussed', + label: 'Best discussed', + description: 'The threads developers had the most to say about.', + icon: , + }, +]; + +export const TagBestOfShowcase = ({ + tag, + topPosts, + mostUpvoted, + bestDiscussed, + className, +}: TagBestOfShowcaseProps): ReactElement => { + const [active, setActive] = useState('top'); + const activeLens = lenses.find((lens) => lens.id === active) ?? lenses[0]; + const railById: Record = { + top: topPosts, + upvoted: mostUpvoted, + discussed: bestDiscussed, + }; + + return ( +
+
+
+ + The best of #{tag} + + + {activeLens.description} + +
+
+ {lenses.map((lens) => { + const isActive = lens.id === active; + return ( + + ); + })} +
+
+
+ {railById[active]} +
+
+ ); +}; diff --git a/packages/shared/src/components/tags/TagJoinStrip.tsx b/packages/shared/src/components/tags/TagJoinStrip.tsx new file mode 100644 index 0000000000..a64f2d1d19 --- /dev/null +++ b/packages/shared/src/components/tags/TagJoinStrip.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { 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 => ( +
+
+ + Stop browsing #{tag}. Make it yours. + + + This page is the map. A free daily.dev account turns it into a living + feed tuned by what you read, save, upvote, and discuss. + +
+ +
+); diff --git a/packages/shared/src/components/tags/TagPageHero.tsx b/packages/shared/src/components/tags/TagPageHero.tsx new file mode 100644 index 0000000000..4f03181753 --- /dev/null +++ b/packages/shared/src/components/tags/TagPageHero.tsx @@ -0,0 +1,169 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { Fragment } from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { BlockIcon, 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; +} + +const TagStatTicker = ({ stats }: { stats: TagPageStat[] }): ReactElement => ( +
+ {stats.map((stat, index) => ( + + {index > 0 && ( + + )} +
+
+ {stat.value} +
+
{stat.label}
+
+
+ ))} +
+); + +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 => { + const microcopy = { + blocked: 'This topic is blocked from your feed.', + followed: `#${tag} is shaping your feed.`, + unfollowed: + 'Free account, no card. Your feed starts learning the moment you follow.', + }[tagStatus]; + + return ( +
+ {sponsoredHero} + +
+
+
+ + # + {title} + + + {description || + `Everything developers are reading, saving, upvoting, and debating around #${tag} โ€” curated first, then the full live stream.`} + +
+ +
+ {tagStatus !== 'blocked' && ( + + )} + {tagStatus !== 'followed' && ( + + )} + {optionsMenu} +
+
+ + + {microcopy} + +
+ + {stats.length > 0 && ( +
+ +
+ )} + + {(recommendedTags || roadmap) && ( +
+ {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..d715a56931 --- /dev/null +++ b/packages/shared/src/components/tags/TagPeopleSources.tsx @@ -0,0 +1,175 @@ +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 ( +
+
+ + People & sources shaping #{tag} + + + The developers and publications most consistently behind this topic. + +
+ + +
+ ); +}; diff --git a/packages/shared/src/components/tags/TagSectionNav.tsx b/packages/shared/src/components/tags/TagSectionNav.tsx new file mode 100644 index 0000000000..521a92b4ab --- /dev/null +++ b/packages/shared/src/components/tags/TagSectionNav.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { PlusIcon } from '../icons'; +import type { TagStatus } from './TagPageHero'; + +export interface TagSectionNavItem { + href: string; + label: string; + isVisible?: boolean; +} + +interface TagSectionNavProps { + items: TagSectionNavItem[]; + tag: string; + tagStatus: TagStatus; + followButtonProps: ButtonProps<'button'>; + className?: string; +} + +export const TagSectionNav = ({ + items, + tag, + tagStatus, + followButtonProps, + 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..97875829e0 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', @@ -467,7 +471,7 @@ it('should load title and description for tag', async () => { await waitFor(() => { expect( - screen.getByRole('heading', { name: 'React custom title' }), + screen.getByRole('heading', { name: /React custom title/ }), ).toBeInTheDocument(); expect( screen.getByText('React is an amazing framework'), diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 7d9d38504a..59b8f62a5e 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: 'Adjacent topics', + value: recommendedTagsCount.toString(), + caption: 'ways to branch out', + }); + } + + 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,293 @@ const TagPage = ({ }, }; + if (isTagPageRedesign) { + return ( + + {jsonLd && ( + +