From b43e19fcc9a544eeebfec029b5a8b0a8ab34c5aa Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 3 Mar 2026 14:58:58 +0100 Subject: [PATCH 1/8] feat(shared): add digest upsell banners to notifications and bookmarks pages Show digest subscription CTA banners for non-Plus users without an active digest. Notifications page gets a full-width banner; bookmarks page gets a rounded card above the feed. Both persist dismissal via IndexedDB and log impression/click events. Co-Authored-By: Claude Opus 4.6 --- .../src/components/BookmarkFeedLayout.tsx | 2 + .../notifications/DigestBookmarkBanner.tsx | 98 +++++++++++++++++++ .../notifications/DigestUpsellBanner.tsx | 94 ++++++++++++++++++ .../shared/src/hooks/usePersistentContext.ts | 2 + packages/shared/src/lib/log.ts | 2 + packages/webapp/pages/notifications.tsx | 2 + 6 files changed, 200 insertions(+) create mode 100644 packages/shared/src/components/notifications/DigestBookmarkBanner.tsx create mode 100644 packages/shared/src/components/notifications/DigestUpsellBanner.tsx diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index dce80cd8712..6ae1f68f452 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -21,6 +21,7 @@ import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query'; import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BookmarkSection } from './sidebar/sections/BookmarkSection'; import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner'; +import { DigestBookmarkBanner } from './notifications/DigestBookmarkBanner'; import { Typography, TypographyTag, @@ -245,6 +246,7 @@ export default function BookmarkFeedLayout({ /> )} + {!plusEntryBookmark && } {tokenRefreshed && (isSearchResults || loadedSort) && ( )} diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx new file mode 100644 index 00000000000..5a0d0ea5297 --- /dev/null +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx @@ -0,0 +1,98 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { MailIcon } from '../icons'; +import { webappUrl } from '../../lib/constants'; +import { LogEvent, TargetId } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { + usePersonalizedDigest, + SendType, +} from '../../hooks/usePersonalizedDigest'; +import { UserPersonalizedDigestType } from '../../graphql/users'; +import usePersistentContext, { + PersistentContextKeys, +} from '../../hooks/usePersistentContext'; + +export function DigestBookmarkBanner(): ReactElement | null { + const { logEvent } = useLogContext(); + const { isPlus } = usePlusSubscription(); + const { getPersonalizedDigest, subscribePersonalizedDigest } = + usePersonalizedDigest(); + const hasDigest = !!getPersonalizedDigest(UserPersonalizedDigestType.Digest); + const [dismissed, setDismissed, isFetched] = usePersistentContext( + PersistentContextKeys.DigestBookmarkUpsellDismissed, + false, + ); + const impressionLogged = useRef(false); + + const showBanner = !isPlus && !hasDigest && !dismissed && isFetched; + + useEffect(() => { + if (showBanner && !impressionLogged.current) { + impressionLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_id: TargetId.DigestUpsellBookmarks, + }); + } + }, [showBanner, logEvent]); + + if (!showBanner) { + return null; + } + + const onEnable = async () => { + logEvent({ + event_name: LogEvent.Click, + target_id: TargetId.DigestUpsellBookmarks, + }); + + await subscribePersonalizedDigest({ + hour: 9, + sendType: SendType.Workdays, + type: UserPersonalizedDigestType.Digest, + }); + + window.location.href = `${webappUrl}account/notifications`; + }; + + const onDismiss = () => { + setDismissed(true); + }; + + return ( +
+ + + Never miss the best posts + +

+ Get a personalized digest of top posts from your favorite topics — + delivered to your inbox daily. +

+
+ +
+ +
+ ); +} diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx new file mode 100644 index 00000000000..8707519b38b --- /dev/null +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { Button, ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { MailIcon } from '../icons'; +import { webappUrl } from '../../lib/constants'; +import { LogEvent, TargetId } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { + usePersonalizedDigest, + SendType, +} from '../../hooks/usePersonalizedDigest'; +import { UserPersonalizedDigestType } from '../../graphql/users'; +import usePersistentContext, { + PersistentContextKeys, +} from '../../hooks/usePersistentContext'; + +export function DigestUpsellBanner(): ReactElement | null { + const { logEvent } = useLogContext(); + const { isPlus } = usePlusSubscription(); + const { getPersonalizedDigest, subscribePersonalizedDigest } = + usePersonalizedDigest(); + const hasDigest = !!getPersonalizedDigest(UserPersonalizedDigestType.Digest); + const [dismissed, setDismissed, isFetched] = usePersistentContext( + PersistentContextKeys.DigestUpsellDismissed, + false, + ); + const impressionLogged = useRef(false); + + const showBanner = !isPlus && !hasDigest && !dismissed && isFetched; + + useEffect(() => { + if (showBanner && !impressionLogged.current) { + impressionLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_id: TargetId.DigestUpsell, + }); + } + }, [showBanner, logEvent]); + + if (!showBanner) { + return null; + } + + const onEnable = async () => { + logEvent({ + event_name: LogEvent.Click, + target_id: TargetId.DigestUpsell, + }); + + await subscribePersonalizedDigest({ + hour: 9, + sendType: SendType.Workdays, + type: UserPersonalizedDigestType.Digest, + }); + + window.location.href = `${webappUrl}account/notifications`; + }; + + const onDismiss = () => { + setDismissed(true); + }; + + return ( +
+ + + Get your personalized digest + +

+ Our recommendation system scans everything on daily.dev and sends you a + tailored summary with just the must-read posts. Choose daily, workdays, + or weekly delivery. +

+
+ +
+ +
+ ); +} diff --git a/packages/shared/src/hooks/usePersistentContext.ts b/packages/shared/src/hooks/usePersistentContext.ts index 61a23db294e..c82e160a8f8 100644 --- a/packages/shared/src/hooks/usePersistentContext.ts +++ b/packages/shared/src/hooks/usePersistentContext.ts @@ -65,4 +65,6 @@ export enum PersistentContextKeys { StreakAlertPushKey = 'streak_alert_push_key', PendingOpportunityId = 'pending_opportunity_id', ReadingReminderLastSeen = 'reading_reminder_last_seen', + DigestUpsellDismissed = 'digest_upsell_dismissed', + DigestBookmarkUpsellDismissed = 'digest_bookmark_upsell_dismissed', } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index c1136d49e92..0f0986a9913 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -507,6 +507,8 @@ export enum TargetId { Fullscreen = 'fullscreen', Popover = 'popover', Navigation = 'navigation', + DigestUpsell = 'digest upsell', + DigestUpsellBookmarks = 'digest upsell bookmarks', } export enum NotificationChannel { diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index 4e1aae4ce3a..c7043f258df 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -19,6 +19,7 @@ import { import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; import FirstNotification from '@dailydotdev/shared/src/components/notifications/FirstNotification'; import EnableNotification from '@dailydotdev/shared/src/components/notifications/EnableNotification'; +import { DigestUpsellBanner } from '@dailydotdev/shared/src/components/notifications/DigestUpsellBanner'; import { useNotificationContext } from '@dailydotdev/shared/src/contexts/NotificationsContext'; import InfiniteScrolling, { checkFetchMore, @@ -108,6 +109,7 @@ const Notifications = (): ReactElement => { className={classNames(pageBorders, pageContainerClassNames, 'pb-12')} > +

Date: Tue, 3 Mar 2026 15:09:08 +0100 Subject: [PATCH 2/8] test(shared): add unit and e2e tests for digest upsell banners Unit tests cover rendering conditions (Plus, digest, dismissed, loading), impression/click analytics, CTA subscription, and dismiss behavior. E2E tests verify banner visibility and dismissal on notifications and bookmarks pages. Co-Authored-By: Claude Opus 4.6 --- .../playwright/tests/digest-upsell.spec.ts | 103 ++++++++++++ .../DigestBookmarkBanner.spec.tsx | 149 +++++++++++++++++ .../notifications/DigestUpsellBanner.spec.tsx | 151 ++++++++++++++++++ .../notifications/DigestUpsellBanner.tsx | 7 +- 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 packages/playwright/tests/digest-upsell.spec.ts create mode 100644 packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx create mode 100644 packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx diff --git a/packages/playwright/tests/digest-upsell.spec.ts b/packages/playwright/tests/digest-upsell.spec.ts new file mode 100644 index 00000000000..78a542d9806 --- /dev/null +++ b/packages/playwright/tests/digest-upsell.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Digest Upsell Banners', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Handle cookie consent + await page + .getByRole('button', { name: 'Accept all' }) + .or(page.getByRole('button', { name: 'I understand' })) + .click(); + + // Log in + await page.getByRole('button', { name: 'Log in' }).click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill(process.env.USER_NAME); + await page.getByRole('textbox', { name: 'Password' }).press('Tab'); + await page + .getByRole('textbox', { name: 'Password' }) + .fill(process.env.PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + + // Wait for auth to complete + await expect( + page + .getByRole('link', { name: 'profile' }) + .or(page.getByRole('button', { name: 'Profile settings' })), + ).toBeVisible(); + }); + + test('notifications page shows digest upsell banner for eligible users', async ({ + page, + }) => { + await page.goto('/notifications'); + + // The banner should be visible if the user is non-Plus and has no digest + const banner = page.getByText('Get your personalized digest'); + const enableButton = page.getByRole('button', { name: 'Enable digest' }); + + // If the banner is visible, verify its structure + if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { + await expect(banner).toBeVisible(); + await expect(enableButton).toBeVisible(); + + // Verify close button exists + const closeButton = page.getByRole('button', { name: 'Close' }); + await expect(closeButton).toBeVisible(); + } + }); + + test('bookmarks page shows digest upsell banner for eligible users', async ({ + page, + }) => { + await page.goto('/bookmarks'); + + // The banner should be visible if the user is non-Plus and has no digest + const banner = page.getByText('Never miss the best posts'); + const enableButton = page.getByRole('button', { name: 'Enable digest' }); + + // If the banner is visible, verify its structure + if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { + await expect(banner).toBeVisible(); + await expect(enableButton).toBeVisible(); + + // Verify close button exists + const closeButton = page.getByRole('button', { name: 'Close' }); + await expect(closeButton).toBeVisible(); + } + }); + + test('digest upsell banner can be dismissed on notifications page', async ({ + page, + }) => { + await page.goto('/notifications'); + + const banner = page.getByText('Get your personalized digest'); + + // Only test dismiss if banner is visible (user is eligible) + if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { + const closeButton = page.getByRole('button', { name: 'Close' }); + await closeButton.click(); + + await expect(banner).toBeHidden(); + } + }); + + test('digest upsell banner can be dismissed on bookmarks page', async ({ + page, + }) => { + await page.goto('/bookmarks'); + + const banner = page.getByText('Never miss the best posts'); + + // Only test dismiss if banner is visible (user is eligible) + if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { + const closeButton = page.getByRole('button', { name: 'Close' }); + await closeButton.click(); + + await expect(banner).toBeHidden(); + } + }); +}); diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx new file mode 100644 index 00000000000..2aaf4c710e0 --- /dev/null +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DigestBookmarkBanner } from './DigestBookmarkBanner'; +import { LogEvent, TargetId } from '../../lib/log'; +import { UserPersonalizedDigestType } from '../../graphql/users'; + +const mockLogEvent = jest.fn(); +const mockGetPersonalizedDigest = jest.fn(); +const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({}); +const mockSetDismissed = jest.fn().mockResolvedValue(undefined); +const mockUsePlusSubscription = jest.fn(); +const mockPersistentContext = jest.fn(); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/usePlusSubscription', () => ({ + usePlusSubscription: () => mockUsePlusSubscription(), +})); + +jest.mock('../../hooks/usePersonalizedDigest', () => ({ + usePersonalizedDigest: () => ({ + getPersonalizedDigest: mockGetPersonalizedDigest, + subscribePersonalizedDigest: mockSubscribePersonalizedDigest, + }), + SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' }, +})); + +jest.mock('../../hooks/usePersistentContext', () => ({ + __esModule: true, + PersistentContextKeys: { + DigestBookmarkUpsellDismissed: 'digest_bookmark_upsell_dismissed', + }, + default: (...args: unknown[]) => mockPersistentContext(...args), +})); + +const client = new QueryClient(); + +const renderComponent = () => + render( + + + , + ); + +describe('DigestBookmarkBanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePlusSubscription.mockReturnValue({ isPlus: false }); + mockGetPersonalizedDigest.mockReturnValue(null); + mockPersistentContext.mockReturnValue([false, mockSetDismissed, true]); + }); + + it('should render banner for non-Plus user without digest', () => { + renderComponent(); + + expect(screen.getByText('Never miss the best posts')).toBeInTheDocument(); + expect(screen.getByText('Enable digest')).toBeInTheDocument(); + }); + + it('should not render for Plus users', () => { + mockUsePlusSubscription.mockReturnValue({ isPlus: true }); + + renderComponent(); + + expect( + screen.queryByText('Never miss the best posts'), + ).not.toBeInTheDocument(); + }); + + it('should not render when user has digest subscription', () => { + mockGetPersonalizedDigest.mockImplementation( + (type: UserPersonalizedDigestType) => + type === UserPersonalizedDigestType.Digest + ? { + type: UserPersonalizedDigestType.Digest, + preferredHour: 9, + flags: { sendType: 'workdays' }, + } + : null, + ); + + renderComponent(); + + expect( + screen.queryByText('Never miss the best posts'), + ).not.toBeInTheDocument(); + }); + + it('should not render when dismissed', () => { + mockPersistentContext.mockReturnValue([true, mockSetDismissed, true]); + + renderComponent(); + + expect( + screen.queryByText('Never miss the best posts'), + ).not.toBeInTheDocument(); + }); + + it('should not render while persistent context is loading', () => { + mockPersistentContext.mockReturnValue([false, mockSetDismissed, false]); + + renderComponent(); + + expect( + screen.queryByText('Never miss the best posts'), + ).not.toBeInTheDocument(); + }); + + it('should log impression on render', () => { + renderComponent(); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_id: TargetId.DigestUpsellBookmarks, + }); + }); + + it('should subscribe and log click on CTA', async () => { + renderComponent(); + + const ctaButton = screen.getByText('Enable digest'); + fireEvent.click(ctaButton); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_id: TargetId.DigestUpsellBookmarks, + }); + + await waitFor(() => { + expect(mockSubscribePersonalizedDigest).toHaveBeenCalledWith({ + hour: 9, + sendType: 'workdays', + type: UserPersonalizedDigestType.Digest, + }); + }); + }); + + it('should dismiss banner on close button click', () => { + renderComponent(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + expect(mockSetDismissed).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx new file mode 100644 index 00000000000..e809e744ead --- /dev/null +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DigestUpsellBanner } from './DigestUpsellBanner'; +import { LogEvent, TargetId } from '../../lib/log'; +import { UserPersonalizedDigestType } from '../../graphql/users'; + +const mockLogEvent = jest.fn(); +const mockGetPersonalizedDigest = jest.fn(); +const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({}); +const mockSetDismissed = jest.fn().mockResolvedValue(undefined); +const mockUsePlusSubscription = jest.fn(); +const mockPersistentContext = jest.fn(); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/usePlusSubscription', () => ({ + usePlusSubscription: () => mockUsePlusSubscription(), +})); + +jest.mock('../../hooks/usePersonalizedDigest', () => ({ + usePersonalizedDigest: () => ({ + getPersonalizedDigest: mockGetPersonalizedDigest, + subscribePersonalizedDigest: mockSubscribePersonalizedDigest, + }), + SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' }, +})); + +jest.mock('../../hooks/usePersistentContext', () => ({ + __esModule: true, + PersistentContextKeys: { + DigestUpsellDismissed: 'digest_upsell_dismissed', + }, + default: (...args: unknown[]) => mockPersistentContext(...args), +})); + +const client = new QueryClient(); + +const renderComponent = () => + render( + + + , + ); + +describe('DigestUpsellBanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePlusSubscription.mockReturnValue({ isPlus: false }); + mockGetPersonalizedDigest.mockReturnValue(null); + mockPersistentContext.mockReturnValue([false, mockSetDismissed, true]); + }); + + it('should render banner for non-Plus user without digest', () => { + renderComponent(); + + expect( + screen.getByText('Get your personalized digest'), + ).toBeInTheDocument(); + expect(screen.getByText('Enable digest')).toBeInTheDocument(); + }); + + it('should not render for Plus users', () => { + mockUsePlusSubscription.mockReturnValue({ isPlus: true }); + + renderComponent(); + + expect( + screen.queryByText('Get your personalized digest'), + ).not.toBeInTheDocument(); + }); + + it('should not render when user has digest subscription', () => { + mockGetPersonalizedDigest.mockImplementation( + (type: UserPersonalizedDigestType) => + type === UserPersonalizedDigestType.Digest + ? { + type: UserPersonalizedDigestType.Digest, + preferredHour: 9, + flags: { sendType: 'workdays' }, + } + : null, + ); + + renderComponent(); + + expect( + screen.queryByText('Get your personalized digest'), + ).not.toBeInTheDocument(); + }); + + it('should not render when dismissed', () => { + mockPersistentContext.mockReturnValue([true, mockSetDismissed, true]); + + renderComponent(); + + expect( + screen.queryByText('Get your personalized digest'), + ).not.toBeInTheDocument(); + }); + + it('should not render while persistent context is loading', () => { + mockPersistentContext.mockReturnValue([false, mockSetDismissed, false]); + + renderComponent(); + + expect( + screen.queryByText('Get your personalized digest'), + ).not.toBeInTheDocument(); + }); + + it('should log impression on render', () => { + renderComponent(); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_id: TargetId.DigestUpsell, + }); + }); + + it('should subscribe and log click on CTA', async () => { + renderComponent(); + + const ctaButton = screen.getByText('Enable digest'); + fireEvent.click(ctaButton); + + expect(mockLogEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_id: TargetId.DigestUpsell, + }); + + await waitFor(() => { + expect(mockSubscribePersonalizedDigest).toHaveBeenCalledWith({ + hour: 9, + sendType: 'workdays', + type: UserPersonalizedDigestType.Digest, + }); + }); + }); + + it('should dismiss banner on close button click', () => { + renderComponent(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + expect(mockSetDismissed).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx index 8707519b38b..4acf753bd8c 100644 --- a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx @@ -1,6 +1,11 @@ import type { ReactElement } from 'react'; import React, { useEffect, useRef } from 'react'; -import { Button, ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import CloseButton from '../CloseButton'; import { MailIcon } from '../icons'; import { webappUrl } from '../../lib/constants'; From 63715a02ab1cf4d194d074e6fa71eaf21e986612 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 3 Mar 2026 15:51:35 +0100 Subject: [PATCH 3/8] refactor(shared): use server-side actions for digest upsell dismissal Replace usePersistentContext (client-only IndexedDB) with useActions (server-side, tied to user account) for banner dismissal. Both dismiss and subscribe now complete the action so banners are shown only once. Adds DismissDigestUpsell and DismissDigestBookmarkUpsell to ActionType. Removes unused PersistentContextKeys and updates tests accordingly. Co-Authored-By: Claude Opus 4.6 --- .../DigestBookmarkBanner.spec.tsx | 47 +++++++++---------- .../notifications/DigestBookmarkBanner.tsx | 17 ++++--- .../notifications/DigestUpsellBanner.spec.tsx | 47 +++++++++---------- .../notifications/DigestUpsellBanner.tsx | 17 ++++--- packages/shared/src/graphql/actions.ts | 2 + .../shared/src/hooks/usePersistentContext.ts | 2 - 6 files changed, 64 insertions(+), 68 deletions(-) diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx index 2aaf4c710e0..fd157011130 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx @@ -4,13 +4,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { DigestBookmarkBanner } from './DigestBookmarkBanner'; import { LogEvent, TargetId } from '../../lib/log'; import { UserPersonalizedDigestType } from '../../graphql/users'; +import { ActionType } from '../../graphql/actions'; const mockLogEvent = jest.fn(); const mockGetPersonalizedDigest = jest.fn(); const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({}); -const mockSetDismissed = jest.fn().mockResolvedValue(undefined); +const mockCompleteAction = jest.fn().mockResolvedValue(undefined); +const mockCheckHasCompleted = jest.fn(); const mockUsePlusSubscription = jest.fn(); -const mockPersistentContext = jest.fn(); jest.mock('../../contexts/LogContext', () => ({ useLogContext: () => ({ logEvent: mockLogEvent }), @@ -28,12 +29,12 @@ jest.mock('../../hooks/usePersonalizedDigest', () => ({ SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' }, })); -jest.mock('../../hooks/usePersistentContext', () => ({ - __esModule: true, - PersistentContextKeys: { - DigestBookmarkUpsellDismissed: 'digest_bookmark_upsell_dismissed', - }, - default: (...args: unknown[]) => mockPersistentContext(...args), +jest.mock('../../hooks/useActions', () => ({ + useActions: () => ({ + checkHasCompleted: mockCheckHasCompleted, + completeAction: mockCompleteAction, + isActionsFetched: true, + }), })); const client = new QueryClient(); @@ -50,7 +51,7 @@ describe('DigestBookmarkBanner', () => { jest.clearAllMocks(); mockUsePlusSubscription.mockReturnValue({ isPlus: false }); mockGetPersonalizedDigest.mockReturnValue(null); - mockPersistentContext.mockReturnValue([false, mockSetDismissed, true]); + mockCheckHasCompleted.mockReturnValue(false); }); it('should render banner for non-Plus user without digest', () => { @@ -89,18 +90,8 @@ describe('DigestBookmarkBanner', () => { ).not.toBeInTheDocument(); }); - it('should not render when dismissed', () => { - mockPersistentContext.mockReturnValue([true, mockSetDismissed, true]); - - renderComponent(); - - expect( - screen.queryByText('Never miss the best posts'), - ).not.toBeInTheDocument(); - }); - - it('should not render while persistent context is loading', () => { - mockPersistentContext.mockReturnValue([false, mockSetDismissed, false]); + it('should not render when dismissed via action', () => { + mockCheckHasCompleted.mockReturnValue(true); renderComponent(); @@ -118,7 +109,7 @@ describe('DigestBookmarkBanner', () => { }); }); - it('should subscribe and log click on CTA', async () => { + it('should subscribe, complete action, and log click on CTA', async () => { renderComponent(); const ctaButton = screen.getByText('Enable digest'); @@ -136,14 +127,22 @@ describe('DigestBookmarkBanner', () => { type: UserPersonalizedDigestType.Digest, }); }); + + await waitFor(() => { + expect(mockCompleteAction).toHaveBeenCalledWith( + ActionType.DismissDigestBookmarkUpsell, + ); + }); }); - it('should dismiss banner on close button click', () => { + it('should complete action on dismiss', () => { renderComponent(); const closeButton = screen.getByRole('button', { name: 'Close' }); fireEvent.click(closeButton); - expect(mockSetDismissed).toHaveBeenCalledWith(true); + expect(mockCompleteAction).toHaveBeenCalledWith( + ActionType.DismissDigestBookmarkUpsell, + ); }); }); diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx index 5a0d0ea5297..da5ccdab4ec 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx @@ -17,9 +17,8 @@ import { SendType, } from '../../hooks/usePersonalizedDigest'; import { UserPersonalizedDigestType } from '../../graphql/users'; -import usePersistentContext, { - PersistentContextKeys, -} from '../../hooks/usePersistentContext'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; export function DigestBookmarkBanner(): ReactElement | null { const { logEvent } = useLogContext(); @@ -27,13 +26,11 @@ export function DigestBookmarkBanner(): ReactElement | null { const { getPersonalizedDigest, subscribePersonalizedDigest } = usePersonalizedDigest(); const hasDigest = !!getPersonalizedDigest(UserPersonalizedDigestType.Digest); - const [dismissed, setDismissed, isFetched] = usePersistentContext( - PersistentContextKeys.DigestBookmarkUpsellDismissed, - false, - ); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const dismissed = checkHasCompleted(ActionType.DismissDigestBookmarkUpsell); const impressionLogged = useRef(false); - const showBanner = !isPlus && !hasDigest && !dismissed && isFetched; + const showBanner = !isPlus && !hasDigest && !dismissed && isActionsFetched; useEffect(() => { if (showBanner && !impressionLogged.current) { @@ -61,11 +58,13 @@ export function DigestBookmarkBanner(): ReactElement | null { type: UserPersonalizedDigestType.Digest, }); + await completeAction(ActionType.DismissDigestBookmarkUpsell); + window.location.href = `${webappUrl}account/notifications`; }; const onDismiss = () => { - setDismissed(true); + completeAction(ActionType.DismissDigestBookmarkUpsell); }; return ( diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx index e809e744ead..678a5e0d29f 100644 --- a/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx @@ -4,13 +4,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { DigestUpsellBanner } from './DigestUpsellBanner'; import { LogEvent, TargetId } from '../../lib/log'; import { UserPersonalizedDigestType } from '../../graphql/users'; +import { ActionType } from '../../graphql/actions'; const mockLogEvent = jest.fn(); const mockGetPersonalizedDigest = jest.fn(); const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({}); -const mockSetDismissed = jest.fn().mockResolvedValue(undefined); +const mockCompleteAction = jest.fn().mockResolvedValue(undefined); +const mockCheckHasCompleted = jest.fn(); const mockUsePlusSubscription = jest.fn(); -const mockPersistentContext = jest.fn(); jest.mock('../../contexts/LogContext', () => ({ useLogContext: () => ({ logEvent: mockLogEvent }), @@ -28,12 +29,12 @@ jest.mock('../../hooks/usePersonalizedDigest', () => ({ SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' }, })); -jest.mock('../../hooks/usePersistentContext', () => ({ - __esModule: true, - PersistentContextKeys: { - DigestUpsellDismissed: 'digest_upsell_dismissed', - }, - default: (...args: unknown[]) => mockPersistentContext(...args), +jest.mock('../../hooks/useActions', () => ({ + useActions: () => ({ + checkHasCompleted: mockCheckHasCompleted, + completeAction: mockCompleteAction, + isActionsFetched: true, + }), })); const client = new QueryClient(); @@ -50,7 +51,7 @@ describe('DigestUpsellBanner', () => { jest.clearAllMocks(); mockUsePlusSubscription.mockReturnValue({ isPlus: false }); mockGetPersonalizedDigest.mockReturnValue(null); - mockPersistentContext.mockReturnValue([false, mockSetDismissed, true]); + mockCheckHasCompleted.mockReturnValue(false); }); it('should render banner for non-Plus user without digest', () => { @@ -91,18 +92,8 @@ describe('DigestUpsellBanner', () => { ).not.toBeInTheDocument(); }); - it('should not render when dismissed', () => { - mockPersistentContext.mockReturnValue([true, mockSetDismissed, true]); - - renderComponent(); - - expect( - screen.queryByText('Get your personalized digest'), - ).not.toBeInTheDocument(); - }); - - it('should not render while persistent context is loading', () => { - mockPersistentContext.mockReturnValue([false, mockSetDismissed, false]); + it('should not render when dismissed via action', () => { + mockCheckHasCompleted.mockReturnValue(true); renderComponent(); @@ -120,7 +111,7 @@ describe('DigestUpsellBanner', () => { }); }); - it('should subscribe and log click on CTA', async () => { + it('should subscribe, complete action, and log click on CTA', async () => { renderComponent(); const ctaButton = screen.getByText('Enable digest'); @@ -138,14 +129,22 @@ describe('DigestUpsellBanner', () => { type: UserPersonalizedDigestType.Digest, }); }); + + await waitFor(() => { + expect(mockCompleteAction).toHaveBeenCalledWith( + ActionType.DismissDigestUpsell, + ); + }); }); - it('should dismiss banner on close button click', () => { + it('should complete action on dismiss', () => { renderComponent(); const closeButton = screen.getByRole('button', { name: 'Close' }); fireEvent.click(closeButton); - expect(mockSetDismissed).toHaveBeenCalledWith(true); + expect(mockCompleteAction).toHaveBeenCalledWith( + ActionType.DismissDigestUpsell, + ); }); }); diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx index 4acf753bd8c..c189987674c 100644 --- a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx @@ -17,9 +17,8 @@ import { SendType, } from '../../hooks/usePersonalizedDigest'; import { UserPersonalizedDigestType } from '../../graphql/users'; -import usePersistentContext, { - PersistentContextKeys, -} from '../../hooks/usePersistentContext'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; export function DigestUpsellBanner(): ReactElement | null { const { logEvent } = useLogContext(); @@ -27,13 +26,11 @@ export function DigestUpsellBanner(): ReactElement | null { const { getPersonalizedDigest, subscribePersonalizedDigest } = usePersonalizedDigest(); const hasDigest = !!getPersonalizedDigest(UserPersonalizedDigestType.Digest); - const [dismissed, setDismissed, isFetched] = usePersistentContext( - PersistentContextKeys.DigestUpsellDismissed, - false, - ); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const dismissed = checkHasCompleted(ActionType.DismissDigestUpsell); const impressionLogged = useRef(false); - const showBanner = !isPlus && !hasDigest && !dismissed && isFetched; + const showBanner = !isPlus && !hasDigest && !dismissed && isActionsFetched; useEffect(() => { if (showBanner && !impressionLogged.current) { @@ -61,11 +58,13 @@ export function DigestUpsellBanner(): ReactElement | null { type: UserPersonalizedDigestType.Digest, }); + await completeAction(ActionType.DismissDigestUpsell); + window.location.href = `${webappUrl}account/notifications`; }; const onDismiss = () => { - setDismissed(true); + completeAction(ActionType.DismissDigestUpsell); }; return ( diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 22b35d80798..2bbd90cb44f 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -59,6 +59,8 @@ export enum ActionType { AchievementSyncPrompt = 'achievement_sync_prompt', DisableAchievementCompletion = 'disable_achievement_completion', DismissInstallExtension = 'dismiss_install_extension', + DismissDigestUpsell = 'dismiss_digest_upsell', + DismissDigestBookmarkUpsell = 'dismiss_digest_bookmark_upsell', } export const cvActions = [ diff --git a/packages/shared/src/hooks/usePersistentContext.ts b/packages/shared/src/hooks/usePersistentContext.ts index c82e160a8f8..61a23db294e 100644 --- a/packages/shared/src/hooks/usePersistentContext.ts +++ b/packages/shared/src/hooks/usePersistentContext.ts @@ -65,6 +65,4 @@ export enum PersistentContextKeys { StreakAlertPushKey = 'streak_alert_push_key', PendingOpportunityId = 'pending_opportunity_id', ReadingReminderLastSeen = 'reading_reminder_last_seen', - DigestUpsellDismissed = 'digest_upsell_dismissed', - DigestBookmarkUpsellDismissed = 'digest_bookmark_upsell_dismissed', } From 027835ce24e3ae396d90d6c0c9ac4fbafe49aadc Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 3 Mar 2026 17:42:07 +0100 Subject: [PATCH 4/8] fix(webapp): prevent digest upsell and push notification banner overlap Only show DigestUpsellBanner on notifications page when push notifications are already subscribed, ensuring it never renders simultaneously with the EnableNotification CTA. Co-Authored-By: Claude Opus 4.6 --- packages/webapp/pages/notifications.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/pages/notifications.tsx b/packages/webapp/pages/notifications.tsx index c7043f258df..2ad90863c0d 100644 --- a/packages/webapp/pages/notifications.tsx +++ b/packages/webapp/pages/notifications.tsx @@ -109,7 +109,7 @@ const Notifications = (): ReactElement => { className={classNames(pageBorders, pageContainerClassNames, 'pb-12')} > - + {isSubscribed && }

Date: Tue, 3 Mar 2026 18:44:48 +0100 Subject: [PATCH 5/8] feat(shared): improve digest upsell banners - Use shared ActionType.DigestUpsell for both banners - Fix notification settings overwrite with setNotificationStatusBulk - Add isAuthReady gate to prevent flash before auth resolves - Show bookmark banner only when feed is empty - Center bookmark banner on desktop - Update toast and bookmark banner copy Co-Authored-By: Claude Opus 4.6 --- .../src/components/BookmarkFeedLayout.tsx | 14 ++++-- .../DigestBookmarkBanner.spec.tsx | 45 +++++++++++++++---- .../notifications/DigestBookmarkBanner.tsx | 45 ++++++++++++++----- .../notifications/DigestUpsellBanner.spec.tsx | 43 ++++++++++++++---- .../notifications/DigestUpsellBanner.tsx | 44 +++++++++++++----- packages/shared/src/graphql/actions.ts | 3 +- .../notifications/useNotificationSettings.ts | 18 ++++++++ packages/webapp/pages/notifications.tsx | 12 ++++- 8 files changed, 175 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index 6ae1f68f452..4fa9fd266a2 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -1,5 +1,11 @@ import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import { @@ -118,6 +124,8 @@ export default function BookmarkFeedLayout({ ], ); const { plusEntryBookmark } = usePlusEntry(); + const [isEmptyFeed, setIsEmptyFeed] = useState(false); + const onEmptyFeed = useCallback(() => setIsEmptyFeed(true), []); const feedProps = useMemo>(() => { if (isSearchResults) { return { @@ -246,9 +254,9 @@ export default function BookmarkFeedLayout({ /> )} - {!plusEntryBookmark && } + {!plusEntryBookmark && isEmptyFeed && } {tokenRefreshed && (isSearchResults || loadedSort) && ( - + )} ); diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx index fd157011130..43daa271f32 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx @@ -12,11 +12,17 @@ const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({}); const mockCompleteAction = jest.fn().mockResolvedValue(undefined); const mockCheckHasCompleted = jest.fn(); const mockUsePlusSubscription = jest.fn(); +const mockSetNotificationStatuses = jest.fn(); +const mockDisplayToast = jest.fn(); jest.mock('../../contexts/LogContext', () => ({ useLogContext: () => ({ logEvent: mockLogEvent }), })); +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: () => ({ isAuthReady: true }), +})); + jest.mock('../../hooks/usePlusSubscription', () => ({ usePlusSubscription: () => mockUsePlusSubscription(), })); @@ -37,6 +43,19 @@ jest.mock('../../hooks/useActions', () => ({ }), })); +jest.mock('../../hooks/notifications/useNotificationSettings', () => ({ + __esModule: true, + default: () => ({ + setNotificationStatusBulk: mockSetNotificationStatuses, + }), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ + displayToast: mockDisplayToast, + }), +})); + const client = new QueryClient(); const renderComponent = () => @@ -57,7 +76,9 @@ describe('DigestBookmarkBanner', () => { it('should render banner for non-Plus user without digest', () => { renderComponent(); - expect(screen.getByText('Never miss the best posts')).toBeInTheDocument(); + expect( + screen.getByText('Not sure what to read? Let us pick for you'), + ).toBeInTheDocument(); expect(screen.getByText('Enable digest')).toBeInTheDocument(); }); @@ -67,7 +88,7 @@ describe('DigestBookmarkBanner', () => { renderComponent(); expect( - screen.queryByText('Never miss the best posts'), + screen.queryByText('Not sure what to read? Let us pick for you'), ).not.toBeInTheDocument(); }); @@ -86,7 +107,7 @@ describe('DigestBookmarkBanner', () => { renderComponent(); expect( - screen.queryByText('Never miss the best posts'), + screen.queryByText('Not sure what to read? Let us pick for you'), ).not.toBeInTheDocument(); }); @@ -96,7 +117,7 @@ describe('DigestBookmarkBanner', () => { renderComponent(); expect( - screen.queryByText('Never miss the best posts'), + screen.queryByText('Not sure what to read? Let us pick for you'), ).not.toBeInTheDocument(); }); @@ -129,8 +150,16 @@ describe('DigestBookmarkBanner', () => { }); await waitFor(() => { - expect(mockCompleteAction).toHaveBeenCalledWith( - ActionType.DismissDigestBookmarkUpsell, + expect(mockSetNotificationStatuses).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); + }); + + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Digest enabled! Check your inbox tomorrow.', ); }); }); @@ -141,8 +170,6 @@ describe('DigestBookmarkBanner', () => { const closeButton = screen.getByRole('button', { name: 'Close' }); fireEvent.click(closeButton); - expect(mockCompleteAction).toHaveBeenCalledWith( - ActionType.DismissDigestBookmarkUpsell, - ); + expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); }); }); diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx index da5ccdab4ec..66e00f0d891 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx @@ -8,9 +8,9 @@ import { } from '../buttons/Button'; import CloseButton from '../CloseButton'; import { MailIcon } from '../icons'; -import { webappUrl } from '../../lib/constants'; import { LogEvent, TargetId } from '../../lib/log'; import { useLogContext } from '../../contexts/LogContext'; +import { useAuthContext } from '../../contexts/AuthContext'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { usePersonalizedDigest, @@ -19,18 +19,26 @@ import { import { UserPersonalizedDigestType } from '../../graphql/users'; import { useActions } from '../../hooks/useActions'; import { ActionType } from '../../graphql/actions'; +import useNotificationSettings from '../../hooks/notifications/useNotificationSettings'; +import { NotificationType } from './utils'; +import { NotificationPreferenceStatus } from '../../graphql/notifications'; +import { useToastNotification } from '../../hooks/useToastNotification'; export function DigestBookmarkBanner(): ReactElement | null { const { logEvent } = useLogContext(); + const { isAuthReady } = useAuthContext(); const { isPlus } = usePlusSubscription(); const { getPersonalizedDigest, subscribePersonalizedDigest } = usePersonalizedDigest(); const hasDigest = !!getPersonalizedDigest(UserPersonalizedDigestType.Digest); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); - const dismissed = checkHasCompleted(ActionType.DismissDigestBookmarkUpsell); + const { setNotificationStatusBulk } = useNotificationSettings(); + const { displayToast } = useToastNotification(); + const dismissed = checkHasCompleted(ActionType.DigestUpsell); const impressionLogged = useRef(false); - const showBanner = !isPlus && !hasDigest && !dismissed && isActionsFetched; + const showBanner = + isAuthReady && !isPlus && !hasDigest && !dismissed && isActionsFetched; useEffect(() => { if (showBanner && !impressionLogged.current) { @@ -58,26 +66,39 @@ export function DigestBookmarkBanner(): ReactElement | null { type: UserPersonalizedDigestType.Digest, }); - await completeAction(ActionType.DismissDigestBookmarkUpsell); + setNotificationStatusBulk([ + { + type: NotificationType.BriefingReady, + channel: 'email', + status: NotificationPreferenceStatus.Subscribed, + }, + { + type: NotificationType.DigestReady, + channel: 'inApp', + status: NotificationPreferenceStatus.Subscribed, + }, + ]); - window.location.href = `${webappUrl}account/notifications`; + await completeAction(ActionType.DigestUpsell); + + displayToast('Digest enabled! Check your inbox tomorrow.'); }; const onDismiss = () => { - completeAction(ActionType.DismissDigestBookmarkUpsell); + completeAction(ActionType.DigestUpsell); }; return ( -
+
- Never miss the best posts + Not sure what to read? Let us pick for you -

- Get a personalized digest of top posts from your favorite topics — - delivered to your inbox daily. +

+ Get a daily digest with the best posts from your favorite topics, + straight to your inbox.

-
+
+ {/* Digest upsell only shown when bookmarks are empty to engage new/inactive users */} {!plusEntryBookmark && isEmptyFeed && } {tokenRefreshed && (isSearchResults || loadedSort) && ( diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx index 70ec83e35a5..2d33d2f55b1 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.spec.tsx @@ -164,7 +164,7 @@ describe('DigestBookmarkBanner', () => { }); }); - it('should log dismiss and complete action on dismiss', () => { + it('should log dismiss and complete action on dismiss', async () => { renderComponent(); const closeButton = screen.getByRole('button', { name: 'Close' }); @@ -175,6 +175,25 @@ describe('DigestBookmarkBanner', () => { target_id: TargetId.DigestUpsellBookmarks, extra: JSON.stringify({ action: 'dismiss' }), }); - expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); + await waitFor(() => { + expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); + }); + }); + + it('should show error toast when subscribe fails', async () => { + mockSubscribePersonalizedDigest.mockRejectedValueOnce( + new Error('API error'), + ); + + renderComponent(); + + fireEvent.click(screen.getByText('Enable digest')); + + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Failed to enable digest. Please try again in settings.', + ); + }); + expect(mockCompleteAction).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx index afc6232847c..33dd0d560ca 100644 --- a/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx +++ b/packages/shared/src/components/notifications/DigestBookmarkBanner.tsx @@ -60,37 +60,41 @@ export function DigestBookmarkBanner(): ReactElement | null { target_id: TargetId.DigestUpsellBookmarks, }); - await subscribePersonalizedDigest({ - hour: 9, - sendType: SendType.Workdays, - type: UserPersonalizedDigestType.Digest, - }); + try { + await subscribePersonalizedDigest({ + hour: 9, + sendType: SendType.Workdays, + type: UserPersonalizedDigestType.Digest, + }); - setNotificationStatusBulk([ - { - type: NotificationType.BriefingReady, - channel: 'email', - status: NotificationPreferenceStatus.Subscribed, - }, - { - type: NotificationType.DigestReady, - channel: 'inApp', - status: NotificationPreferenceStatus.Subscribed, - }, - ]); + setNotificationStatusBulk([ + { + type: NotificationType.BriefingReady, + channel: 'email', + status: NotificationPreferenceStatus.Subscribed, + }, + { + type: NotificationType.DigestReady, + channel: 'inApp', + status: NotificationPreferenceStatus.Subscribed, + }, + ]); - await completeAction(ActionType.DigestUpsell); + await completeAction(ActionType.DigestUpsell); - displayToast('Digest enabled! Check your inbox tomorrow.'); + displayToast('Digest enabled! Check your inbox tomorrow.'); + } catch { + displayToast('Failed to enable digest. Please try again in settings.'); + } }; - const onDismiss = () => { + const onDismiss = async () => { logEvent({ event_name: LogEvent.Click, target_id: TargetId.DigestUpsellBookmarks, extra: JSON.stringify({ action: 'dismiss' }), }); - completeAction(ActionType.DigestUpsell); + await completeAction(ActionType.DigestUpsell); }; return ( diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx index 31df3a96043..f493cb7f82f 100644 --- a/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.spec.tsx @@ -164,7 +164,7 @@ describe('DigestUpsellBanner', () => { }); }); - it('should log dismiss and complete action on dismiss', () => { + it('should log dismiss and complete action on dismiss', async () => { renderComponent(); const closeButton = screen.getByRole('button', { name: 'Close' }); @@ -175,6 +175,25 @@ describe('DigestUpsellBanner', () => { target_id: TargetId.DigestUpsell, extra: JSON.stringify({ action: 'dismiss' }), }); - expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); + await waitFor(() => { + expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell); + }); + }); + + it('should show error toast when subscribe fails', async () => { + mockSubscribePersonalizedDigest.mockRejectedValueOnce( + new Error('API error'), + ); + + renderComponent(); + + fireEvent.click(screen.getByText('Enable digest')); + + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Failed to enable digest. Please try again in settings.', + ); + }); + expect(mockCompleteAction).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx index 8886dc3e77f..d025c35d59a 100644 --- a/packages/shared/src/components/notifications/DigestUpsellBanner.tsx +++ b/packages/shared/src/components/notifications/DigestUpsellBanner.tsx @@ -60,37 +60,41 @@ export function DigestUpsellBanner(): ReactElement | null { target_id: TargetId.DigestUpsell, }); - await subscribePersonalizedDigest({ - hour: 9, - sendType: SendType.Workdays, - type: UserPersonalizedDigestType.Digest, - }); + try { + await subscribePersonalizedDigest({ + hour: 9, + sendType: SendType.Workdays, + type: UserPersonalizedDigestType.Digest, + }); - setNotificationStatusBulk([ - { - type: NotificationType.BriefingReady, - channel: 'email', - status: NotificationPreferenceStatus.Subscribed, - }, - { - type: NotificationType.DigestReady, - channel: 'inApp', - status: NotificationPreferenceStatus.Subscribed, - }, - ]); + setNotificationStatusBulk([ + { + type: NotificationType.BriefingReady, + channel: 'email', + status: NotificationPreferenceStatus.Subscribed, + }, + { + type: NotificationType.DigestReady, + channel: 'inApp', + status: NotificationPreferenceStatus.Subscribed, + }, + ]); - await completeAction(ActionType.DigestUpsell); + await completeAction(ActionType.DigestUpsell); - displayToast('Digest enabled! Check your inbox tomorrow.'); + displayToast('Digest enabled! Check your inbox tomorrow.'); + } catch { + displayToast('Failed to enable digest. Please try again in settings.'); + } }; - const onDismiss = () => { + const onDismiss = async () => { logEvent({ event_name: LogEvent.Click, target_id: TargetId.DigestUpsell, extra: JSON.stringify({ action: 'dismiss' }), }); - completeAction(ActionType.DigestUpsell); + await completeAction(ActionType.DigestUpsell); }; return ( From 2a31d3e6541f685edc1fc301b5ffd1d6cbfc0587 Mon Sep 17 00:00:00 2001 From: capJavert Date: Mon, 9 Mar 2026 15:00:50 +0100 Subject: [PATCH 8/8] chore: remove digest upsell E2E tests Login flow doesn't work on preview deployments. Will revisit once E2E auth pattern is established. Co-Authored-By: Claude Opus 4.6 --- .../playwright/tests/digest-upsell.spec.ts | 105 ------------------ 1 file changed, 105 deletions(-) delete mode 100644 packages/playwright/tests/digest-upsell.spec.ts diff --git a/packages/playwright/tests/digest-upsell.spec.ts b/packages/playwright/tests/digest-upsell.spec.ts deleted file mode 100644 index 40a7471fbc9..00000000000 --- a/packages/playwright/tests/digest-upsell.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Digest Upsell Banners', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - - // Handle cookie consent - await page - .getByRole('button', { name: 'Accept all' }) - .or(page.getByRole('button', { name: 'I understand' })) - .click(); - - // Log in - await page.getByRole('button', { name: 'Log in' }).click(); - await page - .getByRole('textbox', { name: 'Email' }) - .fill(process.env.USER_NAME); - await page.getByRole('textbox', { name: 'Password' }).press('Tab'); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(process.env.PASSWORD); - await page.getByRole('button', { name: 'Log in' }).click(); - - // Wait for auth to complete - await expect( - page - .getByRole('link', { name: 'profile' }) - .or(page.getByRole('button', { name: 'Profile settings' })), - ).toBeVisible(); - }); - - test('notifications page shows digest upsell banner for eligible users', async ({ - page, - }) => { - await page.goto('/notifications'); - - const banner = page.getByText('Get the must-read posts delivered daily'); - const enableButton = page.getByRole('button', { name: 'Enable digest' }); - - // If the banner is visible, verify its structure - if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { - await expect(banner).toBeVisible(); - await expect(enableButton).toBeVisible(); - - // Verify close button exists - const closeButton = page.getByRole('button', { name: 'Close' }); - await expect(closeButton).toBeVisible(); - } - }); - - test('bookmarks page shows digest upsell banner for eligible users', async ({ - page, - }) => { - await page.goto('/bookmarks'); - - const banner = page.getByText( - 'Not sure what to read? Let us pick for you', - ); - const enableButton = page.getByRole('button', { name: 'Enable digest' }); - - // If the banner is visible, verify its structure - if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { - await expect(banner).toBeVisible(); - await expect(enableButton).toBeVisible(); - - // Verify close button exists - const closeButton = page.getByRole('button', { name: 'Close' }); - await expect(closeButton).toBeVisible(); - } - }); - - test('digest upsell banner can be dismissed on notifications page', async ({ - page, - }) => { - await page.goto('/notifications'); - - const banner = page.getByText('Get the must-read posts delivered daily'); - - // Only test dismiss if banner is visible (user is eligible) - if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { - const closeButton = page.getByRole('button', { name: 'Close' }); - await closeButton.click(); - - await expect(banner).toBeHidden(); - } - }); - - test('digest upsell banner can be dismissed on bookmarks page', async ({ - page, - }) => { - await page.goto('/bookmarks'); - - const banner = page.getByText( - 'Not sure what to read? Let us pick for you', - ); - - // Only test dismiss if banner is visible (user is eligible) - if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) { - const closeButton = page.getByRole('button', { name: 'Close' }); - await closeButton.click(); - - await expect(banner).toBeHidden(); - } - }); -});