Skip to content
Merged
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
15 changes: 13 additions & 2 deletions packages/shared/src/components/BookmarkFeedLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,6 +27,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,
Expand Down Expand Up @@ -117,6 +124,8 @@ export default function BookmarkFeedLayout({
],
);
const { plusEntryBookmark } = usePlusEntry();
const [isEmptyFeed, setIsEmptyFeed] = useState(false);
const onEmptyFeed = useCallback(() => setIsEmptyFeed(true), []);
const feedProps = useMemo<FeedProps<unknown>>(() => {
if (isSearchResults) {
return {
Expand Down Expand Up @@ -245,8 +254,10 @@ export default function BookmarkFeedLayout({
/>
)}
</div>
{/* Digest upsell only shown when bookmarks are empty to engage new/inactive users */}
{!plusEntryBookmark && isEmptyFeed && <DigestBookmarkBanner />}
{tokenRefreshed && (isSearchResults || loadedSort) && (
<Feed {...feedProps} />
<Feed {...feedProps} onEmptyFeed={onEmptyFeed} />
)}
</FeedPageLayoutComponent>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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';
import { ActionType } from '../../graphql/actions';

const mockLogEvent = jest.fn();
const mockGetPersonalizedDigest = jest.fn();
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(),
}));

jest.mock('../../hooks/usePersonalizedDigest', () => ({
usePersonalizedDigest: () => ({
getPersonalizedDigest: mockGetPersonalizedDigest,
subscribePersonalizedDigest: mockSubscribePersonalizedDigest,
}),
SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' },
}));

jest.mock('../../hooks/useActions', () => ({
useActions: () => ({
checkHasCompleted: mockCheckHasCompleted,
completeAction: mockCompleteAction,
isActionsFetched: true,
}),
}));

jest.mock('../../hooks/notifications/useNotificationSettings', () => ({
__esModule: true,
default: () => ({
setNotificationStatusBulk: mockSetNotificationStatuses,
}),
}));

jest.mock('../../hooks/useToastNotification', () => ({
useToastNotification: () => ({
displayToast: mockDisplayToast,
}),
}));

const client = new QueryClient();

const renderComponent = () =>
render(
<QueryClientProvider client={client}>
<DigestBookmarkBanner />
</QueryClientProvider>,
);

describe('DigestBookmarkBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUsePlusSubscription.mockReturnValue({ isPlus: false });
mockGetPersonalizedDigest.mockReturnValue(null);
mockCheckHasCompleted.mockReturnValue(false);
});

it('should render banner for non-Plus user without digest', () => {
renderComponent();

expect(
screen.getByText('Not sure what to read? Let us pick for you'),
).toBeInTheDocument();
expect(screen.getByText('Enable digest')).toBeInTheDocument();
});

it('should not render for Plus users', () => {
mockUsePlusSubscription.mockReturnValue({ isPlus: true });

renderComponent();

expect(
screen.queryByText('Not sure what to read? Let us pick for you'),
).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('Not sure what to read? Let us pick for you'),
).not.toBeInTheDocument();
});

it('should not render when dismissed via action', () => {
mockCheckHasCompleted.mockReturnValue(true);

renderComponent();

expect(
screen.queryByText('Not sure what to read? Let us pick for you'),
).not.toBeInTheDocument();
});

it('should log impression on render', () => {
renderComponent();

expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Impression,
target_id: TargetId.DigestUpsellBookmarks,
});
});

it('should subscribe, complete action, 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,
});
});

await waitFor(() => {
expect(mockSetNotificationStatuses).toHaveBeenCalled();
});

await waitFor(() => {
expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell);
});

await waitFor(() => {
expect(mockDisplayToast).toHaveBeenCalledWith(
'Digest enabled! Check your inbox tomorrow.',
);
});
});

it('should log dismiss and complete action on dismiss', async () => {
renderComponent();

const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);

expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Click,
target_id: TargetId.DigestUpsellBookmarks,
extra: JSON.stringify({ action: 'dismiss' }),
});
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();
});
});
127 changes: 127 additions & 0 deletions packages/shared/src/components/notifications/DigestBookmarkBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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 { LogEvent, TargetId } from '../../lib/log';
import { useLogContext } from '../../contexts/LogContext';
import { useAuthContext } from '../../contexts/AuthContext';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';
import {
usePersonalizedDigest,
SendType,
} from '../../hooks/usePersonalizedDigest';
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 { setNotificationStatusBulk } = useNotificationSettings();
const { displayToast } = useToastNotification();
const dismissed = checkHasCompleted(ActionType.DigestUpsell);
const impressionLogged = useRef(false);

const showBanner =
isAuthReady && !isPlus && !hasDigest && !dismissed && isActionsFetched;

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

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

await completeAction(ActionType.DigestUpsell);

displayToast('Digest enabled! Check your inbox tomorrow.');
} catch {
displayToast('Failed to enable digest. Please try again in settings.');
}
};

const onDismiss = async () => {
logEvent({
event_name: LogEvent.Click,
target_id: TargetId.DigestUpsellBookmarks,
extra: JSON.stringify({ action: 'dismiss' }),
});
await completeAction(ActionType.DigestUpsell);
};

return (
<div className="relative mx-4 mb-4 overflow-hidden rounded-16 border border-accent-cabbage-default bg-surface-float px-6 py-4 typo-callout laptop:mx-auto laptop:max-w-[40rem]">
<span className="flex flex-row items-center font-bold">
<MailIcon className="mr-2" />
Not sure what to read? Let us pick for you
</span>
<p className="mt-2 text-text-tertiary">
Get a daily digest with the best posts from your favorite topics,
straight to your inbox.
</p>
<div className="mt-3 flex items-center">
<Button
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
color={ButtonColor.Cabbage}
onClick={onEnable}
>
Enable digest
</Button>
</div>
<CloseButton
size={ButtonSize.XSmall}
className="absolute right-1 top-1 laptop:right-3 laptop:top-3"
onClick={onDismiss}
/>
</div>
);
}
Loading