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
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useFeature } from '../../GrowthBookProvider';
import { iOSSupportsReview, openIOSWriteReview } from '../../../lib/ios';
import { LogEvent, TargetType } from '../../../lib/log';
import { rateAppTitle, ResourceSection } from './ResourceSection';

jest.mock('next/router', () => ({
useRouter: () => ({ prefetch: jest.fn(() => Promise.resolve()) }),
}));
jest.mock('../../../hooks', () => ({
useViewSize: () => false,
ViewSize: { MobileL: 'mobileL' },
}));
jest.mock('../../GrowthBookProvider', () => ({ useFeature: jest.fn() }));
jest.mock('../../../lib/ios', () => ({
...jest.requireActual('../../../lib/ios'),
iOSSupportsReview: jest.fn(),
openIOSWriteReview: jest.fn(() => true),
}));

const mockLogEvent = jest.fn();

jest.mock('../../../contexts/LogContext', () => ({
useLogContext: () => ({ logEvent: mockLogEvent }),
}));

describe('ResourceSection', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useFeature).mockReturnValue(true);
jest.mocked(iOSSupportsReview).mockReturnValue(true);
});

it('shows the rate row in the iOS app with the review bridge', () => {
render(<ResourceSection />);

const row = screen.getByRole('button', { name: rateAppTitle });
expect(row).toBeInTheDocument();
expect(screen.getByText('Changelog').compareDocumentPosition(row)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);

fireEvent.click(row);

expect(openIOSWriteReview).toHaveBeenCalledTimes(1);
expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Click,
target_type: TargetType.AppStoreReview,
target_id: 'profile_menu',
});
});

it('hides the rate row without the review bridge', () => {
jest.mocked(iOSSupportsReview).mockReturnValue(false);
render(<ResourceSection />);

expect(
screen.queryByRole('button', { name: rateAppTitle }),
).not.toBeInTheDocument();
expect(screen.getByText('Changelog')).toBeInTheDocument();
});

