diff --git a/packages/shared/src/components/LoginButton.tsx b/packages/shared/src/components/LoginButton.tsx index 4d29121e4a9..da4daff2cf0 100644 --- a/packages/shared/src/components/LoginButton.tsx +++ b/packages/shared/src/components/LoginButton.tsx @@ -5,7 +5,7 @@ import { Button, ButtonVariant } from './buttons/Button'; import { useLogContext } from '../contexts/LogContext'; import type { LogEvent } from '../hooks/log/useLogQueue'; import { TargetType } from '../lib/log'; -import { useAuthContext } from '../contexts/AuthContext'; +import { type LoginState, useAuthContext } from '../contexts/AuthContext'; import { AuthTriggers } from '../lib/auth'; interface ClassName { @@ -15,6 +15,7 @@ interface ClassName { interface LoginButtonProps { className?: ClassName; + onRegistrationSuccess?: LoginState['onRegistrationSuccess']; } enum ButtonCopy { @@ -32,6 +33,7 @@ const getLogEvent = (copy: ButtonCopy): LogEvent => ({ export default function LoginButton({ className = {}, + onRegistrationSuccess, }: LoginButtonProps): ReactElement { const { logEvent } = useLogContext(); const { showLogin } = useAuthContext(); @@ -41,6 +43,7 @@ export default function LoginButton({ trigger: AuthTriggers.MainButton, options: { isLogin: copy === ButtonCopy.Login, + onRegistrationSuccess, }, }); }; diff --git a/packages/shared/src/components/auth/AuthenticationBanner.tsx b/packages/shared/src/components/auth/AuthenticationBanner.tsx index ab8b74e6c58..2178b43dbdd 100644 --- a/packages/shared/src/components/auth/AuthenticationBanner.tsx +++ b/packages/shared/src/components/auth/AuthenticationBanner.tsx @@ -5,7 +5,7 @@ import classed from '../../lib/classed'; import { OnboardingHeadline } from './OnboardingHeadline'; import AuthOptions from './AuthOptions'; import { AuthTriggers } from '../../lib/auth'; -import { useAuthContext } from '../../contexts/AuthContext'; +import { type LoginState, useAuthContext } from '../../contexts/AuthContext'; import { authGradientBg, BottomBannerContainer } from '../marketing/banners'; import { ButtonVariant } from '../buttons/common'; import { Image } from '../image/Image'; @@ -20,11 +20,13 @@ const Section = classed('div', 'flex flex-col'); interface AuthenticationBannerProps extends PropsWithChildren { compact?: boolean; + onRegistrationSuccess?: LoginState['onRegistrationSuccess']; } export function AuthenticationBanner({ children, compact, + onRegistrationSuccess, }: AuthenticationBannerProps): ReactElement { const { showLogin } = useAuthContext(); @@ -89,6 +91,7 @@ export function AuthenticationBanner({ isLogin: !!props.isLoginFlow, defaultDisplay: props.defaultDisplay, formValues: props.email ? { email: props.email } : undefined, + onRegistrationSuccess, }, }); }} diff --git a/packages/shared/src/components/auth/CustomAuthBanner.tsx b/packages/shared/src/components/auth/CustomAuthBanner.tsx index 0ab2d48cdc8..41ae2e1feb2 100644 --- a/packages/shared/src/components/auth/CustomAuthBanner.tsx +++ b/packages/shared/src/components/auth/CustomAuthBanner.tsx @@ -1,19 +1,36 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; +import { useRouter } from 'next/router'; import { useOnboardingActions } from '../../hooks/auth'; import { useAuthContext } from '../../contexts/AuthContext'; import { useViewSize, ViewSize } from '../../hooks'; import LoginButton from '../LoginButton'; import { authGradientBg } from '../marketing/banners'; +import { + isPostOnboardingPreviewEnabled, + markPostSignupActivation, + POST_ONBOARDING_PREVIEW_QUERY, +} from '../../lib/postSignupActivation'; +import { isDevelopment } from '../../lib/constants'; const CustomAuthBanner = (): ReactElement | null => { const { shouldShowAuthBanner } = useOnboardingActions(); const { shouldShowLogin } = useAuthContext(); + const router = useRouter(); const isLaptop = useViewSize(ViewSize.Laptop); const isTablet = useViewSize(ViewSize.Tablet); + const isActivationPreview = + router.pathname === '/posts/[id]' && + (isDevelopment || + isPostOnboardingPreviewEnabled( + router.query?.[POST_ONBOARDING_PREVIEW_QUERY], + )); const isValid = - shouldShowAuthBanner && !isLaptop && (isTablet || !shouldShowLogin); + !isActivationPreview && + shouldShowAuthBanner && + !isLaptop && + (isTablet || !shouldShowLogin); if (!isValid) { return null; @@ -21,6 +38,7 @@ const CustomAuthBanner = (): ReactElement | null => { return ( @@ -36,17 +37,40 @@ export const PostAuthBanner = ({ const userId = searchParams?.get('userid'); if (userId) { - return ; + return ( + + ); } const social = getSocialReferrer(); if (social) { - return ; + return ( + + ); } if (geo?.region) { - return ; + return ( + + ); } - return ; + return ( + + ); }; diff --git a/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx b/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx index 5bbb9a15ce8..e3ef1e28c36 100644 --- a/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx +++ b/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx @@ -2,19 +2,25 @@ import type { ReactElement } from 'react'; import React from 'react'; import { geoToCountry, geoToEmoji } from '../../../../lib/geo'; import { AuthenticationBanner, OnboardingHeadline } from '../../../auth'; +import type { LoginState } from '../../../../contexts/AuthContext'; const GeoPersonalizedBanner = ({ geo, compact, + onRegistrationSuccess, }: { geo: string; compact?: boolean; + onRegistrationSuccess?: LoginState['onRegistrationSuccess']; }): ReactElement => { const emoji = geoToEmoji(geo); const country = geoToCountry(geo); return ( - + { const Icon = socialIcon[site]; const gradient = socialGradient[site]; return ( - + { const key = generateQueryKey(RequestKey.ReferringUser); const { data: user, isError } = useQuery({ @@ -20,13 +23,21 @@ const UserPersonalizedBanner = ({ }); if (isError) { - return ; + return ( + + ); } const name = user?.name ? user?.name.split(' ')[0] : user?.username; return ( - + {user?.image && } ({ + useRouter: () => ({ + asPath: '/posts/post-1?ref=share', + 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('../../lib/postSignupActivation', () => ({ + clearPostSignupActivation: jest.fn(), + hasPostSignupActivation: jest.fn(() => true), + isPostOnboardingPreviewEnabled: jest.fn(() => false), + POST_ONBOARDING_PREVIEW_QUERY: 'postOnboardingPreview', +})); + +const mockHasPostSignupActivation = jest.mocked(hasPostSignupActivation); +const mockClearPostSignupActivation = jest.mocked(clearPostSignupActivation); + +describe('PostOnboardingActivation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHasPostSignupActivation.mockReturnValue(true); + }); + + it('frames feed setup as the final step with a clear value proposition', async () => { + render(); + + expect( + await screen.findByText("You're in. Now make daily.dev yours."), + ).toBeInTheDocument(); + expect(screen.getByText('Account ready')).toBeInTheDocument(); + expect(screen.getByText('Final setup')).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'Personalize my feed' }), + ); + + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/onboarding', + query: { after_auth: '/posts/post-1?ref=share' }, + }); + }); + + it('dismisses the prompt without completing onboarding', async () => { + render(); + + await screen.findByRole('complementary', { + name: 'Personalize your feed', + }); + await userEvent.click( + screen.getByRole('button', { + name: 'Dismiss feed personalization', + }), + ); + + expect(mockClearPostSignupActivation).toHaveBeenCalledTimes(1); + await waitFor(() => { + 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..369e0b48bbc --- /dev/null +++ b/packages/shared/src/components/post/PostOnboardingActivation.tsx @@ -0,0 +1,180 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; +import { useLogContext } from '../../contexts/LogContext'; +import { TargetType } from '../../lib/log'; +import { isDevelopment } from '../../lib/constants'; +import { + clearPostSignupActivation, + hasPostSignupActivation, + isPostOnboardingPreviewEnabled, + POST_ONBOARDING_PREVIEW_QUERY, +} from '../../lib/postSignupActivation'; +import { AFTER_AUTH_PARAM } from '../auth/common'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { VIcon } from '../icons/V'; +import { IconSize } from '../Icon'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +export const PostOnboardingActivation = (): ReactElement | null => { + const router = useRouter(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { isOnboardingActionsReady, isOnboardingComplete } = + useOnboardingActions(); + const [isVisible, setIsVisible] = useState(false); + const [isLocalPreviewDismissed, setIsLocalPreviewDismissed] = useState(false); + const hasLoggedImpression = useRef(false); + const isPreviewMode = + isDevelopment || + isPostOnboardingPreviewEnabled( + router.query?.[POST_ONBOARDING_PREVIEW_QUERY], + ); + + useEffect(() => { + if (!user?.id || !isOnboardingActionsReady) { + return; + } + + if (isOnboardingComplete) { + clearPostSignupActivation(); + return; + } + + setIsVisible(hasPostSignupActivation(user.id)); + }, [isOnboardingActionsReady, isOnboardingComplete, user?.id]); + + const shouldShow = + (isPreviewMode && !isLocalPreviewDismissed) || + (!!user?.id && + isOnboardingActionsReady && + !isOnboardingComplete && + isVisible); + + useEffect(() => { + if (!shouldShow || hasLoggedImpression.current) { + return; + } + + hasLoggedImpression.current = true; + logEvent({ + event_name: 'impression', + target_type: TargetType.PostSignupActivation, + target_id: 'post signup activation', + }); + }, [logEvent, shouldShow]); + + if (!shouldShow) { + return null; + } + + const onDismiss = (): void => { + if (isPreviewMode) { + setIsLocalPreviewDismissed(true); + } else { + clearPostSignupActivation(); + } + setIsVisible(false); + logEvent({ + event_name: 'click', + target_type: TargetType.PostSignupActivation, + target_id: 'dismiss post signup activation', + }); + }; + + 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 ( + + ); +}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 93c6b2c9e80..85d318d2773 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -532,6 +532,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/shared/src/lib/postSignupActivation.spec.ts b/packages/shared/src/lib/postSignupActivation.spec.ts new file mode 100644 index 00000000000..9e1f736a753 --- /dev/null +++ b/packages/shared/src/lib/postSignupActivation.spec.ts @@ -0,0 +1,51 @@ +import { + clearPostSignupActivation, + hasPostSignupActivation, + isPostOnboardingPreviewEnabled, + markPostSignupActivation, +} from './postSignupActivation'; + +describe('post signup activation', () => { + beforeEach(() => { + clearPostSignupActivation(); + window.history.replaceState({}, '', '/posts/original-post'); + }); + + it('marks the newly registered user on the originating post', () => { + markPostSignupActivation({ id: 'new-user' }); + + expect(hasPostSignupActivation('new-user')).toBe(true); + expect(hasPostSignupActivation('another-user')).toBe(false); + }); + + it('does not carry the activation prompt to another post', () => { + markPostSignupActivation({ id: 'new-user' }); + + window.history.replaceState({}, '', '/posts/another-post'); + + expect(hasPostSignupActivation('new-user')).toBe(false); + }); + + it('does not mark registrations outside a post page', () => { + window.history.replaceState({}, '', '/sources/javascript'); + + markPostSignupActivation({ id: 'new-user' }); + + expect(hasPostSignupActivation('new-user')).toBe(false); + }); + + it('clears the activation prompt', () => { + markPostSignupActivation({ id: 'new-user' }); + + clearPostSignupActivation(); + + expect(hasPostSignupActivation('new-user')).toBe(false); + }); + + it('supports explicit local and hosted preview values', () => { + expect(isPostOnboardingPreviewEnabled('1')).toBe(true); + expect(isPostOnboardingPreviewEnabled('true')).toBe(true); + expect(isPostOnboardingPreviewEnabled(['0', '1'])).toBe(true); + expect(isPostOnboardingPreviewEnabled('0')).toBe(false); + }); +}); diff --git a/packages/shared/src/lib/postSignupActivation.ts b/packages/shared/src/lib/postSignupActivation.ts new file mode 100644 index 00000000000..725667ef86d --- /dev/null +++ b/packages/shared/src/lib/postSignupActivation.ts @@ -0,0 +1,90 @@ +import { generateStorageKey, StorageTopic } from './storage'; +import { storageWrapper as storage } from './storageWrapper'; + +const activationStorageKey = generateStorageKey( + StorageTopic.Onboarding, + 'postSignupActivation', +); + +const activationTtl = 7 * 24 * 60 * 60 * 1000; + +export const POST_ONBOARDING_PREVIEW_QUERY = 'postOnboardingPreview'; + +export const isPostOnboardingPreviewEnabled = ( + value?: string | string[], +): boolean => + value === '1' || + value === 'true' || + (Array.isArray(value) && (value.includes('1') || value.includes('true'))); + +interface PostSignupActivation { + userId: string; + postPath: string; + createdAt: number; +} + +interface ActivationUser { + id?: string; +} + +const getCurrentPostPath = (): string | null => { + if (typeof window === 'undefined') { + return null; + } + + const { pathname } = window.location; + return pathname.startsWith('/posts/') ? pathname : null; +}; + +const readPostSignupActivation = (): PostSignupActivation | null => { + const value = storage.getItem(activationStorageKey); + if (!value) { + return null; + } + + try { + return JSON.parse(value) as PostSignupActivation; + } catch { + storage.removeItem(activationStorageKey); + return null; + } +}; + +export const markPostSignupActivation = (user?: ActivationUser): void => { + const postPath = getCurrentPostPath(); + if (!user?.id || !postPath) { + return; + } + + storage.setItem( + activationStorageKey, + JSON.stringify({ + userId: user.id, + postPath, + createdAt: Date.now(), + } satisfies PostSignupActivation), + ); +}; + +export const hasPostSignupActivation = (userId?: string): boolean => { + const postPath = getCurrentPostPath(); + if (!userId || !postPath) { + return false; + } + + const activation = readPostSignupActivation(); + if (!activation) { + return false; + } + + if (Date.now() - activation.createdAt > activationTtl) { + storage.removeItem(activationStorageKey); + return false; + } + + return activation.userId === userId && activation.postPath === postPath; +}; + +export const clearPostSignupActivation = (): void => { + storage.removeItem(activationStorageKey); +}; diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 8fb970f07ea..8122741dd46 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -52,6 +52,12 @@ import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditio import { isPostRedesignEligible } from '@dailydotdev/shared/src/hooks/post/usePostRedesign'; import { featurePostRedesign } from '@dailydotdev/shared/src/lib/featureManagement'; import { PostFocusCard } from '@dailydotdev/shared/src/components/post/focus/PostFocusCard'; +import { PostOnboardingActivation } from '@dailydotdev/shared/src/components/post/PostOnboardingActivation'; +import { + isPostOnboardingPreviewEnabled, + POST_ONBOARDING_PREVIEW_QUERY, +} from '@dailydotdev/shared/src/lib/postSignupActivation'; +import { isDevelopment } from '@dailydotdev/shared/src/lib/constants'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -191,6 +197,12 @@ export const PostPage = ({ const router = useRouter(); const isFallback = false; const { shouldShowAuthBanner } = useOnboardingActions(); + const isActivationPreview = + isDevelopment || + isPostOnboardingPreviewEnabled( + router.query?.[POST_ONBOARDING_PREVIEW_QUERY], + ); + const showAuthBanner = shouldShowAuthBanner && !isActivationPreview; const isLaptop = useViewSize(ViewSize.Laptop); const { post, isError, isLoading } = usePostById({ id, @@ -285,6 +297,7 @@ export const PostPage = ({ + {showRedesign ? (
@@ -298,7 +311,7 @@ export const PostPage = ({ backToSquad={!!router?.query?.squad} shouldOnboardAuthor={!!router.query?.author} origin={Origin.ArticlePage} - isBannerVisible={shouldShowAuthBanner && !isLaptop} + isBannerVisible={showAuthBanner && !isLaptop} className={{ container: containerClass, fixedNavigation: { container: 'flex laptop:hidden' }, @@ -309,9 +322,7 @@ export const PostPage = ({ }} /> )} - {!showRedesign && shouldShowAuthBanner && isLaptop && ( - - )} + {!showRedesign && showAuthBanner && isLaptop && }