From f1240f9dac6a58ed1e92b71d4e9b6bcf9111e037 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 20:58:14 +0000 Subject: [PATCH 01/15] feat(shared): add a Polymarket-style feed hero section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hero section for the top of the feed, plus a Storybook page to iterate on it: - FeedHeroCarousel: pages through featured posts with the existing featured-wide cards, dot indicators and prev/next chips labelled with the neighbouring headlines. - FeedHeroAd: a compact native/direct ad placement for the rail, built from the existing ad primitives (link, image, attribution, pixel, viewability). - FeedHeroSection: carousel on the left, rail on the right with the ad slot, the Happening Now highlights card and an Explore all CTA. - FeedSectionToolbar: section title with search, filter and bookmark actions for the row above the tag chips. Nothing renders this yet — the layout lives in Storybook under Features/Feed/Hero, composed with the existing ExploreChipsBar and feed cards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FWv8BuKdrX1GtDnSobxFAa --- .../src/components/feeds/hero/FeedHeroAd.tsx | 81 ++++ .../feeds/hero/FeedHeroCarousel.spec.tsx | 83 ++++ .../feeds/hero/FeedHeroCarousel.tsx | 108 +++++ .../components/feeds/hero/FeedHeroSection.tsx | 81 ++++ .../feeds/hero/FeedSectionToolbar.tsx | 63 +++ .../features/feed/FeedHero.stories.tsx | 118 ++++++ .../stories/features/feed/feedHero.mocks.tsx | 368 ++++++++++++++++++ 7 files changed, 902 insertions(+) create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroAd.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroSection.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx create mode 100644 packages/storybook/stories/features/feed/FeedHero.stories.tsx create mode 100644 packages/storybook/stories/features/feed/feedHero.mocks.tsx diff --git a/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx new file mode 100644 index 0000000000..3fb03c1f7c --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad } from '../../../graphql/posts'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { useAdClickUrl } from '../../../features/monetization/useAdClickUrl'; +import AdLink from '../../cards/ad/common/AdLink'; +import AdAttribution from '../../cards/ad/common/AdAttribution'; +import { AdImage } from '../../cards/ad/common/AdImage'; +import { AdPixel } from '../../cards/ad/common/AdPixel'; +import { AdViewability } from '../../cards/ad/common/AdViewability'; +import { Image } from '../../image/Image'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { combinedClicks } from '../../../lib/click'; +import classed from '../../../lib/classed'; + +const AdThumbnail = classed(Image, 'h-full object-cover'); + +interface FeedHeroAdProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; + className?: string; +} + +export const FeedHeroAd = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdProps): ReactElement => { + const clickUrl = useAdClickUrl(ad); + + return ( +
+ + +
+

+ {ad.tagLine || ad.company} +

+

+ {ad.description} +

+ +
+ {!!ad.callToAction && ( + + )} + + {!!onViewable && ( + onViewable(ad, data)} /> + )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx new file mode 100644 index 0000000000..f170fd5f57 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import basePost from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Post } from '../../../graphql/posts'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); +}); + +const titles = ['First hero post', 'Second hero post', 'Third hero post']; + +const posts: Post[] = titles.map((title, index) => ({ + ...basePost, + id: `hero-${index}`, + title, +})); + +const renderComponent = (carouselPosts: Post[] = posts): RenderResult => + render( + + + , + ); + +const getTitle = (title: string) => + screen.getByRole('heading', { name: title }); + +describe('FeedHeroCarousel', () => { + it('renders the first post and both neighbours as navigation labels', () => { + renderComponent(); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Next: ${titles[1]}` }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Previous: ${titles[2]}` }), + ).toBeInTheDocument(); + }); + + it('wraps around when paging past the last post', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + expect(getTitle(titles[1])).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[2]}` })); + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[0]}` })); + expect(getTitle(titles[0])).toBeInTheDocument(); + }); + + it('jumps to the post picked from the indicators', () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show featured post 3' }), + ); + + expect(getTitle(titles[2])).toBeInTheDocument(); + }); + + it('hides the controls for a single post', () => { + renderComponent([posts[0]]); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^Show featured post/ }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx new file mode 100644 index 0000000000..aa20cdc340 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { PostTypeToWideCard } from '../../cards/common/wideCards'; +import { ArticleFeaturedWideGridCard } from '../../cards/article/ArticleFeaturedWideGridCard'; +import { Button } from '../../buttons/Button'; +import { + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../../buttons/common'; +import { ArrowIcon } from '../../icons'; + +export type FeedHeroCarouselProps = Omit & { + posts: Post[]; + className?: string; +}; + +const wrapIndex = (index: number, total: number): number => + (index + total) % total; + +export const FeedHeroCarousel = ({ + posts, + className, + wideColSpan = 2, + ...cardProps +}: FeedHeroCarouselProps): ReactElement | null => { + const [index, setIndex] = useState(0); + + if (!posts.length) { + return null; + } + + const total = posts.length; + const active = wrapIndex(index, total); + const post = posts[active]; + const WideCard = PostTypeToWideCard[post.type] ?? ArticleFeaturedWideGridCard; + const previous = posts[wrapIndex(active - 1, total)]; + const next = posts[wrapIndex(active + 1, total)]; + + return ( +
+
+ +
+ {total > 1 && ( +
+
+ {posts.map((item, position) => ( +
+
+ + +
+
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx new file mode 100644 index 0000000000..735f97c0e1 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad, Post } from '../../../graphql/posts'; +import type { PostHighlight } from '../../../graphql/highlights'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { HighlightCardContent } from '../../cards/highlight/common'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import Link from '../../utilities/Link'; +import { FeedHeroAd } from './FeedHeroAd'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; + +interface FeedHeroSectionProps { + posts: Post[]; + highlights: PostHighlight[]; + ad?: Ad; + exploreHref?: string; + cardProps?: Omit; + onAdLinkClick?: (ad: Ad) => unknown; + onAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; + className?: string; +} + +export const FeedHeroSection = ({ + posts, + highlights, + ad, + exploreHref, + cardProps, + onAdLinkClick, + onAdViewable, + onHighlightClick, + onReadAllClick, + className, +}: FeedHeroSectionProps): ReactElement => ( +
+ + +
+); diff --git a/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx new file mode 100644 index 0000000000..fe19af6227 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '../../utilities/Link'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { BookmarkIcon, FilterIcon, SearchIcon } from '../../icons'; + +interface FeedSectionToolbarProps { + title: string; + searchHref?: string; + bookmarksHref?: string; + onFiltersClick?: () => void; + className?: string; +} + +const iconLinkProps = { + tag: 'a', + variant: ButtonVariant.Tertiary, + size: ButtonSize.Medium, +} as const; + +export const FeedSectionToolbar = ({ + title, + searchHref, + bookmarksHref, + onFiltersClick, + className, +}: FeedSectionToolbarProps): ReactElement => ( +
+

{title}

+ {!!searchHref && ( + +
+ +
+
+ )} + {!!onFiltersClick && ( + +
+ + )} + +); diff --git a/packages/storybook/stories/features/feed/FeedHero.stories.tsx b/packages/storybook/stories/features/feed/FeedHero.stories.tsx new file mode 100644 index 0000000000..5c15609334 --- /dev/null +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ExploreChipsBar } from '@dailydotdev/shared/src/components/feeds/ExploreChipsBar'; +import { FeedHeroSection } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroSection'; +import { FeedSectionToolbar } from '@dailydotdev/shared/src/components/feeds/hero/FeedSectionToolbar'; +import { + cardHandlers, + exploreCategories, + feedPosts, + FeedHeroProviders, + heroAd, + heroPosts, + highlights, +} from './feedHero.mocks'; + +const Page = ({ children }: { children: React.ReactNode }): ReactElement => ( + +
+
+ {children} +
+
+
+); + +const FeedGrid = (): ReactElement => ( +
+ {feedPosts.map((post) => ( + + ))} +
+); + +const meta: Meta = { + title: 'Features/Feed/Hero', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const FullLayout: Story = { + name: 'Hero + all posts', + render: () => ( + + +
+ + + +
+
+ ), +}; + +export const HeroOnly: Story = { + name: 'Hero section', + render: () => ( + + + + ), +}; + +export const WithoutAd: Story = { + name: 'Hero without ad placement', + render: () => ( + + + + ), +}; + +export const SingleHeroPost: Story = { + name: 'Hero with a single post', + render: () => ( + + + + ), +}; diff --git a/packages/storybook/stories/features/feed/feedHero.mocks.tsx b/packages/storybook/stories/features/feed/feedHero.mocks.tsx new file mode 100644 index 0000000000..73a67e5490 --- /dev/null +++ b/packages/storybook/stories/features/feed/feedHero.mocks.tsx @@ -0,0 +1,368 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Ad, Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; +import type { PostHighlight } from '@dailydotdev/shared/src/graphql/highlights'; +import type { ExploreCategory } from '@dailydotdev/shared/src/components/feeds/exploreCategories'; +import { featureHeroCards } from '@dailydotdev/shared/src/lib/featureManagement'; +import { FeatureOverrides } from '../../../mock/GrowthBookProvider'; +import ExtensionProviders from '../../extension/_providers'; + +const hoursAgo = (hours: number): string => + new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + +const createSource = (id: string, name: string): Source => ({ + id, + handle: id, + name, + permalink: `https://app.daily.dev/sources/${id}`, + image: `https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/${id}`, + type: SourceType.Machine, + public: true, +}); + +const sources = { + tds: createSource('tds', 'Towards Data Science'), + tc: createSource('tc', 'TechCrunch'), + ph: createSource('ph', 'Product Hunt'), + tkdodo: createSource('tkdodo', 'TkDodo'), +}; + +const placeholder = (index: number): string => + `https://media.daily.dev/image/upload/f_auto/v1/placeholders/${index}`; + +const basePost = { + numUpvotes: 128, + numComments: 24, + bookmarked: false, + read: false, + upvoted: false, + commented: false, + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, +}; + +export const heroPosts: Post[] = [ + { + ...basePost, + id: 'hero-1', + type: PostType.Article, + title: + 'React 20 ships the compiler by default — what breaks and what to do', + summary: + 'The compiler is no longer opt-in. Memoization hooks become no-ops, refs behave differently inside effects, and a handful of popular libraries need a patch release before you upgrade.', + permalink: 'https://api.daily.dev/r/hero-1', + commentsPermalink: 'https://app.daily.dev/posts/hero-1', + createdAt: hoursAgo(3), + readTime: 9, + image: placeholder(1), + source: sources.tds, + tags: ['react', 'javascript', 'webdev'], + numUpvotes: 842, + numComments: 96, + hero: { + id: 'hero-sig-1', + headline: 'React 20 ships the compiler by default', + significance: 'breaking', + size: 2, + highlightedAt: hoursAgo(3), + }, + }, + { + ...basePost, + id: 'hero-2', + type: PostType.Article, + title: + 'Postgres 19 makes logical replication usable for zero-downtime migrations', + summary: + 'Sequences finally replicate, DDL is carried across publications, and failover slots survive a promotion — the three gaps that used to force a maintenance window.', + permalink: 'https://api.daily.dev/r/hero-2', + commentsPermalink: 'https://app.daily.dev/posts/hero-2', + createdAt: hoursAgo(7), + readTime: 12, + image: placeholder(2), + source: sources.tc, + tags: ['postgres', 'databases', 'devops'], + numUpvotes: 511, + numComments: 48, + hero: { + id: 'hero-sig-2', + headline: 'Postgres 19 lands zero-downtime logical replication', + significance: 'major', + size: 2, + highlightedAt: hoursAgo(7), + }, + }, + { + ...basePost, + id: 'hero-3', + type: PostType.Article, + title: 'We replaced our GraphQL gateway with a 400-line Rust proxy', + summary: + 'p99 dropped from 340ms to 28ms and the on-call pager went quiet. A walkthrough of what the gateway was actually doing, and why almost none of it was needed.', + permalink: 'https://api.daily.dev/r/hero-3', + commentsPermalink: 'https://app.daily.dev/posts/hero-3', + createdAt: hoursAgo(14), + readTime: 15, + image: placeholder(3), + source: sources.tkdodo, + tags: ['rust', 'graphql', 'performance'], + numUpvotes: 1204, + numComments: 187, + hero: { + id: 'hero-sig-3', + headline: 'A 400-line Rust proxy replaced a GraphQL gateway', + significance: 'breakout', + size: 2, + highlightedAt: hoursAgo(14), + }, + }, + { + ...basePost, + id: 'hero-4', + type: PostType.Article, + title: + 'The agent benchmark everyone quotes has been measuring the wrong thing', + summary: + 'A reproduction of the headline numbers, the prompt leak that inflated them, and a rerun on a clean split that puts every model within four points of each other.', + permalink: 'https://api.daily.dev/r/hero-4', + commentsPermalink: 'https://app.daily.dev/posts/hero-4', + createdAt: hoursAgo(20), + readTime: 11, + image: placeholder(4), + source: sources.ph, + tags: ['ai', 'machine-learning', 'agents'], + numUpvotes: 933, + numComments: 142, + hero: { + id: 'hero-sig-4', + headline: 'The agent benchmark everyone quotes is broken', + significance: 'notable', + size: 2, + highlightedAt: hoursAgo(20), + }, + }, +]; + +export const feedPosts: Post[] = [ + { + ...basePost, + id: 'feed-1', + type: PostType.Article, + title: 'Stop reaching for useEffect: a decision tree', + summary: 'Six common effects and where each one actually belongs.', + permalink: 'https://api.daily.dev/r/feed-1', + commentsPermalink: 'https://app.daily.dev/posts/feed-1', + createdAt: hoursAgo(5), + readTime: 6, + image: placeholder(5), + source: sources.tkdodo, + tags: ['react', 'hooks'], + }, + { + ...basePost, + id: 'feed-2', + type: PostType.Article, + title: 'Shipping a monorepo with pnpm workspaces and Turborepo in 2026', + summary: + 'Caching, task graphs, and the traps that make CI slower, not faster.', + permalink: 'https://api.daily.dev/r/feed-2', + commentsPermalink: 'https://app.daily.dev/posts/feed-2', + createdAt: hoursAgo(9), + readTime: 10, + image: placeholder(6), + source: sources.tds, + tags: ['monorepo', 'pnpm', 'ci'], + }, + { + ...basePost, + id: 'feed-3', + type: PostType.Article, + title: 'A practical guide to CSS container queries', + summary: 'Component-level breakpoints without a single media query.', + permalink: 'https://api.daily.dev/r/feed-3', + commentsPermalink: 'https://app.daily.dev/posts/feed-3', + createdAt: hoursAgo(11), + readTime: 7, + image: placeholder(1), + source: sources.tc, + tags: ['css', 'frontend'], + }, + { + ...basePost, + id: 'feed-4', + type: PostType.Article, + title: 'What a year of on-call taught us about alert design', + summary: + 'Every alert that woke someone up, categorized and mostly deleted.', + permalink: 'https://api.daily.dev/r/feed-4', + commentsPermalink: 'https://app.daily.dev/posts/feed-4', + createdAt: hoursAgo(16), + readTime: 8, + image: placeholder(2), + source: sources.ph, + tags: ['sre', 'observability'], + }, + { + ...basePost, + id: 'feed-5', + type: PostType.Article, + title: 'Type-safe environment variables without a build step', + summary: 'Zod, a tiny loader, and failing fast on boot.', + permalink: 'https://api.daily.dev/r/feed-5', + commentsPermalink: 'https://app.daily.dev/posts/feed-5', + createdAt: hoursAgo(22), + readTime: 5, + image: placeholder(3), + source: sources.tkdodo, + tags: ['typescript', 'zod'], + }, + { + ...basePost, + id: 'feed-6', + type: PostType.Article, + title: 'Reading the SQLite source to understand WAL mode', + summary: 'What the checkpointer does, and why your writes stall.', + permalink: 'https://api.daily.dev/r/feed-6', + commentsPermalink: 'https://app.daily.dev/posts/feed-6', + createdAt: hoursAgo(28), + readTime: 14, + image: placeholder(4), + source: sources.tds, + tags: ['sqlite', 'databases'], + }, +]; + +export const highlights: PostHighlight[] = [ + { + id: 'highlight-1', + channel: 'frontend', + headline: 'React 20 makes the compiler the default in every new app', + highlightedAt: hoursAgo(1), + post: { + id: 'hero-1', + commentsPermalink: 'https://app.daily.dev/posts/hero-1', + }, + }, + { + id: 'highlight-2', + channel: 'ai', + headline: 'OpenAI and Anthropic both ship agent sandboxes on the same day', + highlightedAt: hoursAgo(2), + post: { + id: 'highlight-post-2', + commentsPermalink: 'https://app.daily.dev/posts/highlight-post-2', + }, + }, + { + id: 'highlight-3', + channel: 'devops', + headline: 'Cloudflare outage takes down half the JS ecosystem CDNs', + highlightedAt: hoursAgo(4), + post: { + id: 'highlight-post-3', + commentsPermalink: 'https://app.daily.dev/posts/highlight-post-3', + }, + }, + { + id: 'highlight-4', + channel: 'languages', + headline: 'TypeScript 7 preview lands with the Go-based compiler', + highlightedAt: hoursAgo(9), + post: { + id: 'highlight-post-4', + commentsPermalink: 'https://app.daily.dev/posts/highlight-post-4', + }, + }, + { + id: 'highlight-5', + channel: 'security', + headline: 'Another postinstall supply-chain attack hits 40 npm packages', + highlightedAt: hoursAgo(13), + post: { + id: 'highlight-post-5', + commentsPermalink: 'https://app.daily.dev/posts/highlight-post-5', + }, + }, +]; + +export const heroAd: Ad = { + company: 'Vercel', + source: 'Vercel', + tagLine: 'Ship your Next.js app in seconds', + description: + 'Zero-config deploys, a preview URL on every push, and analytics that come with it.', + image: 'https://media.daily.dev/image/upload/f_auto/v1/placeholders/5', + link: 'https://vercel.com', + referralLink: 'https://vercel.com', + companyLogo: 'https://svgl.app/library/vercel.svg', + callToAction: 'Start deploying', + adDomain: 'vercel.com', + providerId: 'sb-provider', +}; + +export const exploreCategories: ExploreCategory[] = [ + { id: 'ai', label: 'AI', path: '/explore/ai', tag: 'ai' }, + { id: 'react', label: 'React', path: '/explore/react', tag: 'react' }, + { + id: 'typescript', + label: 'TypeScript', + path: '/explore/typescript', + tag: 'typescript', + }, + { id: 'devops', label: 'DevOps', path: '/explore/devops', tag: 'devops' }, + { id: 'rust', label: 'Rust', path: '/explore/rust', tag: 'rust' }, + { id: 'career', label: 'Career', path: '/explore/career', tag: 'career' }, + { + id: 'databases', + label: 'Databases', + path: '/explore/databases', + tag: 'databases', + }, + { + id: 'security', + label: 'Security', + path: '/explore/security', + tag: 'security', + }, + { id: 'webdev', label: 'Web dev', path: '/explore/webdev', tag: 'webdev' }, + { + id: 'open-source', + label: 'Open source', + path: '/explore/open-source', + tag: 'open-source', + }, +]; + +export const cardHandlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +export const FeedHeroProviders = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( + + + {children} + + +); From e7c6ba5546c8f0d0cc6f70450224a85e98c90298 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 21:36:23 +0000 Subject: [PATCH 02/15] feat(shared): rebuild the hero ad slot from the feed ad card's elements The rail placement led with a call-to-action button that dominated a 20rem card, even though the whole card is already a click target. It now carries the same elements as the feed ad card and nothing else: advertiser logo and disclosure, the ad title, matching tags, and the cover image in its own column. Also adds the Storybook coverage needed to review it: responsive breakpoints rendered in per-width iframes, hero section states (no ad, single post, short highlights list), one carousel case per post type, ad placement variants, and toolbar variants. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FWv8BuKdrX1GtDnSobxFAa --- .../src/components/feeds/hero/FeedHeroAd.tsx | 55 ++-- .../features/feed/FeedHero.stories.tsx | 236 ++++++++++++++++-- .../stories/features/feed/feedHero.mocks.tsx | 101 ++++++++ 3 files changed, 340 insertions(+), 52 deletions(-) diff --git a/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx index 3fb03c1f7c..6ab4e268cd 100644 --- a/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx +++ b/packages/shared/src/components/feeds/hero/FeedHeroAd.tsx @@ -3,19 +3,17 @@ import React from 'react'; import classNames from 'classnames'; import type { Ad } from '../../../graphql/posts'; import type { ViewabilityData } from '../../../features/monetization/viewability'; -import { useAdClickUrl } from '../../../features/monetization/useAdClickUrl'; import AdLink from '../../cards/ad/common/AdLink'; import AdAttribution from '../../cards/ad/common/AdAttribution'; +import { AdFavicon } from '../../cards/ad/common/AdFavicon'; import { AdImage } from '../../cards/ad/common/AdImage'; import { AdPixel } from '../../cards/ad/common/AdPixel'; import { AdViewability } from '../../cards/ad/common/AdViewability'; +import PostTags from '../../cards/common/PostTags'; import { Image } from '../../image/Image'; -import { Button } from '../../buttons/Button'; -import { ButtonSize, ButtonVariant } from '../../buttons/common'; -import { combinedClicks } from '../../../lib/click'; import classed from '../../../lib/classed'; -const AdThumbnail = classed(Image, 'h-full object-cover'); +const AdCover = classed(Image, 'h-full object-cover'); interface FeedHeroAdProps { ad: Ad; @@ -30,47 +28,38 @@ export const FeedHeroAd = ({ onViewable, className, }: FeedHeroAdProps): ReactElement => { - const clickUrl = useAdClickUrl(ad); + const matchingTags = ad.matchingTags ?? []; return (
- -
-

- {ad.tagLine || ad.company} -

-

+

+
+ + +
+

{ad.description}

- 0 && ( + + )} +
+ {!!ad.image && ( + -
- {!!ad.callToAction && ( - )} {!!onViewable && ( diff --git a/packages/storybook/stories/features/feed/FeedHero.stories.tsx b/packages/storybook/stories/features/feed/FeedHero.stories.tsx index 5c15609334..c8b6c28ac2 100644 --- a/packages/storybook/stories/features/feed/FeedHero.stories.tsx +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -1,12 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { fn } from 'storybook/test'; import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; import { ExploreChipsBar } from '@dailydotdev/shared/src/components/feeds/ExploreChipsBar'; +import { FeedHeroAd } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroAd'; +import { FeedHeroCarousel } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroCarousel'; import { FeedHeroSection } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroSection'; import { FeedSectionToolbar } from '@dailydotdev/shared/src/components/feeds/hero/FeedSectionToolbar'; import { + adWithLongCopy, + adWithoutAdvertiser, + adWithoutImage, + adWithoutTags, cardHandlers, exploreCategories, feedPosts, @@ -14,9 +20,12 @@ import { heroAd, heroPosts, highlights, + mixedTypeHeroPosts, + noImageHeroPost, + readHeroPost, } from './feedHero.mocks'; -const Page = ({ children }: { children: React.ReactNode }): ReactElement => ( +const Page = ({ children }: { children: ReactNode }): ReactElement => (
@@ -26,6 +35,26 @@ const Page = ({ children }: { children: React.ReactNode }): ReactElement => ( ); +const Case = ({ + title, + note, + width, + children, +}: { + title: string; + note?: string; + width?: string; + children: ReactNode; +}): ReactElement => ( +
+

{title}

+ {!!note &&

{note}

} +
+ {children} +
+
+); + const FeedGrid = (): ReactElement => (
{feedPosts.map((post) => ( @@ -88,31 +117,200 @@ export const HeroOnly: Story = { ), }; -export const WithoutAd: Story = { - name: 'Hero without ad placement', +// Each breakpoint gets its own iframe so Tailwind's media queries resolve +// against a real viewport width, not a resized container. +const BREAKPOINTS = [ + { label: 'Mobile', width: 390, height: 900 }, + { label: 'Tablet', width: 768, height: 900 }, + { label: 'Laptop', width: 1024, height: 760 }, + { label: 'Desktop', width: 1440, height: 760 }, +]; + +export const Responsive: Story = { + name: 'Responsive breakpoints', + render: (args, { globals }) => ( +
+

+ The same story rendered at each breakpoint. Laptop and up is the + two-column hero; below that the rail stacks under the carousel. +

+
+ {BREAKPOINTS.map(({ label, width, height }) => ( +
+ + {label} · {width}px + +