diff --git a/.gitignore b/.gitignore index 8174566fc2..7a5b2e5aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ plans/*.md # Claude Code runtime state (hooks/skills/settings remain tracked) .claude/scheduled_tasks.lock -.claude/drafts/ \ No newline at end of file +.claude/drafts/.claude/launch.json diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index e3ab6d5004..ac0a80bb55 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { useRouter } from 'next/router'; import dynamic from 'next/dynamic'; import PromotionalBanner from './PromotionalBanner'; +import { PostOnboardingActivation } from './post/PostOnboardingActivation'; import useSidebarRendered from '../hooks/useSidebarRendered'; import { useLogContext } from '../contexts/LogContext'; import SettingsContext from '../contexts/SettingsContext'; @@ -296,6 +297,7 @@ function MainLayoutComponent({ )} > {canGoBack && } + {customBanner} {isBannerAvailable && } diff --git a/packages/shared/src/components/post/PostOnboardingActivation.module.css b/packages/shared/src/components/post/PostOnboardingActivation.module.css new file mode 100644 index 0000000000..31d03def83 --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivation.module.css @@ -0,0 +1,60 @@ +/* The banner is always dark regardless of the app theme, so its light-on-dark + * colours are declared here with explicit rgba values. Tailwind's opacity + * modifier on `white` (e.g. text-white/60) does NOT compile in this config — + * it silently falls back to the inherited (dark) theme colour and disappears + * on the near-black panel — so we can't rely on it. */ + +/* Soft brand glow anchored to the right of the strip. Uses radial gradients + * with color-mix on the theme accent vars (mirrors .top-hero-aurora) so it + * renders reliably instead of relying on opacity-modified accent utilities. */ +.glow { + background: + radial-gradient( + 60% 170% at 50% 50%, + color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 64%) 0%, + transparent 62% + ), + radial-gradient( + 42% 150% at 15% 50%, + color-mix(in srgb, var(--theme-accent-onion-default), transparent 76%) 0%, + transparent 58% + ), + radial-gradient( + 42% 150% at 85% 50%, + color-mix(in srgb, var(--theme-accent-onion-default), transparent 76%) 0%, + transparent 58% + ); +} + +.border { + border-color: rgba(255, 255, 255, 0.08); +} + +.sheen { + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.22), + transparent + ); +} + +.description { + color: rgba(255, 255, 255, 0.62); +} + +.ringTrack { + stroke: rgba(255, 255, 255, 0.16); +} + +/* CTA is pinned to the white (dark-theme) primary look on the dark panel. */ +.cta, +.cta:hover { + background-color: #fff !important; + color: #0b0d12 !important; + border-color: transparent !important; +} + +.cta:hover { + background-color: rgba(255, 255, 255, 0.88) !important; +} diff --git a/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx b/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx new file mode 100644 index 0000000000..4bc41ce085 --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivation.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PostOnboardingActivation } from './PostOnboardingActivation'; + +const mockPush = jest.fn(); +const mockLogEvent = jest.fn(); +const mockUseConditionalFeature = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => ({ + asPath: '/posts/post-1?ref=share', + pathname: '/posts/[id]', + query: {}, + push: mockPush, + }), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => ({ user: { id: 'new-user' } }), +})); + +jest.mock('../../hooks/auth/useOnboardingActions', () => ({ + useOnboardingActions: () => ({ + isOnboardingActionsReady: true, + isOnboardingComplete: false, + }), +})); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: (args: unknown) => mockUseConditionalFeature(args), +})); + +describe('PostOnboardingActivation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + }); + + it('shows the feed setup prompt and routes to onboarding on click', async () => { + render(); + + expect( + await screen.findByText("Your feed isn't set up yet"), + ).toBeInTheDocument(); + expect( + screen.getByText("You're one step away from discovering what's next."), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Finish setup' })); + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/onboarding', + query: { after_auth: '/posts/post-1?ref=share' }, + }); + }); + + it('is a required step with no dismiss control', async () => { + render(); + + await screen.findByRole('complementary', { + name: 'Personalize your feed', + }); + + expect( + screen.queryByRole('button', { name: 'Dismiss feed personalization' }), + ).not.toBeInTheDocument(); + }); + + it('stays hidden while the feature flag is off', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + + render(); + + expect( + screen.queryByRole('complementary', { name: 'Personalize your feed' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/PostOnboardingActivation.tsx b/packages/shared/src/components/post/PostOnboardingActivation.tsx new file mode 100644 index 0000000000..b10a4479c7 --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx @@ -0,0 +1,119 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import { PostOnboardingActivationView } from './PostOnboardingActivationView'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; +import { useLogContext } from '../../contexts/LogContext'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featurePostSignupActivation } from '../../lib/featureManagement'; +import { TargetType } from '../../lib/log'; +import { AFTER_AUTH_PARAM } from '../auth/common'; + +// Force the bar on for local review without GrowthBook via `?=1`. +const PREVIEW_QUERY = 'postOnboardingPreview'; +const isPreviewEnabled = (value?: string | string[]): boolean => + value === '1' || + value === 'true' || + (Array.isArray(value) && (value.includes('1') || value.includes('true'))); + +export const PostOnboardingActivation = (): ReactElement | null => { + const router = useRouter(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { isOnboardingActionsReady, isOnboardingComplete } = + useOnboardingActions(); + const hasLoggedImpression = useRef(false); + const wrapperRef = useRef(null); + + // Never show on the onboarding flow itself — that's where the CTA leads. + const isOnboardingRoute = router.pathname?.startsWith('/onboarding'); + const isPreviewMode = isPreviewEnabled(router.query?.[PREVIEW_QUERY]); + + // Signed-in user who registered but hasn't set up their feed (no tag/content + // customization). This is the audience for the required activation step. + const isEligible = + !isOnboardingRoute && + !!user?.id && + isOnboardingActionsReady && + !isOnboardingComplete; + + const { value: isFeatureEnabled } = useConditionalFeature({ + feature: featurePostSignupActivation, + shouldEvaluate: isEligible, + }); + + const shouldShow = + !isOnboardingRoute && (isPreviewMode || (isEligible && isFeatureEnabled)); + + useEffect(() => { + if (!shouldShow || hasLoggedImpression.current) { + return; + } + + hasLoggedImpression.current = true; + logEvent({ + event_name: 'impression', + target_type: TargetType.PostSignupActivation, + target_id: 'post signup activation', + }); + }, [logEvent, shouldShow]); + + // The bar is pinned above all fixed chrome. Reserve its height at the top of + // the app by feeding it into `--safe-area-top` (which shifts the rail, header + // and body content down) while preserving the real device notch inset. + useEffect(() => { + const el = wrapperRef.current; + if (!shouldShow || !el || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const root = document.documentElement; + const apply = (): void => { + const height = Math.round(el.getBoundingClientRect().height); + root.style.setProperty( + '--safe-area-top', + `calc(env(safe-area-inset-top, 0px) + ${height}px)`, + ); + }; + + apply(); + const observer = new ResizeObserver(apply); + observer.observe(el); + + return () => { + observer.disconnect(); + root.style.removeProperty('--safe-area-top'); + }; + }, [shouldShow]); + + if (!shouldShow) { + return null; + } + + const onBuildFeed = (): void => { + logEvent({ + event_name: 'click', + target_type: TargetType.PostSignupActivation, + target_id: 'build feed from post signup activation', + }); + + router.push({ + pathname: '/onboarding', + query: { [AFTER_AUTH_PARAM]: router.asPath }, + }); + }; + + return ( + // Pinned above everything. `top` is set inline (not the `top-0` class) so + // the global safe-area rule doesn't push the bar itself down; z sits above + // the notch-fill (`max`) so it stays visible. +
+ +
+ ); +}; diff --git a/packages/shared/src/components/post/PostOnboardingActivationView.tsx b/packages/shared/src/components/post/PostOnboardingActivationView.tsx new file mode 100644 index 0000000000..332cc49109 --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivationView.tsx @@ -0,0 +1,131 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './PostOnboardingActivation.module.css'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +export interface PostOnboardingActivationViewProps { + title?: string; + description?: string; + ctaLabel?: string; + /** Completed steps of the onboarding, drives the progress ring. */ + progress?: number; + /** Total steps of the onboarding. */ + steps?: number; + onCtaClick?: () => void; + className?: string; +} + +const RADIUS = 15; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export const PostOnboardingActivationView = ({ + title = "Your feed isn't set up yet", + description = "You're one step away from discovering what's next.", + ctaLabel = 'Finish setup', + progress = 1, + steps = 2, + onCtaClick, + className, +}: PostOnboardingActivationViewProps): ReactElement => { + const ratio = steps > 0 ? Math.min(Math.max(progress / steps, 0), 1) : 0; + + return ( + + ); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index e30adb28f5..278492bfe0 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -293,3 +293,12 @@ export const featureNotificationsRedesign = new Feature( // `analytics.impressions` field. Control hides it entirely. Keep the default // `false` — GrowthBook ramps it. export const featureCardImpressions = new Feature('card_impressions', false); + +// Post-signup feed activation bar: a persistent, non-dismissible strip shown +// above the header on every page for signed-in users who registered but have +// not set up their feed yet (no tag/content customization). Control hides it +// entirely. Keep the default `false` — GrowthBook ramps it. +export const featurePostSignupActivation = new Feature( + 'post_signup_activation', + false, +); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 4d37a4b92b..97dc8b6619 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -531,6 +531,7 @@ export enum TargetType { SpotlightCommand = 'spotlight command', MyFeedModal = 'my feed modal', ArticleAnonymousCTA = 'article anonymous cta', + PostSignupActivation = 'post signup activation', EnableNotifications = 'enable notifications', OnboardingChecklist = 'onboarding checklist', LoginButton = 'login button', diff --git a/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx new file mode 100644 index 0000000000..a4c6c3b2e1 --- /dev/null +++ b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PostOnboardingActivationView } from '@dailydotdev/shared/src/components/post/PostOnboardingActivationView'; + +const meta: Meta = { + title: 'Components/Post/PostOnboardingActivation', + component: PostOnboardingActivationView, + args: { + title: "Your feed isn't set up yet", + description: "You're one step away from discovering what's next.", + ctaLabel: 'Finish setup', + progress: 1, + steps: 2, + }, + argTypes: { + progress: { control: { type: 'number', min: 0, max: 5, step: 1 } }, + steps: { control: { type: 'number', min: 1, max: 5, step: 1 } }, + onCtaClick: { action: 'cta clicked' }, + className: { table: { disable: true } }, + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +/** + * Interactive playground — tweak the copy, CTA label and progress ring via + * the Controls panel. The panel is intentionally always dark, so it looks the + * same under the light/dark theme toolbar toggle. + */ +export const Playground: Story = {}; + +/** The exact configuration that ships on the post page. */ +export const Default: Story = {}; + +/** Different progress fractions to sanity-check the ring + label. */ +export const ProgressVariations: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; + +/** Copy-length stress test to check wrapping and alignment. */ +export const CopyVariations: Story = { + render: (args) => ( +
+ + +
+ ), +}; + +/** + * Narrow viewport — the layout stacks, the CTA sits under the copy and the + * close control moves to the top-right corner. + */ +export const Mobile: Story = { + parameters: { + viewport: { defaultViewport: 'mobile1' }, + }, +};