diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.spec.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.spec.tsx new file mode 100644 index 00000000000..07289ec34c8 --- /dev/null +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.spec.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import nock from 'nock'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import AskForReviewConfirmModal from './AskForReviewConfirmModal'; +import type { ReviewDestination } from '../../lib/askForReview'; +import { ASK_FOR_REVIEW_DISMISSED_KEY } from '../../lib/askForReview'; +import * as actionsHook from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; + +const CHROME_DEST: ReviewDestination = { + id: 'chrome_web_store', + label: 'Chrome Web Store', + href: 'https://example.test/chrome', + headline: 'Help fellow devs find daily.dev', + body: 'Take 10 seconds to rate daily.dev on the Chrome Web Store.', + ctaText: 'Rate on Chrome Web Store', + image: 'https://example.test/image.png', +}; + +const APP_STORE_DEST: ReviewDestination = { + id: 'app_store', + label: 'App Store', + href: 'https://example.test/app-store', + headline: 'Help fellow devs find daily.dev', + body: 'Take 10 seconds to rate daily.dev on the App Store.', + ctaText: 'Rate on the App Store', + image: 'https://example.test/image.png', +}; + +const completeAction = jest.fn(); +const onRequestClose = jest.fn(); + +const renderModal = (destination: ReviewDestination = CHROME_DEST): void => { + const client = new QueryClient(); + render( + + + , + ); +}; + +beforeEach(() => { + window.localStorage.clear(); + completeAction.mockReset(); + onRequestClose.mockReset(); + jest.spyOn(actionsHook, 'useActions').mockReturnValue({ + completeAction, + checkHasCompleted: () => false, + isActionsFetched: true, + actions: [], + }); + nock.cleanAll(); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); +}); + +it('renders the destination-specific headline, body, and CTA text', () => { + renderModal(); + expect( + screen.getByText('Help fellow devs find daily.dev'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Take 10 seconds to rate daily.dev on the Chrome Web Store.', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Rate on Chrome Web Store' }), + ).toBeInTheDocument(); +}); + +it('points the CTA at the destination href with target=_blank', () => { + renderModal(); + const cta = screen.getByRole('link', { name: 'Rate on Chrome Web Store' }); + expect(cta).toHaveAttribute('href', 'https://example.test/chrome'); + expect(cta).toHaveAttribute('target', '_blank'); +}); + +it('renders the destination hero image with a meaningful alt', () => { + renderModal(); + const image = screen.getByAltText('Leave a review on Chrome Web Store'); + expect(image).toHaveAttribute('src', 'https://example.test/image.png'); +}); + +it('marks the action complete and closes when the CTA is clicked', () => { + renderModal(); + fireEvent.click( + screen.getByRole('link', { name: 'Rate on Chrome Web Store' }), + ); + expect(completeAction).toHaveBeenCalledWith( + ActionType.AskedForReviewComplete, + ); + expect(onRequestClose).toHaveBeenCalled(); +}); + +it('writes a cooldown timestamp and closes when dismissed without engaging', () => { + renderModal(); + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss review prompt' }), + ); + expect(completeAction).not.toHaveBeenCalled(); + expect( + window.localStorage.getItem(ASK_FOR_REVIEW_DISMISSED_KEY), + ).not.toBeNull(); + expect(onRequestClose).toHaveBeenCalled(); +}); + +it('reflects a different destination when passed App Store', () => { + renderModal(APP_STORE_DEST); + expect( + screen.getByRole('link', { name: 'Rate on the App Store' }), + ).toHaveAttribute('href', 'https://example.test/app-store'); +}); diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx new file mode 100644 index 00000000000..ba728c84175 --- /dev/null +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import type { LazyModalCommonProps, ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { MiniCloseIcon } from '../icons'; +import { CTAButton, Description, Title } from '../marketing/cta/common'; +import { CardCover } from '../cards/common/CardCover'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import type { ReviewDestination } from '../../lib/askForReview'; +import { setDismissedAt } from '../../lib/askForReview'; + +type AskForReviewConfirmModalProps = Omit & { + onRequestClose?: LazyModalCommonProps['onRequestClose']; + destination: ReviewDestination; + streakValue?: number; +}; + +const AskForReviewConfirmModal = ({ + onRequestClose, + destination, + streakValue, + ...props +}: AskForReviewConfirmModalProps): ReactElement => { + const { completeAction } = useActions(); + const { logEvent } = useLogContext(); + + const log = useCallback( + (clickId: string) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.AskForReview, + target_id: clickId, + extra: JSON.stringify({ + platform: destination.id, + streak: streakValue, + step: 'confirm_modal', + }), + }); + }, + [logEvent, destination.id, streakValue], + ); + + const handleLeaveReview = useCallback(() => { + log('leave_review'); + // Marks the action complete on click-through, not on actual review + // submission — there's no callback from the store page back to us. + // Click-through is the engagement signal we re-ask against. + completeAction(ActionType.AskedForReviewComplete); + onRequestClose?.(undefined); + }, [log, completeAction, onRequestClose]); + + const handleMaybeLater = useCallback(() => { + log('confirm_dismiss'); + setDismissedAt(); + onRequestClose?.(undefined); + }, [log, onRequestClose]); + + return ( + +
+
+
+ ); +}; + +export default AskForReviewConfirmModal; diff --git a/packages/shared/src/components/modals/FeedbackModal.tsx b/packages/shared/src/components/modals/FeedbackModal.tsx index 2d7cde0d9b8..cecf2425d89 100644 --- a/packages/shared/src/components/modals/FeedbackModal.tsx +++ b/packages/shared/src/components/modals/FeedbackModal.tsx @@ -31,6 +31,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; const FEEDBACK_MAX_LENGTH = 2000; type FeedbackModalProps = Omit & { onRequestClose?: LazyModalCommonProps['onRequestClose']; + defaultCategory?: FeedbackCategory; }; const categoryOptions: { value: FeedbackCategory; label: string }[] = [ @@ -43,6 +44,7 @@ const categoryOptions: { value: FeedbackCategory; label: string }[] = [ const FeedbackModal = ({ onRequestClose, + defaultCategory = FeedbackCategory.BugReport, ...props }: FeedbackModalProps): ReactElement => { const { displayToast } = useToastNotification(); @@ -50,9 +52,7 @@ const FeedbackModal = ({ const fileInputRef = useRef(null); const hasSubmitted = useRef(false); - const [category, setCategory] = useState( - FeedbackCategory.BugReport, - ); + const [category, setCategory] = useState(defaultCategory); const [description, setDescription] = useState(''); const [screenshot, setScreenshot] = useState(null); const [screenshotPreview, setScreenshotPreview] = useState( diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 73d0b0c45b8..a5454d5cafe 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -449,6 +449,13 @@ const FeedbackModal = dynamic( () => import(/* webpackChunkName: "feedbackModal" */ './FeedbackModal'), ); +const AskForReviewConfirmModal = dynamic( + () => + import( + /* webpackChunkName: "askForReviewConfirmModal" */ './AskForReviewConfirmModal' + ), +); + const HotAndColdModal = dynamic( () => import( @@ -567,6 +574,7 @@ export const modals = { [LazyModal.RecruiterSeats]: RecruiterSeatsModal, [LazyModal.CandidateSignIn]: CandidateSignInModal, [LazyModal.Feedback]: FeedbackModal, + [LazyModal.AskForReviewConfirm]: AskForReviewConfirmModal, [LazyModal.AchievementSyncPrompt]: AchievementSyncPromptModal, [LazyModal.HotAndCold]: HotAndColdModal, [LazyModal.AchievementPicker]: AchievementPickerModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 8f8add1ee11..3c3ed9472d2 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -97,6 +97,7 @@ export enum LazyModal { RecruiterSeats = 'recruiterSeats', CandidateSignIn = 'candidateSignIn', Feedback = 'feedback', + AskForReviewConfirm = 'askForReviewConfirm', HotAndCold = 'hotAndCold', AchievementSyncPrompt = 'achievementSyncPrompt', AchievementPicker = 'achievementPicker', diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 0e1d81a904b..3fa8bb4782d 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; +import { AskForReviewStrip } from '../postReview/AskForReviewStrip'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -61,6 +62,11 @@ export function BasePostContent({ /> )} + {/* Lives inside the post content (both modal + page) so it renders on + every post surface from a single mount point. Visually styled as a + standalone card with its own border/shadow so it reads as separate + from the article body. */} + {children} {!!engagementProps && ( { + const client = new QueryClient(); + render( + + + , + ); +}; + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + completeAction.mockReset(); + openModal.mockReset(); + jest.spyOn(actionsHook, 'useActions').mockReturnValue({ + completeAction, + checkHasCompleted: () => false, + isActionsFetched: true, + actions: [], + }); + jest.spyOn(lazyModalHook, 'useLazyModal').mockReturnValue({ + modal: undefined as never, + openModal, + closeModal: jest.fn(), + }); + nock.cleanAll(); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); +}); + +it('renders the question with explicit daily.dev copy and Yes/No buttons', () => { + renderStrip(); + expect(screen.getByText('Enjoying daily.dev so far?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Not really' }), + ).toBeInTheDocument(); +}); + +it('marks the session-shown flag on mount', () => { + renderStrip(); + expect(window.sessionStorage.getItem(ASK_FOR_REVIEW_SESSION_KEY)).toBe('1'); +}); + +it('opens the AskForReviewConfirm modal with destination + streak when user clicks Yes', async () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + await waitFor(() => { + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.AskForReviewConfirm, + props: { destination: CHROME_DEST, streakValue: 3 }, + }); + }); + expect(completeAction).not.toHaveBeenCalled(); +}); + +it('opens the Feedback modal with UxIssue category and marks complete on No', async () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'Not really' })); + await waitFor(() => { + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.Feedback, + props: { defaultCategory: FeedbackCategory.UxIssue }, + }); + expect(completeAction).toHaveBeenCalledWith( + ActionType.AskedForReviewComplete, + ); + }); +}); + +it('writes the dismissed-at timestamp when user dismisses without engaging', () => { + renderStrip(); + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss review prompt' }), + ); + expect(completeAction).not.toHaveBeenCalled(); + expect( + window.localStorage.getItem(ASK_FOR_REVIEW_DISMISSED_KEY), + ).not.toBeNull(); +}); + +it('passes the destination through to the confirm modal (App Store)', async () => { + const appStore: ReviewDestination = { + id: 'app_store', + label: 'App Store', + href: 'https://example.test/app-store', + headline: 'Help fellow devs find daily.dev', + body: 'Take 10 seconds to rate daily.dev on the App Store.', + ctaText: 'Rate on the App Store', + image: 'https://example.test/image.png', + }; + renderStrip(appStore); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + await waitFor(() => { + expect(openModal).toHaveBeenCalledWith({ + type: LazyModal.AskForReviewConfirm, + props: { destination: appStore, streakValue: 3 }, + }); + }); +}); diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx new file mode 100644 index 00000000000..060d52f8bff --- /dev/null +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -0,0 +1,242 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { MiniCloseIcon } from '../icons/MiniClose'; +import { FeedbackIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; +import { useLogContext } from '../../contexts/LogContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { LogEvent, TargetType } from '../../lib/log'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { LazyModal } from '../modals/common/types'; +import { FeedbackCategory } from '../../graphql/feedback'; +import type { ReviewDestination } from '../../lib/askForReview'; +import { markShownThisSession, setDismissedAt } from '../../lib/askForReview'; +import { useAskForReviewVisibility } from '../../hooks/useAskForReviewVisibility'; + +export const ASK_FOR_REVIEW_RESET_EVENT = 'askForReview:reset'; + +type AskForReviewClickId = 'enjoy_yes' | 'enjoy_no' | 'dismiss_strip'; + +interface AskForReviewStripBaseProps { + destination: ReviewDestination; + streakValue: number; + variantEnabled?: boolean; + streakThreshold?: number; + cooldownDays?: number; + className?: string; + onAction?: (action: AskForReviewClickId) => void; + onClose?: () => void; +} + +const buildExtra = (data: Record): string => + JSON.stringify(data); + +export const AskForReviewStripView = ({ + destination, + streakValue, + variantEnabled = true, + streakThreshold, + cooldownDays, + className, + onAction, + onClose, +}: AskForReviewStripBaseProps): ReactElement => { + const { completeAction } = useActions(); + const { openModal } = useLazyModal(); + const { logEvent } = useLogContext(); + + const extraPayload = buildExtra({ + platform: destination.id, + streak: streakValue, + variant_enabled: variantEnabled, + streak_threshold: streakThreshold, + cooldown_days: cooldownDays, + }); + + useLogEventOnce(() => ({ + event_name: LogEvent.Impression, + target_type: TargetType.AskForReview, + target_id: destination.id, + extra: extraPayload, + })); + + useEffect(() => { + markShownThisSession(); + }, []); + + const log = useCallback( + (clickId: AskForReviewClickId) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.AskForReview, + target_id: clickId, + extra: buildExtra({ + platform: destination.id, + streak: streakValue, + step: 'enjoy', + variant_enabled: variantEnabled, + }), + }); + }, + [logEvent, destination.id, streakValue, variantEnabled], + ); + + const onYes = () => { + log('enjoy_yes'); + openModal({ + type: LazyModal.AskForReviewConfirm, + props: { destination, streakValue }, + }); + onAction?.('enjoy_yes'); + onClose?.(); + }; + + const onNo = () => { + log('enjoy_no'); + completeAction(ActionType.AskedForReviewComplete); + openModal({ + type: LazyModal.Feedback, + props: { defaultCategory: FeedbackCategory.UxIssue }, + }); + onAction?.('enjoy_no'); + onClose?.(); + }; + + const onDismiss = () => { + log('dismiss_strip'); + setDismissedAt(); + onAction?.('dismiss_strip'); + onClose?.(); + }; + + return ( +
+ + + +
+ ); +}; + +export const AskForReviewStrip = ({ + className, +}: { + className?: string; +}): ReactElement | null => { + const { + visible, + destination, + streakValue, + variantEnabled, + streakThreshold, + cooldownDays, + } = useAskForReviewVisibility(); + const [closed, setClosed] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + const handleReset = () => setClosed(false); + window.addEventListener(ASK_FOR_REVIEW_RESET_EVENT, handleReset); + return () => { + window.removeEventListener(ASK_FOR_REVIEW_RESET_EVENT, handleReset); + }; + }, []); + + if (!visible || !destination || closed) { + return null; + } + + return ( + setClosed(true)} + /> + ); +}; diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 999d0ec1513..485b3844a49 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -68,6 +68,7 @@ export enum ActionType { DismissedNewTabCustomizer = 'dismissed_new_tab_customizer', SeenKeepItOverlay = 'seen_keep_it_overlay', DismissCompanionDemoWidget = 'dismiss_companion_demo_widget', + AskedForReviewComplete = 'asked_for_review_complete', } export const cvActions = [ diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx b/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx new file mode 100644 index 00000000000..f78980c9482 --- /dev/null +++ b/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import nock from 'nock'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { TestBootProvider } from '../../__tests__/helpers/boot'; +import { useAskForReviewVisibility } from './useAskForReviewVisibility'; +import * as actionsHook from './useActions'; +import * as streakHook from './streaks/useReadingStreak'; +import * as askForReviewLib from '../lib/askForReview'; +import type { ReviewDestination } from '../lib/askForReview'; +import { ActionType } from '../graphql/actions'; +import { DayOfWeek } from '../lib/date'; +import type { AskForReviewFeatureValue } from '../lib/featureManagement'; + +const CHROME_DEST: ReviewDestination = { + id: 'chrome_web_store', + label: 'Chrome Web Store', + href: 'https://example.test/chrome', + headline: 'Help fellow devs find daily.dev', + body: 'Take 10 seconds to rate daily.dev on the Chrome Web Store.', + ctaText: 'Rate on Chrome Web Store', + image: 'https://example.test/image.png', +}; + +const buildStreak = (current: number) => ({ + isLoading: false, + isStreaksEnabled: true, + isUpdatingConfig: false, + streak: { + current, + max: current, + total: current, + weekStart: DayOfWeek.Monday, + lastViewAt: new Date(), + }, + updateStreakConfig: jest.fn(), + checkReadingStreak: jest.fn(), +}); + +const buildGrowthBook = (value: AskForReviewFeatureValue): GrowthBook => { + const gb = new GrowthBook(); + gb.setFeatures({ + ask_for_review: { + defaultValue: value, + }, + }); + return gb; +}; + +const renderVisibility = ( + current = 3, + featureValue: AskForReviewFeatureValue = { + enabled: true, + streakThreshold: 3, + cooldownDays: 14, + }, + checkHasCompletedImpl: (type: ActionType) => boolean = () => false, + destination: ReviewDestination | null = CHROME_DEST, +) => { + jest + .spyOn(streakHook, 'useReadingStreak') + .mockReturnValue(buildStreak(current)); + jest.spyOn(actionsHook, 'useActions').mockReturnValue({ + completeAction: jest.fn(), + checkHasCompleted: checkHasCompletedImpl, + isActionsFetched: true, + actions: [], + }); + jest + .spyOn(askForReviewLib, 'getReviewDestination') + .mockReturnValue(destination); + + const client = new QueryClient(); + return renderHook(() => useAskForReviewVisibility(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + nock.cleanAll(); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); +}); + +it('is visible when all gates pass', async () => { + const { result } = renderVisibility(); + await waitFor(() => expect(result.current.visible).toBe(true)); + expect(result.current.destination?.id).toBe('chrome_web_store'); +}); + +it('is hidden when the feature flag is disabled', async () => { + const { result } = renderVisibility(3, { + enabled: false, + streakThreshold: 3, + cooldownDays: 14, + }); + await waitFor(() => expect(result.current.variantEnabled).toBe(false)); + expect(result.current.visible).toBe(false); +}); + +it('is hidden when current streak is below threshold', async () => { + const { result } = renderVisibility(2); + await waitFor(() => expect(result.current.streakValue).toBe(2)); + expect(result.current.visible).toBe(false); +}); + +it('is hidden when the permanent action is already completed', async () => { + const { result } = renderVisibility( + 3, + { enabled: true, streakThreshold: 3, cooldownDays: 14 }, + (type) => type === ActionType.AskedForReviewComplete, + ); + await waitFor(() => expect(result.current.visible).toBe(false)); +}); + +it('is hidden when there is no destination for the current platform', async () => { + const { result } = renderVisibility( + 3, + { enabled: true, streakThreshold: 3, cooldownDays: 14 }, + () => false, + null, + ); + await waitFor(() => expect(result.current.destination).toBeNull()); + expect(result.current.visible).toBe(false); +}); + +it('is hidden while inside the cooldown window', async () => { + askForReviewLib.setDismissedAt(Date.now() - 1000); + const { result } = renderVisibility(); + await waitFor(() => expect(result.current.visible).toBe(false)); +}); + +it('becomes visible again after the cooldown elapses (loop)', async () => { + const fifteenDays = 15 * 24 * 60 * 60 * 1000; + askForReviewLib.setDismissedAt(Date.now() - fifteenDays); + const { result } = renderVisibility(); + await waitFor(() => expect(result.current.visible).toBe(true)); +}); + +it('is hidden after the session-shown flag is set', async () => { + askForReviewLib.markShownThisSession(); + const { result } = renderVisibility(); + await waitFor(() => expect(result.current.visible).toBe(false)); +}); diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts new file mode 100644 index 00000000000..45775f39b2b --- /dev/null +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -0,0 +1,89 @@ +import { useMemo } from 'react'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useAlertsContext } from '../contexts/AlertContext'; +import { useActions } from './useActions'; +import { useReadingStreak } from './streaks/useReadingStreak'; +import { useConditionalFeature } from './useConditionalFeature'; +import { featureAskForReview } from '../lib/featureManagement'; +import { ActionType } from '../graphql/actions'; +import type { ReviewDestination } from '../lib/askForReview'; +import { + getReviewDestination, + hasShownThisSession, + isCooldownActive, +} from '../lib/askForReview'; + +interface UseAskForReviewVisibility { + visible: boolean; + destination: ReviewDestination | null; + streakValue: number; + variantEnabled: boolean; + streakThreshold: number; + cooldownDays: number; + isCompletedPermanent: boolean; + isCooldownLive: boolean; + isSessionShown: boolean; + isStreaksEnabled: boolean; + platformDestination: ReviewDestination | null; +} + +export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { + const { user, isAuthReady, isLoggedIn } = useAuthContext(); + const { alerts, loadedAlerts } = useAlertsContext(); + const { checkHasCompleted, isActionsFetched } = useActions(); + const { streak, isStreaksEnabled } = useReadingStreak(); + + const platformDestination = useMemo(() => getReviewDestination(), []); + const destination = platformDestination; + const sessionShown = hasShownThisSession(); + const completedPermanent = checkHasCompleted( + ActionType.AskedForReviewComplete, + ); + + const streakThresholdDefault = 3; + const cooldownDaysDefault = 14; + + const baseGate = + isAuthReady && + isLoggedIn && + !!user?.id && + isActionsFetched && + loadedAlerts && + isStreaksEnabled && + !completedPermanent && + !sessionShown && + !alerts?.showStreakMilestone && + destination !== null; + + const { value: featureValue } = useConditionalFeature({ + feature: featureAskForReview, + shouldEvaluate: baseGate, + }); + + const variantEnabled = !!featureValue?.enabled; + const streakThreshold = + featureValue?.streakThreshold ?? streakThresholdDefault; + const cooldownDays = featureValue?.cooldownDays ?? cooldownDaysDefault; + const streakValue = streak?.current ?? 0; + const streakPasses = streakValue >= streakThreshold; + const cooldownLive = isCooldownActive(cooldownDays); + const cooldownPasses = !cooldownLive; + + const visible = Boolean( + baseGate && variantEnabled && streakPasses && cooldownPasses, + ); + + return { + visible, + destination, + streakValue, + variantEnabled, + streakThreshold, + cooldownDays, + isCompletedPermanent: completedPermanent, + isCooldownLive: cooldownLive, + isSessionShown: sessionShown, + isStreaksEnabled: !!isStreaksEnabled, + platformDestination, + }; +}; diff --git a/packages/shared/src/lib/askForReview.spec.ts b/packages/shared/src/lib/askForReview.spec.ts new file mode 100644 index 00000000000..d3613d2e3cd --- /dev/null +++ b/packages/shared/src/lib/askForReview.spec.ts @@ -0,0 +1,162 @@ +import { + ASK_FOR_REVIEW_DISMISSED_KEY, + ASK_FOR_REVIEW_SESSION_KEY, + getDismissedAt, + getReviewDestination, + hasShownThisSession, + isCooldownActive, + markShownThisSession, + setDismissedAt, +} from './askForReview'; + +const ORIGINAL_USER_AGENT = window.navigator.userAgent; +const ORIGINAL_VENDOR = window.navigator.vendor; + +const setUserAgent = (userAgent: string, vendor = ''): void => { + Object.defineProperty(window.navigator, 'userAgent', { + value: userAgent, + configurable: true, + }); + Object.defineProperty(window.navigator, 'vendor', { + value: vendor, + configurable: true, + }); +}; + +const restoreNavigator = (): void => { + setUserAgent(ORIGINAL_USER_AGENT, ORIGINAL_VENDOR); +}; + +describe('getReviewDestination (webapp)', () => { + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + restoreNavigator(); + }); + + it('routes iPhone Safari to App Store', () => { + setUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + ); + expect(getReviewDestination()?.id).toBe('app_store'); + }); + + it('routes Android Chrome to Play Store', () => { + setUserAgent( + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36', + 'Google Inc.', + ); + expect(getReviewDestination()?.id).toBe('play_store'); + }); + + it('routes desktop Chrome to Chrome Web Store', () => { + setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + 'Google Inc.', + ); + expect(getReviewDestination()?.id).toBe('chrome_web_store'); + }); + + it('routes Edge to Edge Add-ons', () => { + setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', + ); + expect(getReviewDestination()?.id).toBe('edge_addons'); + }); + + it('falls back to X share for Firefox desktop', () => { + setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0', + ); + expect(getReviewDestination()?.id).toBe('twitter_share'); + }); + + it('falls back to X share for Safari desktop', () => { + setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + ); + expect(getReviewDestination()?.id).toBe('twitter_share'); + }); + + it('routes iPad Safari to App Store', () => { + setUserAgent( + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + ); + expect(getReviewDestination()?.id).toBe('app_store'); + }); + + it('routes Chrome on iOS (CriOS) to App Store', () => { + setUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.0.0 Mobile/15E148 Safari/604.1', + ); + expect(getReviewDestination()?.id).toBe('app_store'); + }); + + it('returns a destination with per-store copy and image', () => { + setUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + 'Google Inc.', + ); + const dest = getReviewDestination(); + expect(dest).not.toBeNull(); + expect(dest?.headline).toMatch(/daily\.dev/i); + expect(dest?.body).toMatch(/Chrome Web Store/i); + expect(dest?.ctaText).toMatch(/Chrome Web Store/i); + expect(dest?.image).toMatch(/^https?:\/\//); + }); +}); + +describe('dismissed-at cooldown', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('returns null when no timestamp stored', () => { + expect(getDismissedAt()).toBeNull(); + expect(isCooldownActive(14)).toBe(false); + }); + + it('round-trips a dismissal timestamp', () => { + setDismissedAt(1_700_000_000_000); + expect(getDismissedAt()).toBe(1_700_000_000_000); + expect(window.localStorage.getItem(ASK_FOR_REVIEW_DISMISSED_KEY)).toBe( + '1700000000000', + ); + }); + + it('treats the cooldown as active inside the window', () => { + const now = Date.now(); + setDismissedAt(now - 1000); + expect(isCooldownActive(14, now)).toBe(true); + }); + + it('treats the cooldown as expired after the window', () => { + const now = Date.now(); + const fifteenDays = 15 * 24 * 60 * 60 * 1000; + setDismissedAt(now - fifteenDays); + expect(isCooldownActive(14, now)).toBe(false); + }); + + it('ignores invalid timestamps', () => { + window.localStorage.setItem(ASK_FOR_REVIEW_DISMISSED_KEY, 'not-a-number'); + expect(getDismissedAt()).toBeNull(); + }); +}); + +describe('session shown flag', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('starts unset', () => { + expect(hasShownThisSession()).toBe(false); + }); + + it('marks itself shown until session storage is cleared', () => { + markShownThisSession(); + expect(hasShownThisSession()).toBe(true); + expect(window.sessionStorage.getItem(ASK_FOR_REVIEW_SESSION_KEY)).toBe('1'); + }); +}); diff --git a/packages/shared/src/lib/askForReview.ts b/packages/shared/src/lib/askForReview.ts new file mode 100644 index 00000000000..c77223aa263 --- /dev/null +++ b/packages/shared/src/lib/askForReview.ts @@ -0,0 +1,198 @@ +import { + appStoreReviewUrl, + chromeWebStoreReviewUrl, + edgeAddonsReviewUrl, + firefoxAddonsReviewUrl, + playStoreReviewUrl, + twitterShareReviewUrl, +} from './constants'; +import { + BrowserName, + getCurrentBrowserName, + isAndroid, + isChromeExtension, + isExtension, + isFirefoxExtension, + isIOS, +} from './func'; +import { askForReviewPlaceholderImage } from './image'; + +export type ReviewDestinationId = + | 'chrome_web_store' + | 'edge_addons' + | 'firefox_addons' + | 'app_store' + | 'play_store' + | 'twitter_share'; + +export interface ReviewDestination { + id: ReviewDestinationId; + label: string; + href: string; + // Per-store copy for the confirm modal. Each destination owns its own + // headline / body / CTA wording so the prompt matches the store the user + // will land on. + headline: string; + body: string; + ctaText: string; + // Placeholder hero image — engineering will swap per-store assets in later. + image: string; +} + +const CHROME_DEST: ReviewDestination = { + id: 'chrome_web_store', + label: 'Chrome Web Store', + href: chromeWebStoreReviewUrl, + headline: 'Help fellow devs find daily.dev 💜', + body: "Take 10 seconds to rate daily.dev on the Chrome Web Store. Your review helps other developers trust it's worth installing.", + ctaText: 'Rate on Chrome Web Store', + image: askForReviewPlaceholderImage, +}; +const EDGE_DEST: ReviewDestination = { + id: 'edge_addons', + label: 'Edge Add-ons', + href: edgeAddonsReviewUrl, + headline: 'Help fellow devs find daily.dev 💜', + body: "Take 10 seconds to rate daily.dev on Microsoft Edge Add-ons. Your review helps other developers trust it's worth installing.", + ctaText: 'Rate on Edge Add-ons', + image: askForReviewPlaceholderImage, +}; +const FIREFOX_DEST: ReviewDestination = { + id: 'firefox_addons', + label: 'Firefox Add-ons', + href: firefoxAddonsReviewUrl, + headline: 'Help fellow devs find daily.dev 💜', + body: "Take 10 seconds to rate daily.dev on Firefox Add-ons. Your review helps other developers trust it's worth installing.", + ctaText: 'Rate on Firefox Add-ons', + image: askForReviewPlaceholderImage, +}; +const APP_STORE_DEST: ReviewDestination = { + id: 'app_store', + label: 'App Store', + href: appStoreReviewUrl, + headline: 'Help fellow devs find daily.dev 💜', + body: "Take 10 seconds to rate daily.dev on the App Store. Your review helps other developers trust it's worth the download.", + ctaText: 'Rate on the App Store', + image: askForReviewPlaceholderImage, +}; +const PLAY_STORE_DEST: ReviewDestination = { + id: 'play_store', + label: 'Play Store', + href: playStoreReviewUrl, + headline: 'Help fellow devs find daily.dev 💜', + body: "Take 10 seconds to rate daily.dev on Google Play. Your review helps other developers trust it's worth the download.", + ctaText: 'Rate on Google Play', + image: askForReviewPlaceholderImage, +}; +const TWITTER_DEST: ReviewDestination = { + id: 'twitter_share', + label: 'X', + href: twitterShareReviewUrl, + headline: 'Spread the word 💜', + body: "We can't send you to a review page from this browser, but a quick post on X helps other devs discover daily.dev.", + ctaText: 'Share on X', + image: askForReviewPlaceholderImage, +}; + +export const getReviewDestination = (): ReviewDestination | null => { + if (typeof window === 'undefined') { + return null; + } + + if (isExtension) { + if (isChromeExtension) { + return CHROME_DEST; + } + if (process.env.TARGET_BROWSER === 'edge') { + return EDGE_DEST; + } + if (isFirefoxExtension) { + return FIREFOX_DEST; + } + return null; + } + + if (isIOS()) { + return APP_STORE_DEST; + } + if (isAndroid()) { + return PLAY_STORE_DEST; + } + + const browser = getCurrentBrowserName(); + if (browser === BrowserName.Chrome || browser === BrowserName.Brave) { + return CHROME_DEST; + } + if (browser === BrowserName.Edge) { + return EDGE_DEST; + } + return TWITTER_DEST; +}; + +export const ASK_FOR_REVIEW_DISMISSED_KEY = 'askForReview:dismissedAt'; + +export const getDismissedAt = (): number | null => { + if (typeof window === 'undefined') { + return null; + } + try { + const raw = window.localStorage.getItem(ASK_FOR_REVIEW_DISMISSED_KEY); + if (!raw) { + return null; + } + const value = Number(raw); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +}; + +export const setDismissedAt = (timestamp: number = Date.now()): void => { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem( + ASK_FOR_REVIEW_DISMISSED_KEY, + String(timestamp), + ); + } catch { + // ignore: cookie/localStorage may be disabled + } +}; + +export const isCooldownActive = ( + cooldownDays: number, + now = Date.now(), +): boolean => { + const dismissedAt = getDismissedAt(); + if (!dismissedAt) { + return false; + } + const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000; + return now - dismissedAt < cooldownMs; +}; + +export const ASK_FOR_REVIEW_SESSION_KEY = 'askForReview:shownThisSession'; + +export const hasShownThisSession = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + try { + return window.sessionStorage.getItem(ASK_FOR_REVIEW_SESSION_KEY) === '1'; + } catch { + return false; + } +}; + +export const markShownThisSession = (): void => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem(ASK_FOR_REVIEW_SESSION_KEY, '1'); + } catch { + // ignore + } +}; diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index c7907deefda..8f1932c140e 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -37,6 +37,16 @@ export const appsUrl = 'https://daily.dev/apps'; export const appStoreUrl = 'https://apps.apple.com/app/daily-dev/id6740634400'; export const playStoreUrl = 'https://play.google.com/store/apps/details?id=dev.daily'; +export const appStoreReviewUrl = `${appStoreUrl}?action=write-review`; +export const playStoreReviewUrl = `${playStoreUrl}&showAllReviews=true`; +export const chromeWebStoreReviewUrl = + 'https://chromewebstore.google.com/detail/jlmpjdjjbgclbocgajdjefcidcncaied/reviews'; +export const edgeAddonsReviewUrl = + 'https://microsoftedge.microsoft.com/addons/detail/cbdhgldgiancdheindpekpcbkccpjaeb'; +export const firefoxAddonsReviewUrl = + 'https://addons.mozilla.org/en-US/firefox/addon/daily/'; +export const twitterShareReviewUrl = + 'https://twitter.com/intent/tweet?text=I%20love%20%40dailydotdev%20%E2%80%94%20every%20new%20tab%20is%20full%20of%20great%20developer%20content.%20Check%20it%20out%3A%20https%3A%2F%2Fdaily.dev'; export const timezoneSettingsUrl = 'https://r.daily.dev/timezone'; export const isDevelopment = process.env.NODE_ENV === 'development'; export const isProductionAPI = diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 7e401a653bf..7de28329282 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -199,4 +199,19 @@ export const featureCompanionDemoWidget = new Feature( export const featureFeedTagChips = new Feature('feed_tag_chips', false); +export type AskForReviewFeatureValue = { + enabled: boolean; + streakThreshold: number; + cooldownDays: number; +}; + +export const featureAskForReview = new Feature( + 'ask_for_review', + { + enabled: false, + streakThreshold: 3, + cooldownDays: 14, + }, +); + export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); diff --git a/packages/shared/src/lib/func.ts b/packages/shared/src/lib/func.ts index 61b37577547..8d318e4c7af 100644 --- a/packages/shared/src/lib/func.ts +++ b/packages/shared/src/lib/func.ts @@ -82,6 +82,9 @@ export const isAppleDevice = (): boolean => { export const isIOS = (): boolean => /iPhone|iPad/i.test(globalThis?.navigator.userAgent); +export const isAndroid = (): boolean => + /Android/i.test(globalThis?.navigator?.userAgent ?? ''); + export const isIOSNative = (): boolean => { const runtimeWindow = globalThis as unknown as RuntimeWindow; return ( diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts index a45ce4d6dbf..89782970886 100644 --- a/packages/shared/src/lib/image.ts +++ b/packages/shared/src/lib/image.ts @@ -275,6 +275,11 @@ export const clickbaitShieldModalImage = 'https://media.daily.dev/image/upload/s--GWqpMG8r--/f_auto/v1732802237/Streak_together_with_a_friend_1_1_pwoill'; export const cloudinaryGiftedPlusModalImage = `https://media.daily.dev/image/upload/s--JNm5gqXz--/f_auto/v1733838699/daily-dev-plus-gift_qosjrm`; + +// Placeholder for the ask-for-review confirm modal hero image. +// Engineering will replace this with a per-store image (Chrome Web Store, +// App Store, Play Store, etc.) keyed by ReviewDestinationId. +export const askForReviewPlaceholderImage = `https://media.daily.dev/image/upload/s--GWqpMG8r--/f_auto/v1732802237/Streak_together_with_a_friend_1_1_pwoill`; export const smallPostImage = (url: string): string => { if (!url) { return cloudinaryPostImageCoverPlaceholder; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index f9568acd49d..a7f48509f72 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -510,6 +510,7 @@ export enum TargetType { ResendVerificationCode = 'resend verification code', StreaksMilestone = 'streaks milestone', StreakRecover = 'streak restore', + AskForReview = 'ask for review', PromotionCard = 'promotion_card', PromotionalBanner = 'promotion_banner', MarketingCtaPopover = 'promotion_popover',