From dfac1cefc1334c3942ab06a45dc14da74f153cf6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 09:41:29 +0300 Subject: [PATCH 01/17] feat: streak-triggered ask-for-review strip Adds an inline strip at the top of the post modal that asks long-streak users for a store review (Yes routes to the right Chrome/Edge/Firefox/App Store/Play Store URL by platform, falls back to X share for Firefox/Safari desktop). No routes them into the existing Feedback modal with a UX-issue category. Step-1 dismissal triggers a 14-day cooldown loop; any engagement past step 1 is permanent. Threshold and rollout are GrowthBook-controlled via featureAskForReview. Covered by unit specs for the destination helper, visibility hook (including the cooldown loop), and the strip UI. Storybook DemoPanel exposes every state for design review. Co-authored-by: Cursor --- .../src/components/modals/FeedbackModal.tsx | 6 +- .../src/components/post/BasePostContent.tsx | 2 + .../postReview/AskForReviewStrip.spec.tsx | 148 ++++++++++ .../postReview/AskForReviewStrip.tsx | 252 ++++++++++++++++++ packages/shared/src/graphql/actions.ts | 1 + .../hooks/useAskForReviewVisibility.spec.tsx | 162 +++++++++++ .../src/hooks/useAskForReviewVisibility.ts | 74 +++++ packages/shared/src/lib/askForReview.spec.ts | 142 ++++++++++ packages/shared/src/lib/askForReview.ts | 178 +++++++++++++ packages/shared/src/lib/constants.ts | 10 + packages/shared/src/lib/featureManagement.ts | 15 ++ packages/shared/src/lib/log.ts | 1 + .../components/AskForReviewStrip.stories.tsx | 209 +++++++++++++++ 13 files changed, 1197 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx create mode 100644 packages/shared/src/components/postReview/AskForReviewStrip.tsx create mode 100644 packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx create mode 100644 packages/shared/src/hooks/useAskForReviewVisibility.ts create mode 100644 packages/shared/src/lib/askForReview.spec.ts create mode 100644 packages/shared/src/lib/askForReview.ts create mode 100644 packages/storybook/stories/components/AskForReviewStrip.stories.tsx 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/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 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 step 1 with explicit copy that mentions daily.dev', () => { + renderStrip(); + expect(screen.getByText('Enjoying daily.dev so far?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument(); +}); + +it('marks the session-shown flag on mount', () => { + renderStrip(); + expect(window.sessionStorage.getItem(ASK_FOR_REVIEW_SESSION_KEY)).toBe('1'); +}); + +it('advances to the review step when user clicks Yes', () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + expect( + screen.getByText('Awesome! Leave a quick Chrome Web Store review'), + ).toBeInTheDocument(); + const cta = screen.getByRole('link', { name: 'Leave a review' }); + expect(cta).toHaveAttribute('href', CHROME_DEST.href); + expect(cta).toHaveAttribute('target', '_blank'); +}); + +it('marks the action complete when user clicks Leave a review', async () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + fireEvent.click(screen.getByRole('link', { name: 'Leave a review' })); + await waitFor(() => { + expect(completeAction).toHaveBeenCalledWith( + ActionType.AskedForReviewComplete, + ); + }); +}); + +it('opens the Feedback modal with UxIssue category and marks complete on No', async () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'No' })); + 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 step 1 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('marks the action complete when user dismisses step 2', async () => { + renderStrip(); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss review prompt' }), + ); + await waitFor(() => { + expect(completeAction).toHaveBeenCalledWith( + ActionType.AskedForReviewComplete, + ); + }); +}); + +it('renders the destination label dynamically (App Store)', () => { + renderStrip({ + id: 'app_store', + label: 'App Store', + href: 'https://example.test/app-store', + }); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + expect( + screen.getByText('Awesome! Leave a quick App Store review'), + ).toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx new file mode 100644 index 00000000000..da90e9c9eb1 --- /dev/null +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -0,0 +1,252 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { StarIcon } from '../icons/Star'; +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'; + +type Step = 'enjoy' | 'review'; +type AskForReviewClickId = + | 'enjoy_yes' + | 'enjoy_no' + | 'leave_review' + | 'dismiss_step1' + | 'dismiss_step2'; + +const STAR_INDICES = [0, 1, 2, 3, 4] as const; + +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 [step, setStep] = useState('enjoy'); + + 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, currentStep: Step) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.AskForReview, + target_id: clickId, + extra: buildExtra({ + platform: destination.id, + streak: streakValue, + step: currentStep, + variant_enabled: variantEnabled, + }), + }); + }, + [logEvent, destination.id, streakValue, variantEnabled], + ); + + const onYes = () => { + log('enjoy_yes', 'enjoy'); + onAction?.('enjoy_yes'); + setStep('review'); + }; + + const onNo = () => { + log('enjoy_no', 'enjoy'); + completeAction(ActionType.AskedForReviewComplete); + openModal({ + type: LazyModal.Feedback, + props: { defaultCategory: FeedbackCategory.UxIssue }, + }); + onAction?.('enjoy_no'); + onClose?.(); + }; + + const onLeaveReview = () => { + log('leave_review', 'review'); + completeAction(ActionType.AskedForReviewComplete); + onAction?.('leave_review'); + onClose?.(); + }; + + const onDismissStep1 = () => { + log('dismiss_step1', 'enjoy'); + setDismissedAt(); + onAction?.('dismiss_step1'); + onClose?.(); + }; + + const onDismissStep2 = () => { + log('dismiss_step2', 'review'); + completeAction(ActionType.AskedForReviewComplete); + onAction?.('dismiss_step2'); + onClose?.(); + }; + + const isReviewStep = step === 'review'; + + return ( +
+
+
+ {STAR_INDICES.map((i) => ( + + ))} +
+ +
+ + {isReviewStep + ? `Awesome! Leave a quick ${destination.label} review` + : 'Enjoying daily.dev so far?'} + + + {isReviewStep + ? 'It takes 30 seconds and helps other developers find us.' + : `You've read ${streakValue} days in a row \u2014 we'd love your honest take.`} + +
+ +
+ {isReviewStep ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); +}; + +export const AskForReviewStrip = (): ReactElement | null => { + const { + visible, + destination, + streakValue, + variantEnabled, + streakThreshold, + cooldownDays, + } = useAskForReviewVisibility(); + const [closed, setClosed] = useState(false); + + 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..417d1aabb39 --- /dev/null +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -0,0 +1,74 @@ +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; +} + +export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { + const { user, isAuthReady, isLoggedIn } = useAuthContext(); + const { alerts, loadedAlerts } = useAlertsContext(); + const { checkHasCompleted, isActionsFetched } = useActions(); + const { streak, isStreaksEnabled } = useReadingStreak(); + + const destination = useMemo(() => getReviewDestination(), []); + const sessionShown = hasShownThisSession(); + const completedPermanent = checkHasCompleted( + ActionType.AskedForReviewComplete, + ); + + 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 ?? 3; + const cooldownDays = featureValue?.cooldownDays ?? 14; + const streakValue = streak?.current ?? 0; + + const visible = Boolean( + baseGate && + variantEnabled && + streakValue >= streakThreshold && + !isCooldownActive(cooldownDays), + ); + + return { + visible, + destination, + streakValue, + variantEnabled, + streakThreshold, + cooldownDays, + }; +}; 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..d266f006ee8 --- /dev/null +++ b/packages/shared/src/lib/askForReview.ts @@ -0,0 +1,178 @@ +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 + } +}; 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 403b59cad6e..c6202a8a82b 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', true); + +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..02f01230ed4 --- /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: 'Step 1 \u2014 default (Chrome Web Store on Yes)', + args: { destination: DESTINATIONS[0] }, +}; + +export const EdgeAddons: Story = { + name: 'Step 1 \u2014 Edge Add-ons on Yes', + args: { destination: DESTINATIONS[1] }, +}; + +export const FirefoxAddons: Story = { + name: 'Step 1 \u2014 Firefox Add-ons on Yes', + args: { destination: DESTINATIONS[2] }, +}; + +export const AppStore: Story = { + name: 'Step 1 \u2014 App Store on Yes (iOS)', + args: { destination: DESTINATIONS[3] }, +}; + +export const PlayStore: Story = { + name: 'Step 1 \u2014 Play Store on Yes (Android)', + args: { destination: DESTINATIONS[4] }, +}; + +export const TwitterFallback: Story = { + name: 'Step 1 \u2014 X share fallback (Firefox/Safari)', + args: { destination: DESTINATIONS[5] }, +}; + +export const HighStreak: Story = { + name: 'Step 1 \u2014 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)} + /> + + )} +
+ ); + }, +}; From 384569d94b20b7fab13ec991ff4325296fff5aff Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 10:35:34 +0300 Subject: [PATCH 02/17] chore: retrigger CI Co-authored-by: Cursor From 81e93963c8641ce1c5808691ec49b3da18ca8975 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 11:13:20 +0300 Subject: [PATCH 03/17] feat: add in-app QA panel for ask-for-review strip Adds a floating QA panel mounted in MainLayout, gated on ?ask-for-review-qa=1, that bypasses all visibility gates and lets you trigger the real strip + real handlers on any post page. Includes state inspector, destination override, session/cooldown reset, and manual Feedback modal/action-complete triggers. Co-authored-by: Cursor --- packages/shared/src/components/MainLayout.tsx | 2 + .../postReview/AskForReviewQAPanel.tsx | 349 ++++++++++++++++++ .../postReview/AskForReviewStrip.tsx | 13 + .../src/hooks/useAskForReviewVisibility.ts | 31 +- packages/shared/src/lib/askForReview.ts | 71 ++++ 5 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/components/postReview/AskForReviewQAPanel.tsx 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 && ( { + 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.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx index da90e9c9eb1..496f1c68e9f 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -22,6 +22,8 @@ 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 Step = 'enjoy' | 'review'; type AskForReviewClickId = | 'enjoy_yes' @@ -235,6 +237,17 @@ export const AskForReviewStrip = (): ReactElement | null => { } = 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; } diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts index 417d1aabb39..05f058ad942 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.ts +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -8,6 +8,8 @@ import { featureAskForReview } from '../lib/featureManagement'; import { ActionType } from '../graphql/actions'; import type { ReviewDestination } from '../lib/askForReview'; import { + getDestinationById, + getQAOverride, getReviewDestination, hasShownThisSession, isCooldownActive, @@ -20,6 +22,11 @@ interface UseAskForReviewVisibility { variantEnabled: boolean; streakThreshold: number; cooldownDays: number; + isCompletedPermanent: boolean; + isCooldownLive: boolean; + isSessionShown: boolean; + isStreaksEnabled: boolean; + platformDestination: ReviewDestination | null; } export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { @@ -28,7 +35,11 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { const { checkHasCompleted, isActionsFetched } = useActions(); const { streak, isStreaksEnabled } = useReadingStreak(); - const destination = useMemo(() => getReviewDestination(), []); + const qa = useMemo(() => getQAOverride(), []); + const platformDestination = useMemo(() => getReviewDestination(), []); + const destination = qa?.destinationId + ? getDestinationById(qa.destinationId) + : platformDestination; const sessionShown = hasShownThisSession(); const completedPermanent = checkHasCompleted( ActionType.AskedForReviewComplete, @@ -41,8 +52,8 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { isActionsFetched && loadedAlerts && isStreaksEnabled && - !completedPermanent && - !sessionShown && + (qa?.ignoreCompletedAction || !completedPermanent) && + (qa?.ignoreSession || !sessionShown) && !alerts?.showStreakMilestone && destination !== null; @@ -51,16 +62,15 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { shouldEvaluate: baseGate, }); - const variantEnabled = !!featureValue?.enabled; + 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 && - streakValue >= streakThreshold && - !isCooldownActive(cooldownDays), + baseGate && variantEnabled && streakPasses && cooldownPasses, ); return { @@ -70,5 +80,10 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { variantEnabled, streakThreshold, cooldownDays, + isCompletedPermanent: completedPermanent, + isCooldownLive: isCooldownActive(cooldownDays), + isSessionShown: sessionShown, + isStreaksEnabled: !!isStreaksEnabled, + platformDestination, }; }; diff --git a/packages/shared/src/lib/askForReview.ts b/packages/shared/src/lib/askForReview.ts index d266f006ee8..9a8b539a55d 100644 --- a/packages/shared/src/lib/askForReview.ts +++ b/packages/shared/src/lib/askForReview.ts @@ -176,3 +176,74 @@ export const markShownThisSession = (): void => { // 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]; From 8c89c98d72efa1c451f7b1d835bf8f81cd16a52a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 11:42:47 +0300 Subject: [PATCH 04/17] feat: float ask-for-review strip above modal and route Yes to centered modal - Portal the strip to document.body with fixed top + z-max so it sits above the post modal, navigation, and X button - Simplify to a single step (Yes / No / dismiss); fill stars in yellow - Yes now opens a centered AskForReviewConfirmModal (star cover image, production-style review CTA) instead of a second inline step - No still opens the prefilled Feedback modal - Update tests + Storybook story labels to match the new flow Co-authored-by: Cursor --- .../modals/AskForReviewConfirmModal.tsx | 125 ++++++++++++++++ .../shared/src/components/modals/common.tsx | 8 ++ .../src/components/modals/common/types.ts | 1 + .../postReview/AskForReviewStrip.spec.tsx | 55 +++---- .../postReview/AskForReviewStrip.tsx | 134 ++++++------------ .../components/AskForReviewStrip.stories.tsx | 14 +- 6 files changed, 205 insertions(+), 132 deletions(-) create mode 100644 packages/shared/src/components/modals/AskForReviewConfirmModal.tsx diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx new file mode 100644 index 00000000000..90442dd8ab3 --- /dev/null +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -0,0 +1,125 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import type { LazyModalCommonProps, ModalProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { ModalSize } from './common/types'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { StarIcon } from '../icons/Star'; +import { IconSize } from '../Icon'; +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'; + +const STAR_INDICES = [0, 1, 2, 3, 4] as const; + +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'); + completeAction(ActionType.AskedForReviewComplete); + onRequestClose?.(undefined); + }, [log, completeAction, onRequestClose]); + + const handleMaybeLater = useCallback(() => { + log('confirm_dismiss'); + setDismissedAt(); + onRequestClose?.(undefined); + }, [log, onRequestClose]); + + return ( + + +
+ {STAR_INDICES.map((i) => ( + + ))} +
+ +
+ + Mind leaving a quick review? + + + {`It takes 30 seconds and helps other developers discover daily.dev on ${destination.label}.`} + +
+ +
+ + +
+
+
+ ); +}; + +export default AskForReviewConfirmModal; 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/postReview/AskForReviewStrip.spec.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx index a822cf4cb8e..f8ee9d946af 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx @@ -63,7 +63,7 @@ beforeEach(() => { .reply(200, { data: {} }); }); -it('renders step 1 with explicit copy that mentions daily.dev', () => { +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(); @@ -75,26 +75,16 @@ it('marks the session-shown flag on mount', () => { expect(window.sessionStorage.getItem(ASK_FOR_REVIEW_SESSION_KEY)).toBe('1'); }); -it('advances to the review step when user clicks Yes', () => { +it('opens the AskForReviewConfirm modal with destination + streak when user clicks Yes', async () => { renderStrip(); fireEvent.click(screen.getByRole('button', { name: 'Yes' })); - expect( - screen.getByText('Awesome! Leave a quick Chrome Web Store review'), - ).toBeInTheDocument(); - const cta = screen.getByRole('link', { name: 'Leave a review' }); - expect(cta).toHaveAttribute('href', CHROME_DEST.href); - expect(cta).toHaveAttribute('target', '_blank'); -}); - -it('marks the action complete when user clicks Leave a review', async () => { - renderStrip(); - fireEvent.click(screen.getByRole('button', { name: 'Yes' })); - fireEvent.click(screen.getByRole('link', { name: 'Leave a review' })); await waitFor(() => { - expect(completeAction).toHaveBeenCalledWith( - ActionType.AskedForReviewComplete, - ); + 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 () => { @@ -111,7 +101,7 @@ it('opens the Feedback modal with UxIssue category and marks complete on No', as }); }); -it('writes the dismissed-at timestamp when user dismisses step 1 without engaging', () => { +it('writes the dismissed-at timestamp when user dismisses without engaging', () => { renderStrip(); fireEvent.click( screen.getByRole('button', { name: 'Dismiss review prompt' }), @@ -122,27 +112,18 @@ it('writes the dismissed-at timestamp when user dismisses step 1 without engagin ).not.toBeNull(); }); -it('marks the action complete when user dismisses step 2', async () => { - renderStrip(); - fireEvent.click(screen.getByRole('button', { name: 'Yes' })); - fireEvent.click( - screen.getByRole('button', { name: 'Dismiss review prompt' }), - ); - await waitFor(() => { - expect(completeAction).toHaveBeenCalledWith( - ActionType.AskedForReviewComplete, - ); - }); -}); - -it('renders the destination label dynamically (App Store)', () => { - renderStrip({ +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' })); - expect( - screen.getByText('Awesome! Leave a quick App Store review'), - ).toBeInTheDocument(); + 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 index 496f1c68e9f..67364b4aeb4 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; -import classNames from 'classnames'; +import { createPortal } from 'react-dom'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { Typography, @@ -24,13 +24,7 @@ import { useAskForReviewVisibility } from '../../hooks/useAskForReviewVisibility export const ASK_FOR_REVIEW_RESET_EVENT = 'askForReview:reset'; -type Step = 'enjoy' | 'review'; -type AskForReviewClickId = - | 'enjoy_yes' - | 'enjoy_no' - | 'leave_review' - | 'dismiss_step1' - | 'dismiss_step2'; +type AskForReviewClickId = 'enjoy_yes' | 'enjoy_no' | 'dismiss_strip'; const STAR_INDICES = [0, 1, 2, 3, 4] as const; @@ -59,7 +53,6 @@ export const AskForReviewStripView = ({ const { completeAction } = useActions(); const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); - const [step, setStep] = useState('enjoy'); const extraPayload = buildExtra({ platform: destination.id, @@ -81,7 +74,7 @@ export const AskForReviewStripView = ({ }, []); const log = useCallback( - (clickId: AskForReviewClickId, currentStep: Step) => { + (clickId: AskForReviewClickId) => { logEvent({ event_name: LogEvent.Click, target_type: TargetType.AskForReview, @@ -89,7 +82,7 @@ export const AskForReviewStripView = ({ extra: buildExtra({ platform: destination.id, streak: streakValue, - step: currentStep, + step: 'enjoy', variant_enabled: variantEnabled, }), }); @@ -98,13 +91,17 @@ export const AskForReviewStripView = ({ ); const onYes = () => { - log('enjoy_yes', 'enjoy'); + log('enjoy_yes'); + openModal({ + type: LazyModal.AskForReviewConfirm, + props: { destination, streakValue }, + }); onAction?.('enjoy_yes'); - setStep('review'); + onClose?.(); }; const onNo = () => { - log('enjoy_no', 'enjoy'); + log('enjoy_no'); completeAction(ActionType.AskedForReviewComplete); openModal({ type: LazyModal.Feedback, @@ -114,37 +111,16 @@ export const AskForReviewStripView = ({ onClose?.(); }; - const onLeaveReview = () => { - log('leave_review', 'review'); - completeAction(ActionType.AskedForReviewComplete); - onAction?.('leave_review'); - onClose?.(); - }; - - const onDismissStep1 = () => { - log('dismiss_step1', 'enjoy'); + const onDismiss = () => { + log('dismiss_strip'); setDismissedAt(); - onAction?.('dismiss_step1'); - onClose?.(); - }; - - const onDismissStep2 = () => { - log('dismiss_step2', 'review'); - completeAction(ActionType.AskedForReviewComplete); - onAction?.('dismiss_step2'); + onAction?.('dismiss_strip'); onClose?.(); }; - const isReviewStep = step === 'review'; - return (
@@ -152,73 +128,49 @@ export const AskForReviewStripView = ({ {STAR_INDICES.map((i) => ( ))}
- {isReviewStep - ? `Awesome! Leave a quick ${destination.label} review` - : 'Enjoying daily.dev so far?'} + Enjoying daily.dev so far? - {isReviewStep - ? 'It takes 30 seconds and helps other developers find us.' - : `You've read ${streakValue} days in a row \u2014 we'd love your honest take.`} + {`You've read ${streakValue} days in a row \u2014 we'd love your honest take.`}
- {isReviewStep ? ( - - ) : ( - <> - - - - )} + +
@@ -236,6 +188,11 @@ export const AskForReviewStrip = (): ReactElement | null => { cooldownDays, } = useAskForReviewVisibility(); const [closed, setClosed] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); useEffect(() => { if (typeof window === 'undefined') { @@ -248,11 +205,11 @@ export const AskForReviewStrip = (): ReactElement | null => { }; }, []); - if (!visible || !destination || closed) { + if (!mounted || !visible || !destination || closed) { return null; } - return ( + return createPortal( { streakThreshold={streakThreshold} cooldownDays={cooldownDays} onClose={() => setClosed(true)} - /> + />, + document.body, ); }; diff --git a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx index 02f01230ed4..d93f4dd9ec7 100644 --- a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx +++ b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx @@ -86,37 +86,37 @@ export default meta; type Story = StoryObj; export const ChromeWebStore: Story = { - name: 'Step 1 \u2014 default (Chrome Web Store on Yes)', + name: 'Default (routes to Chrome Web Store on Yes)', args: { destination: DESTINATIONS[0] }, }; export const EdgeAddons: Story = { - name: 'Step 1 \u2014 Edge Add-ons on Yes', + name: 'Edge Add-ons destination', args: { destination: DESTINATIONS[1] }, }; export const FirefoxAddons: Story = { - name: 'Step 1 \u2014 Firefox Add-ons on Yes', + name: 'Firefox Add-ons destination', args: { destination: DESTINATIONS[2] }, }; export const AppStore: Story = { - name: 'Step 1 \u2014 App Store on Yes (iOS)', + name: 'App Store destination (iOS)', args: { destination: DESTINATIONS[3] }, }; export const PlayStore: Story = { - name: 'Step 1 \u2014 Play Store on Yes (Android)', + name: 'Play Store destination (Android)', args: { destination: DESTINATIONS[4] }, }; export const TwitterFallback: Story = { - name: 'Step 1 \u2014 X share fallback (Firefox/Safari)', + name: 'X share fallback (unsupported browser)', args: { destination: DESTINATIONS[5] }, }; export const HighStreak: Story = { - name: 'Step 1 \u2014 long streak copy', + name: 'Long streak copy', args: { destination: DESTINATIONS[0], streakValue: 42 }, }; From e8858c8fb28b3ce3f651c6b1801b3d8ae81dc292 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 12:41:17 +0300 Subject: [PATCH 05/17] fix: align ask-for-review prompt with production modal patterns Use the shared cover-image success modal for the review ask and tighten the floating strip so it reads as part of the post modal instead of a full-width page banner. Co-authored-by: Cursor --- .../modals/AskForReviewConfirmModal.tsx | 90 ++++++------------- .../postReview/AskForReviewStrip.tsx | 4 +- 2 files changed, 28 insertions(+), 66 deletions(-) diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx index 90442dd8ab3..b3f9e3e7947 100644 --- a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -1,16 +1,8 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; import type { LazyModalCommonProps, ModalProps } from './common/Modal'; -import { Modal } from './common/Modal'; -import { ModalSize } from './common/types'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; -import { StarIcon } from '../icons/Star'; -import { IconSize } from '../Icon'; +import { ActionSuccessModal } from './utils/ActionSuccessModal'; +import { ButtonVariant } from '../buttons/Button'; import { useActions } from '../../hooks/useActions'; import { ActionType } from '../../graphql/actions'; import { useLogContext } from '../../contexts/LogContext'; @@ -18,7 +10,8 @@ import { LogEvent, TargetType } from '../../lib/log'; import type { ReviewDestination } from '../../lib/askForReview'; import { setDismissedAt } from '../../lib/askForReview'; -const STAR_INDICES = [0, 1, 2, 3, 4] as const; +const reviewPromptCover = + 'https://media.daily.dev/image/upload/s--44oMC43t--/f_auto/v1744094774/public/Rating'; type AskForReviewConfirmModalProps = Omit & { onRequestClose?: LazyModalCommonProps['onRequestClose']; @@ -64,61 +57,30 @@ const AskForReviewConfirmModal = ({ }, [log, onRequestClose]); return ( - - -
- {STAR_INDICES.map((i) => ( - - ))} -
- -
- - Mind leaving a quick review? - - - {`It takes 30 seconds and helps other developers discover daily.dev on ${destination.label}.`} - -
- -
- - -
-
-
+ content={{ + cover: reviewPromptCover, + title: 'Would you leave a quick review?', + description: `Honest reviews help other developers discover daily.dev on ${destination.label} β€” it takes 30 seconds.`, + }} + cta={{ + copy: `Leave a ${destination.label} review`, + tag: 'a', + href: destination.href, + target: '_blank', + rel: 'noopener', + onClick: handleLeaveReview, + }} + secondaryCta={{ + copy: 'Maybe later', + onClick: handleMaybeLater, + }} + modalCloseButtonProps={{ + variant: ButtonVariant.Tertiary, + }} + /> ); }; diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx index 67364b4aeb4..2d40e61a656 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -120,10 +120,10 @@ export const AskForReviewStripView = ({ return (
-
+
{STAR_INDICES.map((i) => ( Date: Wed, 20 May 2026 13:58:13 +0300 Subject: [PATCH 06/17] fix: match ask-for-review popup design Replace the generic cover-image review modal with the compact dark review card that mirrors the production ask, including generated rating artwork and destination-specific platform icon. Co-authored-by: Cursor --- .../modals/AskForReviewConfirmModal.tsx | 132 ++++++++++++++---- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx index b3f9e3e7947..b25388f5ce8 100644 --- a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -1,17 +1,36 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; import type { LazyModalCommonProps, ModalProps } from './common/Modal'; -import { ActionSuccessModal } from './utils/ActionSuccessModal'; -import { ButtonVariant } from '../buttons/Button'; +import { Modal } from './common/Modal'; +import { ModalClose } from './common/ModalClose'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { + AppleIcon, + BrowserGroupIcon, + ChromeIcon, + DailyIcon, + EdgeIcon, + GoogleIcon, + StarIcon, + TwitterIcon, +} from '../icons'; +import { IconSize } from '../Icon'; 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 type { + ReviewDestination, + ReviewDestinationId, +} from '../../lib/askForReview'; import { setDismissedAt } from '../../lib/askForReview'; -const reviewPromptCover = - 'https://media.daily.dev/image/upload/s--44oMC43t--/f_auto/v1744094774/public/Rating'; +const STAR_INDICES = [0, 1, 2, 3, 4] as const; type AskForReviewConfirmModalProps = Omit & { onRequestClose?: LazyModalCommonProps['onRequestClose']; @@ -19,6 +38,19 @@ type AskForReviewConfirmModalProps = Omit & { streakValue?: number; }; +const platformIcons: Record = { + chrome_web_store: , + edge_addons: , + firefox_addons: , + app_store: ( + + + + ), + play_store: , + twitter_share: , +}; + const AskForReviewConfirmModal = ({ onRequestClose, destination, @@ -57,30 +89,76 @@ const AskForReviewConfirmModal = ({ }, [log, onRequestClose]); return ( - + size={Modal.Size.Small} + > + + +
+ + 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]} +
+
+ + + + ); }; From 3f04241362ed886a5c849f9494d2db21c363ce2b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 15:05:06 +0300 Subject: [PATCH 07/17] fix: render ask-for-review strip inline Make the review question participate in the post layout so it pushes modal and page content down instead of covering navigation, and clarify the Yes/No actions with stronger visual affordance. Co-authored-by: Cursor --- .../postReview/AskForReviewStrip.spec.tsx | 16 +++-- .../postReview/AskForReviewStrip.tsx | 65 ++++++++----------- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx index f8ee9d946af..ab9188d02d8 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx @@ -65,9 +65,13 @@ beforeEach(() => { 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: 'No' })).toBeInTheDocument(); + 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', () => { @@ -77,7 +81,7 @@ it('marks the session-shown flag on mount', () => { it('opens the AskForReviewConfirm modal with destination + streak when user clicks Yes', async () => { renderStrip(); - fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, I enjoy it' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.AskForReviewConfirm, @@ -89,7 +93,7 @@ it('opens the AskForReviewConfirm modal with destination + streak when user clic it('opens the Feedback modal with UxIssue category and marks complete on No', async () => { renderStrip(); - fireEvent.click(screen.getByRole('button', { name: 'No' })); + fireEvent.click(screen.getByRole('button', { name: 'Not really' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.Feedback, @@ -119,7 +123,7 @@ it('passes the destination through to the confirm modal (App Store)', async () = href: 'https://example.test/app-store', }; renderStrip(appStore); - fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, I enjoy it' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.AskForReviewConfirm, diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx index 2d40e61a656..59de7cda465 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -1,13 +1,16 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import { Typography, TypographyColor, TypographyType, } from '../typography/Typography'; -import { StarIcon } from '../icons/Star'; import { MiniCloseIcon } from '../icons/MiniClose'; import { IconSize } from '../Icon'; import { useActions } from '../../hooks/useActions'; @@ -26,8 +29,6 @@ export const ASK_FOR_REVIEW_RESET_EVENT = 'askForReview:reset'; type AskForReviewClickId = 'enjoy_yes' | 'enjoy_no' | 'dismiss_strip'; -const STAR_INDICES = [0, 1, 2, 3, 4] as const; - interface AskForReviewStripBaseProps { destination: ReviewDestination; streakValue: number; @@ -120,49 +121,45 @@ export const AskForReviewStripView = ({ return (
-
-
- {STAR_INDICES.map((i) => ( - - ))} -
- -
- - Enjoying daily.dev so far? - +
+
+
+ + Quick question + + + Are you enjoying daily.dev? + +
- {`You've read ${streakValue} days in a row \u2014 we'd love your honest take.`} + {`You've read ${streakValue} days in a row. Your answer helps us decide what to ask next.`}
-
-
+ ); }; -export const AskForReviewStrip = (): ReactElement | null => { +export const AskForReviewStrip = ({ + className, +}: { + className?: string; +}): ReactElement | null => { const { visible, destination, @@ -208,6 +249,7 @@ export const AskForReviewStrip = (): ReactElement | null => { variantEnabled={variantEnabled} streakThreshold={streakThreshold} cooldownDays={cooldownDays} + className={className} onClose={() => setClosed(true)} /> ); diff --git a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx index d93f4dd9ec7..b655112923a 100644 --- a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx +++ b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx @@ -48,7 +48,7 @@ const DESTINATIONS: ReviewDestination[] = [ ]; const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( -
{children}
+
{children}
); const meta: Meta = { diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 752f690bead..b90e7a5df46 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -50,6 +50,7 @@ import { useLegacyPostLayoutOptOut } from '@dailydotdev/shared/src/components/po import { useReaderModalEligibility } from '@dailydotdev/shared/src/components/post/reader/hooks/useReaderModalEligibility'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; +import { AskForReviewStrip } from '@dailydotdev/shared/src/components/postReview/AskForReviewStrip'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -313,6 +314,7 @@ export const PostPage = ({ + {shouldUseReaderLayout ? ( Date: Mon, 25 May 2026 11:23:16 +0300 Subject: [PATCH 09/17] chore(ask-for-review): add force-show URL param for review Add a ?force-ask-for-review=1 query param that bypasses all gates (auth, streak, feature flag, cooldown, session, destination) so reviewers can see and visually QA the strip on any post page or in the post modal without setting up a real streak or flipping the GrowthBook flag. Falls back to Chrome Web Store as the destination when the platform has no real one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/hooks/useAskForReviewVisibility.ts | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts index 05f058ad942..171f377bbab 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.ts +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -15,6 +15,20 @@ import { isCooldownActive, } from '../lib/askForReview'; +const FORCE_SHOW_QUERY_KEY = 'force-ask-for-review'; + +const getForceShow = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + try { + const params = new URLSearchParams(window.location.search); + return params.get(FORCE_SHOW_QUERY_KEY) === '1'; + } catch { + return false; + } +}; + interface UseAskForReviewVisibility { visible: boolean; destination: ReviewDestination | null; @@ -35,11 +49,19 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { const { checkHasCompleted, isActionsFetched } = useActions(); const { streak, isStreaksEnabled } = useReadingStreak(); + const forceShow = useMemo(() => getForceShow(), []); const qa = useMemo(() => getQAOverride(), []); const platformDestination = useMemo(() => getReviewDestination(), []); - const destination = qa?.destinationId - ? getDestinationById(qa.destinationId) - : platformDestination; + let destination: ReviewDestination | null; + if (qa?.destinationId) { + destination = getDestinationById(qa.destinationId); + } else if (platformDestination) { + destination = platformDestination; + } else if (forceShow) { + destination = getDestinationById('chrome_web_store'); + } else { + destination = null; + } const sessionShown = hasShownThisSession(); const completedPermanent = checkHasCompleted( ActionType.AskedForReviewComplete, @@ -69,14 +91,14 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { const streakPasses = qa?.ignoreStreak || streakValue >= streakThreshold; const cooldownPasses = qa?.ignoreCooldown || !isCooldownActive(cooldownDays); - const visible = Boolean( - baseGate && variantEnabled && streakPasses && cooldownPasses, - ); + const visible = + forceShow || + Boolean(baseGate && variantEnabled && streakPasses && cooldownPasses); return { visible, destination, - streakValue, + streakValue: forceShow && streakValue === 0 ? 3 : streakValue, variantEnabled, streakThreshold, cooldownDays, From 8b8b8448b73f15ab427962e604349444fe1b15fa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 11:30:58 +0300 Subject: [PATCH 10/17] fix(ask-for-review): restore in-content placement so the strip actually renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the portal-into-overlay approach β€” it was the right intent (sit physically above the modal box) but in practice the strip wasn't rendering for reviewers. Put it back in BasePostContent where it mounts reliably, and let the redesigned card visuals (rounded border, surface-float background, shadow, and the new copy/contrast) carry the "this is its own thing, not part of the article" feeling instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/modals/BasePostModal.tsx | 16 +--------------- .../src/components/post/BasePostContent.tsx | 2 ++ .../components/postReview/AskForReviewStrip.tsx | 2 +- packages/webapp/pages/posts/[id]/index.tsx | 2 -- 4 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/components/modals/BasePostModal.tsx b/packages/shared/src/components/modals/BasePostModal.tsx index ee30aaf3e24..03b99ab6efa 100644 --- a/packages/shared/src/components/modals/BasePostModal.tsx +++ b/packages/shared/src/components/modals/BasePostModal.tsx @@ -1,9 +1,8 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useState, useCallback } from 'react'; -import { createPortal } from 'react-dom'; import classNames from 'classnames'; import type { ModalProps } from './common/Modal'; -import { Modal, modalSizeToClassName } from './common/Modal'; +import { Modal } from './common/Modal'; import styles from './BasePostModal.module.css'; import PostLoadingSkeleton from '../post/PostLoadingSkeleton'; import type { Post, PostType } from '../../graphql/posts'; @@ -18,7 +17,6 @@ import { useLogContext } from '../../contexts/LogContext'; import { useEventListener } from '../../hooks'; import useDebounceFn from '../../hooks/useDebounceFn'; import { useEngagementAdsContext } from '../../contexts/EngagementAdsContext'; -import { AskForReviewStrip } from '../postReview/AskForReviewStrip'; interface BasePostModalProps extends ModalProps { postType: PostType; @@ -85,8 +83,6 @@ function BasePostModal({ const [debouncedOnScroll] = useDebounceFn(onScroll, 100); useEventListener(scrollNode, 'scroll', debouncedOnScroll); - const stripWidthClassName = modalSizeToClassName[size] ?? ''; - return ( - {scrollNode && - createPortal( - , - scrollNode, - )} import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -61,6 +62,7 @@ export function BasePostContent({ /> )} + {children} {!!engagementProps && ( diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index b90e7a5df46..752f690bead 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -50,7 +50,6 @@ import { useLegacyPostLayoutOptOut } from '@dailydotdev/shared/src/components/po import { useReaderModalEligibility } from '@dailydotdev/shared/src/components/post/reader/hooks/useReaderModalEligibility'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; -import { AskForReviewStrip } from '@dailydotdev/shared/src/components/postReview/AskForReviewStrip'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -314,7 +313,6 @@ export const PostPage = ({ - {shouldUseReaderLayout ? ( Date: Mon, 25 May 2026 11:38:31 +0300 Subject: [PATCH 11/17] fix(ask-for-review): lighter compact strip, cabbage green, feedback icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim the strip to feel less content-heavy: - One short question + a small streak/CTA line, no separate "Quick check-in" label or oversized streak pill. - Smaller padding (p-3), rounded-12, shadow-1, smaller 9px avatar circle, ButtonSize.Small actions. - Swap the orange bun palette for cabbage-subtler / cabbage-default β€” the streak flame icon is replaced with FeedbackIcon since the strip is about feedback, not the streak itself. Also persist the force-show flag in localStorage so ?force-ask-for-review=1 survives navigation from the feed into the article modal (and `?force-ask-for-review=0` clears it). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../postReview/AskForReviewStrip.spec.tsx | 16 ++-- .../postReview/AskForReviewStrip.tsx | 82 ++++++++----------- .../src/hooks/useAskForReviewVisibility.ts | 12 ++- 3 files changed, 51 insertions(+), 59 deletions(-) diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx index 83d06108ee1..da078b3ce7e 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx @@ -65,14 +65,10 @@ beforeEach(() => { 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.getByText('How is daily.dev working out for you?'), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: "I'm loving it" }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Could be better' }), + screen.getByRole('button', { name: 'Not really' }), ).toBeInTheDocument(); }); @@ -83,7 +79,7 @@ it('marks the session-shown flag on mount', () => { it('opens the AskForReviewConfirm modal with destination + streak when user clicks Yes', async () => { renderStrip(); - fireEvent.click(screen.getByRole('button', { name: "I'm loving it" })); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.AskForReviewConfirm, @@ -95,7 +91,7 @@ it('opens the AskForReviewConfirm modal with destination + streak when user clic it('opens the Feedback modal with UxIssue category and marks complete on No', async () => { renderStrip(); - fireEvent.click(screen.getByRole('button', { name: 'Could be better' })); + fireEvent.click(screen.getByRole('button', { name: 'Not really' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.Feedback, @@ -125,7 +121,7 @@ it('passes the destination through to the confirm modal (App Store)', async () = href: 'https://example.test/app-store', }; renderStrip(appStore); - fireEvent.click(screen.getByRole('button', { name: "I'm loving it" })); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); await waitFor(() => { expect(openModal).toHaveBeenCalledWith({ type: LazyModal.AskForReviewConfirm, diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx index 942f24a8fec..9c144bf888c 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -13,7 +13,7 @@ import { TypographyType, } from '../typography/Typography'; import { MiniCloseIcon } from '../icons/MiniClose'; -import { ReadingStreakIcon } from '../icons'; +import { FeedbackIcon } from '../icons'; import { IconSize } from '../Icon'; import { useActions } from '../../hooks/useActions'; import { ActionType } from '../../graphql/actions'; @@ -128,7 +128,7 @@ export const AskForReviewStripView = ({ aria-label="Quick feedback request" data-testid="ask-for-review-strip" className={classNames( - 'relative flex w-full rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4 shadow-2 tablet:p-5', + 'shadow-1 relative flex w-full flex-col gap-3 rounded-12 border border-border-subtlest-tertiary bg-surface-float p-3 tablet:flex-row tablet:items-center', className, )} > @@ -139,74 +139,60 @@ export const AskForReviewStripView = ({ variant={ButtonVariant.Tertiary} icon={} onClick={onDismiss} - className="absolute right-2 top-2" + className="absolute right-2 top-2 tablet:static tablet:order-3 tablet:shrink-0" /> -
+
-
-
- - {`${streakValue}-day streak`} - - - Quick check-in - -
- How is daily.dev working out for you? + Enjoying daily.dev so far? - Tell us in one tap. If you're happy, we'll ask for a quick - review. If not, share what we can do better. + {`${streakValue}-day streak β€” tell us in one tap.`}
+
-
- - -
+
+ +
); diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts index 171f377bbab..4eec515ac2c 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.ts +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -16,6 +16,7 @@ import { } from '../lib/askForReview'; const FORCE_SHOW_QUERY_KEY = 'force-ask-for-review'; +const FORCE_SHOW_STORAGE_KEY = 'askForReview:forceShow'; const getForceShow = (): boolean => { if (typeof window === 'undefined') { @@ -23,7 +24,16 @@ const getForceShow = (): boolean => { } try { const params = new URLSearchParams(window.location.search); - return params.get(FORCE_SHOW_QUERY_KEY) === '1'; + const fromUrl = params.get(FORCE_SHOW_QUERY_KEY); + if (fromUrl === '1') { + window.localStorage.setItem(FORCE_SHOW_STORAGE_KEY, '1'); + return true; + } + if (fromUrl === '0') { + window.localStorage.removeItem(FORCE_SHOW_STORAGE_KEY); + return false; + } + return window.localStorage.getItem(FORCE_SHOW_STORAGE_KEY) === '1'; } catch { return false; } From 8b9a9f93917b7b6a9022a2b8fc5ccc8e99e5db3e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 11:48:31 +0300 Subject: [PATCH 12/17] fix(ask-for-review): natural subtitle, equal-width buttons, stronger icon contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the em-dash and rewrite the subtitle as something a human would say: "You've shown up {N} days in a row. Worth a sec?" - Pin both Yes and Not really buttons to the same tablet:w-28 so they line up at equal width on tablet+ (still flex-1 on mobile). - Flip the avatar circle from cabbage-subtler with cabbage-default icon (low contrast, washed out) to a solid cabbage-default circle with a white icon β€” same brand color, much more visible. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/postReview/AskForReviewStrip.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.tsx index 9c144bf888c..060d52f8bff 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.tsx @@ -145,12 +145,12 @@ export const AskForReviewStripView = ({
@@ -168,7 +168,7 @@ export const AskForReviewStripView = ({ color={TypographyColor.Tertiary} className="block" > - {`${streakValue}-day streak β€” tell us in one tap.`} + {`You've shown up ${streakValue} days in a row. Worth a sec?`}
@@ -180,7 +180,7 @@ export const AskForReviewStripView = ({ size={ButtonSize.Small} variant={ButtonVariant.Primary} onClick={onYes} - className="flex-1 tablet:flex-none" + className="flex-1 tablet:w-28 tablet:flex-none" > Yes @@ -189,7 +189,7 @@ export const AskForReviewStripView = ({ size={ButtonSize.Small} variant={ButtonVariant.Subtle} onClick={onNo} - className="flex-1 tablet:flex-none" + className="flex-1 tablet:w-28 tablet:flex-none" > Not really From f70d2edb67bf5b80bad11b20482cfcd5db4e967e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 12:00:23 +0300 Subject: [PATCH 13/17] refactor(ask-for-review): rebuild confirm modal on marketing CTA primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the hand-rolled gradient + logo + stars + platform-icon illustration in AskForReviewConfirmModal and rebuild it on the same primitives MarketingCtaModal uses β€” Modal (FlexibleCenter, Small), Title, Description, CTAButton from marketing/cta/common β€” so it matches every other campaign popup in the product instead of looking like a one-off. Not coupling to boot's MarketingCta type because that's server-driven (campaignId + clearMarketingCta mutation) and would conflict with any real campaign in the boot cache. Reusing the visual building blocks keeps the look consistent without touching campaign state. Also rewrites the copy: "Thanks for being a regular πŸ’œ" + "Mind leaving us a quick review on {platform}? It helps other devs find daily.dev." with a "Leave a review" CTA. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modals/AskForReviewConfirmModal.tsx | 120 ++++-------------- 1 file changed, 24 insertions(+), 96 deletions(-) diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx index b25388f5ce8..d4487b3b0e0 100644 --- a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -2,55 +2,22 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; import type { LazyModalCommonProps, ModalProps } from './common/Modal'; import { Modal } from './common/Modal'; -import { ModalClose } from './common/ModalClose'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; -import { - AppleIcon, - BrowserGroupIcon, - ChromeIcon, - DailyIcon, - EdgeIcon, - GoogleIcon, - StarIcon, - TwitterIcon, -} from '../icons'; -import { IconSize } from '../Icon'; +import { MiniCloseIcon } from '../icons'; +import { CTAButton, Description, Title } from '../marketing/cta/common'; import { useActions } from '../../hooks/useActions'; import { ActionType } from '../../graphql/actions'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, TargetType } from '../../lib/log'; -import type { - ReviewDestination, - ReviewDestinationId, -} from '../../lib/askForReview'; +import type { ReviewDestination } from '../../lib/askForReview'; import { setDismissedAt } from '../../lib/askForReview'; -const STAR_INDICES = [0, 1, 2, 3, 4] as const; - type AskForReviewConfirmModalProps = Omit & { 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, @@ -91,73 +58,34 @@ const AskForReviewConfirmModal = ({ 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]} -
-
- +
- + buttonSize={ButtonSize.Medium} + className="mt-6 w-full" + /> +
); }; From 996477fca45f15b81af197b5fb80acce057d7a1a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 12:16:14 +0300 Subject: [PATCH 14/17] feat(ask-for-review): per-store copy, hero image, harden device routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the confirm modal store-aware so the wording matches the store the user will land on: - ReviewDestination gains headline / body / ctaText / image so each store (Chrome Web Store, Edge Add-ons, Firefox Add-ons, App Store, Play Store, X share fallback) owns its own copy. - AskForReviewConfirmModal now reads those fields and renders a CardCover hero image above the CTA β€” matching MarketingCtaModal layout. - Added askForReviewPlaceholderImage in lib/image.ts as a temporary single asset for all destinations. Engineering will swap per-store images later. Tightened the device routing tests: - iPad Safari β†’ App Store - Chrome on iOS (CriOS) β†’ App Store - Sanity-check that the active destination carries store-specific headline / body / ctaText / image. Full matrix (already covered + new) is now: Extension Chrome β†’ Chrome Web Store Extension Edge β†’ Edge Add-ons Extension Firefoxβ†’ Firefox Add-ons iOS Safari β†’ App Store iOS Chrome β†’ App Store iPad Safari β†’ App Store Android Chrome β†’ Play Store Desktop Chrome/Brave β†’ Chrome Web Store Desktop Edge β†’ Edge Add-ons Desktop Safari/Firefox β†’ X share fallback Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modals/AskForReviewConfirmModal.tsx | 21 +++++++----- .../postReview/AskForReviewStrip.spec.tsx | 8 +++++ .../hooks/useAskForReviewVisibility.spec.tsx | 4 +++ packages/shared/src/lib/askForReview.spec.ts | 27 +++++++++++++++ packages/shared/src/lib/askForReview.ts | 33 +++++++++++++++++++ packages/shared/src/lib/image.ts | 5 +++ .../components/AskForReviewStrip.stories.tsx | 29 ++++++++++++---- 7 files changed, 113 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx index d4487b3b0e0..d7369b9cd42 100644 --- a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -5,6 +5,7 @@ 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'; @@ -72,18 +73,22 @@ const AskForReviewConfirmModal = ({ aria-label="Close" onClick={handleMaybeLater} /> - - Thanks for being a regular πŸ’œ - - - {`Mind leaving us a quick review on ${destination.label}? It helps other devs find daily.dev.`} - + {destination.headline} + {destination.body} +
diff --git a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx index da078b3ce7e..a0271e7bd90 100644 --- a/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx +++ b/packages/shared/src/components/postReview/AskForReviewStrip.spec.tsx @@ -19,6 +19,10 @@ 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 completeAction = jest.fn(); @@ -119,6 +123,10 @@ it('passes the destination through to the confirm modal (App Store)', async () = 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' })); diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx b/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx index 16d877299a8..f78980c9482 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx +++ b/packages/shared/src/hooks/useAskForReviewVisibility.spec.tsx @@ -17,6 +17,10 @@ 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) => ({ diff --git a/packages/shared/src/lib/askForReview.spec.ts b/packages/shared/src/lib/askForReview.spec.ts index 6453b1ac5b1..f887806d5bf 100644 --- a/packages/shared/src/lib/askForReview.spec.ts +++ b/packages/shared/src/lib/askForReview.spec.ts @@ -80,6 +80,33 @@ describe('getReviewDestination (webapp)', () => { ); 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', () => { diff --git a/packages/shared/src/lib/askForReview.ts b/packages/shared/src/lib/askForReview.ts index 9a8b539a55d..d384159ac8b 100644 --- a/packages/shared/src/lib/askForReview.ts +++ b/packages/shared/src/lib/askForReview.ts @@ -14,6 +14,7 @@ import { isFirefoxExtension, isIOS, } from './func'; +import { askForReviewPlaceholderImage } from './image'; export type ReviewDestinationId = | 'chrome_web_store' @@ -27,37 +28,69 @@ 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, }; const isAndroidUserAgent = (): boolean => 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/storybook/stories/components/AskForReviewStrip.stories.tsx b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx index b655112923a..286dc9a6360 100644 --- a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx +++ b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx @@ -38,13 +38,30 @@ const withProviders = (Story: () => ReactElement): ReactElement => ( ); +const PLACEHOLDER_IMAGE = + 'https://media.daily.dev/image/upload/s--GWqpMG8r--/f_auto/v1732802237/Streak_together_with_a_friend_1_1_pwoill'; + +const makeDest = ( + id: ReviewDestination['id'], + label: string, + href: string, +): ReviewDestination => ({ + id, + label, + href, + headline: 'Help fellow devs find daily.dev πŸ’œ', + body: `Take 10 seconds to rate daily.dev on ${label}.`, + ctaText: `Rate on ${label}`, + image: PLACEHOLDER_IMAGE, +}); + 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' }, + makeDest('chrome_web_store', 'Chrome Web Store', '#chrome'), + makeDest('edge_addons', 'Edge Add-ons', '#edge'), + makeDest('firefox_addons', 'Firefox Add-ons', '#firefox'), + makeDest('app_store', 'App Store', '#appstore'), + makeDest('play_store', 'Play Store', '#playstore'), + makeDest('twitter_share', 'X', '#twitter'), ]; const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( From a533cac0d9a24538976f83f588c898372a7f32d0 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 12:29:55 +0300 Subject: [PATCH 15/17] fix(ask-for-review): address PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the ?force-ask-for-review URL flag and forceShow paths from useAskForReviewVisibility. It bypassed every gate (auth, streak, GB, cooldown, completed action) and persisted in localStorage forever β€” not safe to ship. Internal testing should use the existing ?ask-for-review-qa=1 panel. - Lazy-load AskForReviewQAPanel via next/dynamic + webpackChunkName so the ~300 LOC QA module is no longer parsed on every MainLayout mount. - Add AskForReviewConfirmModal.spec.tsx (6 tests): destination copy renders, CTA href + target=_blank, hero image alt, click-through marks action complete + closes, dismiss writes cooldown + closes, App Store path. - Move isAndroidUserAgent from askForReview.ts into lib/func.ts as `isAndroid`, sitting next to `isIOS` for consistency. - Compute isCooldownActive once in the visibility hook and reuse for both the gate and the return value. - Add why-comments at the strip mount point (placement choice) and at the CTA handler (action marked complete on click-through, not actual review submission β€” click-through is the engagement signal we re-ask against). - Rename confirm modal close aria-label to "Dismiss review prompt" so it doesn't collide with the drawer's built-in Close button in tests. 37 tests passing in the ask-for-review surface (was 31). Strict typecheck clean for changed files. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/components/MainLayout.tsx | 12 +- .../modals/AskForReviewConfirmModal.spec.tsx | 123 ++++++++++++++++++ .../modals/AskForReviewConfirmModal.tsx | 5 +- .../src/components/post/BasePostContent.tsx | 4 + .../src/hooks/useAskForReviewVisibility.ts | 59 +++------ packages/shared/src/lib/askForReview.ts | 6 +- packages/shared/src/lib/func.ts | 3 + 7 files changed, 163 insertions(+), 49 deletions(-) create mode 100644 packages/shared/src/components/modals/AskForReviewConfirmModal.spec.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 270e16f427e..5a6ba6d8393 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -27,7 +27,6 @@ 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'; @@ -38,6 +37,17 @@ import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; +// Lazy-loaded so the 300+ LOC QA panel module isn't shipped on every page. +// The panel internally gates its own UI on the ?ask-for-review-qa=1 flag, +// but loading it eagerly would still parse all its code for every visitor. +const AskForReviewQAPanel = dynamic( + () => + import( + /* webpackChunkName: "askForReviewQAPanel" */ './postReview/AskForReviewQAPanel' + ).then((mod) => mod.AskForReviewQAPanel), + { ssr: false }, +); + const GoBackHeaderMobile = dynamic( () => import( 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 index d7369b9cd42..ba728c84175 100644 --- a/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx +++ b/packages/shared/src/components/modals/AskForReviewConfirmModal.tsx @@ -46,6 +46,9 @@ const AskForReviewConfirmModal = ({ 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]); @@ -70,7 +73,7 @@ const AskForReviewConfirmModal = ({ size={ButtonSize.Small} variant={ButtonVariant.Tertiary} icon={} - aria-label="Close" + aria-label="Dismiss review prompt" onClick={handleMaybeLater} /> {destination.headline} diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 12e3e1e5a92..3fa8bb4782d 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -62,6 +62,10 @@ 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 && ( diff --git a/packages/shared/src/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts index 4eec515ac2c..f2b718caca6 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.ts +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -15,30 +15,6 @@ import { isCooldownActive, } from '../lib/askForReview'; -const FORCE_SHOW_QUERY_KEY = 'force-ask-for-review'; -const FORCE_SHOW_STORAGE_KEY = 'askForReview:forceShow'; - -const getForceShow = (): boolean => { - if (typeof window === 'undefined') { - return false; - } - try { - const params = new URLSearchParams(window.location.search); - const fromUrl = params.get(FORCE_SHOW_QUERY_KEY); - if (fromUrl === '1') { - window.localStorage.setItem(FORCE_SHOW_STORAGE_KEY, '1'); - return true; - } - if (fromUrl === '0') { - window.localStorage.removeItem(FORCE_SHOW_STORAGE_KEY); - return false; - } - return window.localStorage.getItem(FORCE_SHOW_STORAGE_KEY) === '1'; - } catch { - return false; - } -}; - interface UseAskForReviewVisibility { visible: boolean; destination: ReviewDestination | null; @@ -59,24 +35,19 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { const { checkHasCompleted, isActionsFetched } = useActions(); const { streak, isStreaksEnabled } = useReadingStreak(); - const forceShow = useMemo(() => getForceShow(), []); const qa = useMemo(() => getQAOverride(), []); const platformDestination = useMemo(() => getReviewDestination(), []); - let destination: ReviewDestination | null; - if (qa?.destinationId) { - destination = getDestinationById(qa.destinationId); - } else if (platformDestination) { - destination = platformDestination; - } else if (forceShow) { - destination = getDestinationById('chrome_web_store'); - } else { - destination = null; - } + const destination = qa?.destinationId + ? getDestinationById(qa.destinationId) + : platformDestination; const sessionShown = hasShownThisSession(); const completedPermanent = checkHasCompleted( ActionType.AskedForReviewComplete, ); + const streakThresholdDefault = 3; + const cooldownDaysDefault = 14; + const baseGate = isAuthReady && isLoggedIn && @@ -95,25 +66,27 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { }); const variantEnabled = !!featureValue?.enabled || !!qa; - const streakThreshold = featureValue?.streakThreshold ?? 3; - const cooldownDays = featureValue?.cooldownDays ?? 14; + const streakThreshold = + featureValue?.streakThreshold ?? streakThresholdDefault; + const cooldownDays = featureValue?.cooldownDays ?? cooldownDaysDefault; const streakValue = streak?.current ?? 0; const streakPasses = qa?.ignoreStreak || streakValue >= streakThreshold; - const cooldownPasses = qa?.ignoreCooldown || !isCooldownActive(cooldownDays); + const cooldownLive = isCooldownActive(cooldownDays); + const cooldownPasses = qa?.ignoreCooldown || !cooldownLive; - const visible = - forceShow || - Boolean(baseGate && variantEnabled && streakPasses && cooldownPasses); + const visible = Boolean( + baseGate && variantEnabled && streakPasses && cooldownPasses, + ); return { visible, destination, - streakValue: forceShow && streakValue === 0 ? 3 : streakValue, + streakValue, variantEnabled, streakThreshold, cooldownDays, isCompletedPermanent: completedPermanent, - isCooldownLive: isCooldownActive(cooldownDays), + isCooldownLive: cooldownLive, isSessionShown: sessionShown, isStreaksEnabled: !!isStreaksEnabled, platformDestination, diff --git a/packages/shared/src/lib/askForReview.ts b/packages/shared/src/lib/askForReview.ts index d384159ac8b..513171ab279 100644 --- a/packages/shared/src/lib/askForReview.ts +++ b/packages/shared/src/lib/askForReview.ts @@ -9,6 +9,7 @@ import { import { BrowserName, getCurrentBrowserName, + isAndroid, isChromeExtension, isExtension, isFirefoxExtension, @@ -93,9 +94,6 @@ const TWITTER_DEST: ReviewDestination = { image: askForReviewPlaceholderImage, }; -const isAndroidUserAgent = (): boolean => - /Android/i.test(globalThis?.navigator?.userAgent ?? ''); - export const getReviewDestination = (): ReviewDestination | null => { if (typeof window === 'undefined') { return null; @@ -117,7 +115,7 @@ export const getReviewDestination = (): ReviewDestination | null => { if (isIOS()) { return APP_STORE_DEST; } - if (isAndroidUserAgent()) { + if (isAndroid()) { return PLAY_STORE_DEST; } 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 ( From 2ed9133f324d27b6dca097fe97716dc998c8e1e8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 12:55:30 +0300 Subject: [PATCH 16/17] chore(ask-for-review): drop Storybook story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The story duplicated what the in-app QA panel (?ask-for-review-qa=1) already provides β€” switching destinations and streak values to preview every state β€” except the QA panel runs against real auth, streak, and GrowthBook context instead of mocks. Storybook in this repo is for design-system primitives (Tooltip, Dropdown, FormWrapper, etc.), not for one-off feature components behind a flag. Removing the story drops 226 LOC of mock scaffolding, eliminates a parallel fixture to maintain alongside the QA panel, and unblocks the failing Vercel storybook check. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/AskForReviewStrip.stories.tsx | 226 ------------------ 1 file changed, 226 deletions(-) delete mode 100644 packages/storybook/stories/components/AskForReviewStrip.stories.tsx diff --git a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx b/packages/storybook/stories/components/AskForReviewStrip.stories.tsx deleted file mode 100644 index 286dc9a6360..00000000000 --- a/packages/storybook/stories/components/AskForReviewStrip.stories.tsx +++ /dev/null @@ -1,226 +0,0 @@ -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 PLACEHOLDER_IMAGE = - 'https://media.daily.dev/image/upload/s--GWqpMG8r--/f_auto/v1732802237/Streak_together_with_a_friend_1_1_pwoill'; - -const makeDest = ( - id: ReviewDestination['id'], - label: string, - href: string, -): ReviewDestination => ({ - id, - label, - href, - headline: 'Help fellow devs find daily.dev πŸ’œ', - body: `Take 10 seconds to rate daily.dev on ${label}.`, - ctaText: `Rate on ${label}`, - image: PLACEHOLDER_IMAGE, -}); - -const DESTINATIONS: ReviewDestination[] = [ - makeDest('chrome_web_store', 'Chrome Web Store', '#chrome'), - makeDest('edge_addons', 'Edge Add-ons', '#edge'), - makeDest('firefox_addons', 'Firefox Add-ons', '#firefox'), - makeDest('app_store', 'App Store', '#appstore'), - makeDest('play_store', 'Play Store', '#playstore'), - makeDest('twitter_share', 'X', '#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)} - /> - - )} -
- ); - }, -}; From 18cfb4b8f5f1fd8587321633ed8aab4daf340e3c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 25 May 2026 13:03:31 +0300 Subject: [PATCH 17/17] chore(ask-for-review): drop QA panel + override plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QA panel was a dev-only test scaffold but it lived in the shared library and got mounted by MainLayout for every visitor. Removing it entirely: - Delete AskForReviewQAPanel.tsx (~349 LOC) and its MainLayout mount. - Drop getQAOverride / setQAOverride / AskForReviewQAOverride / the ASK_FOR_REVIEW_QA_KEY storage key from lib/askForReview.ts. - Drop the dev-only clearDismissedAt / clearShownThisSession helpers and the unused getDestinationById lookup table. - Strip qa-* gate bypasses from useAskForReviewVisibility β€” the hook now evaluates the production gates only. - Drop the spec line for clearDismissedAt. If we need to manually preview the strip during development, the path is to flip ask_for_review in GrowthBook dev mode or seed the streak/action state via the existing test infra β€” not to ship a QA UI to every user. Net: -461 / +6 LOC. 36 tests still passing, strict typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/components/MainLayout.tsx | 12 - .../postReview/AskForReviewQAPanel.tsx | 349 ------------------ .../src/hooks/useAskForReviewVisibility.ts | 17 +- packages/shared/src/lib/askForReview.spec.ts | 7 - packages/shared/src/lib/askForReview.ts | 82 ---- 5 files changed, 6 insertions(+), 461 deletions(-) delete mode 100644 packages/shared/src/components/postReview/AskForReviewQAPanel.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 5a6ba6d8393..ef63d4e726a 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -37,17 +37,6 @@ import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; -// Lazy-loaded so the 300+ LOC QA panel module isn't shipped on every page. -// The panel internally gates its own UI on the ?ask-for-review-qa=1 flag, -// but loading it eagerly would still parse all its code for every visitor. -const AskForReviewQAPanel = dynamic( - () => - import( - /* webpackChunkName: "askForReviewQAPanel" */ './postReview/AskForReviewQAPanel' - ).then((mod) => mod.AskForReviewQAPanel), - { ssr: false }, -); - const GoBackHeaderMobile = dynamic( () => import( @@ -200,7 +189,6 @@ function MainLayoutComponent({ - {plusEntryAnnouncementBar && ( { - 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/hooks/useAskForReviewVisibility.ts b/packages/shared/src/hooks/useAskForReviewVisibility.ts index f2b718caca6..45775f39b2b 100644 --- a/packages/shared/src/hooks/useAskForReviewVisibility.ts +++ b/packages/shared/src/hooks/useAskForReviewVisibility.ts @@ -8,8 +8,6 @@ import { featureAskForReview } from '../lib/featureManagement'; import { ActionType } from '../graphql/actions'; import type { ReviewDestination } from '../lib/askForReview'; import { - getDestinationById, - getQAOverride, getReviewDestination, hasShownThisSession, isCooldownActive, @@ -35,11 +33,8 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { 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 destination = platformDestination; const sessionShown = hasShownThisSession(); const completedPermanent = checkHasCompleted( ActionType.AskedForReviewComplete, @@ -55,8 +50,8 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { isActionsFetched && loadedAlerts && isStreaksEnabled && - (qa?.ignoreCompletedAction || !completedPermanent) && - (qa?.ignoreSession || !sessionShown) && + !completedPermanent && + !sessionShown && !alerts?.showStreakMilestone && destination !== null; @@ -65,14 +60,14 @@ export const useAskForReviewVisibility = (): UseAskForReviewVisibility => { shouldEvaluate: baseGate, }); - const variantEnabled = !!featureValue?.enabled || !!qa; + const variantEnabled = !!featureValue?.enabled; const streakThreshold = featureValue?.streakThreshold ?? streakThresholdDefault; const cooldownDays = featureValue?.cooldownDays ?? cooldownDaysDefault; const streakValue = streak?.current ?? 0; - const streakPasses = qa?.ignoreStreak || streakValue >= streakThreshold; + const streakPasses = streakValue >= streakThreshold; const cooldownLive = isCooldownActive(cooldownDays); - const cooldownPasses = qa?.ignoreCooldown || !cooldownLive; + const cooldownPasses = !cooldownLive; const visible = Boolean( baseGate && variantEnabled && streakPasses && cooldownPasses, diff --git a/packages/shared/src/lib/askForReview.spec.ts b/packages/shared/src/lib/askForReview.spec.ts index f887806d5bf..d3613d2e3cd 100644 --- a/packages/shared/src/lib/askForReview.spec.ts +++ b/packages/shared/src/lib/askForReview.spec.ts @@ -1,7 +1,6 @@ import { ASK_FOR_REVIEW_DISMISSED_KEY, ASK_FOR_REVIEW_SESSION_KEY, - clearDismissedAt, getDismissedAt, getReviewDestination, hasShownThisSession, @@ -144,12 +143,6 @@ describe('dismissed-at cooldown', () => { 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', () => { diff --git a/packages/shared/src/lib/askForReview.ts b/packages/shared/src/lib/askForReview.ts index 513171ab279..c77223aa263 100644 --- a/packages/shared/src/lib/askForReview.ts +++ b/packages/shared/src/lib/askForReview.ts @@ -161,17 +161,6 @@ export const setDismissedAt = (timestamp: number = Date.now()): void => { } }; -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(), @@ -207,74 +196,3 @@ export const markShownThisSession = (): void => { // 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];