From 5d8890a89a43fdef8f407240fcb1f1d32d6e5347 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 23:38:13 +0300 Subject: [PATCH 1/3] feat: redesign tag topic hub Co-authored-by: Cursor --- .../components/tags/TagPageSections.tsx | 799 ++++++++++++++++++ packages/webapp/pages/tags/[tag].tsx | 694 +++++++-------- 2 files changed, 1155 insertions(+), 338 deletions(-) create mode 100644 packages/webapp/components/tags/TagPageSections.tsx diff --git a/packages/webapp/components/tags/TagPageSections.tsx b/packages/webapp/components/tags/TagPageSections.tsx new file mode 100644 index 0000000000..9181353c0d --- /dev/null +++ b/packages/webapp/components/tags/TagPageSections.tsx @@ -0,0 +1,799 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import type { ButtonProps } from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Pill, PillSize } from '@dailydotdev/shared/src/components/Pill'; +import { Accordion } from '@dailydotdev/shared/src/components/accordion'; +import { + Tab, + TabContainer, +} from '@dailydotdev/shared/src/components/tabs/TabContainer'; +import { + HashtagIcon, + OpenLinkIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import type { TagsData } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { GET_RECOMMENDED_TAGS_QUERY } from '@dailydotdev/shared/src/graphql/feedSettings'; +import type { TopPost } from '@dailydotdev/shared/src/graphql/feed'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SOURCES_BY_TAG_QUERY } from '@dailydotdev/shared/src/graphql/sources'; +import type { Connection } from '@dailydotdev/shared/src/graphql/common'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import type { UserShortProfile } from '@dailydotdev/shared/src/lib/user'; +import { TOP_CREATORS_BY_TAG_QUERY } from '@dailydotdev/shared/src/graphql/users'; +import { RequestKey, StaleTime } from '@dailydotdev/shared/src/lib/query'; +import { getTagPageLink } from '@dailydotdev/shared/src/lib/links'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; +import { cloudinarySourceRoadmap } from '@dailydotdev/shared/src/lib/image'; + +type TagStatus = 'followed' | 'blocked' | 'unfollowed'; + +export type TagFaqItem = { + question: string; + answer: string; +}; + +const formatCompactNumber = (value?: number): string => { + if (!value) { + return '0'; + } + + return new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); +}; + +const uniqueTagNames = (tags: TagsData['tags'] = []): string[] => + Array.from( + new Set( + tags + .map((relatedTag) => relatedTag.name) + .filter((relatedTag): relatedTag is string => !!relatedTag), + ), + ); + +const joinNames = (names: string[]): string => { + if (names.length === 0) { + return ''; + } + + if (names.length === 1) { + return names[0]; + } + + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +}; + +export const getTagFaqItems = ({ + tag, + title, + description, + occurrences, + recommendedTags, + topContributors, + topSources, +}: { + tag: string; + title: string; + description?: string; + occurrences?: number; + recommendedTags?: TagsData['tags']; + topContributors?: UserShortProfile[]; + topSources?: Source[]; +}): TagFaqItem[] => { + const relatedTagNames = uniqueTagNames(recommendedTags).slice(0, 6); + const contributorNames = (topContributors ?? []) + .map((contributor) => contributor.name) + .filter(Boolean) + .slice(0, 5); + const sourceNames = (topSources ?? []) + .map((source) => source.name) + .filter(Boolean) + .slice(0, 5); + const intro = + description || + `daily.dev collects developer posts, videos, updates, and discussions about ${title} in one place.`; + + return [ + { + question: `What is ${title} on daily.dev?`, + answer: intro, + }, + { + question: `How many ${title} posts are on daily.dev?`, + answer: `daily.dev currently indexes ${formatCompactNumber( + occurrences, + )} posts and discussions related to ${title}.`, + }, + ...(contributorNames.length + ? [ + { + question: `Who are the top ${title} contributors?`, + answer: `Top contributors in this topic include ${joinNames( + contributorNames, + )}.`, + }, + ] + : []), + ...(sourceNames.length + ? [ + { + question: `Which sources cover ${title}?`, + answer: `Sources covering ${title} include ${joinNames( + sourceNames, + )}.`, + }, + ] + : []), + ...(relatedTagNames.length + ? [ + { + question: `What topics are related to ${title}?`, + answer: `Related topics include ${joinNames(relatedTagNames)}.`, + }, + ] + : []), + { + question: `How do I get a personalized ${title} feed?`, + answer: `Follow the ${tag} tag on daily.dev to add it to your personalized developer feed and discover relevant posts from the community.`, + }, + ]; +}; + +const SectionShell = ({ + eyebrow, + title, + description, + children, + className, +}: { + eyebrow?: string; + title: string; + description?: string; + children: ReactNode; + className?: string; +}): ReactElement => ( +
+
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

{title}

+ {description && ( +

+ {description} +

+ )} +
+ {children} +
+); + +const TagStat = ({ + label, + value, + accent, +}: { + label: string; + value: string; + accent: string; +}): ReactElement => ( +
+

{value}

+

{label}

+
+); + +export const TagStatsBar = ({ + occurrences, + contributorsCount, + sourcesCount, + relatedTagsCount, +}: { + occurrences?: number; + contributorsCount: number; + sourcesCount: number; + relatedTagsCount: number; +}): ReactElement => ( +
+ + + + +
+); + +export const TagHero = ({ + tag, + title, + description, + occurrences, + contributorsCount, + sourcesCount, + relatedTagsCount, + tagStatus, + isAnonymous, + onPrimaryAction, + actions, + sponsoredHero, + seoLinks, +}: { + tag: string; + title: string; + description?: string; + occurrences?: number; + contributorsCount: number; + sourcesCount: number; + relatedTagsCount: number; + tagStatus: TagStatus; + isAnonymous: boolean; + onPrimaryAction: ButtonProps<'button'>['onClick']; + actions: ReactNode; + sponsoredHero?: ReactNode; + seoLinks?: ReactNode; +}): ReactElement => { + let primaryLabel = 'Follow topic'; + if (tagStatus === 'followed') { + primaryLabel = 'Unfollow topic'; + } else if (tagStatus === 'blocked') { + primaryLabel = 'Unblock topic'; + } else if (isAnonymous) { + primaryLabel = 'Follow & personalize my feed'; + } + + return ( +
+
+
+
+
+ {sponsoredHero} +
+
+ +
+
+ +
+

+ {title} +

+
+ {description && ( +

+ {description} +

+ )} +

+ Start with the best {title} posts, follow the topic, and turn it + into a personalized feed shaped by the daily.dev community. +

+
+
+ + {actions} +
+
+ + {isAnonymous && ( +
+

+ One click turns #{tag} into your first daily.dev signal. +

+

+ We will use this topic and its related tags to help your first + feed feel relevant immediately after signup. +

+
+ )} + {seoLinks} +
+
+ ); +}; + +export const TagFeaturedPost = ({ + tagTitle, + post, +}: { + tagTitle: string; + post?: TopPost; +}): ReactElement | null => { + if (!post?.title) { + return null; + } + + return ( + + + +
+
+
+ +

{post.title}

+

+ Read the post, then keep scrolling for more top discussions and + fresh posts. +

+
+ + Read post + + +
+
+ + + ); +}; + +type BestOfTab = 'Top posts' | 'Most upvoted' | 'Best discussed'; + +export const TagBestOfTabs = ({ + tagTitle, + topPostsFeed, + mostUpvotedFeed, + bestDiscussedFeed, +}: { + tagTitle: string; + topPostsFeed: ReactNode; + mostUpvotedFeed: ReactNode; + bestDiscussedFeed: ReactNode; +}): ReactElement => ( + + + shouldMountInactive + showBorder={false} + className={{ + container: 'rounded-20 border border-border-subtlest-tertiary', + header: + 'no-scrollbar overflow-x-auto rounded-t-20 bg-surface-primary px-2', + }} + tabListProps={{ + dragScroll: true, + autoScrollActive: true, + className: { + item: 'whitespace-nowrap', + indicator: 'bg-accent-cabbage-default', + }, + }} + > + label="Top posts" className="pt-4"> + {topPostsFeed} + + label="Most upvoted" className="pt-4"> + {mostUpvotedFeed} + + label="Best discussed" className="pt-4"> + {bestDiscussedFeed} + + + +); + +const EntityPlaceholder = (): ReactElement => ( +
+); + +const EntityCard = ({ + image, + imageAlt, + name, + permalink, + badge, +}: { + image: string; + imageAlt: string; + name: string; + permalink: string; + badge?: string; +}): ReactElement => ( + + + {badge && ( + + {badge} + + )} + {imageAlt} +

{name}

+
+ +); + +const TagTopSourcesGrid = ({ + tag, + initialSources = [], +}: { + tag: string; + initialSources?: Source[]; +}): ReactElement | null => { + const { data: topSources, isPending } = useQuery({ + queryKey: [RequestKey.SourceByTag, null, tag], + queryFn: async () => + await gqlClient.request<{ sourcesByTag: Connection }>( + SOURCES_BY_TAG_QUERY, + { + tag, + first: 6, + }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const sources = + topSources?.sourcesByTag?.edges?.map((edge) => edge.node) ?? initialSources; + + if (isPending && initialSources.length === 0) { + return ; + } + + if (!sources || sources.length === 0) { + return null; + } + + return ( +
+

Trusted sources

+
+ {sources.map((source) => ( + + ))} +
+
+ ); +}; + +const TagTopContributorsGrid = ({ + tag, + initialUsers = [], +}: { + tag: string; + initialUsers?: UserShortProfile[]; +}): ReactElement | null => { + const { data: topContributors, isPending } = useQuery({ + queryKey: [RequestKey.TopCreatorsByTag, null, tag], + queryFn: async () => + await gqlClient.request<{ topCreatorsByTag: UserShortProfile[] }>( + TOP_CREATORS_BY_TAG_QUERY, + { + tag, + limit: 6, + }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const users = topContributors?.topCreatorsByTag ?? initialUsers; + + if (isPending && initialUsers.length === 0) { + return ; + } + + if (!users || users.length === 0) { + return null; + } + + return ( +
+

Top contributors

+
+ {users.map((user, index) => ( + + ))} +
+
+ ); +}; + +export const TagCommunity = ({ + tag, + tagTitle, + initialUsers, + initialSources, +}: { + tag: string; + tagTitle: string; + initialUsers?: UserShortProfile[]; + initialSources?: Source[]; +}): ReactElement => ( + +
+ + +
+
+); + +export const TagUniverse = ({ + tag, + tagTitle, + blockedTags, + initialTags = [], +}: { + tag: string; + tagTitle: string; + blockedTags?: string[]; + initialTags?: TagsData['tags']; +}): ReactElement | null => { + const { data: recommendedTags, isPending } = useQuery({ + queryKey: [RequestKey.RecommendedTags, null, tag], + queryFn: async () => + await gqlClient.request<{ + recommendedTags: TagsData; + }>(GET_RECOMMENDED_TAGS_QUERY, { + tags: [tag], + excludedTags: blockedTags || [], + }), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const tags = recommendedTags?.recommendedTags?.tags ?? initialTags; + const tagNames = uniqueTagNames(tags); + + if (isPending && initialTags.length === 0) { + return ( + +
+ + ); + } + + if (tagNames.length === 0) { + return null; + } + + return ( + +
+ {tagNames.map((relatedTag) => ( + + + + {relatedTag} + + + ))} +
+
+ ); +}; + +export const TagLearnSection = ({ + tag, + tagTitle, + roadmapUrl, + archive, +}: { + tag: string; + tagTitle: string; + roadmapUrl?: string; + archive: ReactNode; +}): ReactElement => ( + + + +); + +export const TagFaq = ({ + tag, + tagTitle, + description, + occurrences, + recommendedTags, + topContributors, + topSources, +}: { + tag: string; + tagTitle: string; + description?: string; + occurrences?: number; + recommendedTags?: TagsData['tags']; + topContributors?: UserShortProfile[]; + topSources?: Source[]; +}): ReactElement => { + const items = getTagFaqItems({ + tag, + title: tagTitle, + description, + occurrences, + recommendedTags, + topContributors, + topSources, + }); + + return ( + +
+ {items.map((item, index) => ( +
0 && 'pt-4')}> + {item.question}

} + initiallyOpen={index === 0} + className={{ + button: '!bg-transparent', + }} + > +

+ {item.answer} +

+
+
+ ))} +
+
+ ); +}; + +export const TagSignupCta = ({ + tagTitle, + relatedTagsCount, + onClick, +}: { + tagTitle: string; + relatedTagsCount: number; + onClick: ButtonProps<'button'>['onClick']; +}): ReactElement => ( +
+
+
+

+ Build your developer feed around {tagTitle} +

+

+ Follow this topic + {relatedTagsCount > 0 + ? ` plus ${relatedTagsCount} related signals` + : ''} + , then get relevant posts daily. +

+
+ +
+
+); diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 7d9d38504a..e75e7b45c4 100644 --- a/packages/webapp/pages/tags/[tag].tsx +++ b/packages/webapp/pages/tags/[tag].tsx @@ -6,15 +6,11 @@ import type { import Head from 'next/head'; import type { ParsedUrlQuery } from 'querystring'; import type { ReactElement } from 'react'; -import React, { useContext, useMemo } from 'react'; -import classNames from 'classnames'; +import React, { useContext, useEffect, useMemo } from 'react'; import { BlockIcon, DiscussIcon, HashtagIcon, - MiniCloseIcon as XIcon, - OpenLinkIcon, - PlusIcon, UpvoteIcon, } from '@dailydotdev/shared/src/components/icons'; import useFeedSettings from '@dailydotdev/shared/src/hooks/useFeedSettings'; @@ -32,33 +28,21 @@ import type { TopPostsData, } from '@dailydotdev/shared/src/graphql/feed'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import type { ButtonProps } from '@dailydotdev/shared/src/components/buttons/Button'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { PageInfoHeader } from '@dailydotdev/shared/src/components/utilities'; +import { MenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; +import { ButtonVariant } from '@dailydotdev/shared/src/components/buttons/Button'; import useTagAndSource from '@dailydotdev/shared/src/hooks/useTagAndSource'; import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { - OtherFeedPage, - RequestKey, - StaleTime, -} from '@dailydotdev/shared/src/lib/query'; +import { OtherFeedPage } from '@dailydotdev/shared/src/lib/query'; import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; import type { Keyword } from '@dailydotdev/shared/src/graphql/keywords'; import { KEYWORD_QUERY } from '@dailydotdev/shared/src/graphql/keywords'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import { useQuery } from '@tanstack/react-query'; import type { TagsData } from '@dailydotdev/shared/src/graphql/feedSettings'; import { GET_RECOMMENDED_TAGS_QUERY } from '@dailydotdev/shared/src/graphql/feedSettings'; import { ReferralCampaignKey, useFeedLayout, } from '@dailydotdev/shared/src/hooks'; -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'; import { SOURCES_BY_TAG_QUERY } from '@dailydotdev/shared/src/graphql/sources'; import type { Connection } from '@dailydotdev/shared/src/graphql/common'; @@ -68,8 +52,6 @@ import HorizontalFeed from '@dailydotdev/shared/src/components/feeds/HorizontalF 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 { cloudinarySourceRoadmap } from '@dailydotdev/shared/src/lib/image'; -import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import CustomFeedOptionsMenu from '@dailydotdev/shared/src/components/CustomFeedOptionsMenu'; import { ArchiveEntryCard } from '@dailydotdev/shared/src/components/archive/ArchiveEntryCard'; @@ -80,6 +62,17 @@ 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 { + getTagFaqItems, + TagBestOfTabs, + TagCommunity, + TagFaq, + TagFeaturedPost, + TagHero, + TagLearnSection, + TagSignupCta, + TagUniverse, +} from '../../components/tags/TagPageSections'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { getLayout } from '../../components/layouts/FeedLayout'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -95,130 +88,23 @@ interface TagPageProps extends DynamicSeoProps { topPosts: TopPost[]; recommendedTags: TagsData['tags']; topContributors: UserShortProfile[]; + topSources: Source[]; } -interface TagRecommendedTagsProps { - tag: string; - blockedTags?: string[]; - initialTags?: TagsData['tags']; -} - -const TagRecommendedTags = ({ - tag, - blockedTags, - initialTags = [], -}: TagRecommendedTagsProps): ReactElement => { - const { data: recommendedTags, isPending } = useQuery({ - queryKey: [RequestKey.RecommendedTags, null, tag], - - queryFn: async () => - await gqlClient.request<{ - recommendedTags: TagsData; - }>(GET_RECOMMENDED_TAGS_QUERY, { - tags: [tag], - excludedTags: blockedTags || [], - }), - enabled: !!tag, - staleTime: StaleTime.OneHour, - }); - - const tags = recommendedTags?.recommendedTags?.tags ?? initialTags; - - return ( - - ); -}; - -const TagTopSources = ({ tag }: { tag: string }) => { - const { data: topSources, isPending } = useQuery({ - queryKey: [RequestKey.SourceByTag, null, tag], - - queryFn: async () => - await gqlClient.request<{ sourcesByTag: Connection }>( - SOURCES_BY_TAG_QUERY, - { - tag, - first: 6, - }, - ), - - enabled: !!tag, - staleTime: StaleTime.OneHour, - }); - - const sources = topSources?.sourcesByTag?.edges?.map((edge) => edge.node); - if (!sources || sources.length === 0) { - return null; - } - - return ( - ({ - id: source.id, - image: source.image, - imageAlt: `${source.name} logo`, - name: source.name, - permalink: source.permalink, - }))} - title="🔔 Top sources covering it" - className="mx-4" - /> - ); -}; - -const TagTopContributors = ({ - tag, - initialUsers = [], -}: { - tag: string; - initialUsers?: UserShortProfile[]; -}): ReactElement | null => { - const { data: topContributors, isPending } = useQuery({ - queryKey: [RequestKey.TopCreatorsByTag, null, tag], - - queryFn: async () => - await gqlClient.request<{ topCreatorsByTag: UserShortProfile[] }>( - TOP_CREATORS_BY_TAG_QUERY, - { - tag, - limit: 6, - }, - ), - - enabled: !!tag, - staleTime: StaleTime.OneHour, - }); - - const users = topContributors?.topCreatorsByTag ?? initialUsers; - - return ( - ({ - id: user.id, - image: user.image, - imageAlt: `${user.name} avatar`, - name: user.name, - permalink: user.permalink, - }))} - title="👥 Top contributors" - className="mx-4" - /> - ); -}; - const getTagPageJsonLd = ({ tag, initialData, topPosts, + recommendedTags, + topContributors, + topSources, }: { tag: string; initialData: Keyword; topPosts: TopPost[]; + recommendedTags: TagsData['tags']; + topContributors: UserShortProfile[]; + topSources: Source[]; }): string => { const encodedTag = encodeURIComponent(tag); const tagTitle = initialData.flags?.title || tag; @@ -226,6 +112,15 @@ const getTagPageJsonLd = ({ initialData.flags?.description || `Find all the recent posts, videos, updates and discussions about ${tagTitle}`; const tagUrl = `${appOrigin}/tags/${encodedTag}`; + const faqItems = getTagFaqItems({ + tag, + title: tagTitle, + description: tagDescription, + occurrences: initialData.occurrences, + recommendedTags, + topContributors, + topSources, + }); return JSON.stringify({ '@context': 'https://schema.org', @@ -236,8 +131,20 @@ const getTagPageJsonLd = ({ url: tagUrl, name: `${tagTitle} posts on daily.dev`, description: tagDescription, + about: { '@id': `${tagUrl}#term` }, isPartOf: { '@type': 'WebSite', url: appOrigin }, }, + { + '@type': 'DefinedTerm', + '@id': `${tagUrl}#term`, + name: tagTitle, + description: tagDescription, + inDefinedTermSet: { + '@type': 'DefinedTermSet', + name: 'daily.dev developer topics', + url: `${appOrigin}/tags`, + }, + }, ...(topPosts.length ? [ { @@ -253,6 +160,22 @@ const getTagPageJsonLd = ({ }, ] : []), + ...(faqItems.length + ? [ + { + '@type': 'FAQPage', + '@id': `${tagUrl}#faq`, + mainEntity: faqItems.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + }, + ] + : []), { '@type': 'BreadcrumbList', itemListElement: [ @@ -285,8 +208,9 @@ const TagPage = ({ topPosts, recommendedTags, topContributors, + topSources, }: TagPageProps): ReactElement => { - const { push } = useRouter(); + const { push, replace, query } = useRouter(); const showRoadmap = useFeature(feature.showRoadmap); const { user, showLogin } = useContext(AuthContext); const mostUpvotedQueryVariables = useMemo( @@ -356,11 +280,25 @@ const TagPage = ({ useTagAndSource({ origin: Origin.TagPage }); const title = initialData?.flags?.title || tag; const jsonLd = initialData - ? getTagPageJsonLd({ tag, initialData, topPosts }) + ? getTagPageJsonLd({ + tag, + initialData, + topPosts, + recommendedTags, + topContributors, + topSources, + }) : null; const { follow, unfollow } = useContentPreference({ showToastOnSuccess: false, }); + const personalizedTags = useMemo(() => { + const relatedTags = recommendedTags + .map((recommendedTag) => recommendedTag.name) + .filter((recommendedTag): recommendedTag is string => !!recommendedTag); + + return Array.from(new Set([tag, ...relatedTags])).slice(0, 6); + }, [recommendedTags, tag]); const tagStatus = useMemo(() => { if (!feedSettings) { @@ -377,38 +315,95 @@ const TagPage = ({ return 'unfollowed'; }, [feedSettings, tag]); - const followButtonProps: ButtonProps<'button'> = { - size: ButtonSize.Small, - icon: tagStatus === 'followed' ? : , - onClick: async (): Promise => { - if (user) { - if (tagStatus === 'followed') { - await onUnfollowTags({ tags: [tag] }); - } else { - await onFollowTags({ tags: [tag] }); - } - } else { - showLogin({ trigger: AuthTriggers.Filter }); - } - }, + useEffect(() => { + const tagsToFollowParam = query.followTags; + if (!user || typeof tagsToFollowParam !== 'string') { + return; + } + + const tagsToFollow = tagsToFollowParam + .split(',') + .map((value) => decodeURIComponent(value.trim())) + .filter(Boolean); + + if (tagsToFollow.length === 0) { + return; + } + + const followTags = async (): Promise => { + await onFollowTags({ tags: Array.from(new Set(tagsToFollow)) }); + + const params = new URLSearchParams(globalThis.location.search); + params.delete('followTags'); + const search = params.toString(); + await replace( + `${globalThis.location.pathname}${search ? `?${search}` : ''}`, + undefined, + { shallow: true }, + ); + }; + + followTags().catch(() => undefined); + }, [onFollowTags, query.followTags, replace, user]); + + const openPersonalizedSignup = (): void => { + const encodedTags = personalizedTags.map(encodeURIComponent).join(','); + showLogin({ + trigger: AuthTriggers.Filter, + options: { + afterAuth: `/tags/${encodeURIComponent(tag)}?followTags=${encodedTags}`, + }, + }); }; - const blockButtonProps: ButtonProps<'button'> = { - size: ButtonSize.Small, - icon: tagStatus === 'blocked' ? : , - onClick: async (): Promise => { - if (user) { - if (tagStatus === 'blocked') { - await onUnblockTags({ tags: [tag] }); - } else { - await onBlockTags({ tags: [tag] }); - } - } else { - showLogin({ trigger: AuthTriggers.Filter }); - } - }, + const handleFollowClick = async (): Promise => { + if (!user) { + openPersonalizedSignup(); + return; + } + + if (tagStatus === 'followed') { + await onUnfollowTags({ tags: [tag] }); + return; + } + + await onFollowTags({ tags: [tag] }); + }; + + const handleBlockClick = async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + + if (tagStatus === 'blocked') { + await onUnblockTags({ tags: [tag] }); + return; + } + + await onBlockTags({ tags: [tag] }); + }; + + const handlePrimaryAction = async (): Promise => { + if (tagStatus === 'blocked') { + await handleBlockClick(); + return; + } + + await handleFollowClick(); }; + const tagMenuOptions = + tagStatus === 'followed' + ? [] + : [ + { + icon: , + label: tagStatus === 'blocked' ? 'Unblock tag' : 'Block tag', + action: handleBlockClick, + }, + ]; + return ( {jsonLd && ( @@ -423,32 +418,22 @@ const TagPage = ({ items={[{ label: 'Tags', href: '/tags' }, { label: title }]} className="mx-4" /> - - -
- -

{title}

-
-
- {tagStatus !== 'blocked' && ( - - )} - {tagStatus !== 'followed' && ( - - )} + } + actions={ push( `/feeds/new?entityId=${tag}&entityType=${ContentPreferenceType.Keyword}`, @@ -480,155 +465,177 @@ const TagPage = ({ }), }} /> -
- {initialData?.flags?.description && ( -

{initialData?.flags?.description}

- )} - {topPosts.length > 0 && ( -
- {topPosts.map((post) => ( - - {post.title} - - ))} -
- )} - {recommendedTags.length > 0 && ( -
- {recommendedTags - .map((relatedTag) => relatedTag.name) - .filter((relatedTag): relatedTag is string => !!relatedTag) - .map((relatedTag) => ( - - Posts about {relatedTag} - - ))} -
- )} - {topContributors.length > 0 && ( -
- {topContributors.map((contributor) => ( - - Posts by {contributor.name} - - ))} -
- )} - {tag && ( - - )} - {showRoadmap && initialData?.flags?.roadmap && ( - - - roadmap.sh logo -
-

- Comprehensive roadmap for {tag} -

-

- By roadmap.sh -

+ } + seoLinks={ + <> + {topPosts.length > 0 && ( +
-