mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): mobile advert for small screen sizes (#66212)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user