Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dfac1ce
feat: streak-triggered ask-for-review strip
tsahimatsliah May 20, 2026
2e85a27
Merge remote-tracking branch 'origin/main' into feat/ask-for-review-p…
tsahimatsliah May 20, 2026
384569d
chore: retrigger CI
tsahimatsliah May 20, 2026
81e9396
feat: add in-app QA panel for ask-for-review strip
tsahimatsliah May 20, 2026
8c89c98
feat: float ask-for-review strip above modal and route Yes to centere…
tsahimatsliah May 20, 2026
e8858c8
fix: align ask-for-review prompt with production modal patterns
tsahimatsliah May 20, 2026
81dde76
Merge remote-tracking branch 'origin/main' into feat/ask-for-review-p…
tsahimatsliah May 20, 2026
7469ef8
fix: match ask-for-review popup design
tsahimatsliah May 20, 2026
3f04241
fix: render ask-for-review strip inline
tsahimatsliah May 20, 2026
9e18ead
fix(ask-for-review): redesign strip as separate card outside post
tsahimatsliah May 25, 2026
879af12
chore(ask-for-review): add force-show URL param for review
tsahimatsliah May 25, 2026
8b8b844
fix(ask-for-review): restore in-content placement so the strip actual…
tsahimatsliah May 25, 2026
61334b1
fix(ask-for-review): lighter compact strip, cabbage green, feedback icon
tsahimatsliah May 25, 2026
8b9a9f9
fix(ask-for-review): natural subtitle, equal-width buttons, stronger …
tsahimatsliah May 25, 2026
f70d2ed
refactor(ask-for-review): rebuild confirm modal on marketing CTA prim…
tsahimatsliah May 25, 2026
996477f
feat(ask-for-review): per-store copy, hero image, harden device routing
tsahimatsliah May 25, 2026
a533cac
fix(ask-for-review): address PR review
tsahimatsliah May 25, 2026
e5e9c7f
Merge remote-tracking branch 'origin/main' into feat/ask-for-review-p…
tsahimatsliah May 25, 2026
2ed9133
chore(ask-for-review): drop Storybook story
tsahimatsliah May 25, 2026
18cfb4b
chore(ask-for-review): drop QA panel + override plumbing
tsahimatsliah May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<TestBootProvider client={client}>
<AskForReviewConfirmModal
isOpen
destination={destination}
streakValue={5}
onRequestClose={onRequestClose}
/>
</TestBootProvider>,
);
};

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');
});
101 changes: 101 additions & 0 deletions packages/shared/src/components/modals/AskForReviewConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { ReactElement } from 'react';
import React, { useCallback } from 'react';
import type { LazyModalCommonProps, ModalProps } from './common/Modal';
import { Modal } from './common/Modal';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { MiniCloseIcon } from '../icons';
import { CTAButton, Description, Title } from '../marketing/cta/common';
import { CardCover } from '../cards/common/CardCover';
import { useActions } from '../../hooks/useActions';
import { ActionType } from '../../graphql/actions';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent, TargetType } from '../../lib/log';
import type { ReviewDestination } from '../../lib/askForReview';
import { setDismissedAt } from '../../lib/askForReview';

type AskForReviewConfirmModalProps = Omit<ModalProps, 'onRequestClose'> & {
onRequestClose?: LazyModalCommonProps['onRequestClose'];
destination: ReviewDestination;
streakValue?: number;
};

