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 => (
+
+);
+
+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}
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ Start with the best {title} posts, follow the topic, and turn it
+ into a personalized feed shaped by the daily.dev community.
+
+
+
+ }
+ onClick={onPrimaryAction}
+ aria-label={primaryLabel}
+ >
+ {primaryLabel}
+
+ {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}
+
+ )}
+
+ {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 (
+
+
+
+ );
+};
+
+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 && (
-
- )}
- {recommendedTags.length > 0 && (
-
- {recommendedTags
- .map((relatedTag) => relatedTag.name)
- .filter((relatedTag): relatedTag is string => !!relatedTag)
- .map((relatedTag) => (
-
-
Posts about {relatedTag}
-
- ))}
-
- )}
- {topContributors.length > 0 && (
-
- )}
- {tag && (
-
- )}
- {showRoadmap && initialData?.flags?.roadmap && (
-
-
-
-
-
- Comprehensive roadmap for {tag}
-
-
- By roadmap.sh
-
+ }
+ seoLinks={
+ <>
+ {topPosts.length > 0 && (
+
-
}
- size={ButtonSize.Small}
- variant={ButtonVariant.Tertiary}
- />
-
-
- )}
-
-
-
-
- ,
- }}
- className="laptop:!mx-4"
- emptyScreen={<>>}
- />
-
-
- ,
- }}
- className="laptop:!mx-4"
- emptyScreen={<>>}
- />
-
-
- ,
- }}
- className="laptop:!mx-4"
- emptyScreen={<>>}
+ )}
+ {recommendedTags.length > 0 && (
+
+ {recommendedTags
+ .map((relatedTag) => relatedTag.name)
+ .filter((relatedTag): relatedTag is string => !!relatedTag)
+ .map((relatedTag) => (
+
+
Posts about {relatedTag}
+
+ ))}
+
+ )}
+ {topContributors.length > 0 && (
+
+ )}
+ {topSources.length > 0 && (
+
+ )}
+ >
+ }
+ />
+
+ {!user && (
+
-
-
+ ,
+ }}
+ className="!mx-0 !mb-0 laptop:!mx-0"
+ emptyScreen={<>>}
+ />
+
+ }
+ mostUpvotedFeed={
+
+ ,
+ }}
+ className="!mx-0 !mb-0 laptop:!mx-0"
+ emptyScreen={<>>}
+ />
+
+ }
+ bestDiscussedFeed={
+
+ ,
+ }}
+ className="!mx-0 !mb-0 laptop:!mx-0"
+ emptyScreen={<>>}
+ />
+
+ }
+ />
+
+
+
+ }
+ />
+
@@ -698,6 +705,7 @@ export async function getStaticProps({
topPosts: [],
recommendedTags: [],
topContributors: [],
+ topSources: [],
seo: getSeoData(tag),
},
};
@@ -708,6 +716,7 @@ export async function getStaticProps({
topPostsResult,
recommendedTagsResult,
topContributorsResult,
+ topSourcesResult,
] = await Promise.all([
gqlClient.request<{ keyword: Keyword }>(KEYWORD_QUERY, {
value: tag,
@@ -733,6 +742,12 @@ export async function getStaticProps({
},
)
.catch(() => null),
+ gqlClient
+ .request<{ sourcesByTag: Connection }>(SOURCES_BY_TAG_QUERY, {
+ tag,
+ first: 6,
+ })
+ .catch(() => null),
]);
if (!keywordResult?.keyword) {
@@ -746,6 +761,8 @@ export async function getStaticProps({
.filter((post) => !!post.title) ?? [];
const recommendedTags = recommendedTagsResult?.recommendedTags?.tags ?? [];
const topContributors = topContributorsResult?.topCreatorsByTag ?? [];
+ const topSources =
+ topSourcesResult?.sourcesByTag?.edges?.map((edge) => edge.node) ?? [];
const seo = getSeoData(
initialData.flags?.title || tag,
initialData.flags?.description,
@@ -759,6 +776,7 @@ export async function getStaticProps({
topPosts,
recommendedTags,
topContributors,
+ topSources,
},
revalidate: 3600,
};