From 619d1cd26fdc637fd8f8cadf19a245504a1134b5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 13:21:08 +0300 Subject: [PATCH 1/8] feat(tags): new-user conversion layer for tag page + directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a flag-gated (tag_page_redesign, default ON) redesign of the tag page and tags directory. The existing logged-in layout stays the baseline; anonymous SEO/share traffic gets a conversion-focused layer on top. No SEO content is removed — elements are reorganized and added to. - TagPageHeader: value-prop hero + single "Get my {tag} feed" CTA for logged-out visitors; members keep the familiar header. - TagBestOfPosts: collapses the 3 stacked carousels into one tabbed surface (Top / Most upvoted / Best discussed) — nothing removed. - TagBuildYourFeed: anonymous multi-select related-topics funnel. - Directory: anon value-prop hero + CTA and a client-side search filter over the A-Z wall (full list stays in SSG HTML for crawlers). - All sr-only links, JSON-LD, breadcrumbs, sources/contributors, archive card and roadmap preserved in both layouts. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/lib/featureManagement.ts | 4 + .../webapp/components/tags/TagBestOfPosts.tsx | 128 ++++++ .../components/tags/TagBuildYourFeed.tsx | 110 +++++ .../webapp/components/tags/TagPageHeader.tsx | 95 ++++ packages/webapp/pages/tags/[tag].tsx | 419 +++++++++++------- packages/webapp/pages/tags/index.tsx | 92 +++- 6 files changed, 685 insertions(+), 163 deletions(-) create mode 100644 packages/webapp/components/tags/TagBestOfPosts.tsx create mode 100644 packages/webapp/components/tags/TagBuildYourFeed.tsx create mode 100644 packages/webapp/components/tags/TagPageHeader.tsx 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/components/tags/TagBestOfPosts.tsx b/packages/webapp/components/tags/TagBestOfPosts.tsx new file mode 100644 index 0000000000..b9aaaf99db --- /dev/null +++ b/packages/webapp/components/tags/TagBestOfPosts.tsx @@ -0,0 +1,128 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import HorizontalFeed from '@dailydotdev/shared/src/components/feeds/HorizontalFeed'; +import { + MOST_DISCUSSED_FEED_QUERY, + MOST_UPVOTED_FEED_QUERY, + TAG_FEED_QUERY, +} from '@dailydotdev/shared/src/graphql/feed'; +import { OtherFeedPage } from '@dailydotdev/shared/src/lib/query'; +import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import TabList from '@dailydotdev/shared/src/components/tabs/TabList'; + +const supportedTypes = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Collection, + PostType.Share, + PostType.Freeform, + PostType.LiveRoom, +]; + +type BestOfTabKey = 'top' | 'upvoted' | 'discussed'; + +interface BestOfTab { + key: BestOfTabKey; + label: string; + query: string; + feedName: OtherFeedPage; + queryKeyPrefix: string; +} + +const tabs: BestOfTab[] = [ + { + key: 'top', + label: 'Top posts', + query: TAG_FEED_QUERY, + feedName: OtherFeedPage.TagsTopPosts, + queryKeyPrefix: 'tagsTopPosts', + }, + { + key: 'upvoted', + label: 'Most upvoted', + query: MOST_UPVOTED_FEED_QUERY, + feedName: OtherFeedPage.TagsMostUpvoted, + queryKeyPrefix: 'tagsMostUpvoted', + }, + { + key: 'discussed', + label: 'Best discussed', + query: MOST_DISCUSSED_FEED_QUERY, + feedName: OtherFeedPage.TagsBestDiscussed, + queryKeyPrefix: 'tagsBestDiscussed', + }, +]; + +interface TagBestOfPostsProps { + tag: string; + userId?: string; +} + +/** + * "Best of {tag}" — collapses the three previously-stacked horizontal feeds + * (Top posts, Most upvoted, Best discussed) into a single tabbed surface. + * Nothing is removed: every ranking is still reachable, just one tap away + * instead of three full-height carousels competing for the same screen space. + * The query keys/variables match the originals so the TanStack Query cache is + * shared with anywhere else these feeds are used. + */ +export function TagBestOfPosts({ + tag, + userId, +}: TagBestOfPostsProps): ReactElement { + const [active, setActive] = useState('top'); + const activeTab = tabs.find((tab) => tab.key === active) ?? tabs[0]; + + const variables = useMemo(() => { + const base = { tag, supportedTypes }; + switch (activeTab.key) { + case 'top': + return { ...base, ranking: 'POPULARITY' }; + case 'upvoted': + return { ...base, period: 365 }; + case 'discussed': + return { ...base, period: 365 }; + default: + return base; + } + }, [tag, activeTab.key]); + + return ( +
+
+

Best of {tag}

+
+
+ + items={tabs.map((tab) => ({ label: tab.label }))} + active={activeTab.label} + onClick={(label) => { + const next = tabs.find((tab) => tab.label === label); + if (next) { + setActive(next.key); + } + }} + autoScrollActive + className={{ item: '!py-2' }} + /> +
+ + } + /> + +
+ ); +} diff --git a/packages/webapp/components/tags/TagBuildYourFeed.tsx b/packages/webapp/components/tags/TagBuildYourFeed.tsx new file mode 100644 index 0000000000..fa86906577 --- /dev/null +++ b/packages/webapp/components/tags/TagBuildYourFeed.tsx @@ -0,0 +1,110 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PlusIcon, VIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; + +interface TagBuildYourFeedProps { + tag: string; + relatedTags: { name?: string }[]; + /** + * Fires with the set of selected topics. Pre-signup persistence of the + * selection is a backend follow-up; today this hands off to the auth flow so + * the visitor signs up with clear intent (and we can seed the feed afterwards). + */ + onCreateFeed: (tags: string[]) => void; +} + +/** + * Anonymous-only "aha before auth" funnel. Instead of asking a cold visitor to + * follow a single tag, we let them assemble a starter feed from the tag they + * landed on plus related topics, then convert with a single CTA. The value + * (a personalized feed) is felt before the signup form appears. + */ +export function TagBuildYourFeed({ + tag, + relatedTags, + onCreateFeed, +}: TagBuildYourFeedProps): ReactElement | null { + const options = useMemo(() => { + const names = [ + tag, + ...relatedTags + .map((related) => related.name) + .filter((name): name is string => !!name), + ]; + return Array.from(new Set(names)); + }, [tag, relatedTags]); + + const [selected, setSelected] = useState>(() => new Set([tag])); + + if (options.length <= 1) { + return null; + } + + const toggle = (name: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + return ( +
+
+

Build your {tag} feed

+

+ Pick the topics you care about and get a feed tailored to you — joined + by millions of developers. +

+
+
    + {options.map((name) => { + const isSelected = selected.has(name); + return ( +
  • + +
  • + ); + })} +
+ +
+ ); +} diff --git a/packages/webapp/components/tags/TagPageHeader.tsx b/packages/webapp/components/tags/TagPageHeader.tsx new file mode 100644 index 0000000000..aec65ff9c3 --- /dev/null +++ b/packages/webapp/components/tags/TagPageHeader.tsx @@ -0,0 +1,95 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { HashtagIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +interface TagPageHeaderProps { + title: string; + description?: string; + /** + * Logged-out visitors (mostly SEO / shared-link traffic) get the + * conversion-focused value proposition and a single primary CTA on top of the + * regular header. Logged-in members keep the existing, familiar header. + */ + isLoggedIn: boolean; + /** Follow / Block buttons + feed options menu, owned by the page. */ + actions: ReactNode; + /** Sponsored brand hero, rendered only when the tag is sponsored. */ + sponsoredHero?: ReactNode; + /** Anonymous primary CTA: build a personalized feed seeded with this tag. */ + onGetFeed: () => void; + /** Related tags / build-your-feed, sr-only SEO links, roadmap card, etc. */ + children?: ReactNode; +} + +/** + * Redesigned tag page header. + * + * Existing members see essentially the same compact header they have today + * (icon + title + actions + description). Anonymous visitors additionally get a + * value-proposition headline, social proof, and a single clear "Get my feed" + * CTA so the daily.dev "aha moment" is visible the instant they land from SEO + * or a shared link — without removing any of the SEO-relevant content. + */ +export function TagPageHeader({ + title, + description, + isLoggedIn, + actions, + sponsoredHero, + onGetFeed, + children, +}: TagPageHeaderProps): ReactElement { + return ( +
+ {sponsoredHero} +
+ +

{title}

+
+ + {!isLoggedIn && ( +

+ Everything happening in {title}, in one feed. +

+ )} + + {description &&

{description}

} + + {!isLoggedIn && ( +

+ The best {title} posts, videos, and discussions — curated daily by + millions of developers. +

+ )} + +
+ {!isLoggedIn && ( + + )} + {actions} +
+ + {children} +
+ ); +} diff --git a/packages/webapp/pages/tags/[tag].tsx b/packages/webapp/pages/tags/[tag].tsx index 7d9d38504a..30f978cf4b 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,9 @@ 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 { TagPageHeader } from '../../components/tags/TagPageHeader'; +import { TagBestOfPosts } from '../../components/tags/TagBestOfPosts'; +import { TagBuildYourFeed } from '../../components/tags/TagBuildYourFeed'; import { getLayout } from '../../components/layouts/FeedLayout'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; import type { DynamicSeoProps } from '../../components/common'; @@ -409,123 +419,262 @@ const TagPage = ({ }, }; - return ( - - {jsonLd && ( - -