diff --git a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.spec.tsx b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.spec.tsx new file mode 100644 index 00000000000..b5e0d136ee5 --- /dev/null +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.spec.tsx @@ -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(); + + 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(); + + 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(); + + expect( + screen.queryByRole('button', { name: rateAppTitle }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx index 99519cae038..4572dc22648 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx @@ -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, @@ -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 ( { icon: TerminalIcon, href: `${webappUrl}sources/daily_updates`, }, + ...rateAppItems, { title: 'Advertise', icon: MegaphoneIcon, diff --git a/packages/shared/src/components/appStoreReview/common.tsx b/packages/shared/src/components/appStoreReview/common.tsx new file mode 100644 index 00000000000..57939dabd39 --- /dev/null +++ b/packages/shared/src/components/appStoreReview/common.tsx @@ -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} + {' '} + {appStoreReviewCopy.emoji} + +); diff --git a/packages/shared/src/components/marketing/cta/common.tsx b/packages/shared/src/components/marketing/cta/common.tsx index 89f979e2815..d281676761c 100644 --- a/packages/shared/src/components/marketing/cta/common.tsx +++ b/packages/shared/src/components/marketing/cta/common.tsx @@ -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 { diff --git a/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx b/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx new file mode 100644 index 00000000000..4e76e4c6b13 --- /dev/null +++ b/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx @@ -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( + + + , + ); + +describe('AppStoreReviewModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.spyOn(bootHook, 'useBoot').mockReturnValue({ + clearMarketingCta, + } as unknown as ReturnType); + }); + + 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); + }); +}); diff --git a/packages/shared/src/components/modals/AppStoreReviewModal.tsx b/packages/shared/src/components/modals/AppStoreReviewModal.tsx new file mode 100644 index 00000000000..37797faea04 --- /dev/null +++ b/packages/shared/src/components/modals/AppStoreReviewModal.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { AppleIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import LogoIcon from '../../svg/LogoIcon'; +import type { MarketingCta } from '../marketing/cta/common'; +import { useAppStoreReviewAsk } from '../../hooks/useAppStoreReviewAsk'; +import { appStoreReviewCopy, AskHeadline } from '../appStoreReview/common'; + +export interface AppStoreReviewModalProps extends ModalProps { + marketingCta: MarketingCta; +} + +// The plain ask Customer.io schedules through the `app_store_review` +// marketing CTA: a streak milestone, a heavy reading week, a big release. +// The campaign's copy wins over the defaults when it sets any. +export default function AppStoreReviewModal({ + marketingCta, + onRequestClose, + ...modalProps +}: AppStoreReviewModalProps): ReactElement { + const { campaignId, flags } = marketingCta; + const { onRate, onDismiss } = useAppStoreReviewAsk({ + campaignId, + onRequestClose, + }); + const headlineRef = useRef(null); + const description = flags?.description || appStoreReviewCopy.text; + const ctaText = flags?.ctaText || appStoreReviewCopy.cta; + + // Focus starts on the headline, not the CTA, so nobody rates by accident. + useEffect(() => { + const frame = requestAnimationFrame(() => headlineRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + return ( + + +
+ {flags?.image ? ( + + ) : ( + + + + )} + + {flags?.title || } + + + {description} + +
+
+ + +
+
+
+ ); +} diff --git a/packages/shared/src/components/modals/BootPopups.tsx b/packages/shared/src/components/modals/BootPopups.tsx index efae094e570..ee1f10aab93 100644 --- a/packages/shared/src/components/modals/BootPopups.tsx +++ b/packages/shared/src/components/modals/BootPopups.tsx @@ -22,6 +22,7 @@ import useProfileForm from '../../hooks/useProfileForm'; import { useJobsFeature } from '../../hooks/useJobsFeature'; import { useReferralReminderModal } from '../../hooks/referral/useReferralReminderModal'; import { useIsBootPopupsPaused } from '../../lib/bootPopupsPause'; +import { useAppStoreReviewPrompt } from '../../hooks/useAppStoreReviewPrompt'; const REP_TRESHOLD = 250; @@ -94,6 +95,9 @@ export const BootPopups = (): ReactElement => { const { streak, isStreaksEnabled } = useReadingStreak(); + // Not a boot popup: the review ask waits for a route change, not page load. + useAppStoreReviewPrompt(); + const isDisabledMilestone = checkHasCompleted( ActionType.DisableReadingStreakMilestone, ); diff --git a/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx b/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx new file mode 100644 index 00000000000..a69fed0fe57 --- /dev/null +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx @@ -0,0 +1,137 @@ +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 PlusRenewalReviewModal, { + plusRenewalCopy, +} from './PlusRenewalReviewModal'; + +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-plus-renewal', + createdAt: new Date('2026-11-10T00:00:00.000Z'), + variant: MarketingCtaVariant.AppStoreReviewPlus, + flags: { title: '', ctaUrl: '', ctaText: '' }, +}; + +const renderModal = (cta: MarketingCta = marketingCta) => + render( + + + , + ); + +describe('PlusRenewalReviewModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.spyOn(bootHook, 'useBoot').mockReturnValue({ + clearMarketingCta, + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('thanks the member and asks for a rating', () => { + renderModal(); + + expect( + screen.getByRole('heading', { name: plusRenewalCopy.headline }), + ).toBeInTheDocument(); + expect(screen.getByText(plusRenewalCopy.text)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }), + ).toHaveTextContent(appStoreReviewCopy.cta); + expect( + screen.queryByRole('button', { name: /gift/i }), + ).not.toBeInTheDocument(); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.AppStoreReview, + target_id: marketingCta.campaignId, + }); + + act(() => { + jest.advanceTimersByTime(appStoreReviewRequestDelayMs); + }); + + expect(requestIOSReview).toHaveBeenCalledTimes(1); + }); + + it('uses the campaign copy when Customer.io sets it', () => { + renderModal({ + ...marketingCta, + flags: { + title: 'One year of Plus', + description: 'Thanks for a whole year with us.', + ctaText: '', + ctaUrl: '', + }, + }); + + expect( + screen.getByRole('heading', { name: 'One year of Plus' }), + ).toBeInTheDocument(); + expect( + screen.getByText('Thanks for a whole year with us.'), + ).toBeInTheDocument(); + }); + + it('opens the write-a-review screen from the CTA', () => { + renderModal(); + + fireEvent.click( + screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }), + ); + + expect(openIOSWriteReview).toHaveBeenCalledTimes(1); + expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it('goes back to the feed without rating', () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: plusRenewalCopy.back })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.MarketingCtaDismiss, + target_type: TargetType.AppStoreReview, + target_id: marketingCta.campaignId, + }); + expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); + expect(onRequestClose).toHaveBeenCalledTimes(1); + expect(openIOSWriteReview).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx b/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx new file mode 100644 index 00000000000..7d9b41c692e --- /dev/null +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx @@ -0,0 +1,107 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import type { ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { AppleIcon, DevPlusIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { PlusUser } from '../PlusUser'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import type { MarketingCta } from '../marketing/cta/common'; +import { useAppStoreReviewAsk } from '../../hooks/useAppStoreReviewAsk'; +import { appStoreReviewCopy } from '../appStoreReview/common'; + +export const plusRenewalCopy = { + headline: 'Thanks for staying with Plus', + text: 'You chose to keep daily.dev in your routine. If it has made your days a little better, a quick rating would mean a lot to us.', + back: 'Back to my feed', +}; + +export interface PlusRenewalReviewModalProps extends ModalProps { + marketingCta: MarketingCta; +} + +// The thank-you Customer.io schedules through the `app_store_review_plus` +// marketing CTA after a renewal or on a Plus anniversary. People who pay +// already decided daily.dev is worth it, so the ask sits inside a thank-you. +// Gifting Plus is not available inside the iOS app (StoreKit has no gift +// product), so rating is the one action. The campaign's copy wins over the +// defaults when it sets any. +export default function PlusRenewalReviewModal({ + marketingCta, + onRequestClose, + ...modalProps +}: PlusRenewalReviewModalProps): ReactElement { + const { campaignId, flags } = marketingCta; + const { onRate, onDismiss } = useAppStoreReviewAsk({ + campaignId, + onRequestClose, + }); + const headlineRef = useRef(null); + + useEffect(() => { + const frame = requestAnimationFrame(() => headlineRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + return ( + + +
+ + + + + + {flags?.title || plusRenewalCopy.headline} + + + {flags?.description || plusRenewalCopy.text} + +
+
+ + +
+
+
+ ); +} diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 93d81b4364d..b5a6d9bcc48 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -129,6 +129,20 @@ const ShareModal = dynamic( () => import(/* webpackChunkName: "shareModal" */ './ShareModal'), ); +const AppStoreReviewModal = dynamic( + () => + import( + /* webpackChunkName: "appStoreReviewModal" */ './AppStoreReviewModal' + ), +); + +const PlusRenewalReviewModal = dynamic( + () => + import( + /* webpackChunkName: "plusRenewalReviewModal" */ './PlusRenewalReviewModal' + ), +); + const BookmarkReminderModal = dynamic( () => import( @@ -649,6 +663,8 @@ export const modals = { [LazyModal.LfgReachOut]: LfgReachOutModal, [LazyModal.LfgHelper]: LfgHelperModal, [LazyModal.FeedExplanation]: FeedExplanationModal, + [LazyModal.AppStoreReview]: AppStoreReviewModal, + [LazyModal.AppStoreReviewPlus]: PlusRenewalReviewModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index e145d0959fa..341f3178ec8 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -114,6 +114,8 @@ export enum LazyModal { LfgReachOut = 'lfgReachOut', LfgHelper = 'lfgHelper', FeedExplanation = 'feedExplanation', + AppStoreReview = 'appStoreReview', + AppStoreReviewPlus = 'appStoreReviewPlus', } export type ModalTabItem = { diff --git a/packages/shared/src/components/shell/YouPage.spec.tsx b/packages/shared/src/components/shell/YouPage.spec.tsx index 36b1cf63dad..29888ea7ccd 100644 --- a/packages/shared/src/components/shell/YouPage.spec.tsx +++ b/packages/shared/src/components/shell/YouPage.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; @@ -8,6 +8,8 @@ import AuthContext from '../../contexts/AuthContext'; import type { LoggedUser } from '../../lib/user'; import { SubscriptionStatus } from '../../lib/plus'; import { AlertColor } from '../AlertDot'; +import { rateAppTitle } from '../../hooks/useRateAppRow'; +import { ShellPageProvider, useShellActionsSlot } from './ShellPageContext'; import { YouPage } from './YouPage'; jest.mock('next/router', () => ({ @@ -51,6 +53,12 @@ jest.mock('../../hooks/useLazyModal', () => ({ useLazyModal: () => ({ openModal: jest.fn() }), })); +const mockRateApp = { isVisible: true, onClick: jest.fn() }; +jest.mock('../../hooks/useRateAppRow', () => ({ + ...jest.requireActual('../../hooks/useRateAppRow'), + useRateAppRow: () => mockRateApp, +})); + jest.mock('../../hooks/useActions', () => ({ useActions: () => ({ isActionsFetched: true, @@ -70,6 +78,13 @@ const user = { createdAt: '2026-10-01T00:00:00Z', } as unknown as LoggedUser; +// The page portals its header actions (Invite, Help) into the shell's slot; +// in production MainLayout's top block registers it. +const ActionsSlot = (): React.ReactElement => { + const setActionsSlot = useShellActionsSlot(); + return
; +}; + const renderPage = (pageUser: LoggedUser = user) => { (useRouter as jest.Mock).mockReturnValue({ pathname: '/you', @@ -83,7 +98,10 @@ const renderPage = (pageUser: LoggedUser = user) => { { user: pageUser, isLoggedIn: true } as unknown as AuthContextData } > - + + + + , ); @@ -103,6 +121,32 @@ describe('YouPage', () => { mockCores.has = true; mockPhone.browser = true; mockSettings.isQuestExperienceEnabled = true; + mockRateApp.isVisible = true; + mockRateApp.onClick.mockClear(); + }); + + it('rates the app from the Help drawer in the iOS app', () => { + renderPage(); + + fireEvent.click(screen.getByLabelText('Help')); + const row = screen.getByRole('button', { name: rateAppTitle }); + expect(screen.getByText('Changelog').compareDocumentPosition(row)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + + fireEvent.click(row); + expect(mockRateApp.onClick).toHaveBeenCalledTimes(1); + }); + + it('keeps the Help drawer as it is without the review bridge', () => { + mockRateApp.isVisible = false; + renderPage(); + + fireEvent.click(screen.getByLabelText('Help')); + expect(screen.getByText('Changelog')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: rateAppTitle }), + ).not.toBeInTheDocument(); }); it('shows the member, the counts and one list ending in Settings', () => { diff --git a/packages/shared/src/components/shell/YouPage.tsx b/packages/shared/src/components/shell/YouPage.tsx index 5db72df4163..25edf56bae2 100644 --- a/packages/shared/src/components/shell/YouPage.tsx +++ b/packages/shared/src/components/shell/YouPage.tsx @@ -30,6 +30,7 @@ import { UserIcon, } from '../icons'; import { AppIcon } from '../icons/App'; +import { AppleIcon } from '../icons/Apple'; import { CoreIcon } from '../icons/Core'; import { AlertColor, AlertDot } from '../AlertDot'; import { useActions } from '../../hooks/useActions'; @@ -57,6 +58,7 @@ import { import { usePhoneBrowser } from '../../features/getApp/hooks/usePhoneBrowser'; import { largeNumberFormat } from '../../lib/numberFormat'; import { useLazyModal } from '../../hooks/useLazyModal'; +import { rateAppTitle, useRateAppRow } from '../../hooks/useRateAppRow'; import { LazyModal } from '../modals/common/types'; import { ContentPreferenceType } from '../../graphql/contentPreference'; import { useUserFollowStats } from '../../hooks/profile/useUserFollowStats'; @@ -236,6 +238,8 @@ const usePlusRow = (): { label: string; meta: string } => { // following, the streak) is not repeated here. export function YouPage(): ReactElement | null { const { openModal } = useLazyModal(); + // iPhones only reach this page, so the App Store row lives here. + const rateApp = useRateAppRow('you_page'); const router = useRouter(); const queryClient = useQueryClient(); const { user, isAuthReady } = useAuthContext(); @@ -493,6 +497,16 @@ export function YouPage(): ReactElement | null { label="Changelog" href={`${webappUrl}sources/daily_updates`} /> + {rateApp.isVisible && ( + { + setIsHelpOpen(false); + rateApp.onClick(); + }} + /> + )} {isPhoneBrowser && ( void; + // Closes the surface without rating. + onDismiss: (event: CloseEvent) => void; +} + +/** + * What every App Store review surface does the same way: log the impression, + * ask iOS for Apple's rating pop-up after a short pause, and clear the + * Customer.io marketing CTA behind it on any close so it is asked once. + */ +export const useAppStoreReviewAsk = ({ + campaignId, + onRequestClose, +}: { + campaignId: string; + onRequestClose?: (event: CloseEvent) => void; +}): UseAppStoreReviewAsk => { + const { clearMarketingCta } = useBoot(); + const { logEvent } = useLogContext(); + const hasOpened = useRef(false); + const hasRated = useRef(false); + + useEffect(() => { + if (hasOpened.current) { + return; + } + hasOpened.current = true; + + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + + // Scheduled when the surface opens and never cancelled: closing it early + // must not turn the pop-up into something only people who stay get, which + // would be the review gating Apple rejects. The one skip is a reader who + // already tapped Rate: they are on the App Store writing a review, so the + // pop-up would only spend one of Apple's yearly prompts on them. + setTimeout(() => { + if (!hasRated.current && requestIOSReview()) { + logEvent({ + event_name: LogEvent.RequestAppStoreReview, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + } + }, appStoreReviewRequestDelayMs); + }, [campaignId, logEvent]); + + const close = (event: CloseEvent, eventName: LogEvent) => { + logEvent({ + event_name: eventName, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + clearMarketingCta(campaignId); + onRequestClose?.(event); + }; + + return { + onRate: (event) => { + hasRated.current = true; + // A missing handler means the app is older than the bridge; the surface + // then closes with no error toast, since the ask is optional. + openIOSWriteReview(); + close(event, LogEvent.Click); + }, + onDismiss: (event) => close(event, LogEvent.MarketingCtaDismiss), + }; +}; diff --git a/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx b/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx new file mode 100644 index 00000000000..311bd2def69 --- /dev/null +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx @@ -0,0 +1,275 @@ +import { renderHook } from '@testing-library/react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useFeature } from '../components/GrowthBookProvider'; +import { iOSSupportsReview } from '../lib/ios'; +import { isIOSNative } from '../lib/func'; +import { useBoot } from './useBoot'; +import { useLazyModal } from './useLazyModal'; +import { useIsBootPopupsPaused } from '../lib/bootPopupsPause'; +import { LazyModal } from '../components/modals/common/types'; +import type { MarketingCta } from '../components/marketing/cta/common'; +import { MarketingCtaVariant } from '../components/marketing/cta/common'; +import loggedUser from '../../__tests__/fixture/loggedUser'; +import { isPostUrl, useAppStoreReviewPrompt } from './useAppStoreReviewPrompt'; + +jest.mock('next/router', () => ({ useRouter: jest.fn() })); +jest.mock('../contexts/AuthContext', () => ({ useAuthContext: jest.fn() })); +jest.mock('../components/GrowthBookProvider', () => ({ + useFeature: jest.fn(), +})); +jest.mock('../lib/ios', () => ({ + ...jest.requireActual('../lib/ios'), + iOSSupportsReview: jest.fn(), +})); +jest.mock('../lib/func', () => ({ + ...jest.requireActual('../lib/func'), + isIOSNative: jest.fn(), +})); +jest.mock('./useBoot', () => ({ useBoot: jest.fn() })); +jest.mock('./useLazyModal', () => ({ useLazyModal: jest.fn() })); +jest.mock('../lib/bootPopupsPause', () => ({ + useIsBootPopupsPaused: 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 plusCta: MarketingCta = { + ...marketingCta, + campaignId: 'review-plus-renewal', + variant: MarketingCtaVariant.AppStoreReviewPlus, +}; + +const openModal = jest.fn(); +let routeHandlers: Record void>; +// Next hands out a new router object per navigation but keeps one emitter. +const events = { + on: jest.fn((event: string, handler: (url: string) => void) => { + routeHandlers[event] = handler; + }), + off: jest.fn((event: string) => { + delete routeHandlers[event]; + }), + emit: jest.fn(), +}; + +const mockRouter = (asPath = '/'): NextRouter => + ({ asPath, events }) as unknown as NextRouter; + +const clearMarketingCta = jest.fn(); + +const mockCtas = (ctas: MarketingCta[]) => + jest.mocked(useBoot).mockReturnValue({ + getMarketingCta: (variant: MarketingCtaVariant) => + ctas.find((cta) => cta.variant === variant) ?? null, + clearMarketingCta, + } as unknown as ReturnType); + +const navigate = (url: string) => routeHandlers.routeChangeComplete?.(url); + +describe('isPostUrl', () => { + it('matches post pages on either layout', () => { + expect(isPostUrl('/posts/abc123')).toBe(true); + expect(isPostUrl('/posts/abc123?ref=share#comments')).toBe(true); + expect(isPostUrl('/layout-v2/posts/abc123')).toBe(true); + expect(isPostUrl('/')).toBe(false); + expect(isPostUrl('/popular')).toBe(false); + expect(isPostUrl('/squads/watercooler')).toBe(false); + }); +}); + +describe('useAppStoreReviewPrompt', () => { + beforeEach(() => { + jest.clearAllMocks(); + routeHandlers = {}; + jest.mocked(useRouter).mockReturnValue(mockRouter()); + jest.mocked(useAuthContext).mockReturnValue({ + user: loggedUser, + } as unknown as ReturnType); + jest.mocked(useFeature).mockReturnValue(true); + jest.mocked(iOSSupportsReview).mockReturnValue(true); + jest.mocked(isIOSNative).mockReturnValue(true); + jest.mocked(useIsBootPopupsPaused).mockReturnValue(false); + mockCtas([marketingCta]); + jest.mocked(useLazyModal).mockReturnValue({ + openModal, + closeModal: jest.fn(), + modal: null, + } as unknown as ReturnType); + }); + + it('asks once, when a post is left for the feed', () => { + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/popular'); + expect(openModal).not.toHaveBeenCalled(); + + navigate('/posts/abc123'); + expect(openModal).not.toHaveBeenCalled(); + + navigate('/posts/def456'); + expect(openModal).not.toHaveBeenCalled(); + + navigate('/'); + expect(openModal).toHaveBeenCalledTimes(1); + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.AppStoreReview, + props: { marketingCta }, + }); + + navigate('/posts/ghi789'); + navigate('/'); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + it('asks when the app opened on a post page', () => { + jest.mocked(useRouter).mockReturnValue(mockRouter('/posts/abc123')); + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/'); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + it('keeps one listener while the router object changes', () => { + const { rerender } = renderHook(() => useAppStoreReviewPrompt()); + + navigate('/posts/abc123'); + jest.mocked(useRouter).mockReturnValue(mockRouter('/posts/abc123')); + rerender(); + expect(events.on).toHaveBeenCalledTimes(1); + + navigate('/'); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + it('opens the Plus thank-you for a member', () => { + mockCtas([plusCta]); + jest.mocked(useAuthContext).mockReturnValue({ + user: { ...loggedUser, isPlus: true }, + } as unknown as ReturnType); + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/posts/abc123'); + navigate('/'); + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.AppStoreReviewPlus, + props: { marketingCta: plusCta }, + }); + }); + + it('clears a Plus thank-you aimed at someone without Plus', () => { + mockCtas([plusCta]); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + expect(clearMarketingCta).toHaveBeenCalledWith(plusCta.campaignId); + }); + + it('keeps a plain ask for someone without Plus', () => { + mockCtas([marketingCta]); + renderHook(() => useAppStoreReviewPrompt()); + + expect(clearMarketingCta).not.toHaveBeenCalled(); + }); + + it('stays quiet on a page that pauses pop-ups', () => { + jest.mocked(useIsBootPopupsPaused).mockReturnValue(true); + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/posts/abc123'); + navigate('/squads/startup/setup'); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('waits while another modal is open', () => { + jest.mocked(useLazyModal).mockReturnValue({ + openModal, + closeModal: jest.fn(), + modal: { type: LazyModal.NewStreak }, + } as unknown as ReturnType); + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/posts/abc123'); + navigate('/'); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('does nothing without a scheduled ask', () => { + mockCtas([]); + const { result } = renderHook(() => useAppStoreReviewPrompt()); + + expect(result.current).toBeUndefined(); + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + it('clears the ask in an iOS app without the review bridge', () => { + jest.mocked(iOSSupportsReview).mockReturnValue(false); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + expect(clearMarketingCta).toHaveBeenCalledTimes(1); + expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); + }); + + it('clears a Plus thank-you in an iOS app without the review bridge', () => { + jest.mocked(iOSSupportsReview).mockReturnValue(false); + jest.mocked(useAuthContext).mockReturnValue({ + user: { ...loggedUser, isPlus: true }, + } as unknown as ReturnType); + mockCtas([plusCta]); + renderHook(() => useAppStoreReviewPrompt()); + + expect(openModal).not.toHaveBeenCalled(); + expect(clearMarketingCta).toHaveBeenCalledTimes(1); + expect(clearMarketingCta).toHaveBeenCalledWith(plusCta.campaignId); + }); + + it('keeps the ask outside the iOS app', () => { + jest.mocked(iOSSupportsReview).mockReturnValue(false); + jest.mocked(isIOSNative).mockReturnValue(false); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + expect(clearMarketingCta).not.toHaveBeenCalled(); + }); + + it('keeps the ask in an old iOS app while logged out', () => { + jest.mocked(iOSSupportsReview).mockReturnValue(false); + jest.mocked(useAuthContext).mockReturnValue({ + user: null, + } as unknown as ReturnType); + renderHook(() => useAppStoreReviewPrompt()); + + expect(clearMarketingCta).not.toHaveBeenCalled(); + }); + + it('does nothing when the feature is off', () => { + jest.mocked(useFeature).mockReturnValue(false); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + it('does nothing when logged out', () => { + jest.mocked(useAuthContext).mockReturnValue({ + user: null, + } as unknown as ReturnType); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + it('stops listening on unmount', () => { + const { unmount } = renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeDefined(); + unmount(); + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/hooks/useAppStoreReviewPrompt.ts b/packages/shared/src/hooks/useAppStoreReviewPrompt.ts new file mode 100644 index 00000000000..7f90a0a4f98 --- /dev/null +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.ts @@ -0,0 +1,116 @@ +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useFeature } from '../components/GrowthBookProvider'; +import { featureAppStoreReview } from '../lib/featureManagement'; +import { MarketingCtaVariant } from '../components/marketing/cta/common'; +import { LazyModal } from '../components/modals/common/types'; +import { iOSSupportsReview } from '../lib/ios'; +import { isIOSNative } from '../lib/func'; +import { withoutLayoutVariantPrefix } from '../lib/layoutVariant'; +import { useIsBootPopupsPaused } from '../lib/bootPopupsPause'; +import { useBoot } from './useBoot'; +import { useLazyModal } from './useLazyModal'; + +// A post page. A post opened over the feed pushes `/posts/:id` as its URL +// too, so this covers the modal as well. +export const isPostUrl = (url: string): boolean => { + const [path = ''] = url.split('#')[0].split('?'); + + return withoutLayoutVariantPrefix(path).startsWith('/posts/'); +}; + +/** + * Opens the App Store review ask when Customer.io has scheduled one for this + * person through the `app_store_review` or `app_store_review_plus` marketing + * CTA. Who and when is the campaign's call; this only waits for the natural + * pause Apple asks for: leaving a post and landing back on the feed. At most + * one ask per session, only in the iOS app with the review bridge, never over + * another modal and never on a page that pauses pop-ups. + */ +export const useAppStoreReviewPrompt = (): void => { + const router = useRouter(); + const { user } = useAuthContext(); + const { getMarketingCta, clearMarketingCta } = useBoot(); + const { openModal, modal } = useLazyModal(); + const isPaused = useIsBootPopupsPaused(); + const isEnabled = useFeature(featureAppStoreReview); + const plainCta = getMarketingCta(MarketingCtaVariant.AppStoreReview); + const plusCta = getMarketingCta(MarketingCtaVariant.AppStoreReviewPlus); + const marketingCta = plainCta ?? (user?.isPlus ? plusCta : null); + const campaignId = marketingCta?.campaignId; + // Boot carries one marketing CTA. A Plus thank-you aimed at someone who + // is not (or no longer) a member would hold that slot forever, so it is + // cleared instead of shown. + // The same goes for an iOS app older than the review bridge (before 1.26): + // Customer.io can't tell app versions apart, and this app can never show + // either ask, so it is cleared instead of holding the slot. + const isMissingBridge = !!user && isIOSNative() && !iOSSupportsReview(); + const stalePlusCampaignId = user && !user.isPlus ? plusCta?.campaignId : null; + const staleCampaignId = isMissingBridge + ? (plainCta ?? plusCta)?.campaignId + : stalePlusCampaignId; + + useEffect(() => { + if (staleCampaignId) { + clearMarketingCta(staleCampaignId); + } + }, [clearMarketingCta, staleCampaignId]); + const hasAsked = useRef(false); + const wasOnPost = useRef(false); + // Read through refs so nothing re-subscribes the listener: `router` itself + // is a new instance on every navigation, only `router.events` is stable. + const asPathRef = useRef(router?.asPath); + asPathRef.current = router?.asPath; + const modalRef = useRef(modal); + modalRef.current = modal; + const isPausedRef = useRef(isPaused); + isPausedRef.current = isPaused; + const marketingCtaRef = useRef(marketingCta); + marketingCtaRef.current = marketingCta; + const events = router?.events; + + useEffect(() => { + if (!campaignId || !user || !isEnabled || !events || !iOSSupportsReview()) { + return undefined; + } + + wasOnPost.current = isPostUrl(asPathRef.current ?? ''); + + const onRouteChange = (url: string) => { + const onPost = isPostUrl(url); + const leftPost = wasOnPost.current && !onPost; + wasOnPost.current = onPost; + + if ( + !leftPost || + hasAsked.current || + modalRef.current || + isPausedRef.current || + !marketingCtaRef.current + ) { + return; + } + + hasAsked.current = true; + const { current } = marketingCtaRef; + if (current.variant === MarketingCtaVariant.AppStoreReviewPlus) { + openModal({ + type: LazyModal.AppStoreReviewPlus, + props: { marketingCta: current }, + }); + return; + } + openModal({ + type: LazyModal.AppStoreReview, + props: { marketingCta: current }, + }); + }; + + events.on('routeChangeComplete', onRouteChange); + + return () => { + events.off('routeChangeComplete', onRouteChange); + }; + }, [campaignId, events, isEnabled, openModal, user]); +}; diff --git a/packages/shared/src/hooks/useRateAppRow.ts b/packages/shared/src/hooks/useRateAppRow.ts new file mode 100644 index 00000000000..a3e26d2cc0b --- /dev/null +++ b/packages/shared/src/hooks/useRateAppRow.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { useFeature } from '../components/GrowthBookProvider'; +import { featureAppStoreReview } from '../lib/featureManagement'; +import { iOSSupportsReview, openIOSWriteReview } from '../lib/ios'; +import { useLogContext } from '../contexts/LogContext'; +import { LogEvent, TargetType } from '../lib/log'; + +export const rateAppTitle = 'Show daily.dev some love ❤️'; + +interface UseRateAppRow { + isVisible: boolean; + onClick: () => void; +} + +/** + * The permanent "Rate daily.dev" row, shown only in the iOS app with the + * review bridge. Apple's advice for a button like this: never request the + * pop-up from a tap, since it may show nothing. The row opens the App + * Store's write-a-review screen instead, which never spends the yearly + * prompts. + */ +export const useRateAppRow = (source: string): UseRateAppRow => { + const { logEvent } = useLogContext(); + const isEnabled = useFeature(featureAppStoreReview); + // The bridge is only known on the client, so read it after mount. + const [canReview, setCanReview] = useState(false); + + useEffect(() => { + setCanReview(iOSSupportsReview()); + }, []); + + return { + isVisible: canReview && !!isEnabled, + onClick: () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.AppStoreReview, + target_id: source, + }); + openIOSWriteReview(); + }, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index bcceed727c7..c21087e9d27 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -44,6 +44,10 @@ export const featureLuckyButton = new Feature('lucky_button', false); export const featureJobsUI = new Feature('jobs_ui', false); +// Kill switch for the App Store review asks on iOS: the profile menu row and +// the sheet behind the `app_store_review` marketing CTA. +export const featureAppStoreReview = new Feature('app_store_review', true); + export const featureAutorotateAds = new Feature('autorotate_ads', 0); export enum AdRefreshEngineVersion { diff --git a/packages/shared/src/lib/ios.spec.ts b/packages/shared/src/lib/ios.spec.ts index d3b2de0232e..eb85c81cbfa 100644 --- a/packages/shared/src/lib/ios.spec.ts +++ b/packages/shared/src/lib/ios.spec.ts @@ -1,4 +1,10 @@ -import { requestIOSTracking, WebKitMessageHandlers } from './ios'; +import { + iOSSupportsReview, + openIOSWriteReview, + requestIOSReview, + requestIOSTracking, + WebKitMessageHandlers, +} from './ios'; type WebkitWindow = typeof globalThis & { webkit?: { messageHandlers?: Record }; @@ -41,3 +47,72 @@ describe('requestIOSTracking', () => { expect(() => requestIOSTracking()).not.toThrow(); }); }); + +const runtime = globalThis as WebkitWindow; + +const installBridge = (handlers: WebKitMessageHandlers[]) => { + const messageHandlers = Object.fromEntries( + handlers.map((handler) => [handler, { postMessage: jest.fn() }]), + ); + runtime.webkit = { messageHandlers }; + document.documentElement.classList.add('ios'); + return messageHandlers; +}; + +describe('iOS review bridge', () => { + afterEach(() => { + delete runtime.webkit; + document.documentElement.classList.remove('ios'); + }); + + it('is unsupported outside the iOS app', () => { + expect(iOSSupportsReview()).toBe(false); + expect(requestIOSReview()).toBe(false); + expect(openIOSWriteReview()).toBe(false); + }); + + it('is unsupported on an app older than the review bridge', () => { + installBridge([WebKitMessageHandlers.RequestTracking]); + + expect(iOSSupportsReview()).toBe(false); + expect(requestIOSReview()).toBe(false); + expect(openIOSWriteReview()).toBe(false); + }); + + it('needs both review handlers', () => { + installBridge([WebKitMessageHandlers.ReviewRequest]); + + expect(iOSSupportsReview()).toBe(false); + }); + + it('asks iOS for the rating pop-up', () => { + const handlers = installBridge([ + WebKitMessageHandlers.ReviewRequest, + WebKitMessageHandlers.ReviewOpenStore, + ]); + + expect(iOSSupportsReview()).toBe(true); + expect(requestIOSReview()).toBe(true); + expect( + handlers[WebKitMessageHandlers.ReviewRequest].postMessage, + ).toHaveBeenCalledWith(null); + expect( + handlers[WebKitMessageHandlers.ReviewOpenStore].postMessage, + ).not.toHaveBeenCalled(); + }); + + it('opens the write-a-review screen', () => { + const handlers = installBridge([ + WebKitMessageHandlers.ReviewRequest, + WebKitMessageHandlers.ReviewOpenStore, + ]); + + expect(openIOSWriteReview()).toBe(true); + expect( + handlers[WebKitMessageHandlers.ReviewOpenStore].postMessage, + ).toHaveBeenCalledWith(null); + expect( + handlers[WebKitMessageHandlers.ReviewRequest].postMessage, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/lib/ios.ts b/packages/shared/src/lib/ios.ts index e888e9f5998..5b85c176f7a 100644 --- a/packages/shared/src/lib/ios.ts +++ b/packages/shared/src/lib/ios.ts @@ -26,6 +26,9 @@ export enum WebKitMessageHandlers { Reading = 'reading', RequestTracking = 'request-tracking', + + ReviewRequest = 'review-request', + ReviewOpenStore = 'review-open-store', } type IOSWebkitGlobal = typeof globalThis & { @@ -82,3 +85,32 @@ export const requestIOSTracking = (): void => { postWebKitMessage(WebKitMessageHandlers.RequestTracking, null); }; + +// Both review handlers ship together in iOS 1.26, so one check covers the +// native rating pop-up and the App Store's write-a-review screen. +export const iOSSupportsReview = (): boolean => + messageHandlerExists(WebKitMessageHandlers.ReviewRequest) && + messageHandlerExists(WebKitMessageHandlers.ReviewOpenStore); + +// Asks iOS for StoreKit's rating pop-up. Apple decides whether anything shows +// (at most three times a year per app) and never reports back, and the app +// keeps its own 120-day cooldown, so callers must not depend on the result. +export const requestIOSReview = (): boolean => { + if (!iOSSupportsReview()) { + return false; + } + + postWebKitMessage(WebKitMessageHandlers.ReviewRequest, null); + return true; +}; + +// Opens the App Store's write-a-review screen. Unlike the pop-up this always +// works, so it is the CTA behind every review ask. +export const openIOSWriteReview = (): boolean => { + if (!iOSSupportsReview()) { + return false; + } + + postWebKitMessage(WebKitMessageHandlers.ReviewOpenStore, null); + return true; +}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 055c08b7d44..044572acd61 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -403,6 +403,8 @@ export enum LogEvent { // Tags - end // marketing CTA MarketingCtaDismiss = 'dismiss promotion', + // App Store review + RequestAppStoreReview = 'request app store review', // Reading reminder ScheduleReadingReminder = 'schedule reading reminder', SkipReadingReminder = 'skip reading reminder', @@ -789,6 +791,7 @@ export enum TargetType { MarketingCtaPopoverSmall = 'promotion_popover_small', MarketingCtaPlus = 'promotion_plus', MarketingCtaHelpGuide = 'promotion_help_guide', + AppStoreReview = 'app_store_review', PlusEntryCard = 'plus_entry_card', PlusEntryForYouTab = 'plus_entry_for_you_tab', PlusEntryBookmarkTab = 'plus_entry_bookmark_tab', diff --git a/packages/storybook/stories/app-store-reviews/Production.stories.tsx b/packages/storybook/stories/app-store-reviews/Production.stories.tsx new file mode 100644 index 00000000000..7107e09d09f --- /dev/null +++ b/packages/storybook/stories/app-store-reviews/Production.stories.tsx @@ -0,0 +1,251 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { BootDataProvider } from '@dailydotdev/shared/src/contexts/BootProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { WebKitMessageHandlers } from '@dailydotdev/shared/src/lib/ios'; +import type { MarketingCta } from '@dailydotdev/shared/src/components/marketing/cta/common'; +import { MarketingCtaVariant } from '@dailydotdev/shared/src/components/marketing/cta/common'; +import AppStoreReviewModal from '@dailydotdev/shared/src/components/modals/AppStoreReviewModal'; +import PlusRenewalReviewModal from '@dailydotdev/shared/src/components/modals/PlusRenewalReviewModal'; +import { ResourceSection } from '@dailydotdev/shared/src/components/ProfileMenu/sections/ResourceSection'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import defaultUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import { cloudinaryGiftedPlusModalImage } from '@dailydotdev/shared/src/lib/image'; +import { defaultBootData, getBootMock } from '../../mock/boot'; + +// The shipped surfaces behind the App Store review asks, on a fake iOS 1.26 +// bridge so the review handlers exist. Every message the web app posts to the +// bridge is listed under the surface, since Apple's pop-up and Write a Review +// screen only exist on a device. + +type BridgeMessage = { handler: WebKitMessageHandlers; at: string }; + +type WebKitGlobal = typeof globalThis & { + webkit?: { + messageHandlers?: Partial< + Record void }> + >; + }; +}; + +const reviewHandlers = [ + WebKitMessageHandlers.ReviewRequest, + WebKitMessageHandlers.ReviewOpenStore, +]; + +const useIOSBridge = (): BridgeMessage[] => { + const [messages, setMessages] = useState([]); + + // Installed in the state initializer so the bridge exists before any child + // effect reads it, the way the native app injects it before the page loads. + useState(() => { + const webkitGlobal = globalThis as WebKitGlobal; + webkitGlobal.webkit = { + messageHandlers: Object.fromEntries( + reviewHandlers.map((handler) => [ + handler, + { + postMessage: () => + setMessages((current) => [ + ...current, + { handler, at: new Date().toLocaleTimeString() }, + ]), + }, + ]), + ), + }; + document.documentElement.classList.add('ios'); + return null; + }); + + return messages; +}; + +const bridgeLabels: Record = { + [WebKitMessageHandlers.ReviewRequest]: + 'review-request: iOS asks StoreKit for the rating pop-up (Apple decides whether it shows)', + [WebKitMessageHandlers.ReviewOpenStore]: + 'review-open-store: iOS opens the App Store’s Write a Review screen', +}; + +const BridgeLog = ({ + messages, +}: { + messages: BridgeMessage[]; +}): ReactElement => ( +
+ + Messages posted to the iOS bridge + + {messages.length === 0 ? ( + + Nothing yet. The rating pop-up is requested about 2s after the sheet + opens; the button posts review-open-store. + + ) : ( +
    + {messages.map((message, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  1. + {message.at} · + {bridgeLabels[message.handler]} +
  2. + ))} +
+ )} +
+); + +const Providers = ({ + children, + isPlus = false, +}: { + children: ReactNode; + isPlus?: boolean; +}): ReactElement => { + const [client] = useState(() => new QueryClient()); + + return ( + + + {/* Modal mounts on production's app root. */} +
{children}
+
+
+ ); +}; + +const defaultCta: MarketingCta = { + campaignId: 'storybook-app-store-review', + createdAt: new Date('2026-11-10T00:00:00.000Z'), + variant: MarketingCtaVariant.AppStoreReview, + flags: { title: '', ctaUrl: '', ctaText: '' }, +}; + +// A Customer.io campaign with its own copy and image on the plain sheet. +const campaignCta: MarketingCta = { + ...defaultCta, + campaignId: 'storybook-big-release', + flags: { + title: 'Thanks for reading the new daily.dev', + description: + 'If the new version has made your days a little better, a quick rating would mean a lot to us.', + ctaText: 'Rate daily.dev', + ctaUrl: '', + image: cloudinaryGiftedPlusModalImage, + }, +}; + +// The Plus thank-you after a renewal, on its own variant. +const plusCta: MarketingCta = { + ...defaultCta, + campaignId: 'storybook-plus-renewal', + variant: MarketingCtaVariant.AppStoreReviewPlus, +}; + +const Sheet = ({ + marketingCta, +}: { + marketingCta: MarketingCta; +}): ReactElement => { + const messages = useIOSBridge(); + const isPlus = + marketingCta.variant === MarketingCtaVariant.AppStoreReviewPlus; + const ReviewModal = isPlus ? PlusRenewalReviewModal : AppStoreReviewModal; + // Opened after mount: Modal binds to #__next, which exists once rendered. + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setIsOpen(true); + }, []); + + return ( + +
+ + {!isOpen && ( + + )} + {isOpen && ( + setIsOpen(false)} + /> + )} +
+
+ ); +}; + +const ProfileRow = (): ReactElement => { + const messages = useIOSBridge(); + + return ( + +
+
+ +
+ +
+
+ ); +}; + +const meta: Meta = { + title: 'Growth/App Store reviews/Production', + id: 'app-store-review-production', + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const AskSheet: Story = { + name: 'Ask sheet (default copy)', + render: () => , +}; + +export const CampaignCopy: Story = { + name: 'Ask sheet (Customer.io copy)', + render: () => , +}; + +export const PlusRenewal: Story = { + name: 'Plus renewal thank-you', + render: () => , +}; + +export const RateRow: Story = { + name: 'Profile menu row', + render: () => , +};