diff --git a/dotcom-rendering/src/client/deviceDetection/iPadDetection.test.ts b/dotcom-rendering/src/client/deviceDetection/iPadDetection.test.ts new file mode 100644 index 00000000000..8d2c8bb64ab --- /dev/null +++ b/dotcom-rendering/src/client/deviceDetection/iPadDetection.test.ts @@ -0,0 +1,147 @@ +import { getCookie, removeCookie } from '@guardian/libs'; +import { isIPad, setDeviceClassCookie } from './iPadDetection'; + +const DEVICE_CLASS_COOKIE = 'device_class'; + +describe('iPadDetection', () => { + const originalPlatform = navigator.platform; + const originalMaxTouchPoints = navigator.maxTouchPoints; + + afterEach(() => { + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: originalMaxTouchPoints, + configurable: true, + }); + removeCookie({ name: DEVICE_CLASS_COOKIE }); + }); + + describe('isIPad', () => { + it('returns true for newer iPad (iPadOS 13+, MacIntel with touch support)', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + expect(isIPad()).toBe(true); + }); + + it('returns true for older iPad (iOS 12 and earlier)', () => { + Object.defineProperty(navigator, 'platform', { + value: 'iPad', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + expect(isIPad()).toBe(true); + }); + + it('returns false for Mac desktop (MacIntel without touch)', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + expect(isIPad()).toBe(false); + }); + + it('returns false for iPhone', () => { + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + expect(isIPad()).toBe(false); + }); + + it('returns false for Windows', () => { + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + expect(isIPad()).toBe(false); + }); + + it('returns false for Android tablet', () => { + Object.defineProperty(navigator, 'platform', { + value: 'Linux armv8l', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + expect(isIPad()).toBe(false); + }); + }); + + describe('setDeviceClassCookie', () => { + it('sets device_class cookie to tablet on iPadOS', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + setDeviceClassCookie(); + + expect(getCookie({ name: DEVICE_CLASS_COOKIE })).toBe('tablet'); + }); + + it('does not set cookie on Mac desktop', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + + setDeviceClassCookie(); + + expect(getCookie({ name: DEVICE_CLASS_COOKIE })).toBeNull(); + }); + + it('does not overwrite existing tablet cookie', () => { + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + setDeviceClassCookie(); + const firstCookieValue = getCookie({ name: DEVICE_CLASS_COOKIE }); + + setDeviceClassCookie(); + const secondCookieValue = getCookie({ name: DEVICE_CLASS_COOKIE }); + + expect(firstCookieValue).toBe('tablet'); + expect(secondCookieValue).toBe('tablet'); + }); + }); +}); diff --git a/dotcom-rendering/src/client/deviceDetection/iPadDetection.ts b/dotcom-rendering/src/client/deviceDetection/iPadDetection.ts new file mode 100644 index 00000000000..cf16eb0ff6a --- /dev/null +++ b/dotcom-rendering/src/client/deviceDetection/iPadDetection.ts @@ -0,0 +1,39 @@ +import { getCookie, setCookie } from '@guardian/libs'; + +const DEVICE_CLASS_COOKIE = 'device_class'; + +/** + * Detects iPad devices using feature detection. + * + * - Older iPads (iOS 12 and earlier) report 'iPad' in navigator.platform + * - Newer iPads (iPadOS 13+) report as 'MacIntel' but have touch support + * (navigator.maxTouchPoints > 1, while Macs have 0) + * + * @see https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885#9039885 + */ +export const isIPad = (): boolean => { + const isOlderIPad = navigator.platform.includes('iPad'); + + const isNewerIPad = + navigator.platform === 'MacIntel' && + typeof navigator.maxTouchPoints === 'number' && + navigator.maxTouchPoints > 1; + + return isOlderIPad || isNewerIPad; +}; + +/** + * Sets the device_class cookie if the device is detected as iPadOS. + * This cookie will be sent to the backend (SDC) for proper targeting. + */ +export const setDeviceClassCookie = (): void => { + const existingCookie = getCookie({ name: DEVICE_CLASS_COOKIE }); + + if (isIPad() && existingCookie !== 'tablet') { + setCookie({ + name: DEVICE_CLASS_COOKIE, + value: 'tablet', + daysToLive: 365, + }); + } +}; diff --git a/dotcom-rendering/src/client/main.web.ts b/dotcom-rendering/src/client/main.web.ts index 95a572c1246..8331ee71ca0 100644 --- a/dotcom-rendering/src/client/main.web.ts +++ b/dotcom-rendering/src/client/main.web.ts @@ -1,9 +1,13 @@ import './webpackPublicPath'; import { adaptSite, shouldAdapt } from './adaptiveSite'; +import { setDeviceClassCookie } from './deviceDetection/iPadDetection'; import { startup } from './startup'; import { maybeSIndicatorCapiKey } from './userFeatures/cookies/sIndicatorCapiKey'; void (async () => { + // Set device class cookie for iPadOS detection (runs immediately, before any SDC requests) + setDeviceClassCookie(); + if (await shouldAdapt()) { adaptSite('Web'); }