From dfac1cefc1334c3942ab06a45dc14da74f153cf6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 20 May 2026 09:41:29 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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.`}