it('hides the rate row when the feature is off', () => {
jest.mocked(useFeature).mockReturnValue(false);
render(<ResourceSection />);

expect(
screen.queryByRole('button', { name: rateAppTitle }),
).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import React from 'react';
import type { ReactElement } from 'react';

import { ProfileSection } from '../ProfileSection';
import type { ProfileSectionItemProps } from '../ProfileSectionItem';
import {
AppleIcon,
DocsIcon,
FeedbackIcon,
MegaphoneIcon,
Expand All @@ -14,8 +16,18 @@ import {
feedback,
webappUrl,
} from '../../../lib/constants';
import { rateAppTitle, useRateAppRow } from '../../../hooks/useRateAppRow';

export { rateAppTitle };

export const ResourceSection = (): ReactElement => {
// Only reachable at laptop width, so inside the iOS app this is an iPad;
// iPhones get the same row on the phone shell's You page.
const rateApp = useRateAppRow('profile_menu');
const rateAppItems: ProfileSectionItemProps[] = rateApp.isVisible
? [{ title: rateAppTitle, icon: AppleIcon, onClick: rateApp.onClick }]
: [];

return (
<ProfileSection
items={[
Expand All @@ -24,6 +36,7 @@ export const ResourceSection = (): ReactElement => {
icon: TerminalIcon,
href: `${webappUrl}sources/daily_updates`,
},
...rateAppItems,
{
title: 'Advertise',
icon: MegaphoneIcon,
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/components/appStoreReview/common.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ReactElement } from 'react';
import React from 'react';

// Our own ask around Apple's review tools. Apple's rating pop-up is requested
// on its own a moment after a surface lands, never from a tap, and whether it
// shows is Apple's call. Every button opens the App Store's write-a-review
// screen, which always works, so each surface stands on its own.
export const appStoreReviewCopy = {
headline: 'A little love goes a long way.',
emoji: '❤️',
text: 'If daily.dev has made your day a little better, a quick rating would mean a lot to us.',
cta: 'Rate daily.dev',
ctaLabel: 'Rate daily.dev on the App Store',
dismiss: 'Not now',
};

// Apple's sample asks only after people pause on a finished step.
export const appStoreReviewRequestDelayMs = 2000;

// A non-breaking space keeps the emoji with the last word, and screen
// readers skip it.
export const AskHeadline = (): ReactElement => (
<>
{appStoreReviewCopy.headline}
{' '}
<span aria-hidden>{appStoreReviewCopy.emoji}</span>
</>
);
2 changes: 2 additions & 0 deletions packages/shared/src/components/marketing/cta/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export enum MarketingCtaVariant {
YearInReview = 'year_in_review',
HelpGuide = 'help_guide',
Video = 'video',
AppStoreReview = 'app_store_review',
AppStoreReviewPlus = 'app_store_review_plus',
}

interface MarketingCtaTargets {
Expand Down
172 changes: 172 additions & 0 deletions packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import React from 'react';
import { QueryClient } from '@tanstack/react-query';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import loggedUser from '../../../__tests__/fixture/loggedUser';
import { LogEvent, TargetType } from '../../lib/log';
import { openIOSWriteReview, requestIOSReview } from '../../lib/ios';
import * as bootHook from '../../hooks/useBoot';
import type { MarketingCta } from '../marketing/cta/common';
import { MarketingCtaVariant } from '../marketing/cta/common';
import {
appStoreReviewCopy,
appStoreReviewRequestDelayMs,
} from '../appStoreReview/common';
import AppStoreReviewModal from './AppStoreReviewModal';

jest.mock('../../lib/ios', () => ({
...jest.requireActual('../../lib/ios'),
openIOSWriteReview: jest.fn(() => true),
requestIOSReview: jest.fn(() => true),
}));

const logEvent = jest.fn();
const clearMarketingCta = jest.fn();
const onRequestClose = jest.fn();

const marketingCta: MarketingCta = {
campaignId: 'review-heavy-week',
createdAt: new Date('2026-11-10T00:00:00.000Z'),
variant: MarketingCtaVariant.AppStoreReview,
flags: { title: '', ctaUrl: '', ctaText: '' },
};

const renderModal = (cta: MarketingCta = marketingCta) =>
render(
<TestBootProvider
auth={{ user: loggedUser }}
client={new QueryClient()}
log={{ logEvent }}
>
<AppStoreReviewModal
marketingCta={cta}
ariaHideApp={false}
isOpen
onRequestClose={onRequestClose}
/>
</TestBootProvider>,
);

describe('AppStoreReviewModal', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.spyOn(bootHook, 'useBoot').mockReturnValue({
clearMarketingCta,
} as unknown as ReturnType<typeof bootHook.useBoot>);
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

it('shows the default copy and logs the impression', () => {
renderModal();

expect(
screen.getByRole('heading', { name: appStoreReviewCopy.headline }),
).toBeInTheDocument();
expect(screen.getByText(appStoreReviewCopy.text)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }),
).toHaveTextContent(appStoreReviewCopy.cta);
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.Impression,
target_type: TargetType.AppStoreReview,
target_id: marketingCta.campaignId,
});
});

it('uses the campaign copy when Customer.io sets it', () => {
renderModal({
...marketingCta,
flags: {
title: 'Thanks for staying with Plus',
description: 'Glad to have you for another month.',
ctaText: 'Leave a rating',
ctaUrl: '',
},
});

expect(
screen.getByRole('heading', { name: 'Thanks for staying with Plus' }),
).toBeInTheDocument();
expect(
screen.getByText('Glad to have you for another month.'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }),
).toHaveTextContent('Leave a rating');
});

it('asks iOS for the rating pop-up after a pause, even once closed', () => {
renderModal();

expect(requestIOSReview).not.toHaveBeenCalled();

fireEvent.click(
screen.getByRole('button', { name: appStoreReviewCopy.dismiss }),
);

act(() => {
jest.advanceTimersByTime(appStoreReviewRequestDelayMs);
});

expect(requestIOSReview).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.RequestAppStoreReview,
target_type: TargetType.AppStoreReview,
target_id: marketingCta.campaignId,
});
});

it('skips the pop-up once Rate was tapped', () => {
renderModal();

fireEvent.click(
screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }),
);

act(() => {
jest.advanceTimersByTime(appStoreReviewRequestDelayMs);
});

expect(openIOSWriteReview).toHaveBeenCalledTimes(1);
expect(requestIOSReview).not.toHaveBeenCalled();
});

it('opens the write-a-review screen from the CTA and clears the ask', () => {
renderModal();

fireEvent.click(
screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }),
);

expect(openIOSWriteReview).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.Click,
target_type: TargetType.AppStoreReview,
target_id: marketingCta.campaignId,
});
expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId);
expect(onRequestClose).toHaveBeenCalledTimes(1);
});

it('clears the ask on not now without opening the store', () => {
renderModal();

fireEvent.click(
screen.getByRole('button', { name: appStoreReviewCopy.dismiss }),
);

expect(openIOSWriteReview).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.MarketingCtaDismiss,
target_type: TargetType.AppStoreReview,
target_id: marketingCta.campaignId,
});
expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId);
expect(onRequestClose).toHaveBeenCalledTimes(1);
});
});
Loading
Loading