From ececde3e5fffe491b33c021ed0a9787baeabd90d Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 12 Nov 2026 13:30:17 +0200 Subject: [PATCH 1/4] feat(ios): App Store review ask and profile row Customer.io schedules the ask through a new `app_store_review` marketing CTA; the sheet opens when the reader leaves a post, posts `review-request` to the iOS bridge two seconds later, and its button opens the App Store's Write a Review screen. The profile menu gets a permanent "Show daily.dev some love" row on iOS 1.26 and later. Both sit behind the `app_store_review` flag. Co-Authored-By: Claude Fable 5.1 --- .../sections/ResourceSection.spec.tsx | 72 ++++++ .../ProfileMenu/sections/ResourceSection.tsx | 42 +++- .../src/components/marketing/cta/common.tsx | 1 + .../modals/AppStoreReviewModal.spec.tsx | 156 ++++++++++++ .../components/modals/AppStoreReviewModal.tsx | 174 ++++++++++++++ .../src/components/modals/BootPopups.tsx | 4 + .../shared/src/components/modals/common.tsx | 8 + .../src/components/modals/common/types.ts | 1 + .../hooks/useAppStoreReviewPrompt.spec.tsx | 179 ++++++++++++++ .../src/hooks/useAppStoreReviewPrompt.ts | 87 +++++++ packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/src/lib/ios.spec.ts | 77 +++++- packages/shared/src/lib/ios.ts | 32 +++ packages/shared/src/lib/log.ts | 3 + .../app-store-reviews/Production.stories.tsx | 225 ++++++++++++++++++ 15 files changed, 1063 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/components/ProfileMenu/sections/ResourceSection.spec.tsx create mode 100644 packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx create mode 100644 packages/shared/src/components/modals/AppStoreReviewModal.tsx create mode 100644 packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx create mode 100644 packages/shared/src/hooks/useAppStoreReviewPrompt.ts create mode 100644 packages/storybook/stories/app-store-reviews/Production.stories.tsx 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..143e4f25641 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; +import type { ProfileSectionItemProps } from '../ProfileSectionItem'; import { + AppleIcon, DocsIcon, FeedbackIcon, MegaphoneIcon, @@ -14,8 +16,45 @@ import { feedback, webappUrl, } from '../../../lib/constants'; +import { iOSSupportsReview, openIOSWriteReview } from '../../../lib/ios'; +import { useFeature } from '../../GrowthBookProvider'; +import { featureAppStoreReview } from '../../../lib/featureManagement'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; + +export const rateAppTitle = 'Show daily.dev some love ❤️'; export const ResourceSection = (): ReactElement => { + const { logEvent } = useLogContext(); + const isReviewEnabled = useFeature(featureAppStoreReview); + // The bridge is only known on the client, so read it after mount. + const [canReview, setCanReview] = useState(false); + + useEffect(() => { + setCanReview(iOSSupportsReview()); + }, []); + + // Apple's advice for a permanent button: 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. + const rateApp: ProfileSectionItemProps[] = + canReview && isReviewEnabled + ? [ + { + title: rateAppTitle, + icon: AppleIcon, + onClick: () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.AppStoreReview, + target_id: 'profile_menu', + }); + openIOSWriteReview(); + }, + }, + ] + : []; + return ( { icon: TerminalIcon, href: `${webappUrl}sources/daily_updates`, }, + ...rateApp, { title: 'Advertise', icon: MegaphoneIcon, diff --git a/packages/shared/src/components/marketing/cta/common.tsx b/packages/shared/src/components/marketing/cta/common.tsx index 89f979e2815..52e118a6624 100644 --- a/packages/shared/src/components/marketing/cta/common.tsx +++ b/packages/shared/src/components/marketing/cta/common.tsx @@ -32,6 +32,7 @@ export enum MarketingCtaVariant { YearInReview = 'year_in_review', HelpGuide = 'help_guide', Video = 'video', + AppStoreReview = 'app_store_review', } 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..849e668b898 --- /dev/null +++ b/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx @@ -0,0 +1,156 @@ +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 AppStoreReviewModal, { + appStoreReviewCopy, + appStoreReviewRequestDelayMs, +} 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('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..01542759e1d --- /dev/null +++ b/packages/shared/src/components/modals/AppStoreReviewModal.tsx @@ -0,0 +1,174 @@ +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 { useBoot } from '../../hooks'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { openIOSWriteReview, requestIOSReview } from '../../lib/ios'; + +// The ask Customer.io schedules through the `app_store_review` marketing CTA. +// Apple's own rating pop-up is requested on its own a moment after the sheet +// lands, never from a tap, and whether it shows is Apple's call. The button +// opens the App Store's write-a-review screen, which always works, so the +// sheet stands on its own when the pop-up stays away. +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; + +export interface AppStoreReviewModalProps extends ModalProps { + marketingCta: MarketingCta; +} + +export default function AppStoreReviewModal({ + marketingCta, + onRequestClose, + ...modalProps +}: AppStoreReviewModalProps): ReactElement { + const { clearMarketingCta } = useBoot(); + const { logEvent } = useLogContext(); + const { campaignId, flags } = marketingCta; + const headlineRef = useRef(null); + const hasOpened = useRef(false); + const title = flags?.title || appStoreReviewCopy.headline; + const description = flags?.description || appStoreReviewCopy.text; + const ctaText = flags?.ctaText || appStoreReviewCopy.cta; + + useEffect(() => { + if (hasOpened.current) { + return; + } + hasOpened.current = true; + + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + + // Scheduled when the sheet 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. + setTimeout(() => { + if (requestIOSReview()) { + logEvent({ + event_name: LogEvent.RequestAppStoreReview, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + } + }, appStoreReviewRequestDelayMs); + }, [campaignId, logEvent]); + + // Focus starts on the headline, not the CTA, so nobody rates by accident. + useEffect(() => { + const frame = requestAnimationFrame(() => headlineRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + const close = ( + event: React.MouseEvent | React.KeyboardEvent, + eventName: LogEvent, + ) => { + logEvent({ + event_name: eventName, + target_type: TargetType.AppStoreReview, + target_id: campaignId, + }); + clearMarketingCta(campaignId); + onRequestClose?.(event); + }; + + const onRate = (event: React.MouseEvent) => { + // A missing handler means the app is older than the bridge; the sheet + // then closes with no error toast, since the ask is optional. + openIOSWriteReview(); + close(event, LogEvent.Click); + }; + + return ( + close(event, LogEvent.MarketingCtaDismiss)} + isDrawerOnMobile + > + +
+ {flags?.image ? ( + + ) : ( + + + + )} + + {title} + {!flags?.title && ( + <> + {' '} + {appStoreReviewCopy.emoji} + + )} + + + {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/common.tsx b/packages/shared/src/components/modals/common.tsx index 93d81b4364d..bab7beb9061 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -129,6 +129,13 @@ const ShareModal = dynamic( () => import(/* webpackChunkName: "shareModal" */ './ShareModal'), ); +const AppStoreReviewModal = dynamic( + () => + import( + /* webpackChunkName: "appStoreReviewModal" */ './AppStoreReviewModal' + ), +); + const BookmarkReminderModal = dynamic( () => import( @@ -649,6 +656,7 @@ export const modals = { [LazyModal.LfgReachOut]: LfgReachOutModal, [LazyModal.LfgHelper]: LfgHelperModal, [LazyModal.FeedExplanation]: FeedExplanationModal, + [LazyModal.AppStoreReview]: AppStoreReviewModal, }; 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..dfdea0065ed 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -114,6 +114,7 @@ export enum LazyModal { LfgReachOut = 'lfgReachOut', LfgHelper = 'lfgHelper', FeedExplanation = 'feedExplanation', + AppStoreReview = 'appStoreReview', } export type ModalTabItem = { diff --git a/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx b/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx new file mode 100644 index 00000000000..89df48f5eb0 --- /dev/null +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx @@ -0,0 +1,179 @@ +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 { useBoot } from './useBoot'; +import { useLazyModal } from './useLazyModal'; +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('./useBoot', () => ({ useBoot: jest.fn() })); +jest.mock('./useLazyModal', () => ({ useLazyModal: 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 openModal = jest.fn(); +let routeHandlers: Record void>; + +const mockRouter = (asPath = '/'): NextRouter => + ({ + asPath, + events: { + on: jest.fn((event: string, handler: (url: string) => void) => { + routeHandlers[event] = handler; + }), + off: jest.fn((event: string) => { + delete routeHandlers[event]; + }), + emit: jest.fn(), + }, + }) as unknown as NextRouter; + +const navigate = (url: string) => routeHandlers.routeChangeComplete?.(url); + +describe('isPostUrl', () => { + it('matches post pages and posts open in the feed modal', () => { + 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('/?pmid=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(useBoot).mockReturnValue({ + getMarketingCta: (variant: MarketingCtaVariant) => + variant === MarketingCtaVariant.AppStoreReview ? marketingCta : null, + } as unknown as ReturnType); + 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('asks when a post opened in the feed modal is closed', () => { + renderHook(() => useAppStoreReviewPrompt()); + + navigate('/?pmid=abc123'); + navigate('/'); + expect(openModal).toHaveBeenCalledTimes(1); + }); + + 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', () => { + jest.mocked(useBoot).mockReturnValue({ + getMarketingCta: () => null, + } as unknown as ReturnType); + const { result } = renderHook(() => useAppStoreReviewPrompt()); + + expect(result.current).toBeUndefined(); + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + it('does nothing without the review bridge', () => { + jest.mocked(iOSSupportsReview).mockReturnValue(false); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + 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..b25fcd0d452 --- /dev/null +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.ts @@ -0,0 +1,87 @@ +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 { withoutLayoutVariantPrefix } from '../lib/layoutVariant'; +import { useBoot } from './useBoot'; +import { useLazyModal } from './useLazyModal'; + +// A post page, or a feed with a post open in the modal. +export const isPostUrl = (url: string): boolean => { + const [pathWithQuery = ''] = url.split('#'); + const [path = '', query = ''] = pathWithQuery.split('?'); + + return ( + withoutLayoutVariantPrefix(path).startsWith('/posts/') || + new URLSearchParams(query).has('pmid') + ); +}; + +/** + * Opens the App Store review ask when Customer.io has scheduled one for this + * person through the `app_store_review` 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, and never over another modal. + */ +export const useAppStoreReviewPrompt = (): void => { + const router = useRouter(); + const { user } = useAuthContext(); + const { getMarketingCta } = useBoot(); + const { openModal, modal } = useLazyModal(); + const isEnabled = useFeature(featureAppStoreReview); + const marketingCta = getMarketingCta(MarketingCtaVariant.AppStoreReview); + const campaignId = marketingCta?.campaignId; + const hasAsked = useRef(false); + const wasOnPost = useRef(false); + // Read through refs so a route change never re-subscribes the listener. + const modalRef = useRef(modal); + modalRef.current = modal; + const marketingCtaRef = useRef(marketingCta); + marketingCtaRef.current = marketingCta; + + useEffect(() => { + if ( + !campaignId || + !user || + !isEnabled || + !router?.events || + !iOSSupportsReview() + ) { + return undefined; + } + + wasOnPost.current = isPostUrl(router.asPath ?? ''); + + const onRouteChange = (url: string) => { + const onPost = isPostUrl(url); + const leftPost = wasOnPost.current && !onPost; + wasOnPost.current = onPost; + + if ( + !leftPost || + hasAsked.current || + modalRef.current || + !marketingCtaRef.current + ) { + return; + } + + hasAsked.current = true; + openModal({ + type: LazyModal.AppStoreReview, + props: { marketingCta: marketingCtaRef.current }, + }); + }; + + router.events.on('routeChangeComplete', onRouteChange); + + return () => { + router.events.off('routeChangeComplete', onRouteChange); + }; + }, [campaignId, isEnabled, openModal, router, user]); +}; 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..3a289adf23d --- /dev/null +++ b/packages/storybook/stories/app-store-reviews/Production.stories.tsx @@ -0,0 +1,225 @@ +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 { ResourceSection } from '@dailydotdev/shared/src/components/ProfileMenu/sections/ResourceSection'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { cloudinaryGiftedPlusModalImage } from '@dailydotdev/shared/src/lib/image'; +import { 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 }: { children: ReactNode }): 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, here the Plus thank-you. +const plusCta: MarketingCta = { + ...defaultCta, + campaignId: 'storybook-plus-renewal', + flags: { + title: 'Thanks for staying with Plus', + description: + '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.', + ctaText: 'Rate daily.dev', + ctaUrl: '', + image: cloudinaryGiftedPlusModalImage, + }, +}; + +const Sheet = ({ + marketingCta, +}: { + marketingCta: MarketingCta; +}): ReactElement => { + const messages = useIOSBridge(); + // 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 RateRow: Story = { + name: 'Profile menu row', + render: () => , +}; From 3605aba1a2ad88b414936d47720deb4d058d6b2c Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 12 Nov 2026 13:46:30 +0200 Subject: [PATCH 2/4] feat(ios): Plus renewal thank-you and review fixes Adds the `app_store_review_plus` variant with the Plus thank-you sheet, moves the rate row onto the phone shell's You page where iPhones reach it, skips the rating pop-up once Rate was tapped, holds the ask on pages that pause pop-ups, and subscribes once to the stable router emitter. Co-Authored-By: Claude Fable 5.1 --- .../ProfileMenu/sections/ResourceSection.tsx | 47 ++--- .../src/components/appStoreReview/common.tsx | 63 +++++++ .../src/components/marketing/cta/common.tsx | 1 + .../modals/AppStoreReviewModal.spec.tsx | 20 ++- .../components/modals/AppStoreReviewModal.tsx | 94 ++-------- .../modals/PlusRenewalReviewModal.spec.tsx | 164 ++++++++++++++++++ .../modals/PlusRenewalReviewModal.tsx | 122 +++++++++++++ .../shared/src/components/modals/common.tsx | 8 + .../src/components/modals/common/types.ts | 1 + .../src/components/shell/YouPage.spec.tsx | 48 ++++- .../shared/src/components/shell/YouPage.tsx | 14 ++ .../shared/src/hooks/useAppStoreReviewAsk.ts | 83 +++++++++ .../hooks/useAppStoreReviewPrompt.spec.tsx | 93 +++++++--- .../src/hooks/useAppStoreReviewPrompt.ts | 65 ++++--- packages/shared/src/hooks/useRateAppRow.ts | 43 +++++ packages/shared/src/lib/log.ts | 1 + .../app-store-reviews/Production.stories.tsx | 46 +++-- 17 files changed, 731 insertions(+), 182 deletions(-) create mode 100644 packages/shared/src/components/appStoreReview/common.tsx create mode 100644 packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx create mode 100644 packages/shared/src/components/modals/PlusRenewalReviewModal.tsx create mode 100644 packages/shared/src/hooks/useAppStoreReviewAsk.ts create mode 100644 packages/shared/src/hooks/useRateAppRow.ts diff --git a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx index 143e4f25641..4572dc22648 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; @@ -16,44 +16,17 @@ import { feedback, webappUrl, } from '../../../lib/constants'; -import { iOSSupportsReview, openIOSWriteReview } from '../../../lib/ios'; -import { useFeature } from '../../GrowthBookProvider'; -import { featureAppStoreReview } from '../../../lib/featureManagement'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent, TargetType } from '../../../lib/log'; +import { rateAppTitle, useRateAppRow } from '../../../hooks/useRateAppRow'; -export const rateAppTitle = 'Show daily.dev some love ❤️'; +export { rateAppTitle }; export const ResourceSection = (): ReactElement => { - const { logEvent } = useLogContext(); - const isReviewEnabled = useFeature(featureAppStoreReview); - // The bridge is only known on the client, so read it after mount. - const [canReview, setCanReview] = useState(false); - - useEffect(() => { - setCanReview(iOSSupportsReview()); - }, []); - - // Apple's advice for a permanent button: 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. - const rateApp: ProfileSectionItemProps[] = - canReview && isReviewEnabled - ? [ - { - title: rateAppTitle, - icon: AppleIcon, - onClick: () => { - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.AppStoreReview, - target_id: 'profile_menu', - }); - openIOSWriteReview(); - }, - }, - ] - : []; + // 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`, }, - ...rateApp, + ...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..e0abfee2486 --- /dev/null +++ b/packages/shared/src/components/appStoreReview/common.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { AppleIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +// 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.', + shortText: 'Made your day a little better? A quick rating means a lot.', + 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} + +); + +// The ask as a card inside a sheet that is about something else. +export const AskCard = ({ + onRate, +}: { + onRate: (event: React.MouseEvent) => void; +}): ReactElement => ( +
+ + + + + {appStoreReviewCopy.shortText} + + +
+); diff --git a/packages/shared/src/components/marketing/cta/common.tsx b/packages/shared/src/components/marketing/cta/common.tsx index 52e118a6624..d281676761c 100644 --- a/packages/shared/src/components/marketing/cta/common.tsx +++ b/packages/shared/src/components/marketing/cta/common.tsx @@ -33,6 +33,7 @@ export enum MarketingCtaVariant { 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 index 849e668b898..4e76e4c6b13 100644 --- a/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx +++ b/packages/shared/src/components/modals/AppStoreReviewModal.spec.tsx @@ -8,10 +8,11 @@ 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 AppStoreReviewModal, { +import { appStoreReviewCopy, appStoreReviewRequestDelayMs, -} from './AppStoreReviewModal'; +} from '../appStoreReview/common'; +import AppStoreReviewModal from './AppStoreReviewModal'; jest.mock('../../lib/ios', () => ({ ...jest.requireActual('../../lib/ios'), @@ -120,6 +121,21 @@ describe('AppStoreReviewModal', () => { }); }); + 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(); diff --git a/packages/shared/src/components/modals/AppStoreReviewModal.tsx b/packages/shared/src/components/modals/AppStoreReviewModal.tsx index 01542759e1d..37797faea04 100644 --- a/packages/shared/src/components/modals/AppStoreReviewModal.tsx +++ b/packages/shared/src/components/modals/AppStoreReviewModal.tsx @@ -12,104 +12,42 @@ import { } from '../typography/Typography'; import LogoIcon from '../../svg/LogoIcon'; import type { MarketingCta } from '../marketing/cta/common'; -import { useBoot } from '../../hooks'; -import { useLogContext } from '../../contexts/LogContext'; -import { LogEvent, TargetType } from '../../lib/log'; -import { openIOSWriteReview, requestIOSReview } from '../../lib/ios'; - -// The ask Customer.io schedules through the `app_store_review` marketing CTA. -// Apple's own rating pop-up is requested on its own a moment after the sheet -// lands, never from a tap, and whether it shows is Apple's call. The button -// opens the App Store's write-a-review screen, which always works, so the -// sheet stands on its own when the pop-up stays away. -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; +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 { clearMarketingCta } = useBoot(); - const { logEvent } = useLogContext(); const { campaignId, flags } = marketingCta; + const { onRate, onDismiss } = useAppStoreReviewAsk({ + campaignId, + onRequestClose, + }); const headlineRef = useRef(null); - const hasOpened = useRef(false); - const title = flags?.title || appStoreReviewCopy.headline; const description = flags?.description || appStoreReviewCopy.text; const ctaText = flags?.ctaText || appStoreReviewCopy.cta; - useEffect(() => { - if (hasOpened.current) { - return; - } - hasOpened.current = true; - - logEvent({ - event_name: LogEvent.Impression, - target_type: TargetType.AppStoreReview, - target_id: campaignId, - }); - - // Scheduled when the sheet 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. - setTimeout(() => { - if (requestIOSReview()) { - logEvent({ - event_name: LogEvent.RequestAppStoreReview, - target_type: TargetType.AppStoreReview, - target_id: campaignId, - }); - } - }, appStoreReviewRequestDelayMs); - }, [campaignId, logEvent]); - // Focus starts on the headline, not the CTA, so nobody rates by accident. useEffect(() => { const frame = requestAnimationFrame(() => headlineRef.current?.focus()); return () => cancelAnimationFrame(frame); }, []); - const close = ( - event: React.MouseEvent | React.KeyboardEvent, - eventName: LogEvent, - ) => { - logEvent({ - event_name: eventName, - target_type: TargetType.AppStoreReview, - target_id: campaignId, - }); - clearMarketingCta(campaignId); - onRequestClose?.(event); - }; - - const onRate = (event: React.MouseEvent) => { - // A missing handler means the app is older than the bridge; the sheet - // then closes with no error toast, since the ask is optional. - openIOSWriteReview(); - close(event, LogEvent.Click); - }; - return ( close(event, LogEvent.MarketingCtaDismiss)} + onRequestClose={onDismiss} isDrawerOnMobile > @@ -133,13 +71,7 @@ export default function AppStoreReviewModal({ tabIndex={-1} bold > - {title} - {!flags?.title && ( - <> - {' '} - {appStoreReviewCopy.emoji} - - )} + {flags?.title || } - close(event, LogEvent.MarketingCtaDismiss) - } + onClick={onDismiss} > {appStoreReviewCopy.dismiss} 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..5d46ed35bab --- /dev/null +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx @@ -0,0 +1,164 @@ +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, TargetId, TargetType } from '../../lib/log'; +import { openIOSWriteReview, requestIOSReview } from '../../lib/ios'; +import * as bootHook from '../../hooks/useBoot'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import type { MarketingCta } from '../marketing/cta/common'; +import { MarketingCtaVariant } from '../marketing/cta/common'; +import { LazyModal } from './common/types'; +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), +})); +jest.mock('../../hooks/useLazyModal', () => ({ useLazyModal: jest.fn() })); + +const logEvent = jest.fn(); +const clearMarketingCta = jest.fn(); +const onRequestClose = jest.fn(); +const openModal = 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); + jest.mocked(useLazyModal).mockReturnValue({ + openModal, + closeModal: jest.fn(), + modal: null, + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('thanks the member and carries the ask card', () => { + renderModal(); + + expect( + screen.getByRole('heading', { name: plusRenewalCopy.headline }), + ).toBeInTheDocument(); + expect(screen.getByText(plusRenewalCopy.text)).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: appStoreReviewCopy.headline }), + ).toBeInTheDocument(); + expect(screen.getByText(appStoreReviewCopy.shortText)).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 ask card', () => { + renderModal(); + + fireEvent.click( + screen.getByRole('button', { name: appStoreReviewCopy.ctaLabel }), + ); + + expect(openIOSWriteReview).toHaveBeenCalledTimes(1); + expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it('hands over to the gift Plus modal', () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: plusRenewalCopy.gift })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.GiftSubscription, + target_type: TargetType.Plus, + target_id: TargetId.AppStoreReviewPlus, + extra: undefined, + }); + expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); + expect(onRequestClose).toHaveBeenCalledTimes(1); + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.GiftPlus, + props: {}, + }); + expect(openIOSWriteReview).not.toHaveBeenCalled(); + }); + + 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..1ed61844b5d --- /dev/null +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx @@ -0,0 +1,122 @@ +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 { DevPlusIcon, GiftIcon } 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 { LazyModal } from './common/types'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { useAppStoreReviewAsk } from '../../hooks/useAppStoreReviewAsk'; +import { AskCard } from '../appStoreReview/common'; +import { LogEvent, TargetId } from '../../lib/log'; + +export const plusRenewalCopy = { + headline: 'Thanks for staying with Plus', + text: 'Glad to have you for another month.', + gift: 'Gift Plus to a friend', + 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, +// and gifting Plus is the other way to spread the word. 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 { openModal } = useLazyModal(); + const { logSubscriptionEvent } = usePlusSubscription(); + const headlineRef = useRef(null); + + useEffect(() => { + const frame = requestAnimationFrame(() => headlineRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + const onGift = (event: React.MouseEvent) => { + logSubscriptionEvent({ + event_name: LogEvent.GiftSubscription, + target_id: TargetId.AppStoreReviewPlus, + }); + onDismiss(event); + openModal({ type: LazyModal.GiftPlus, props: {} }); + }; + + 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 bab7beb9061..b5a6d9bcc48 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -136,6 +136,13 @@ const AppStoreReviewModal = dynamic( ), ); +const PlusRenewalReviewModal = dynamic( + () => + import( + /* webpackChunkName: "plusRenewalReviewModal" */ './PlusRenewalReviewModal' + ), +); + const BookmarkReminderModal = dynamic( () => import( @@ -657,6 +664,7 @@ export const modals = { [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 dfdea0065ed..341f3178ec8 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -115,6 +115,7 @@ export enum LazyModal { 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 index 89df48f5eb0..494c1b146c1 100644 --- a/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.spec.tsx @@ -6,6 +6,7 @@ import { useFeature } from '../components/GrowthBookProvider'; import { iOSSupportsReview } from '../lib/ios'; 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'; @@ -23,6 +24,9 @@ jest.mock('../lib/ios', () => ({ })); 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', @@ -31,31 +35,41 @@ const marketingCta: MarketingCta = { 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: { - on: jest.fn((event: string, handler: (url: string) => void) => { - routeHandlers[event] = handler; - }), - off: jest.fn((event: string) => { - delete routeHandlers[event]; - }), - emit: jest.fn(), - }, - }) as unknown as NextRouter; + ({ asPath, events }) as unknown as NextRouter; + +const mockCtas = (ctas: MarketingCta[]) => + jest.mocked(useBoot).mockReturnValue({ + getMarketingCta: (variant: MarketingCtaVariant) => + ctas.find((cta) => cta.variant === variant) ?? null, + } as unknown as ReturnType); const navigate = (url: string) => routeHandlers.routeChangeComplete?.(url); describe('isPostUrl', () => { - it('matches post pages and posts open in the feed modal', () => { + 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('/?pmid=abc123')).toBe(true); expect(isPostUrl('/')).toBe(false); expect(isPostUrl('/popular')).toBe(false); expect(isPostUrl('/squads/watercooler')).toBe(false); @@ -72,10 +86,8 @@ describe('useAppStoreReviewPrompt', () => { } as unknown as ReturnType); jest.mocked(useFeature).mockReturnValue(true); jest.mocked(iOSSupportsReview).mockReturnValue(true); - jest.mocked(useBoot).mockReturnValue({ - getMarketingCta: (variant: MarketingCtaVariant) => - variant === MarketingCtaVariant.AppStoreReview ? marketingCta : null, - } as unknown as ReturnType); + jest.mocked(useIsBootPopupsPaused).mockReturnValue(false); + mockCtas([marketingCta]); jest.mocked(useLazyModal).mockReturnValue({ openModal, closeModal: jest.fn(), @@ -115,14 +127,49 @@ describe('useAppStoreReviewPrompt', () => { expect(openModal).toHaveBeenCalledTimes(1); }); - it('asks when a post opened in the feed modal is closed', () => { - renderHook(() => useAppStoreReviewPrompt()); + 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('/?pmid=abc123'); 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('holds the Plus thank-you for someone without Plus', () => { + mockCtas([plusCta]); + renderHook(() => useAppStoreReviewPrompt()); + + expect(routeHandlers.routeChangeComplete).toBeUndefined(); + }); + + 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, @@ -137,9 +184,7 @@ describe('useAppStoreReviewPrompt', () => { }); it('does nothing without a scheduled ask', () => { - jest.mocked(useBoot).mockReturnValue({ - getMarketingCta: () => null, - } as unknown as ReturnType); + mockCtas([]); const { result } = renderHook(() => useAppStoreReviewPrompt()); expect(result.current).toBeUndefined(); diff --git a/packages/shared/src/hooks/useAppStoreReviewPrompt.ts b/packages/shared/src/hooks/useAppStoreReviewPrompt.ts index b25fcd0d452..a94ff74ad5d 100644 --- a/packages/shared/src/hooks/useAppStoreReviewPrompt.ts +++ b/packages/shared/src/hooks/useAppStoreReviewPrompt.ts @@ -7,55 +7,61 @@ import { MarketingCtaVariant } from '../components/marketing/cta/common'; import { LazyModal } from '../components/modals/common/types'; import { iOSSupportsReview } from '../lib/ios'; import { withoutLayoutVariantPrefix } from '../lib/layoutVariant'; +import { useIsBootPopupsPaused } from '../lib/bootPopupsPause'; import { useBoot } from './useBoot'; import { useLazyModal } from './useLazyModal'; -// A post page, or a feed with a post open in the modal. +// 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 [pathWithQuery = ''] = url.split('#'); - const [path = '', query = ''] = pathWithQuery.split('?'); + const [path = ''] = url.split('#')[0].split('?'); - return ( - withoutLayoutVariantPrefix(path).startsWith('/posts/') || - new URLSearchParams(query).has('pmid') - ); + 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` 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, and never over another modal. + * 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 } = useBoot(); const { openModal, modal } = useLazyModal(); + const isPaused = useIsBootPopupsPaused(); const isEnabled = useFeature(featureAppStoreReview); - const marketingCta = getMarketingCta(MarketingCtaVariant.AppStoreReview); + const plainCta = getMarketingCta(MarketingCtaVariant.AppStoreReview); + // The Plus thank-you only makes sense for a member; otherwise it waits in + // boot until the campaign is corrected. + const plusCta = user?.isPlus + ? getMarketingCta(MarketingCtaVariant.AppStoreReviewPlus) + : null; + const marketingCta = plainCta ?? plusCta; const campaignId = marketingCta?.campaignId; const hasAsked = useRef(false); const wasOnPost = useRef(false); - // Read through refs so a route change never re-subscribes the listener. + // 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 || - !router?.events || - !iOSSupportsReview() - ) { + if (!campaignId || !user || !isEnabled || !events || !iOSSupportsReview()) { return undefined; } - wasOnPost.current = isPostUrl(router.asPath ?? ''); + wasOnPost.current = isPostUrl(asPathRef.current ?? ''); const onRouteChange = (url: string) => { const onPost = isPostUrl(url); @@ -66,22 +72,31 @@ export const useAppStoreReviewPrompt = (): void => { !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: marketingCtaRef.current }, + props: { marketingCta: current }, }); }; - router.events.on('routeChangeComplete', onRouteChange); + events.on('routeChangeComplete', onRouteChange); return () => { - router.events.off('routeChangeComplete', onRouteChange); + events.off('routeChangeComplete', onRouteChange); }; - }, [campaignId, isEnabled, openModal, router, user]); + }, [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/log.ts b/packages/shared/src/lib/log.ts index 044572acd61..12e6599ee5b 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -876,6 +876,7 @@ export enum TargetId { AdComment = 'ad comment', MyProfile = 'my profile', PlusBadge = 'plus badge', + AppStoreReviewPlus = 'app_store_review_plus', PlusPage = 'plus page', CoreWallet = 'core wallet', Marketplace = 'marketplace', diff --git a/packages/storybook/stories/app-store-reviews/Production.stories.tsx b/packages/storybook/stories/app-store-reviews/Production.stories.tsx index 3a289adf23d..7107e09d09f 100644 --- a/packages/storybook/stories/app-store-reviews/Production.stories.tsx +++ b/packages/storybook/stories/app-store-reviews/Production.stories.tsx @@ -9,14 +9,16 @@ 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 { getBootMock } from '../../mock/boot'; +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 @@ -104,7 +106,13 @@ const BridgeLog = ({
); -const Providers = ({ children }: { children: ReactNode }): ReactElement => { +const Providers = ({ + children, + isPlus = false, +}: { + children: ReactNode; + isPlus?: boolean; +}): ReactElement => { const [client] = useState(() => new QueryClient()); return ( @@ -115,7 +123,10 @@ const Providers = ({ children }: { children: ReactNode }): ReactElement => { getPage={fn()} getRedirectUri={fn()} version="pwa" - localBootData={getBootMock()} + localBootData={getBootMock({ + ...defaultBootData, + user: { ...defaultUser, isPlus }, + })} > {/* Modal mounts on production's app root. */}
{children}
@@ -131,26 +142,36 @@ const defaultCta: MarketingCta = { flags: { title: '', ctaUrl: '', ctaText: '' }, }; -// A Customer.io campaign with its own copy, here the Plus thank-you. -const plusCta: MarketingCta = { +// A Customer.io campaign with its own copy and image on the plain sheet. +const campaignCta: MarketingCta = { ...defaultCta, - campaignId: 'storybook-plus-renewal', + campaignId: 'storybook-big-release', flags: { - title: 'Thanks for staying with Plus', + title: 'Thanks for reading the new daily.dev', description: - '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.', + '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); @@ -159,7 +180,7 @@ const Sheet = ({ }, []); return ( - +
{!isOpen && ( @@ -172,7 +193,7 @@ const Sheet = ({ )} {isOpen && ( - , +}; + +export const PlusRenewal: Story = { + name: 'Plus renewal thank-you', render: () => , }; From 64dedb777ad51c05168582a521be2b75c95cab01 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 12 Nov 2026 14:22:05 +0200 Subject: [PATCH 3/4] fix(ios): rate from the Plus thank-you, drop gifting on iOS Gifting Plus is not available inside the iOS app, so the Plus renewal sheet asks for the rating directly instead of carrying a gift button and an ask card. A Plus ask that reaches someone without Plus is cleared so it cannot hold the single boot marketing CTA slot. Co-Authored-By: Claude Fable 5.1 --- .../src/components/appStoreReview/common.tsx | 35 --------------- .../modals/PlusRenewalReviewModal.spec.tsx | 43 ++++--------------- .../modals/PlusRenewalReviewModal.tsx | 39 ++++++----------- .../hooks/useAppStoreReviewPrompt.spec.tsx | 13 +++++- .../src/hooks/useAppStoreReviewPrompt.ts | 20 ++++++--- packages/shared/src/lib/log.ts | 1 - 6 files changed, 45 insertions(+), 106 deletions(-) diff --git a/packages/shared/src/components/appStoreReview/common.tsx b/packages/shared/src/components/appStoreReview/common.tsx index e0abfee2486..57939dabd39 100644 --- a/packages/shared/src/components/appStoreReview/common.tsx +++ b/packages/shared/src/components/appStoreReview/common.tsx @@ -1,13 +1,5 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { AppleIcon } from '../icons'; -import { - Typography, - TypographyColor, - TypographyTag, - TypographyType, -} from '../typography/Typography'; // 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 @@ -17,7 +9,6 @@ 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.', - shortText: 'Made your day a little better? A quick rating means a lot.', cta: 'Rate daily.dev', ctaLabel: 'Rate daily.dev on the App Store', dismiss: 'Not now', @@ -35,29 +26,3 @@ export const AskHeadline = (): ReactElement => ( {appStoreReviewCopy.emoji} ); - -// The ask as a card inside a sheet that is about something else. -export const AskCard = ({ - onRate, -}: { - onRate: (event: React.MouseEvent) => void; -}): ReactElement => ( -
- - - - - {appStoreReviewCopy.shortText} - - -
-); diff --git a/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx b/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx index 5d46ed35bab..a69fed0fe57 100644 --- a/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.spec.tsx @@ -3,13 +3,11 @@ 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, TargetId, TargetType } from '../../lib/log'; +import { LogEvent, TargetType } from '../../lib/log'; import { openIOSWriteReview, requestIOSReview } from '../../lib/ios'; import * as bootHook from '../../hooks/useBoot'; -import { useLazyModal } from '../../hooks/useLazyModal'; import type { MarketingCta } from '../marketing/cta/common'; import { MarketingCtaVariant } from '../marketing/cta/common'; -import { LazyModal } from './common/types'; import { appStoreReviewCopy, appStoreReviewRequestDelayMs, @@ -23,12 +21,10 @@ jest.mock('../../lib/ios', () => ({ openIOSWriteReview: jest.fn(() => true), requestIOSReview: jest.fn(() => true), })); -jest.mock('../../hooks/useLazyModal', () => ({ useLazyModal: jest.fn() })); const logEvent = jest.fn(); const clearMarketingCta = jest.fn(); const onRequestClose = jest.fn(); -const openModal = jest.fn(); const marketingCta: MarketingCta = { campaignId: 'review-plus-renewal', @@ -60,11 +56,6 @@ describe('PlusRenewalReviewModal', () => { jest.spyOn(bootHook, 'useBoot').mockReturnValue({ clearMarketingCta, } as unknown as ReturnType); - jest.mocked(useLazyModal).mockReturnValue({ - openModal, - closeModal: jest.fn(), - modal: null, - } as unknown as ReturnType); }); afterEach(() => { @@ -72,7 +63,7 @@ describe('PlusRenewalReviewModal', () => { jest.restoreAllMocks(); }); - it('thanks the member and carries the ask card', () => { + it('thanks the member and asks for a rating', () => { renderModal(); expect( @@ -80,9 +71,11 @@ describe('PlusRenewalReviewModal', () => { ).toBeInTheDocument(); expect(screen.getByText(plusRenewalCopy.text)).toBeInTheDocument(); expect( - screen.getByRole('heading', { name: appStoreReviewCopy.headline }), - ).toBeInTheDocument(); - expect(screen.getByText(appStoreReviewCopy.shortText)).toBeInTheDocument(); + 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, @@ -115,7 +108,7 @@ describe('PlusRenewalReviewModal', () => { ).toBeInTheDocument(); }); - it('opens the write-a-review screen from the ask card', () => { + it('opens the write-a-review screen from the CTA', () => { renderModal(); fireEvent.click( @@ -127,26 +120,6 @@ describe('PlusRenewalReviewModal', () => { expect(onRequestClose).toHaveBeenCalledTimes(1); }); - it('hands over to the gift Plus modal', () => { - renderModal(); - - fireEvent.click(screen.getByRole('button', { name: plusRenewalCopy.gift })); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.GiftSubscription, - target_type: TargetType.Plus, - target_id: TargetId.AppStoreReviewPlus, - extra: undefined, - }); - expect(clearMarketingCta).toHaveBeenCalledWith(marketingCta.campaignId); - expect(onRequestClose).toHaveBeenCalledTimes(1); - expect(openModal).toHaveBeenCalledWith({ - type: LazyModal.GiftPlus, - props: {}, - }); - expect(openIOSWriteReview).not.toHaveBeenCalled(); - }); - it('goes back to the feed without rating', () => { renderModal(); diff --git a/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx b/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx index 1ed61844b5d..7d9b41c692e 100644 --- a/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx +++ b/packages/shared/src/components/modals/PlusRenewalReviewModal.tsx @@ -3,7 +3,7 @@ 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 { DevPlusIcon, GiftIcon } from '../icons'; +import { AppleIcon, DevPlusIcon } from '../icons'; import { IconSize } from '../Icon'; import { PlusUser } from '../PlusUser'; import { @@ -13,17 +13,12 @@ import { TypographyType, } from '../typography/Typography'; import type { MarketingCta } from '../marketing/cta/common'; -import { LazyModal } from './common/types'; -import { useLazyModal } from '../../hooks/useLazyModal'; -import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { useAppStoreReviewAsk } from '../../hooks/useAppStoreReviewAsk'; -import { AskCard } from '../appStoreReview/common'; -import { LogEvent, TargetId } from '../../lib/log'; +import { appStoreReviewCopy } from '../appStoreReview/common'; export const plusRenewalCopy = { headline: 'Thanks for staying with Plus', - text: 'Glad to have you for another month.', - gift: 'Gift Plus to a friend', + 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', }; @@ -33,9 +28,10 @@ export interface PlusRenewalReviewModalProps extends ModalProps { // 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, -// and gifting Plus is the other way to spread the word. The campaign's copy -// wins over the defaults when it sets any. +// 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, @@ -46,8 +42,6 @@ export default function PlusRenewalReviewModal({ campaignId, onRequestClose, }); - const { openModal } = useLazyModal(); - const { logSubscriptionEvent } = usePlusSubscription(); const headlineRef = useRef(null); useEffect(() => { @@ -55,15 +49,6 @@ export default function PlusRenewalReviewModal({ return () => cancelAnimationFrame(frame); }, []); - const onGift = (event: React.MouseEvent) => { - logSubscriptionEvent({ - event_name: LogEvent.GiftSubscription, - target_id: TargetId.AppStoreReviewPlus, - }); - onDismiss(event); - openModal({ type: LazyModal.GiftPlus, props: {} }); - }; - return ( - +
-