Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -296,6 +297,7 @@ function MainLayoutComponent({
)}
>
{canGoBack && <GoBackHeaderMobile />}
<PostOnboardingActivation />
{customBanner}
{isBannerAvailable && <PromotionalBanner />}
<InAppNotificationElement />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* 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 —
* 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;
}
Original file line number Diff line number Diff line change
@@ -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(<PostOnboardingActivation />);

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(<PostOnboardingActivation />);

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(<PostOnboardingActivation />);

expect(
screen.queryByRole('complementary', { name: 'Personalize your feed' }),
).not.toBeInTheDocument();
});
});
76 changes: 76 additions & 0 deletions packages/shared/src/components/post/PostOnboardingActivation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { ReactElement } 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';
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();

// 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 },
);

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` 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.
<div className={classNames('fixed inset-x-0 z-[1001]', styles.pinned)}>
<PostOnboardingActivationView
className="flex h-full items-center"
onCtaClick={onBuildFeed}
/>
</div>
);
};
131 changes: 131 additions & 0 deletions packages/shared/src/components/post/PostOnboardingActivationView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside
aria-label="Personalize your feed"
className={classNames(
'relative w-full overflow-hidden border-b bg-raw-pepper-90 shadow-2',
styles.border,
className,
)}
>
{/* Soft brand glow, centered behind the content. */}
<div
className={classNames(
'pointer-events-none absolute inset-0',
styles.glow,
)}
/>
{/* Hairline sheen along the top edge for the glossy panel feel. */}
<div
className={classNames(
'pointer-events-none absolute inset-x-0 top-0 h-px',
styles.sheen,
)}
/>

{/* Content is capped and centered so it sits mid-page instead of
* stretching across the full-width bar. */}
<div className="relative mx-auto flex w-full max-w-[63.75rem] flex-col gap-3 px-4 py-4 tablet:flex-row tablet:items-center tablet:justify-center tablet:gap-10 tablet:px-6">
<div className="flex w-full min-w-0 items-center gap-3.5 tablet:w-auto">
{/* Progress ring: setup started, not finished. */}
<span
className="relative flex size-11 shrink-0 items-center justify-center"
aria-hidden
>
<svg viewBox="0 0 36 36" className="size-11 -rotate-90">
<circle
cx="18"
cy="18"
r={RADIUS}
fill="none"
strokeWidth="3"
className={styles.ringTrack}
/>
<circle
cx="18"
cy="18"
r={RADIUS}
fill="none"
strokeWidth="3"
strokeLinecap="round"
stroke="currentColor"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE * (1 - ratio)}
className="text-accent-cabbage-default"
/>
</svg>
<span className="absolute font-bold text-white typo-caption1">
{progress}/{steps}
</span>
</span>
<div className="min-w-0 flex-1 tablet:max-w-md tablet:flex-none">
<Typography
tag={TypographyTag.H2}
type={TypographyType.Body}
bold
className="text-white [text-wrap:balance]"
>
{title}
</Typography>
<Typography
tag={TypographyTag.P}
type={TypographyType.Footnote}
className={classNames(
'mt-0.5 [text-wrap:pretty]',
styles.description,
)}
>
{description}
</Typography>
</div>
</div>

<Button
type="button"
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
className={classNames('w-full shrink-0 tablet:w-auto', styles.cta)}
onClick={onCtaClick}
>
{ctaLabel}
</Button>
</div>
</aside>
);
};
Loading
Loading