diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx new file mode 100644 index 00000000000..9ea4ba079bb --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.spec.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ExtensionShowcase } from './ExtensionShowcase'; +import { defaultExtensionShowcaseFeatures } from './defaultFeatures'; +import type { ExtensionShowcaseFeature } from './types'; + +const soloFeature: ExtensionShowcaseFeature = { + id: 'solo', + label: 'Solo', + icon: , + description: 'Solo description', +}; + +describe('ExtensionShowcase', () => { + it('shows the first feature message by default', () => { + render(); + + const [first] = defaultExtensionShowcaseFeatures; + expect(screen.getByText(first.description)).toBeInTheDocument(); + }); + + it('keeps the heading static and swaps the message on selection', () => { + const onFeatureChange = jest.fn(); + render( + , + ); + + const target = defaultExtensionShowcaseFeatures[2]; + const [button] = screen.getAllByRole('button', { name: target.label }); + fireEvent.click(button); + + expect(onFeatureChange).toHaveBeenCalledWith(target.id); + expect( + screen.getByRole('heading', { name: 'Static title' }), + ).toBeInTheDocument(); + expect(screen.getByText(target.description)).toBeInTheDocument(); + }); + + it('shows the active feature CTA and updates it when switching', () => { + render(); + + const [first] = defaultExtensionShowcaseFeatures; + expect(screen.getByRole('link', { name: first.cta })).toBeInTheDocument(); + + const target = defaultExtensionShowcaseFeatures[2]; + const [button] = screen.getAllByRole('button', { name: target.label }); + fireEvent.click(button); + + expect(screen.getByRole('link', { name: target.cta })).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: first.cta }), + ).not.toBeInTheDocument(); + }); + + it('falls back to ctaLabel when the feature has no cta', () => { + render( + , + ); + + expect( + screen.getByRole('link', { name: 'Fallback CTA' }), + ).toBeInTheDocument(); + }); + + it('hides the CTA when neither feature cta nor ctaLabel is set', () => { + render(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx new file mode 100644 index 00000000000..5d292b481c8 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase.tsx @@ -0,0 +1,197 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { Button } from '../../buttons/Button'; +import { ButtonVariant, ButtonSize } from '../../buttons/common'; +import { ChromeIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { downloadBrowserExtension } from '../../../lib/constants'; +import { anchorDefaultRel } from '../../../lib/strings'; +import { ExtensionShowcaseMedia } from './ExtensionShowcaseMedia'; +import { defaultExtensionShowcaseFeatures } from './defaultFeatures'; +import type { ExtensionShowcaseFeature } from './types'; + +export interface ExtensionShowcaseProps { + /** Features to render. Defaults to the daily.dev extension feature set. */ + features?: ExtensionShowcaseFeature[]; + /** Feature selected on first render. Defaults to the first feature. */ + defaultFeatureId?: string; + /** Static heading above the card. */ + title?: string; + /** Static sub-heading above the card. */ + subtitle?: string; + /** Fallback CTA label used when the active feature defines no `cta`. */ + ctaLabel?: string; + /** CTA destination. Defaults to the extension download link. */ + ctaHref?: string; + /** Fires when the CTA is clicked (for tracking / step transitions). */ + onCtaClick?: () => void; + /** Fires when the selected feature changes. */ + onFeatureChange?: (featureId: string) => void; + className?: string; +} + +interface ShowcaseNavItemProps { + feature: ExtensionShowcaseFeature; + isActive: boolean; + vertical: boolean; + onClick: () => void; +} + +function ShowcaseNavItem({ + feature, + isActive, + vertical, + onClick, +}: ShowcaseNavItemProps): ReactElement { + return ( + + ); +} + +export function ExtensionShowcase({ + features = defaultExtensionShowcaseFeatures, + defaultFeatureId, + title = 'Everything the extension unlocks', + subtitle, + ctaLabel = 'Get the daily.dev extension', + ctaHref = downloadBrowserExtension, + onCtaClick, + onFeatureChange, + className, +}: ExtensionShowcaseProps): ReactElement { + if (!features.length) { + throw new Error('ExtensionShowcase requires at least one feature'); + } + + const [activeId, setActiveId] = useState(defaultFeatureId ?? features[0].id); + const activeFeature = + features.find((feature) => feature.id === activeId) ?? features[0]; + + const selectFeature = (featureId: string): void => { + setActiveId(featureId); + onFeatureChange?.(featureId); + }; + + const ctaText = activeFeature.cta ?? ctaLabel; + + return ( +
+ {(title || subtitle) && ( +
+ {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} +
+ )} + +
+
+ + + + +
+ + + {activeFeature.description} + + {ctaText && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseMedia.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseMedia.tsx new file mode 100644 index 00000000000..02486af61b2 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcaseMedia.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ExtensionShowcaseMedia as Media } from './types'; + +interface ExtensionShowcaseMediaProps { + media?: Media; + className?: string; +} + +export function ExtensionShowcaseMedia({ + media, + className, +}: ExtensionShowcaseMediaProps): ReactElement { + const wrapperClass = classNames( + 'relative aspect-video w-full overflow-hidden rounded-16 border border-border-subtlest-tertiary bg-surface-float', + className, + ); + + if (!media) { + return
; + } + + if (media.type === 'video') { + return ( +
+
+ ); + } + + return ( +
+ {media.alt +
+ ); +} diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.tsx b/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.tsx new file mode 100644 index 00000000000..f21fe8d9c50 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/defaultFeatures.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { + NewTabIcon, + SitesIcon, + TLDRIcon, + StarIcon, + ShortcutsIcon, + SidebarArrowLeft, + HotIcon, + MoonIcon, +} from '../../icons'; +import { + cloudinaryOnboardingActivationDemo, + cloudinaryOnboardingExtension, +} from '../../../lib/image'; +import { BrowserName } from '../../../lib/func'; +import type { ExtensionShowcaseFeature } from './types'; + +const screenshot = cloudinaryOnboardingExtension[BrowserName.Chrome]; + +// Real per-feature demo assets do not exist yet — these reuse the existing +// onboarding screenshot/video as placeholders. Swap `media` per feature once +// design delivers dedicated clips. +const placeholderImage: ExtensionShowcaseFeature['media'] = { + type: 'image', + src: screenshot.default, + retinaSrc: screenshot.retina, + alt: 'daily.dev extension preview', +}; + +// Ordered by install pull. Lead with the new-tab feed (what the extension is), +// then reading inside daily.dev, the daily brief, the two shortcut flavors, the +// companion, and the rest. Every item leans on something the extension does +// that the website alone can't. +export const defaultExtensionShowcaseFeatures: ExtensionShowcaseFeature[] = [ + { + id: 'feed', + label: 'New tab feed', + icon: , + description: + 'Every new tab becomes a feed tuned to your stack, so staying current happens in the gaps of your day — with zero effort.', + cta: 'Get daily.dev on every new tab', + media: { + type: 'video', + src: cloudinaryOnboardingActivationDemo, + alt: 'daily.dev new tab feed in action', + }, + }, + { + id: 'read-here', + label: 'Read it here', + icon: , + description: + 'Open any article inside daily.dev in a clean reader with the discussion beside it — no more graveyard of half-read tabs.', + cta: 'Get the in-app reader', + media: placeholderImage, + }, + { + id: 'brief', + label: 'Daily brief', + icon: , + description: + 'Your first tab of the day opens to an AI-built brief that compresses everything that matters into a two-minute read.', + cta: 'Get your daily brief', + media: placeholderImage, + }, + { + id: 'most-visited', + label: 'Most visited', + icon: , + description: + 'Your most-visited sites come straight from your browser, so the new tab still knows where you were headed — no setup.', + cta: 'Bring my top sites back', + media: placeholderImage, + }, + { + id: 'shortcuts', + label: 'Shortcuts', + icon: , + description: + 'Pin the apps you live in — or import your bookmarks bar in a click — so your essentials stay one click from every tab.', + cta: 'Get my shortcuts everywhere', + media: placeholderImage, + }, + { + id: 'companion', + label: 'Companion', + icon: , + description: + 'The companion rides along on any site you visit, adding an instant TLDR, what the community thinks, and related reads.', + cta: 'Get the companion', + media: placeholderImage, + }, + { + id: 'streak', + label: 'Reading streak', + icon: , + description: + 'daily.dev greets you on every new tab, so keeping your reading streak alive takes no willpower.', + cta: 'Start my reading streak', + media: placeholderImage, + }, + { + id: 'focus', + label: 'Focus mode', + icon: , + description: + 'Need to focus? Pause the new tab for as long as you like and point it anywhere — full control, in one click.', + cta: 'Add daily.dev to my browser', + media: placeholderImage, + }, +]; diff --git a/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts b/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts new file mode 100644 index 00000000000..54704779a92 --- /dev/null +++ b/packages/shared/src/components/onboarding/ExtensionShowcase/types.ts @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; + +export type ExtensionShowcaseMedia = + | { + type: 'video'; + src: string; + poster?: string; + alt?: string; + } + | { + type: 'image'; + src: string; + retinaSrc?: string; + alt?: string; + }; + +export interface ExtensionShowcaseFeature { + /** Stable identifier, also used for tracking. */ + id: string; + /** Short label shown in the left dock / mobile tab strip. */ + label: string; + /** Icon rendered in the dock. Should accept `secondary` and `className`. */ + icon: ReactElement; + /** Single-sentence value message shown in the detail panel. */ + description: string; + /** Optional CTA label shown while this feature is active. Falls back to the + * component's `ctaLabel` when omitted. */ + cta?: string; + /** Right-side media. Video is autoplayed muted/looped; image supports retina. */ + media?: ExtensionShowcaseMedia; +} diff --git a/packages/storybook/stories/components/ExtensionShowcase.stories.tsx b/packages/storybook/stories/components/ExtensionShowcase.stories.tsx new file mode 100644 index 00000000000..6d211ce6b00 --- /dev/null +++ b/packages/storybook/stories/components/ExtensionShowcase.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ExtensionShowcase } from '@dailydotdev/shared/src/components/onboarding/ExtensionShowcase/ExtensionShowcase'; + +const meta: Meta = { + title: 'Components/Onboarding/ExtensionShowcase', + component: ExtensionShowcase, + parameters: { + layout: 'fullscreen', + }, + render: (args) => ( +
+ +
+ ), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const CustomCta: Story = { + args: { + ctaLabel: 'Add daily.dev to Chrome — free', + }, +}; + +export const StartOnBrief: Story = { + args: { + defaultFeatureId: 'brief', + }, +};