diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index ef63d4e726a..270e16f427e 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -27,6 +27,7 @@ import { import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BootPopups } from './modals/BootPopups'; import { StreakMilestonePopup } from './modals/streaks/StreakMilestonePopup'; +import { AskForReviewQAPanel } from './postReview/AskForReviewQAPanel'; import { useFeedName } from '../hooks/feed/useFeedName'; import { AuthTriggers } from '../lib/auth'; import PlusMobileEntryBanner from './marketing/banners/PlusMobileEntryBanner'; @@ -189,6 +190,7 @@ function MainLayoutComponent({ + {plusEntryAnnouncementBar && ( & { + onRequestClose?: LazyModalCommonProps['onRequestClose']; + destination: ReviewDestination; + streakValue?: number; +}; + +const platformIcons: Record = { + chrome_web_store: , + edge_addons: , + firefox_addons: , + app_store: ( + + + + ), + play_store: , + twitter_share: , +}; + +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'); + completeAction(ActionType.AskedForReviewComplete); + onRequestClose?.(undefined); + }, [log, completeAction, onRequestClose]); + + const handleMaybeLater = useCallback(() => { + log('confirm_dismiss'); + setDismissedAt(); + onRequestClose?.(undefined); + }, [log, onRequestClose]); + + return ( + + + +
+ + You're a power user 💪 + + + {`You're here every day! help other devs find us on ${destination.label}!`} + +
+ +
+
+
+
+ +
+ + + + + +
+ {STAR_INDICES.map((i) => ( + + ))} +
+ + {platformIcons[destination.id]} +
+
+ + + + + ); +}; + +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..8fdce1982bf 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,7 @@ export function BasePostContent({ /> )} + {children} {!!engagementProps && ( { + const router = useRouter(); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const params = new URLSearchParams(window.location.search); + const fromUrl = params.get(QA_QUERY_KEY); + if (fromUrl === '1') { + window.localStorage.setItem(`${QA_QUERY_KEY}-enabled`, '1'); + } + if (fromUrl === '0') { + window.localStorage.removeItem(`${QA_QUERY_KEY}-enabled`); + } + setEnabled(window.localStorage.getItem(`${QA_QUERY_KEY}-enabled`) === '1'); + }, [router?.asPath]); + + return enabled; +}; + +const StatusRow = ({ + label, + value, + good, +}: { + label: string; + value: string; + good?: boolean; +}): ReactElement => ( +
+ + {label} + + + {value} + +
+); + +export function AskForReviewQAPanel(): ReactElement | null { + const enabled = useQAEnabled(); + const [isCollapsed, setIsCollapsed] = useState(true); + const [override, setOverrideState] = useState( + null, + ); + const [, forceRefresh] = useState(0); + const { openModal } = useLazyModal(); + const { completeAction } = useActions(); + const visibility = useAskForReviewVisibility(); + + useEffect(() => { + if (!enabled) { + return; + } + setOverrideState(getQAOverride()); + }, [enabled]); + + if (isTesting || !enabled) { + return null; + } + + const refresh = () => { + forceRefresh((value) => value + 1); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(ASK_FOR_REVIEW_RESET_EVENT)); + } + }; + + const applyOverride = (next: AskForReviewQAOverride | null) => { + setQAOverride(next); + setOverrideState(next); + refresh(); + }; + + const startQAMode = (destinationId?: ReviewDestinationId) => { + applyOverride({ + enabled: true, + destinationId, + ignoreCompletedAction: true, + ignoreCooldown: true, + ignoreSession: true, + ignoreStreak: true, + }); + clearShownThisSession(); + }; + + const stopQAMode = () => { + applyOverride(null); + clearShownThisSession(); + clearDismissedAt(); + }; + + const platform = getReviewDestination(); + + return ( + + ); +} diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx new file mode 100644 index 00000000000..ab9188d02d8 --- /dev/null +++ b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import nock from 'nock'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { AskForReviewStripView } from './AskForReviewStrip'; +import type { ReviewDestination } from '../../lib/askForReview'; +import * as actionsHook from '../../hooks/useActions'; +import * as lazyModalHook from '../../hooks/useLazyModal'; +import { ActionType } from '../../graphql/actions'; +import { LazyModal } from '../modals/common/types'; +import { FeedbackCategory } from '../../graphql/feedback'; +import { + ASK_FOR_REVIEW_DISMISSED_KEY, + ASK_FOR_REVIEW_SESSION_KEY, +} from '../../lib/askForReview'; + +const CHROME_DEST: ReviewDestination = { + id: 'chrome_web_store', + label: 'Chrome Web Store', + href: 'https://example.test/chrome', +}; + +const completeAction = jest.fn(); +const openModal = jest.fn(); + +const renderStrip = ( + destination: ReviewDestination = CHROME_DEST, + streakValue = 3, +): void => { + 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('Are you enjoying daily.dev?')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Yes, I enjoy it' }), + ).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, I enjoy it' })); + 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', + }; + renderStrip(appStore); + fireEvent.click(screen.getByRole('button', { name: 'Yes, I enjoy it' })); + 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..59de7cda465 --- /dev/null +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -0,0 +1,214 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { MiniCloseIcon } from '../icons/MiniClose'; +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; + onAction?: (action: AskForReviewClickId) => void; + onClose?: () => void; +} + +const buildExtra = (data: Record): string => + JSON.stringify(data); + +export const AskForReviewStripView = ({ + destination, + streakValue, + variantEnabled = true, + streakThreshold, + cooldownDays, + 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 ( +
+
+
+
+ + Quick question + + + Are you enjoying daily.dev? + +
+ + {`You've read ${streakValue} days in a row. Your answer helps us decide what to ask next.`} + +
+ +
+ + +
+
+
+ ); +}; + +export const AskForReviewStrip = (): 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 8415f0da445..8b6d5457bd4 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -69,6 +69,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..16d877299a8 --- /dev/null +++ b/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx @@ -0,0 +1,162 @@ +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', +}; + +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..05f058ad942 --- /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 { + getDestinationById, + getQAOverride, + 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 qa = useMemo(() => getQAOverride(), []); + const platformDestination = useMemo(() => getReviewDestination(), []); + const destination = qa?.destinationId + ? getDestinationById(qa.destinationId) + : platformDestination; + const sessionShown = hasShownThisSession(); + const completedPermanent = checkHasCompleted( + ActionType.AskedForReviewComplete, + ); + + const baseGate = + isAuthReady && + isLoggedIn && + !!user?.id && + isActionsFetched && + loadedAlerts && + isStreaksEnabled && + (qa?.ignoreCompletedAction || !completedPermanent) && + (qa?.ignoreSession || !sessionShown) && + !alerts?.showStreakMilestone && + destination !== null; + + const { value: featureValue } = useConditionalFeature({ + feature: featureAskForReview, + shouldEvaluate: baseGate, + }); + + const variantEnabled = !!featureValue?.enabled || !!qa; + const streakThreshold = featureValue?.streakThreshold ?? 3; + const cooldownDays = featureValue?.cooldownDays ?? 14; + const streakValue = streak?.current ?? 0; + const streakPasses = qa?.ignoreStreak || streakValue >= streakThreshold; + const cooldownPasses = qa?.ignoreCooldown || !isCooldownActive(cooldownDays); + + const visible = Boolean( + baseGate && variantEnabled && streakPasses && cooldownPasses, + ); + + return { + visible, + destination, + streakValue, + variantEnabled, + streakThreshold, + cooldownDays, + isCompletedPermanent: completedPermanent, + isCooldownLive: isCooldownActive(cooldownDays), + 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..6453b1ac5b1 --- /dev/null +++ b/packages/shared/src/lib/askForReview.spec.ts @@ -0,0 +1,142 @@ +import { + ASK_FOR_REVIEW_DISMISSED_KEY, + ASK_FOR_REVIEW_SESSION_KEY, + clearDismissedAt, + 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'); + }); +}); + +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(); + }); + + it('clears the stored timestamp', () => { + setDismissedAt(); + clearDismissedAt(); + 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..9a8b539a55d --- /dev/null +++ b/packages/shared/src/lib/askForReview.ts @@ -0,0 +1,249 @@ +import { + appStoreReviewUrl, + chromeWebStoreReviewUrl, + edgeAddonsReviewUrl, + firefoxAddonsReviewUrl, + playStoreReviewUrl, + twitterShareReviewUrl, +} from './constants'; +import { + BrowserName, + getCurrentBrowserName, + isChromeExtension, + isExtension, + isFirefoxExtension, + isIOS, +} from './func'; + +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; +} + +const CHROME_DEST: ReviewDestination = { + id: 'chrome_web_store', + label: 'Chrome Web Store', + href: chromeWebStoreReviewUrl, +}; +const EDGE_DEST: ReviewDestination = { + id: 'edge_addons', + label: 'Edge Add-ons', + href: edgeAddonsReviewUrl, +}; +const FIREFOX_DEST: ReviewDestination = { + id: 'firefox_addons', + label: 'Firefox Add-ons', + href: firefoxAddonsReviewUrl, +}; +const APP_STORE_DEST: ReviewDestination = { + id: 'app_store', + label: 'App Store', + href: appStoreReviewUrl, +}; +const PLAY_STORE_DEST: ReviewDestination = { + id: 'play_store', + label: 'Play Store', + href: playStoreReviewUrl, +}; +const TWITTER_DEST: ReviewDestination = { + id: 'twitter_share', + label: 'X', + href: twitterShareReviewUrl, +}; + +const isAndroidUserAgent = (): boolean => + /Android/i.test(globalThis?.navigator?.userAgent ?? ''); + +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 (isAndroidUserAgent()) { + 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 clearDismissedAt = (): void => { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.removeItem(ASK_FOR_REVIEW_DISMISSED_KEY); + } catch { + // ignore + } +}; + +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 + } +}; + +export const clearShownThisSession = (): void => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.removeItem(ASK_FOR_REVIEW_SESSION_KEY); + } catch { + // ignore + } +}; + +export const ASK_FOR_REVIEW_QA_KEY = 'askForReview:qa'; + +export interface AskForReviewQAOverride { + enabled: boolean; + destinationId?: ReviewDestinationId; + ignoreCompletedAction?: boolean; + ignoreCooldown?: boolean; + ignoreSession?: boolean; + ignoreStreak?: boolean; +} + +export const getQAOverride = (): AskForReviewQAOverride | null => { + if (typeof window === 'undefined') { + return null; + } + try { + const raw = window.localStorage.getItem(ASK_FOR_REVIEW_QA_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as AskForReviewQAOverride; + return parsed?.enabled ? parsed : null; + } catch { + return null; + } +}; + +export const setQAOverride = ( + override: AskForReviewQAOverride | null, +): void => { + if (typeof window === 'undefined') { + return; + } + try { + if (!override) { + window.localStorage.removeItem(ASK_FOR_REVIEW_QA_KEY); + return; + } + window.localStorage.setItem( + ASK_FOR_REVIEW_QA_KEY, + JSON.stringify(override), + ); + } catch { + // ignore + } +}; + +const ALL_DESTINATIONS: Record = { + chrome_web_store: CHROME_DEST, + edge_addons: EDGE_DEST, + firefox_addons: FIREFOX_DEST, + app_store: APP_STORE_DEST, + play_store: PLAY_STORE_DEST, + twitter_share: TWITTER_DEST, +}; + +export const getDestinationById = ( + id: ReviewDestinationId, +): ReviewDestination => ALL_DESTINATIONS[id]; 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 22a284deab6..a94c2974ad3 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -202,3 +202,18 @@ 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, + }, +); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6aea0ad8fe2..5920b5d42e8 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -513,6 +513,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', diff --git a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx new file mode 100644 index 00000000000..d93f4dd9ec7 --- /dev/null +++ b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx @@ -0,0 +1,209 @@ +import React, { ReactElement, ReactNode, useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { AskForReviewStripView } from '@dailydotdev/shared/src/components/postReview/AskForReviewStrip'; +import type { ReviewDestination } from '@dailydotdev/shared/src/lib/askForReview'; +import { Button, ButtonSize, ButtonVariant } from '@dailydotdev/shared/src/components/buttons/Button'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const withProviders = (Story: () => ReactElement): ReactElement => ( + + + + + +); + +const DESTINATIONS: ReviewDestination[] = [ + { id: 'chrome_web_store', label: 'Chrome Web Store', href: '#chrome' }, + { id: 'edge_addons', label: 'Edge Add-ons', href: '#edge' }, + { id: 'firefox_addons', label: 'Firefox Add-ons', href: '#firefox' }, + { id: 'app_store', label: 'App Store', href: '#appstore' }, + { id: 'play_store', label: 'Play Store', href: '#playstore' }, + { id: 'twitter_share', label: 'X', href: '#twitter' }, +]; + +const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( +
{children}
+); + +const meta: Meta = { + title: 'Components/AskForReviewStrip', + component: AskForReviewStripView, + decorators: [withProviders], + parameters: { + layout: 'fullscreen', + controls: { expanded: true }, + }, + args: { + destination: DESTINATIONS[0], + streakValue: 3, + variantEnabled: true, + streakThreshold: 3, + cooldownDays: 14, + onAction: fn(), + onClose: fn(), + }, + argTypes: { + destination: { + control: 'select', + options: DESTINATIONS.map((d) => d.id), + mapping: Object.fromEntries(DESTINATIONS.map((d) => [d.id, d])), + }, + }, + render: (args) => ( + + + + ), +}; + +export default meta; +type Story = StoryObj; + +export const ChromeWebStore: Story = { + name: 'Default (routes to Chrome Web Store on Yes)', + args: { destination: DESTINATIONS[0] }, +}; + +export const EdgeAddons: Story = { + name: 'Edge Add-ons destination', + args: { destination: DESTINATIONS[1] }, +}; + +export const FirefoxAddons: Story = { + name: 'Firefox Add-ons destination', + args: { destination: DESTINATIONS[2] }, +}; + +export const AppStore: Story = { + name: 'App Store destination (iOS)', + args: { destination: DESTINATIONS[3] }, +}; + +export const PlayStore: Story = { + name: 'Play Store destination (Android)', + args: { destination: DESTINATIONS[4] }, +}; + +export const TwitterFallback: Story = { + name: 'X share fallback (unsupported browser)', + args: { destination: DESTINATIONS[5] }, +}; + +export const HighStreak: Story = { + name: 'Long streak copy', + args: { destination: DESTINATIONS[0], streakValue: 42 }, +}; + +export const DemoPanel: Story = { + name: 'Demo panel (all states, controls)', + render: () => { + const [destination, setDestination] = useState( + DESTINATIONS[0], + ); + const [streakValue, setStreakValue] = useState(3); + const [closed, setClosed] = useState(false); + + return ( +
+
+ + Destination: + + {DESTINATIONS.map((dest) => ( + + ))} + + Streak: + + {[3, 5, 7, 30].map((value) => ( + + ))} + +
+ + {closed ? ( +
+ Strip dismissed. Click Reset to show again. +
+ ) : ( + + setClosed(true)} + /> + + )} +
+ ); + }, +};