diff --git a/packages/shared/src/components/explore/ExploreCategorySection.tsx b/packages/shared/src/components/explore/ExploreCategorySection.tsx new file mode 100644 index 0000000000..e9ce53d5cf --- /dev/null +++ b/packages/shared/src/components/explore/ExploreCategorySection.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { TagCategory } from '../../graphql/feedSettings'; +import { ExploreTagListItem } from './ExploreTagListItem'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { ClickableText } from '../buttons/ClickableText'; + +const COLLAPSED_COUNT = 8; + +interface ExploreCategorySectionProps { + category: TagCategory; + followedTags: Set; + onToggleFollow: (tag: string) => void; + className?: string; +} + +// One column block in the Explore directory: an emoji + title heading above a +// vertical list of topic rows with follow controls. +export function ExploreCategorySection({ + category, + followedTags, + onToggleFollow, + className, +}: ExploreCategorySectionProps): ReactElement | null { + const [expanded, setExpanded] = useState(false); + + if (!category.tags?.length) { + return null; + } + + const visibleTags = expanded + ? category.tags + : category.tags.slice(0, COLLAPSED_COUNT); + const hasMore = category.tags.length > COLLAPSED_COUNT; + + return ( +
+ + {category.emoji ? `${category.emoji} ` : ''} + {category.title} + + + {hasMore && ( + setExpanded((prev) => !prev)} + className="w-fit" + > + {expanded ? 'Show less' : 'More'} + + )} +
+ ); +} diff --git a/packages/shared/src/components/explore/ExploreHeader.spec.tsx b/packages/shared/src/components/explore/ExploreHeader.spec.tsx new file mode 100644 index 0000000000..84cc72f12e --- /dev/null +++ b/packages/shared/src/components/explore/ExploreHeader.spec.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ExploreHeader } from './ExploreHeader'; + +const renderComponent = (props: Partial<{ activeTag: string }> = {}) => + render( + + + , + ); + +describe('ExploreHeader', () => { + it('renders an Explore tab linking back to the lobby', () => { + renderComponent(); + + expect(screen.getByText('Explore').closest('a')).toHaveAttribute( + 'href', + expect.stringContaining('tags'), + ); + }); + + it('renders a tab per recommended topic', () => { + renderComponent(); + + expect(screen.getByText('#vue').closest('a')).toHaveAttribute( + 'href', + expect.stringContaining('tags/vue'), + ); + }); + + it('marks the active topic tab as current', () => { + renderComponent({ activeTag: 'react' }); + + expect(screen.getByText('#react').closest('a')).toHaveAttribute( + 'aria-current', + 'page', + ); + }); +}); diff --git a/packages/shared/src/components/explore/ExploreHeader.tsx b/packages/shared/src/components/explore/ExploreHeader.tsx new file mode 100644 index 0000000000..3e54874245 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreHeader.tsx @@ -0,0 +1,79 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { ButtonSize } from '../buttons/Button'; +import { pageHeaderClassName } from '../layout/PageHeader'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '../squads/layout/SquadDirectoryNavbar'; +import useFeedSettings from '../../hooks/useFeedSettings'; +import { getTagPageLink } from '../../lib/links'; +import { webappUrl } from '../../lib/constants'; + +interface ExploreHeaderProps { + // The tag currently being viewed (topic page) or undefined on the lobby. + activeTag?: string; + // Related / recommended tag names to surface after the followed ones. + recommendedTags?: string[]; + className?: string; +} + +const exploreUrl = `${webappUrl}tags`; +// Keep the header short — show a handful of relevant tabs rather than a long +// horizontally-scrolling list. +const MAX_TAGS = 5; + +// The Explore page header, built with the same tabbed page-header strip as the +// Squad directory (pageHeaderClassName + SquadDirectoryNavbar). An "Explore" +// tab returns to the lobby, followed by the user's tags and recommendations; +// the current topic is the active tab. Shared by the lobby and topic pages. +export function ExploreHeader({ + activeTag, + recommendedTags = [], + className, +}: ExploreHeaderProps): ReactElement { + const { feedSettings } = useFeedSettings(); + + const tags = useMemo(() => { + const followed = feedSettings?.includeTags ?? []; + const followedSet = new Set(followed); + const ordered = + activeTag && !followedSet.has(activeTag) + ? [activeTag, ...followed] + : followed; + const rec = recommendedTags.filter( + (tag) => tag && !followedSet.has(tag) && tag !== activeTag, + ); + return Array.from(new Set([...ordered, ...rec])).slice(0, MAX_TAGS); + }, [feedSettings?.includeTags, recommendedTags, activeTag]); + + return ( +
+ + + {tags.map((tag) => ( + + ))} + +
+ ); +} diff --git a/packages/shared/src/components/explore/ExploreSignupCard.spec.tsx b/packages/shared/src/components/explore/ExploreSignupCard.spec.tsx new file mode 100644 index 0000000000..f0f95b998e --- /dev/null +++ b/packages/shared/src/components/explore/ExploreSignupCard.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent, TargetType } from '../../lib/log'; +import { ExploreSignupCard } from './ExploreSignupCard'; + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: jest.fn(), +})); + +const showLogin = jest.fn(); +const logEvent = jest.fn(); + +const mockAuth = (overrides: Record = {}) => + (useAuthContext as jest.Mock).mockReturnValue({ + isAuthReady: true, + isLoggedIn: false, + showLogin, + ...overrides, + }); + +beforeEach(() => { + jest.clearAllMocks(); + (useLogContext as jest.Mock).mockReturnValue({ logEvent }); +}); + +describe('ExploreSignupCard', () => { + it('makes the tag value explicit and logs a tag_page impression', () => { + mockAuth(); + render(); + + expect( + screen.getByRole('heading', { name: /Get every new #claude post/ }), + ).toBeInTheDocument(); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: 'tag_page', + }); + }); + + it('uses lobby copy and a tags_lobby target when no tag is given', () => { + mockAuth(); + render(); + + expect( + screen.getByRole('heading', { name: /Your personalized developer feed/ }), + ).toBeInTheDocument(); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: 'tags_lobby', + }); + }); + + it('opens registration on signup click', () => { + mockAuth(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Sign up/ })); + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: false }, + }); + }); + + it('renders nothing for logged-in users', () => { + mockAuth({ isLoggedIn: true }); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/shared/src/components/explore/ExploreSignupCard.tsx b/packages/shared/src/components/explore/ExploreSignupCard.tsx new file mode 100644 index 0000000000..c2efce7161 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreSignupCard.tsx @@ -0,0 +1,120 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ClickableText } from '../buttons/ClickableText'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent, TargetType } from '../../lib/log'; +import { authGradientBg } from '../marketing/banners/common'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +interface ExploreSignupCardProps { + // When set, the copy is scoped to the specific tag; omitted on the lobby. + tag?: string; + className?: string; +} + +// A compact, branded signup strip for logged-out visitors — one explicit line +// of value + a single Sign up action, kept deliberately small so it reads at a +// glance without taking over the layout. Renders nothing when logged in. +export function ExploreSignupCard({ + tag, + className, +}: ExploreSignupCardProps): ReactElement | null { + const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + const { logEvent } = useLogContext(); + const impressionLogged = useRef(false); + + const show = isAuthReady && !isLoggedIn; + const targetId = tag ? 'tag_page' : 'tags_lobby'; + + useEffect(() => { + if (show && !impressionLogged.current) { + impressionLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: targetId, + }); + } + }, [show, logEvent, targetId]); + + if (!show) { + return null; + } + + const onSignup = (): void => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.SignupButton, + target_id: targetId, + }); + showLogin({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: false }, + }); + }; + + const onLogin = (): void => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: targetId, + }); + showLogin({ trigger: AuthTriggers.Onboarding, options: { isLogin: true } }); + }; + + const title = tag + ? `Get every new #${tag} post in your feed` + : 'Your personalized developer feed'; + const subtitle = tag + ? `Sign up free to follow #${tag} on daily.dev.` + : 'Sign up free to follow the tags you care about.'; + + return ( + + ); +} diff --git a/packages/shared/src/components/explore/ExploreTagListItem.spec.tsx b/packages/shared/src/components/explore/ExploreTagListItem.spec.tsx new file mode 100644 index 0000000000..4db649e586 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreTagListItem.spec.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ExploreTagListItem } from './ExploreTagListItem'; + +const onToggleFollow = jest.fn(); + +const renderItem = (isFollowed: boolean) => + render( + +
    + +
+
, + ); + +beforeEach(() => jest.clearAllMocks()); + +it('links to the topic and offers a follow action when not followed', () => { + renderItem(false); + + expect(screen.getByText('React').closest('a')).toHaveAttribute( + 'href', + expect.stringContaining('tags/react'), + ); + fireEvent.click(screen.getByLabelText('Follow react')); + expect(onToggleFollow).toHaveBeenCalledWith('react'); +}); + +it('shows a followed indicator (unfollow action) when followed', () => { + renderItem(true); + + fireEvent.click(screen.getByLabelText('Unfollow react')); + expect(onToggleFollow).toHaveBeenCalledWith('react'); +}); diff --git a/packages/shared/src/components/explore/ExploreTagListItem.tsx b/packages/shared/src/components/explore/ExploreTagListItem.tsx new file mode 100644 index 0000000000..d6ea05cc45 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreTagListItem.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '../utilities/Link'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { PlusIcon, VIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { getTagPageLink } from '../../lib/links'; +import { formatKeyword } from '../../lib/strings'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +interface ExploreTagListItemProps { + tag: string; + isFollowed: boolean; + onToggleFollow: (tag: string) => void; +} + +// A directory row: the topic link plus a follow control on the right. Followed +// topics keep a persistent check (so you can tell what you already follow); +// unfollowed topics reveal a subtle "+" on hover/focus. +export function ExploreTagListItem({ + tag, + isFollowed, + onToggleFollow, +}: ExploreTagListItemProps): ReactElement { + return ( +
  • + + + {formatKeyword(tag)} + + + +
  • + ); +} diff --git a/packages/shared/src/components/explore/ExploreTopicPage.tsx b/packages/shared/src/components/explore/ExploreTopicPage.tsx new file mode 100644 index 0000000000..9af4e662ee --- /dev/null +++ b/packages/shared/src/components/explore/ExploreTopicPage.tsx @@ -0,0 +1,583 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useContext, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { useQuery } from '@tanstack/react-query'; +import Head from 'next/head'; +import Feed from '../Feed'; +import { + MOST_DISCUSSED_FEED_QUERY, + MOST_UPVOTED_FEED_QUERY, + TAG_FEED_QUERY, +} from '../../graphql/feed'; +import type { TopPost } from '../../graphql/feed'; +import AuthContext from '../../contexts/AuthContext'; +import type { ButtonProps } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import useTagAndSource from '../../hooks/useTagAndSource'; +import { AuthTriggers } from '../../lib/auth'; +import { OtherFeedPage, RequestKey, StaleTime } from '../../lib/query'; +import { LogEvent, Origin } from '../../lib/log'; +import type { Keyword } from '../../graphql/keywords'; +import { IconSize } from '../Icon'; +import { + BlockIcon, + DiscussIcon, + MiniCloseIcon as XIcon, + OpenLinkIcon, + PlusIcon, + UpvoteIcon, +} from '../icons'; +import type { TagsData } from '../../graphql/feedSettings'; +import useFeedSettings from '../../hooks/useFeedSettings'; +import { ReferralCampaignKey, useFeedLayout } from '../../hooks'; +import type { SourceTooltip } from '../../graphql/sources'; +import { SOURCES_BY_TAG_QUERY } from '../../graphql/sources'; +import type { Connection } from '../../graphql/common'; +import { gqlClient } from '../../graphql/common'; +import { ActiveFeedNameContext } from '../../contexts'; +import FeedContext from '../../contexts/FeedContext'; +import HorizontalFeed from '../feeds/HorizontalFeed'; +import { PostType } from '../../graphql/posts'; +import { useFeature } from '../GrowthBookProvider'; +import { feature } from '../../lib/featureManagement'; +import { cloudinarySourceRoadmap } from '../../lib/image'; +import { anchorDefaultRel, formatKeyword } from '../../lib/strings'; +import Link from '../utilities/Link'; +import CustomFeedOptionsMenu from '../CustomFeedOptionsMenu'; +import { ArchiveEntryCard } from '../archive/ArchiveEntryCard'; +import { ArchiveScopeType } from '../../graphql/archive'; +import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; +import { ContentPreferenceType } from '../../graphql/contentPreference'; +import { TOP_CREATORS_BY_TAG_QUERY } from '../../graphql/users'; +import type { UserShortProfile } from '../../lib/user'; +import { SponsoredTagHero } from '../brand/SponsoredTagHero'; +import UserEntityCard from '../cards/entity/UserEntityCard'; +import SourceEntityCard from '../cards/entity/SourceEntityCard'; +import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; +import { ExploreHeader } from './ExploreHeader'; +import { ExploreSignupCard } from './ExploreSignupCard'; +import { largeNumberFormat } from '../../lib'; +import { webappUrl } from '../../lib/constants'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +const SUPPORTED_TYPES = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Collection, + PostType.Share, + PostType.Freeform, + PostType.LiveRoom, +]; + +export interface ExploreTopicPageProps { + tag: string; + initialData: Keyword | null; + topPosts: TopPost[]; + recommendedTags: TagsData['tags']; + topContributors: UserShortProfile[]; + jsonLd?: string | null; +} + +const SectionHeading = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( + + {children} + +); + +// Wraps a horizontal post rail with a right-edge gradient so the last card +// blends into the background instead of being hard-cut by the scroll overflow. +const RailWithFade = ({ children }: { children: ReactNode }): ReactElement => ( +
    + {children} +
    +
    +); + +// Render the user/source cards in the same grid the post feed uses (same +// column count + card width) so every card on the page lines up identically. +const ENTITY_CARD_CLASS = { container: '!w-full !max-w-[21.5rem] h-full' }; + +const EntityFeedGrid = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const { numCards } = useContext(FeedContext); + const columns = numCards?.eco ?? 1; + + return ( +
    + {children} +
    + ); +}; + +const EntityGridSkeleton = (): ReactElement => ( + + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + +); + +const TagTopSources = ({ tag }: { tag: string }): ReactElement | null => { + const { data: topSources, isPending } = useQuery({ + queryKey: [RequestKey.SourceByTag, null, tag], + queryFn: async () => + 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 (!isPending && sources.length === 0) { + return null; + } + + return ( +
    + Top sources covering it + {isPending ? ( + + ) : ( + + {sources.map((source) => ( + + ))} + + )} +
    + ); +}; + +const WhoToFollow = ({ + tag, + initialUsers = [], +}: { + tag: string; + initialUsers?: UserShortProfile[]; +}): ReactElement | null => { + const { data: topContributors, isPending } = useQuery({ + queryKey: [RequestKey.TopCreatorsByTag, null, tag], + queryFn: async () => + gqlClient.request<{ topCreatorsByTag: UserShortProfile[] }>( + TOP_CREATORS_BY_TAG_QUERY, + { tag, limit: 6 }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const users = topContributors?.topCreatorsByTag ?? initialUsers; + const isLoading = isPending && initialUsers.length === 0; + + if (!isLoading && (!users || users.length === 0)) { + return null; + } + + return ( +
    + Who to follow + {isLoading ? ( + + ) : ( + + {users.map((user) => ( + + ))} + + )} +
    + ); +}; + +export const ExploreTopicPage = ({ + tag, + initialData, + topPosts, + recommendedTags, + topContributors, + jsonLd, +}: ExploreTopicPageProps): ReactElement => { + const { push } = useRouter(); + const showRoadmap = useFeature(feature.showRoadmap); + const { user, showLogin } = useContext(AuthContext); + const { feedSettings } = useFeedSettings(); + const { FeedPageLayoutComponent } = useFeedLayout(); + const { onFollowTags, onUnfollowTags, onBlockTags, onUnblockTags } = + useTagAndSource({ origin: Origin.TagPage }); + const { follow, unfollow } = useContentPreference({ + showToastOnSuccess: false, + }); + + const title = initialData?.flags?.title || formatKeyword(tag); + const followers = initialData?.followers; + const occurrences = initialData?.occurrences ?? 0; + + const topPostsQueryVariables = useMemo( + () => ({ tag, ranking: 'POPULARITY', supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + const mostUpvotedQueryVariables = useMemo( + () => ({ tag, supportedTypes: SUPPORTED_TYPES, period: 365 }), + [tag], + ); + const bestDiscussedQueryVariables = useMemo( + () => ({ tag, period: 365, supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + const mainFeedQueryVariables = useMemo( + () => ({ tag, ranking: 'TIME', supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + + const tagStatus = useMemo(() => { + if (!feedSettings) { + return 'unfollowed'; + } + if ((feedSettings.blockedTags ?? []).includes(tag)) { + return 'blocked'; + } + if ((feedSettings.includeTags ?? []).includes(tag)) { + return 'followed'; + } + return 'unfollowed'; + }, [feedSettings, tag]); + + const followButtonProps: ButtonProps<'button'> = { + size: ButtonSize.Small, + icon: tagStatus === 'followed' ? : , + onClick: async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + if (tagStatus === 'followed') { + await onUnfollowTags({ tags: [tag] }); + } else { + await onFollowTags({ tags: [tag] }); + } + }, + }; + + const blockButtonProps: ButtonProps<'button'> = { + size: ButtonSize.Small, + icon: tagStatus === 'blocked' ? : , + onClick: async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + if (tagStatus === 'blocked') { + await onUnblockTags({ tags: [tag] }); + } else { + await onBlockTags({ tags: [tag] }); + } + }, + }; + + const statParts: ReactNode[] = []; + if (typeof followers === 'number') { + statParts.push( + {largeNumberFormat(followers)} followers, + ); + } + statParts.push( + {largeNumberFormat(occurrences)} stories, + ); + + return ( + + {jsonLd && ( + +