const AskForReviewConfirmModal = ({
onRequestClose,
destination,
streakValue,
...props
}: AskForReviewConfirmModalProps): ReactElement => {
const { completeAction } = useActions();
const { logEvent } = useLogContext();

const log = useCallback(
(clickId: string) => {
logEvent({
event_name: LogEvent.Click,
target_type: TargetType.AskForReview,
target_id: clickId,
extra: JSON.stringify({
platform: destination.id,
streak: streakValue,
step: 'confirm_modal',
}),
});
},
[logEvent, destination.id, streakValue],
);

const handleLeaveReview = useCallback(() => {
log('leave_review');
// Marks the action complete on click-through, not on actual review
// submission — there's no callback from the store page back to us.
// Click-through is the engagement signal we re-ask against.
completeAction(ActionType.AskedForReviewComplete);
onRequestClose?.(undefined);
}, [log, completeAction, onRequestClose]);

const handleMaybeLater = useCallback(() => {
log('confirm_dismiss');
setDismissedAt();
onRequestClose?.(undefined);
}, [log, onRequestClose]);

return (
<Modal
{...props}
isDrawerOnMobile
kind={Modal.Kind.FlexibleCenter}
onRequestClose={handleMaybeLater}
size={Modal.Size.Small}
>
<div className="relative p-6 !pt-4">
<Button
className="absolute right-2 top-2"
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
icon={<MiniCloseIcon />}
aria-label="Dismiss review prompt"
onClick={handleMaybeLater}
/>
<Title className="!typo-large-title">{destination.headline}</Title>
<Description className="!typo-body">{destination.body}</Description>
<CardCover
imageProps={{
loading: 'lazy',
alt: `Leave a review on ${destination.label}`,
src: destination.image,
className: 'w-full my-6 !h-50',
}}
/>
<CTAButton
ctaUrl={destination.href}
ctaText={destination.ctaText}
onClick={handleLeaveReview}
buttonSize={ButtonSize.Medium}
className="w-full"
/>
</div>
</Modal>
);
};

export default AskForReviewConfirmModal;
6 changes: 3 additions & 3 deletions packages/shared/src/components/modals/FeedbackModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext';
const FEEDBACK_MAX_LENGTH = 2000;
type FeedbackModalProps = Omit<ModalProps, 'onRequestClose'> & {
onRequestClose?: LazyModalCommonProps['onRequestClose'];
defaultCategory?: FeedbackCategory;
};

const categoryOptions: { value: FeedbackCategory; label: string }[] = [
Expand All @@ -43,16 +44,15 @@ const categoryOptions: { value: FeedbackCategory; label: string }[] = [

const FeedbackModal = ({
onRequestClose,
defaultCategory = FeedbackCategory.BugReport,
...props
}: FeedbackModalProps): ReactElement => {
const { displayToast } = useToastNotification();
const { themeMode } = useSettingsContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const hasSubmitted = useRef(false);

const [category, setCategory] = useState<FeedbackCategory>(
FeedbackCategory.BugReport,
);
const [category, setCategory] = useState<FeedbackCategory>(defaultCategory);
const [description, setDescription] = useState('');
const [screenshot, setScreenshot] = useState<File | null>(null);
const [screenshotPreview, setScreenshotPreview] = useState<string | null>(
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,13 @@ const FeedbackModal = dynamic(
() => import(/* webpackChunkName: "feedbackModal" */ './FeedbackModal'),
);

const AskForReviewConfirmModal = dynamic(
() =>
import(
/* webpackChunkName: "askForReviewConfirmModal" */ './AskForReviewConfirmModal'
),
);

const HotAndColdModal = dynamic(
() =>
import(
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export enum LazyModal {
RecruiterSeats = 'recruiterSeats',
CandidateSignIn = 'candidateSignIn',
Feedback = 'feedback',
AskForReviewConfirm = 'askForReviewConfirm',
HotAndCold = 'hotAndCold',
AchievementSyncPrompt = 'achievementSyncPrompt',
AchievementPicker = 'achievementPicker',
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/components/post/BasePostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -61,6 +62,11 @@ export function BasePostContent({
/>
</GoBackHeaderMobile>
)}
{/* 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. */}
<AskForReviewStrip className="mb-4 mt-3 laptop:mt-4" />
{children}
{!!engagementProps && (
<PostEngagements
Expand Down
Loading
Loading