diff --git a/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx b/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx new file mode 100644 index 00000000000..6fca3c9bc4e --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AnnouncementCard } from './AnnouncementCard'; +import { AnnouncementCardVariant } from './types'; + +describe('AnnouncementCard', () => { + it('renders the compact variant as a link and fires onClick', () => { + const onClick = jest.fn(); + render( + , + ); + + const link = screen.getByRole('link', { + name: /keyboard shortcuts are here/i, + }); + expect(link).toHaveAttribute('href', '/settings'); + + fireEvent.click(link); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders title, description, badge and CTA for the default variant', () => { + const onCtaClick = jest.fn(); + render( + , + ); + + expect(screen.getByText('Smarter custom feeds')).toBeInTheDocument(); + expect( + screen.getByText('Custom feeds now learn from what you read.'), + ).toBeInTheDocument(); + expect(screen.getByText('Updated')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'See what changed' })); + expect(onCtaClick).toHaveBeenCalledTimes(1); + }); + + it('renders a dismiss control when onClose is provided and fires it', () => { + const onClose = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTitle('Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders a cover image for the cover variant', () => { + const { container } = render( + undefined} + />, + ); + + // Decorative cover (alt=""), so it has no accessible role — query the + // element directly. + // eslint-disable-next-line testing-library/no-container + const image = container.querySelector('img'); + expect(image).toHaveAttribute('src', 'https://media.daily.dev/cover.png'); + }); +}); diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx new file mode 100644 index 00000000000..cc3fc544db9 --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -0,0 +1,236 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { MiniCloseIcon, MoveToIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Image } from '../image/Image'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { AnnouncementBadge, AnnouncementCta } from './types'; +import { AnnouncementCardVariant } from './types'; + +export interface AnnouncementCardProps { + variant?: AnnouncementCardVariant; + title: string; + description?: string; + badge?: AnnouncementBadge; + // Leading icon for the Compact variant (Default/Cover lead with the badge). + icon?: ReactNode; + // Cover image, only rendered by the Cover variant. + image?: string; + cta?: AnnouncementCta; + // Whole-card link target, used by the Compact variant. + href?: string; + // Whole-card click handler (analytics / navigation) for the Compact variant. + onClick?: () => void; + // When provided, a dismiss (×) control is rendered. + onClose?: () => void; + className?: string; +} + +// Subtle, defined surface (the canonical card background) so cards read as a +// clean stack and a card behind is properly occluded. Hover gently brightens +// the border and lifts the card for a tactile, alive feel. +const cardBaseClasses = + 'border border-border-subtlest-quaternary bg-background-subtle transition-all duration-200 ease-out hover:border-border-subtlest-tertiary motion-safe:hover:-translate-y-0.5'; + +// Small, flush-left brand label — visible via the brand color but kept light so +// it never competes with the title (no filled chip). +const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { + if (!badge) { + return null; + } + + return ( + + {badge.label} + + ); +}; + +const renderCta = (cta?: AnnouncementCta): ReactElement | null => { + if (!cta) { + return null; + } + + return ( + + ); +}; + +// Soft white inner gradient hugging the bottom edge — a subtle "lip" that reads +// as depth/3D on the dark sidebar (gracefully invisible on light surfaces). +const bottomDepth = ( + +); + +export function AnnouncementCard({ + variant = AnnouncementCardVariant.Default, + title, + description, + badge, + icon, + image, + cta, + href, + onClick, + onClose, + className, +}: AnnouncementCardProps): ReactElement { + if (variant === AnnouncementCardVariant.Compact) { + const Tag = href ? 'a' : 'button'; + + return ( + + {icon && ( + + {icon} + + )} + + + {title} + + {description && ( + + {description} + + )} + + + + ); + } + + // reserveCloseSpace keeps the title clear of an inline (top-right) close. + // The Cover variant overlays its close on the image, so it passes false. + const renderBody = (reserveCloseSpace: boolean): ReactElement => ( +
+ {renderBadge(badge)} + + {title} + + {description && ( + + {description} + + )} + {renderCta(cta)} +
+ ); + + if (variant === AnnouncementCardVariant.Cover) { + return ( +
+
+ {image && ( + + )} + {onClose && ( + // Frosted glass close over imagery — translucent + backdrop blur so + // the image reads through, matching the floating-control idiom in + // ArticleFeaturedWideGridCard. + + )} +
+
{renderBody(false)}
+ {bottomDepth} +
+ ); + } + + return ( +
+ {onClose && ( + + )} + {renderBody(Boolean(onClose))} + {bottomDepth} +
+ ); +} + +export default AnnouncementCard; diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx new file mode 100644 index 00000000000..57f9dcc5d01 --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { AnnouncementCarousel } from './AnnouncementCarousel'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; + +const items: AnnouncementItem[] = [ + { + id: 'a', + variant: AnnouncementCardVariant.Default, + badge: { label: 'New' }, + title: 'First card', + description: 'first description', + cta: { text: 'Go', onClick: jest.fn() }, + }, + { + id: 'b', + variant: AnnouncementCardVariant.Default, + title: 'Second card', + }, + { + id: 'c', + variant: AnnouncementCardVariant.Compact, + title: 'Third card', + href: '#', + }, +]; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); +}); + +const getDots = () => + screen.getAllByRole('button', { name: /Show announcement/ }); + +describe('AnnouncementCarousel', () => { + it('renders the first card and one dot per item', () => { + render(); + + expect(screen.getByText('First card')).toBeInTheDocument(); + expect(getDots()).toHaveLength(3); + }); + + it('renders nothing when there are no items', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('switches the active card when a dot is hovered', () => { + render(); + + fireEvent.mouseOver(getDots()[1]); + + expect(screen.getByText('Second card')).toBeInTheDocument(); + expect(screen.queryByText('First card')).not.toBeInTheDocument(); + }); + + it('dismisses the active card and reports it to the parent', () => { + const onDismiss = jest.fn(); + render(); + + fireEvent.click(screen.getByTitle('Close')); + act(() => { + jest.advanceTimersByTime(250); + }); + + expect(onDismiss).toHaveBeenCalledWith('a'); + }); + + it('logs an impression once per card after a dwell, not on rapid scrub', () => { + const onView = jest.fn(); + render( + , + ); + + // The first card settles and logs a single impression. + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onView).toHaveBeenCalledTimes(1); + expect(onView).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'a' }), + ); + + // Scrub past 'b' to 'c' without dwelling — only the settled card logs. + fireEvent.mouseOver(getDots()[1]); + fireEvent.mouseOver(getDots()[2]); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(onView).toHaveBeenCalledTimes(2); + expect(onView).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'c' }), + ); + }); +}); diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx new file mode 100644 index 00000000000..845ef6caee7 --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -0,0 +1,176 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { AnnouncementCard } from './AnnouncementCard'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; + +const EXIT_MS = 200; +// How long a card must stay active before it counts as a genuine impression — +// long enough that hovering across the dots to browse doesn't log every card. +const VIEW_DWELL_MS = 400; + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +export interface AnnouncementCarouselProps { + items: AnnouncementItem[]; + onDismiss: (id: string) => void; + // Fired when a card becomes the active (visible) one — for impressions. + onView?: (item: AnnouncementItem) => void; + // Fired when a Compact card's row is clicked. + onItemClick?: (item: AnnouncementItem) => void; + className?: string; +} + +export function AnnouncementCarousel({ + items, + onDismiss, + onView, + onItemClick, + className, +}: AnnouncementCarouselProps): ReactElement | null { + const [active, setActive] = useState(0); + const [exitingId, setExitingId] = useState(null); + const exitTimer = useRef>(); + const viewTimer = useRef>(); + // Ids already counted as viewed, so scrubbing back and forth never + // double-logs an impression. + const viewedRef = useRef>(new Set()); + + const count = items.length; + // Items shrink as cards are dismissed; keep the index in range. + const safeActive = Math.min(active, Math.max(count - 1, 0)); + const current = items[safeActive]; + + // Log an impression only after a card has settled (dwell) and only once — + // hovering across the dots to browse the stack shouldn't fire impressions. + useEffect(() => { + const id = current?.id; + if (!id || viewedRef.current.has(id)) { + return undefined; + } + viewTimer.current = setTimeout(() => { + viewedRef.current.add(id); + onView?.(current); + }, VIEW_DWELL_MS); + return () => clearTimeout(viewTimer.current); + // Re-run only when the visible card changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current?.id]); + + useEffect( + () => () => { + clearTimeout(exitTimer.current); + clearTimeout(viewTimer.current); + }, + [], + ); + + if (!current) { + return null; + } + + const goTo = (index: number): void => { + setActive(Math.max(0, Math.min(count - 1, index))); + }; + + const handleDismiss = (id: string): void => { + // Animate the card out, then let the parent remove it so the next card + // slides into its place. Skip the delay when motion is reduced. + if (prefersReducedMotion()) { + onDismiss(id); + return; + } + setExitingId(id); + exitTimer.current = setTimeout(() => { + setExitingId(null); + onDismiss(id); + }, EXIT_MS); + }; + + const hasMultiple = count > 1; + const isExiting = exitingId === current.id; + + return ( +
+
+ {hasMultiple && ( + // A single card peeking behind, occluded by the active card so only + // its bottom edge shows — like a notification stack. On hover the + // stack "opens": the card behind slides out and brightens. +
+ )} +
+ onItemClick(current) + : undefined + } + onClose={() => handleDismiss(current.id)} + className={ + hasMultiple + ? 'shadow-[0_4px_12px_-6px_rgba(0,0,0,0.25)] hover:shadow-[0_10px_24px_-8px_rgba(0,0,0,0.35)]' + : undefined + } + /> +
+
+ + {hasMultiple && ( + // Centered segmented indicator. Hovering or focusing a dot switches to + // that announcement (this lives in the desktop sidebar; click/focus + // cover keyboard and any non-hover input). The active dot stretches + // into a pill and the fill glides between positions. +
+ {items.map((item, index) => { + const isActive = index === safeActive; + return ( +
+ )} +
+ ); +} + +export default AnnouncementCarousel; diff --git a/packages/shared/src/components/announcements/content.tsx b/packages/shared/src/components/announcements/content.tsx new file mode 100644 index 00000000000..d8baca1ce28 --- /dev/null +++ b/packages/shared/src/components/announcements/content.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; +import { HotIcon, MegaphoneIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '../../lib/image'; + +// Curated list of "what's new" announcements shown in the sidebar. +// Keep this short and high-signal — show fewer, better entries. Newest first. +// Bump/replace entries on each release; once dismissed, a card stays gone +// (persisted client-side by id via useSidebarAnnouncements). +export const SIDEBAR_ANNOUNCEMENTS: AnnouncementItem[] = [ + { + id: 'cover-2026-06-clips', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them with your network in one tap.', + cta: { text: 'Try Clips', href: '/clips' }, + }, + { + id: 'default-2026-06-custom-feeds', + variant: AnnouncementCardVariant.Default, + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '/feeds' }, + }, + { + id: 'compact-2026-06-shortcuts', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '/settings', + }, + { + id: 'compact-2026-05-changelog', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: 'https://daily.dev/changelog', + }, +]; diff --git a/packages/shared/src/components/announcements/types.ts b/packages/shared/src/components/announcements/types.ts new file mode 100644 index 00000000000..54d3e7e414f --- /dev/null +++ b/packages/shared/src/components/announcements/types.ts @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; + +export enum AnnouncementCardVariant { + // icon + title (+ one line) + arrow — for minor updates + Compact = 'compact', + // badge + title + body + CTA — the standard release card + Default = 'default', + // cover image on top of the standard card — for headline launches + Cover = 'cover', +} + +export interface AnnouncementBadge { + label: string; + // Override classes for the label. Defaults to brand-colored text. + className?: string; +} + +export interface AnnouncementCta { + text: string; + href?: string; + onClick?: () => void; +} + +export interface AnnouncementItem { + // Stable id — used for dismissal persistence and analytics. + id: string; + variant: AnnouncementCardVariant; + title: string; + description?: string; + badge?: AnnouncementBadge; + // Cover image url, used by the Cover variant. + image?: string; + // Leading icon for the Compact variant (Default/Cover lead with the badge). + icon?: ReactNode; + cta?: AnnouncementCta; + // Makes the whole card a link when there is no explicit CTA (Compact). + href?: string; +} diff --git a/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx b/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx new file mode 100644 index 00000000000..1eecebbf520 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx @@ -0,0 +1,82 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { AnnouncementCarousel } from '../announcements/AnnouncementCarousel'; +import type { AnnouncementItem } from '../announcements/types'; +import { useSidebarAnnouncements } from '../../hooks/useSidebarAnnouncements'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureSidebarAnnouncements } from '../../lib/featureManagement'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent } from '../../lib/log'; + +interface SidebarAnnouncementsProps { + className?: string; +} + +export function SidebarAnnouncements({ + className, +}: SidebarAnnouncementsProps): ReactElement | null { + const { logEvent } = useLogContext(); + const { items, dismiss, isReady } = useSidebarAnnouncements(); + const { value: isEnabled } = useConditionalFeature({ + feature: featureSidebarAnnouncements, + shouldEvaluate: isReady && items.length > 0, + }); + + // Wrap each CTA so its click is logged before navigation runs. + const loggedItems = useMemo( + () => + items.map((item) => { + if (!item.cta) { + return item; + } + + return { + ...item, + cta: { + ...item.cta, + onClick: () => { + logEvent({ + event_name: LogEvent.ClickAnnouncement, + target_id: item.id, + }); + item.cta?.onClick?.(); + }, + }, + }; + }), + [items, logEvent], + ); + + if (!isReady || !isEnabled || loggedItems.length === 0) { + return null; + } + + return ( +
+ + logEvent({ + event_name: LogEvent.ImpressionAnnouncement, + target_id: item.id, + }) + } + onItemClick={(item) => + logEvent({ + event_name: LogEvent.ClickAnnouncement, + target_id: item.id, + }) + } + onDismiss={(id) => { + logEvent({ + event_name: LogEvent.DismissAnnouncement, + target_id: id, + }); + dismiss(id); + }} + /> +
+ ); +} + +export default SidebarAnnouncements; diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e25c98ceab1..c5ece66d3fe 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -30,6 +30,7 @@ import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; import { GameCenterSection } from './sections/GameCenterSection'; import { HelpWidget } from '../help/HelpWidget'; +import { SidebarAnnouncements } from './SidebarAnnouncements'; import { BookmarkIcon, FeedbackIcon, @@ -924,6 +925,9 @@ export const SidebarDesktopV2 = ({ + {isExpanded && !isUtilityPanelSelected && ( + + )} {!isUtilityPanelSelected && } {showFeedbackWidget && !isUtilityPanelSelected && (
diff --git a/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts b/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts new file mode 100644 index 00000000000..9190fbad23d --- /dev/null +++ b/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; + +const mockSetDismissed = jest.fn(); +let mockPersistentState: [string[], jest.Mock, boolean] = [ + [], + mockSetDismissed, + true, +]; + +jest.mock('./usePersistentState', () => ({ + usePersistentState: () => mockPersistentState, +})); + +jest.mock('../components/announcements/content', () => ({ + SIDEBAR_ANNOUNCEMENTS: [ + { id: 'a', variant: 'default', title: 'A' }, + { id: 'b', variant: 'default', title: 'B' }, + ], +})); + +// eslint-disable-next-line import/first +import { useSidebarAnnouncements } from './useSidebarAnnouncements'; + +describe('useSidebarAnnouncements', () => { + beforeEach(() => { + mockSetDismissed.mockReset(); + mockPersistentState = [[], mockSetDismissed, true]; + }); + + it('returns no items until the dismissed list has hydrated', () => { + mockPersistentState = [[], mockSetDismissed, false]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.isReady).toBe(false); + expect(result.current.items).toHaveLength(0); + }); + + it('returns all announcements once loaded', () => { + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.isReady).toBe(true); + expect(result.current.items.map((item) => item.id)).toEqual(['a', 'b']); + }); + + it('filters out dismissed announcements', () => { + mockPersistentState = [['a'], mockSetDismissed, true]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.items.map((item) => item.id)).toEqual(['b']); + }); + + it('persists a newly dismissed id', () => { + const { result } = renderHook(() => useSidebarAnnouncements()); + + act(() => result.current.dismiss('a')); + + expect(mockSetDismissed).toHaveBeenCalledWith(['a']); + }); + + it('ignores dismissing an already dismissed id', () => { + mockPersistentState = [['a'], mockSetDismissed, true]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + act(() => result.current.dismiss('a')); + + expect(mockSetDismissed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/hooks/useSidebarAnnouncements.ts b/packages/shared/src/hooks/useSidebarAnnouncements.ts new file mode 100644 index 00000000000..d92ca253bf6 --- /dev/null +++ b/packages/shared/src/hooks/useSidebarAnnouncements.ts @@ -0,0 +1,43 @@ +import { useCallback, useMemo } from 'react'; +import { usePersistentState } from './usePersistentState'; +import { SIDEBAR_ANNOUNCEMENTS } from '../components/announcements/content'; +import type { AnnouncementItem } from '../components/announcements/types'; + +const DISMISSED_STORAGE_KEY = 'sidebar_announcements_dismissed'; + +interface UseSidebarAnnouncements { + items: AnnouncementItem[]; + dismiss: (id: string) => void; + // True once the persisted dismissal list has hydrated — gate rendering on + // this so dismissed cards never flash on load. + isReady: boolean; +} + +export const useSidebarAnnouncements = (): UseSidebarAnnouncements => { + const [dismissed, setDismissed, loaded] = usePersistentState( + DISMISSED_STORAGE_KEY, + [], + ); + + const items = useMemo(() => { + if (!loaded) { + return []; + } + + const dismissedSet = new Set(dismissed); + return SIDEBAR_ANNOUNCEMENTS.filter((item) => !dismissedSet.has(item.id)); + }, [dismissed, loaded]); + + const dismiss = useCallback( + (id: string): void => { + if (dismissed.includes(id)) { + return; + } + + setDismissed([...dismissed, id]); + }, + [dismissed, setDismissed], + ); + + return { items, dismiss, isReady: loaded }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 855c8b20012..fb239fe41f6 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -216,6 +216,11 @@ export const featureHijackingVariants = new Feature( export const featureLayoutV2 = new Feature('layout_v2', false); +export const featureSidebarAnnouncements = new Feature( + 'sidebar_announcements', + false, +); + export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); export const featureHeroCards = new Feature('hero_cards', { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index da9420fe1b7..4dd93b555c9 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -257,6 +257,10 @@ export enum LogEvent { // Tags - end // marketing CTA MarketingCtaDismiss = 'dismiss promotion', + // sidebar announcements ("what's new") + ImpressionAnnouncement = 'impression announcement', + ClickAnnouncement = 'click announcement', + DismissAnnouncement = 'dismiss announcement', // Reading reminder ScheduleReadingReminder = 'schedule reading reminder', SkipReadingReminder = 'skip reading reminder', diff --git a/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx new file mode 100644 index 00000000000..ce40315f8e3 --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCard } from '@dailydotdev/shared/src/components/announcements/AnnouncementCard'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import { + HotIcon, + MegaphoneIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +// The card is built for the ~240px expanded sidebar — frame the single-variant +// stories at that width so spacing/truncation read true. +const framed: NonNullable = (Story) => ( +
+ +
+); + +const meta: Meta = { + title: 'Components/Announcements/Card', + component: AnnouncementCard, + parameters: { + controls: { expanded: true }, + backgrounds: { default: 'dark' }, + }, + argTypes: { + variant: { + control: 'select', + options: Object.values(AnnouncementCardVariant), + }, + title: { control: 'text' }, + description: { control: 'text' }, + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Compact: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '#', + }, +}; + +export const CompactWithSubtitle: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: '#', + }, +}; + +export const Default: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Default, + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '#' }, + onClose: () => undefined, + }, +}; + +export const Cover: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them with your network in one tap.', + cta: { text: 'Try Clips', href: '#' }, + onClose: () => undefined, + }, +}; + +export const WithoutDismiss: Story = { + name: 'Default · no dismiss', + decorators: [framed], + args: { + ...Default.args, + onClose: undefined, + }, +}; + +const allVariants = [ + { + label: 'Compact', + props: Compact.args, + }, + { + label: 'Default', + props: Default.args, + }, + { + label: 'Cover', + props: Cover.args, + }, +]; + +export const AllVariants: StoryObj = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ {allVariants.map(({ label, props }) => ( +
+ + {label} + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + +
+ ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx new file mode 100644 index 00000000000..269b00695ef --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCarousel } from '@dailydotdev/shared/src/components/announcements/AnnouncementCarousel'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import type { AnnouncementItem } from '@dailydotdev/shared/src/components/announcements/types'; +import { + HotIcon, + MegaphoneIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +const sampleItems: AnnouncementItem[] = [ + { + id: 'cover', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them in one tap.', + cta: { text: 'Try Clips', href: '#' }, + }, + { + id: 'default', + variant: AnnouncementCardVariant.Default, + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '#' }, + }, + { + id: 'compact-1', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '#', + }, + { + id: 'compact-2', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: '#', + }, +]; + +// Local state so dismiss/advance behaves like the real sidebar hook. +const StatefulCarousel = ({ + initialItems, +}: { + initialItems: AnnouncementItem[]; +}) => { + const [items, setItems] = useState(initialItems); + + return ( +
+ setItems((prev) => prev.filter((i) => i.id !== id))} + /> + {items.length === 0 && ( +

+ All caught up — nothing left to show. +

+ )} +
+ ); +}; + +const meta: Meta = { + title: 'Components/Announcements/Carousel', + component: AnnouncementCarousel, + parameters: { + layout: 'centered', + backgrounds: { default: 'dark' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Stack: Story = { + name: 'Stack (browse + dismiss)', + render: () => , +}; + +export const SingleItem: Story = { + render: () => , +}; + +export const Empty: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx new file mode 100644 index 00000000000..45d7045b59b --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx @@ -0,0 +1,497 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MegaphoneIcon, + MiniCloseIcon, + MoveToIcon, + PlayIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + cloudinaryFeedFiltersScrollDark, + cloudinaryFeedFiltersYourFeedDark, + cloudinaryNotificationsBig, + cloudinarySquadsDirectoryCardBannerDefault, + cloudinarySquadsPromotionBanner, + cloudinarySquadsTourBanner2, +} from '@dailydotdev/shared/src/lib/image'; + +// One sample announcement reused across every direction so they're comparable. +const S = { + badge: 'New', + title: 'Introducing Clips', + desc: 'Save the best moments from any post and share them in one tap.', + cta: 'Try Clips', +}; + +const IMG = { + banner: cloudinarySquadsDirectoryCardBannerDefault, + feed: cloudinaryFeedFiltersYourFeedDark, + scroll: cloudinaryFeedFiltersScrollDark, + notif: cloudinaryNotificationsBig, + tour: cloudinarySquadsTourBanner2, + promo: cloudinarySquadsPromotionBanner, +}; + +// Card frame: relative + clip so every image and scrim respects the radius. +const CARD = + 'relative w-full overflow-hidden rounded-16 border border-border-subtlest-quaternary'; +const SURFACE = 'bg-background-subtle'; + +const cover = (src: string, className: string): ReactElement => ( + +); + +const Close = ({ light = true }: { light?: boolean }): ReactElement => ( + +); + +const Label = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( + + {S.badge} + +); + +const SolidBadge = (): ReactElement => ( + + {S.badge} + +); + +const Title = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( +

+ {S.title} +

+); + +const Desc = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( +

+ {S.desc} +

+); + +const Cta = (): ReactElement => ( + +); + +// Full-width bottom scrim so overlaid text stays readable, edge to edge. +const bottomScrim = ( +
+); + +// ---- Directions ----------------------------------------------------------- + +const Classic = (): ReactElement => ( +
+ {cover(IMG.banner, 'h-28 w-full object-cover')} + +
+