From 614e51a502f5958bc5f371903de8f8845cb10cc1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 14:41:21 +0300 Subject: [PATCH 1/2] feat: post-signup feed activation bar (flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent, non-dismissible activation strip pinned above the header on every page, shown only to signed-in users who registered but haven't set up their feed yet (no tag/content customization). Legacy users (registered before the requirement date) and the /onboarding route are exempt. - Gated behind the `post_signup_activation` GrowthBook flag (default off); flag off restores current behavior exactly. - Mounted in the shared MainLayout top slot. The bar's measured height is fed into `--safe-area-top` so the rail, header and content shift down and it sits above all fixed chrome (device notch inset preserved). - Progress ring (step 1 of 2) + white primary CTA → routes to /onboarding with `after_auth` back to the current page. Impression/click logged via the shared log events. - Presentational `PostOnboardingActivationView` split out for Storybook (playground + progress/copy variations + mobile). Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/MainLayout.tsx | 2 + .../post/PostOnboardingActivation.module.css | 60 ++++++++ .../post/PostOnboardingActivation.spec.tsx | 89 ++++++++++++ .../post/PostOnboardingActivation.tsx | 104 ++++++++++++++ .../post/PostOnboardingActivationView.tsx | 131 ++++++++++++++++++ packages/shared/src/lib/featureManagement.ts | 9 ++ packages/shared/src/lib/log.ts | 1 + .../post/PostOnboardingActivation.stories.tsx | 91 ++++++++++++ 8 files changed, 487 insertions(+) create mode 100644 packages/shared/src/components/post/PostOnboardingActivation.module.css create mode 100644 packages/shared/src/components/post/PostOnboardingActivation.spec.tsx create mode 100644 packages/shared/src/components/post/PostOnboardingActivation.tsx create mode 100644 packages/shared/src/components/post/PostOnboardingActivationView.tsx create mode 100644 packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index e3ab6d50043..ac0a80bb55b 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 00000000000..31d03def83e --- /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 00000000000..4bc41ce0858 --- /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 00000000000..c3fa9ef46c1 --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx @@ -0,0 +1,104 @@ +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 useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featurePostSignupActivation } from '../../lib/featureManagement'; +import { LogEvent, TargetType } from '../../lib/log'; +import { AFTER_AUTH_PARAM } from '../auth/common'; + +export const PostOnboardingActivation = (): ReactElement | null => { + const router = useRouter(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { isOnboardingActionsReady, isOnboardingComplete } = + useOnboardingActions(); + const wrapperRef = useRef(null); + + // Never show on the onboarding flow itself — that's where the CTA leads. + const isOnboardingRoute = router.pathname?.startsWith('/onboarding'); + + // 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 = isEligible && isFeatureEnabled; + + useLogEventOnce( + () => ({ + event_name: LogEvent.Impression, + target_type: TargetType.PostSignupActivation, + }), + { condition: 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: LogEvent.Click, + target_type: TargetType.PostSignupActivation, + }); + + 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 (`1000`) 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 00000000000..332cc49109e --- /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 e30adb28f5f..278492bfe00 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 4d37a4b92b2..97dc8b66199 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 00000000000..13540332d0b --- /dev/null +++ b/packages/storybook/stories/components/post/PostOnboardingActivation.stories.tsx @@ -0,0 +1,91 @@ +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 default configuration mounted app-wide by the activation container. */ +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 and the CTA sits under the copy. */ +export const Mobile: Story = { + parameters: { + viewport: { defaultViewport: 'mobile1' }, + }, +}; From 6fb7f3ec81f3f362ca0a7c75057b035aca7f90c2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 23:25:01 +0300 Subject: [PATCH 2/2] refactor: reserve activation bar height in CSS instead of a ResizeObserver The copy is fixed, so the bar's height is knowable at author time. Replace the measuring effect with a constant per breakpoint and let `body:has()` fold it into `--safe-area-top`, mirroring how PromotionalBanner reserves its space. Co-Authored-By: Claude Opus 4.8 --- .../post/PostOnboardingActivation.module.css | 24 +++++++++ .../post/PostOnboardingActivation.tsx | 50 ++++--------------- 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/packages/shared/src/components/post/PostOnboardingActivation.module.css b/packages/shared/src/components/post/PostOnboardingActivation.module.css index 31d03def83e..cef163f3645 100644 --- a/packages/shared/src/components/post/PostOnboardingActivation.module.css +++ b/packages/shared/src/components/post/PostOnboardingActivation.module.css @@ -1,3 +1,27 @@ +/* The bar sits below the device notch and above every piece of fixed chrome, + * so its height has to be reserved at the top of the app. The copy is fixed, + * so the height is a constant per breakpoint (mobile stacks the CTA under the + * copy) and the whole reservation is plain CSS — no measuring. */ +.pinned { + top: env(safe-area-inset-top, 0px); + height: var(--post-signup-activation-height); +} + +/* Declared on `body` (not `:root`) so it overrides the `:root` default for the + * body, `body::before` and every fixed descendant that reads it. */ +:global(body):has(.pinned) { + --post-signup-activation-height: 9.25rem; + --safe-area-top: calc( + env(safe-area-inset-top, 0px) + var(--post-signup-activation-height) + ); +} + +@media (min-width: 656px) { + :global(body):has(.pinned) { + --post-signup-activation-height: 5rem; + } +} + /* 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 — diff --git a/packages/shared/src/components/post/PostOnboardingActivation.tsx b/packages/shared/src/components/post/PostOnboardingActivation.tsx index c3fa9ef46c1..3c6039bf935 100644 --- a/packages/shared/src/components/post/PostOnboardingActivation.tsx +++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx @@ -1,6 +1,8 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import styles from './PostOnboardingActivation.module.css'; import { PostOnboardingActivationView } from './PostOnboardingActivationView'; import { useAuthContext } from '../../contexts/AuthContext'; import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; @@ -17,7 +19,6 @@ export const PostOnboardingActivation = (): ReactElement | null => { const { logEvent } = useLogContext(); const { isOnboardingActionsReady, isOnboardingComplete } = useOnboardingActions(); - const wrapperRef = useRef(null); // Never show on the onboarding flow itself — that's where the CTA leads. const isOnboardingRoute = router.pathname?.startsWith('/onboarding'); @@ -45,34 +46,6 @@ export const PostOnboardingActivation = (): ReactElement | null => { { condition: 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; } @@ -90,15 +63,14 @@ export const PostOnboardingActivation = (): ReactElement | null => { }; 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 (`1000`) so it stays visible. -
- + // Pinned above everything: `top` and the reserved height live in the module + // (not the `top-0` class) so the global safe-area rule doesn't push the bar + // itself down; z sits above the notch-fill (`1000`) so it stays visible. +
+
); };