feat(client): mobile advert for small screen sizes (#66212)

This commit is contained in:
Sem Bauke
2026-03-23 08:01:58 +01:00
committed by GitHub
parent fb6e30d34a
commit fe421a03c6
9 changed files with 443 additions and 0 deletions
@@ -37,6 +37,7 @@ import ChallengeTitle from '../components/challenge-title';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import ShortcutsModal from '../components/shortcuts-modal';
import MobileAppModal from '../components/mobile-app-modal';
import Output from '../components/output';
import Preview, { type PreviewProps } from '../components/preview';
import ProjectPreviewModal from '../components/project-preview-modal';
@@ -567,6 +568,7 @@ function ShowClassic({
}
/>
<ShortcutsModal />
<MobileAppModal superBlock={superBlock} />
</LearnLayout>
</Hotkeys>
);
@@ -0,0 +1,163 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterEach,
vi,
type Mock
} from 'vitest';
import store from 'store';
vi.mock('react-redux', () => ({
useSelector: vi.fn().mockReturnValue(false)
}));
import { useSelector } from 'react-redux';
import MobileAppModal from './mobile-app-modal';
const mockUseSelector = useSelector as Mock;
const MOBILE_SUPERBLOCK = 'responsive-web-design-v9';
const NON_MOBILE_SUPERBLOCK = 'coding-interview-prep';
const STORE_KEY = 'mobileAppModalDismissedAt';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const ANDROID_UA =
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36';
const IOS_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15';
const DESKTOP_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36';
describe('MobileAppModal', () => {
beforeAll(() => {
// The Modal component uses `ResizeObserver` under the hood.
// However, this property is not available in JSDOM, so we need to manually add it to the window object.
// Ref: https://github.com/jsdom/jsdom/issues/3368
type ResizeObserverMockInstance = {
observe: ResizeObserver['observe'];
unobserve: ResizeObserver['unobserve'];
disconnect: ResizeObserver['disconnect'];
};
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
value: vi.fn(function (
this: ResizeObserverMockInstance,
_cb: ResizeObserverCallback
) {
this.observe = vi.fn();
this.unobserve = vi.fn();
this.disconnect = vi.fn();
})
});
});
beforeEach(() => {
mockUseSelector.mockReturnValue(false); // default: project preview closed
store.remove(STORE_KEY);
Object.defineProperty(navigator, 'userAgent', {
value: ANDROID_UA,
configurable: true
});
});
afterEach(() => {
vi.clearAllMocks();
store.remove(STORE_KEY);
});
it('renders the modal on mobile for a public superblock', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not render on a desktop OS', () => {
Object.defineProperty(navigator, 'userAgent', {
value: DESKTOP_UA,
configurable: true
});
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('does not render for a non-public superblock', () => {
render(<MobileAppModal superBlock={NON_MOBILE_SUPERBLOCK} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('does not render when the project preview is open', () => {
mockUseSelector.mockReturnValue(true);
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('does not render when dismissed within 30 days', () => {
store.set(STORE_KEY, Date.now() - THIRTY_DAYS_MS + 1000);
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders again after 30 days have passed', () => {
store.set(STORE_KEY, Date.now() - THIRTY_DAYS_MS - 1000);
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('displays the correct modal content', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveTextContent('mobile-app-modal.heading');
expect(dialog).toHaveTextContent('mobile-app-modal.body');
});
it('closes the modal without persisting when X is clicked', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
fireEvent.click(screen.getByText('Close'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(store.get(STORE_KEY)).toBeUndefined();
});
it('closes the modal without persisting when the store link is clicked', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
fireEvent.click(screen.getByRole('link'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(store.get(STORE_KEY)).toBeUndefined();
});
it('closes the modal and stores a timestamp when "do not show" is clicked', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
fireEvent.click(screen.getByText('mobile-app-modal.do-not-show'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const stored = store.get(STORE_KEY) as number;
expect(stored).toBeGreaterThan(0);
expect(Date.now() - stored).toBeLessThan(1000);
});
it('shows the correct app store link for iOS', () => {
Object.defineProperty(navigator, 'userAgent', {
value: IOS_UA,
configurable: true
});
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'https://apps.apple.com/us/app/freecodecamp/id6446908151?itsct=apps_box_link&itscg=30200'
);
expect(screen.getByRole('link')).toHaveTextContent('mobile-app-modal.ios');
});
it('shows the correct app store link for Android', () => {
render(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />);
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'https://play.google.com/store/apps/details?id=org.freecodecamp'
);
expect(screen.getByRole('link')).toHaveTextContent(
'mobile-app-modal.android'
);
});
});
@@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import store from 'store';
import { isProjectPreviewModalOpenSelector } from '../redux/selectors';
// Superblocks that are available in the freeCodeCamp mobile app.
// Only includes non-legacy public superblocks from orderedSuperBlockInfo
// (client/tools/external-curriculum/build-external-curricula-data-v2.ts).
const mobileAvailableSuperBlocks = new Set<string>([
SuperBlocks.RespWebDesignV9,
SuperBlocks.JsV9,
SuperBlocks.PythonV9,
SuperBlocks.A2English,
SuperBlocks.B1English,
SuperBlocks.A1Spanish,
SuperBlocks.TheOdinProject
]);
const STORE_KEY = 'mobileAppModalDismissedAt';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const IOS_URL =
'https://apps.apple.com/us/app/freecodecamp/id6446908151?itsct=apps_box_link&itscg=30200';
const ANDROID_URL =
'https://play.google.com/store/apps/details?id=org.freecodecamp';
function detectOS(): 'ios' | 'android' | 'other' {
if (typeof navigator === 'undefined') return 'other';
const ua = navigator.userAgent;
if (/iPad|iPhone|iPod/.test(ua)) return 'ios';
if (/Android/.test(ua)) return 'android';
return 'other';
}
function isDismissedFor30Days(): boolean {
const dismissedAt = store.get(STORE_KEY) as number | undefined;
if (!dismissedAt) return false;
return Date.now() - dismissedAt < THIRTY_DAYS_MS;
}
interface MobileAppModalProps {
superBlock: string;
}
function MobileAppModal({
superBlock
}: MobileAppModalProps): JSX.Element | null {
const { t } = useTranslation();
const isAvailable = mobileAvailableSuperBlocks.has(superBlock);
const isProjectPreviewOpen = useSelector<unknown, boolean>(
isProjectPreviewModalOpenSelector
);
const os = detectOS();
const [dismissed, setDismissed] = useState(isDismissedFor30Days);
useEffect(() => {
if (isProjectPreviewOpen) setDismissed(true);
}, [isProjectPreviewOpen]);
const dismiss = () => setDismissed(true);
const dismissPermanently = () => {
store.set(STORE_KEY, Date.now());
setDismissed(true);
};
const storeUrl = os === 'ios' ? IOS_URL : ANDROID_URL;
const storeName =
os === 'ios' ? t('mobile-app-modal.ios') : t('mobile-app-modal.android');
if (os === 'other' || !isAvailable || isProjectPreviewOpen || dismissed)
return null;
return (
<Modal onClose={dismiss} open={true} size='large'>
<Modal.Header showCloseButton={true} closeButtonClassNames='close'>
<span style={{ fontWeight: 'bold' }}>
{t('mobile-app-modal.heading')}
</span>
</Modal.Header>
<Modal.Body>
<p>{t('mobile-app-modal.body')}</p>
<Spacer size='s' />
<Button block={true} href={storeUrl} target='_blank' onClick={dismiss}>
{storeName}
</Button>
<Spacer size='xs' />
<Button block={true} variant='info' onClick={dismissPermanently}>
{t('mobile-app-modal.do-not-show')}
</Button>
</Modal.Body>
</Modal>
);
}
export default MobileAppModal;
@@ -15,6 +15,7 @@ import { ChallengeLang } from '@freecodecamp/shared/config/curriculum';
// Local Utilities
import ShortcutsModal from '../components/shortcuts-modal';
import MobileAppModal from '../components/mobile-app-modal';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import Hotkeys from '../components/hotkeys';
@@ -337,6 +338,7 @@ const ShowFillInTheBlank = ({
</Row>
</Container>
<ShortcutsModal />
<MobileAppModal superBlock={superBlock} />
</LearnLayout>
</Hotkeys>
);
@@ -36,6 +36,7 @@ import MultipleChoiceQuestions from '../components/multiple-choice-questions';
import ChallengeExplanation from '../components/challenge-explanation';
import ChallengeTranscript from '../components/challenge-transcript';
import HelpModal from '../components/help-modal';
import MobileAppModal from '../components/mobile-app-modal';
import { SceneSubject } from '../components/scene/scene-subject';
import ContentOutline from './content-outline';
@@ -424,6 +425,7 @@ const ShowGeneric = ({
</Container>
)}
</Container>
<MobileAppModal superBlock={superBlock} />
</LearnLayout>
</Hotkeys>
);
@@ -40,6 +40,7 @@ import { usePageLeave } from '../hooks';
import { sounds } from '../components/scene/scene-assets';
import ExitQuizModal from './exit-quiz-modal';
import FinishQuizModal from './finish-quiz-modal';
import MobileAppModal from '../components/mobile-app-modal';
import './show.css';
@@ -393,6 +394,7 @@ const ShowQuiz = ({
<CompletionModal />
<ExitQuizModal onExit={handleExitQuizModalBtnClick} />
<FinishQuizModal onFinish={handleFinishQuizModalBtnClick} />
<MobileAppModal superBlock={superBlock} />
</LearnLayout>
</Hotkeys>
);