diff --git a/src/app/components/ATIAnalytics/canonical/index.tsx b/src/app/components/ATIAnalytics/canonical/index.tsx index c3185f3311f..db040c549ca 100644 --- a/src/app/components/ATIAnalytics/canonical/index.tsx +++ b/src/app/components/ATIAnalytics/canonical/index.tsx @@ -13,6 +13,7 @@ import usePWAInstallTracker from '#app/hooks/usePWAInstallTracker'; import { reverbUrlHelper } from '@bbc/reverb-url-helper'; import useConnectionBackOnlineTracker from '#app/hooks/useConnectionBackOnlineTracker'; import useConnectionTypeTracker from '#app/hooks/useConnectionTypeTracker'; +import usePWAOfflineTracking from '#app/hooks/usePWAOfflineTracking'; import { ATIAnalyticsProps } from '../types'; import getNoScriptTrackingPixelUrl from './getNoScriptTrackingPixelUrl'; import sendPageViewBeaconOperaMini from './sendPageViewBeaconOperaMini'; @@ -47,6 +48,7 @@ const CanonicalATIAnalytics = ({ reverbParams }: ATIAnalyticsProps) => { useConnectionTypeTracker(); useConnectionBackOnlineTracker(); + usePWAOfflineTracking(); const [reverbBeaconConfig] = useState(reverbParams); diff --git a/src/app/hooks/useCustomEventTracker/index.tsx b/src/app/hooks/useCustomEventTracker/index.tsx index 61ce812b392..eaf6d7e4369 100644 --- a/src/app/hooks/useCustomEventTracker/index.tsx +++ b/src/app/hooks/useCustomEventTracker/index.tsx @@ -57,6 +57,22 @@ const useCustomEventTracker = ({ ].every(Boolean); if (shouldSendEvent) { + // TEMP: Adding console logs to help debug event tracking issues - will remove later + // eslint-disable-next-line no-console + console.log('Tracking custom event:', { + eventName, + stringifiedData, + campaignID, + pageIdentifier, + platform, + producerId, + producerName, + service, + statsDestination, + experimentName, + experimentVariant, + }); + try { await sendEventBeacon({ type: VIEW_EVENT, diff --git a/src/app/hooks/useOfflinePageFlag/index.test.tsx b/src/app/hooks/useOfflinePageFlag/index.test.tsx new file mode 100644 index 00000000000..5160c61df49 --- /dev/null +++ b/src/app/hooks/useOfflinePageFlag/index.test.tsx @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react'; +import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; +import { useOfflinePageFlag, OFFLINE_VISIT_FLAG } from './index'; + +describe('useOfflinePageFlag', () => { + beforeEach(() => { + Storage.prototype.setItem = jest.fn(); + Storage.prototype.getItem = jest.fn(); + Storage.prototype.removeItem = jest.fn(); + jest.clearAllMocks(); + }); + + it('should set offline flag when rendered', () => { + renderHook(() => useOfflinePageFlag()); + + expect(localStorage.setItem).toHaveBeenCalledWith( + OFFLINE_VISIT_FLAG, + 'true', + ); + }); + + it('should handle localStorage errors gracefully', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + Storage.prototype.setItem = jest.fn().mockImplementation(() => { + throw new Error('localStorage is full'); + }); + + expect(() => renderHook(() => useOfflinePageFlag())).not.toThrow(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'useOfflinePageFlag', + expect.any(Error), + ); + }); + + it('should not set flag on server side', () => { + renderSSRHook(() => useOfflinePageFlag()); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/hooks/useOfflinePageFlag/index.tsx b/src/app/hooks/useOfflinePageFlag/index.tsx new file mode 100644 index 00000000000..0a79b5dca6d --- /dev/null +++ b/src/app/hooks/useOfflinePageFlag/index.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +const OFFLINE_VISIT_FLAG = 'offline_page_visit'; + +/** + * Sets a flag in localStorage when user visits the offline page. + * Note: Offline page is only accessible in PWA mode (via service worker), + * so no need to check isPWA - if this hook runs, we're already in PWA. + */ +const useOfflinePageFlag = () => { + useEffect(() => { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(OFFLINE_VISIT_FLAG, 'true'); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('useOfflinePageFlag', error); + } + }, []); +}; + +export { useOfflinePageFlag, OFFLINE_VISIT_FLAG }; diff --git a/src/app/hooks/usePWAOfflineTracking/index.test.tsx b/src/app/hooks/usePWAOfflineTracking/index.test.tsx new file mode 100644 index 00000000000..6bffb85cc94 --- /dev/null +++ b/src/app/hooks/usePWAOfflineTracking/index.test.tsx @@ -0,0 +1,247 @@ +import { renderHook } from '@testing-library/react'; +import { renderHook as renderSSRHook } from '@testing-library/react-hooks/server'; +import { EffectiveNetworkType } from '#app/models/types/global'; +import usePWAOfflineTracking from './index'; +import useNetworkStatusTracker from '../useNetworkStatusTracker'; +import useCustomEventTracker from '../useCustomEventTracker'; + +jest.mock('../useNetworkStatusTracker'); +jest.mock('../useCustomEventTracker'); + +describe('usePWAOfflineTracking', () => { + const mockTrackOfflinePageViewEvent = jest.fn(); + const mockUseNetworkStatusTracker = + useNetworkStatusTracker as jest.MockedFunction< + typeof useNetworkStatusTracker + >; + const mockUseCustomEventTracker = + useCustomEventTracker as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + Storage.prototype.getItem = jest.fn(); + Storage.prototype.setItem = jest.fn(); + Storage.prototype.removeItem = jest.fn(); + + mockUseCustomEventTracker.mockReturnValue(mockTrackOfflinePageViewEvent); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not fire event when offline flag is not set', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + Storage.prototype.getItem = jest.fn().mockReturnValue(null); + + renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); + }); + + it('should fire event when online and flag is set', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith('4g'); + expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); + }); + + it('should fire event on offline→online transition', () => { + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + + const { rerender } = renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith('4g'); + expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); + }); + + it('should not fire event again without flag being set', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + const mockGetItem = jest + .fn() + .mockReturnValueOnce('true') + .mockReturnValue(null); + Storage.prototype.getItem = mockGetItem; + + const { rerender } = renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + }); + + it('should fire event again after flag is set again on next offline visit', () => { + const mockGetItem = jest + .fn() + .mockReturnValueOnce('true') + .mockReturnValueOnce('true'); + Storage.prototype.getItem = mockGetItem; + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + + const { rerender } = renderHook(() => usePWAOfflineTracking()); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + + rerender(); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '5g', + }); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(2); + expect(mockTrackOfflinePageViewEvent).toHaveBeenLastCalledWith('5g'); + expect(localStorage.removeItem).toHaveBeenCalledTimes(2); + }); + + it('should remove flag after firing event', () => { + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + + const { rerender } = renderHook(() => usePWAOfflineTracking()); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith('offline_page_visit'); + }); + + it('should not fire when offline even if flag is set', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); + }); + + it('should handle localStorage errors gracefully', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + Storage.prototype.getItem = jest.fn().mockImplementation(() => { + throw new Error('localStorage access denied'); + }); + + expect(() => renderHook(() => usePWAOfflineTracking())).not.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'usePWAOfflineTracking', + expect.any(Error), + ); + expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); + }); + + it('should not track on server side', () => { + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + renderSSRHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).not.toHaveBeenCalled(); + }); + + it.each(['slow-2g', '2g', '3g', '4g', '5g', 'unknown'])( + 'should pass correct network type to tracking function: %s', + networkType => { + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: networkType as EffectiveNetworkType, + }); + + renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledWith(networkType); + }, + ); + + it('should only fire on actual offline→online transition, not online→offline', () => { + Storage.prototype.getItem = jest.fn().mockReturnValue('true'); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: true, + networkType: '4g', + }); + + const { rerender } = renderHook(() => usePWAOfflineTracking()); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + + mockUseNetworkStatusTracker.mockReturnValue({ + isOnline: false, + networkType: 'unknown', + }); + + rerender(); + + expect(mockTrackOfflinePageViewEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/hooks/usePWAOfflineTracking/index.tsx b/src/app/hooks/usePWAOfflineTracking/index.tsx new file mode 100644 index 00000000000..e0bbcee52ae --- /dev/null +++ b/src/app/hooks/usePWAOfflineTracking/index.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import useNetworkStatusTracker from '../useNetworkStatusTracker'; +import useCustomEventTracker from '../useCustomEventTracker'; +import { OFFLINE_VISIT_FLAG } from '../useOfflinePageFlag'; + +const OFFLINE_PAGE_VIEW_EVENT_NAME = 'pwa-offline-page-view'; + +/** + * Tracks offline→online transitions after user has visited offline page. + * Fires when network comes back online while flag is set. + * + * Flag can only be set in PWA mode (offline page requires service worker). + * By not checking isPWA at dispatch time, we track each offline session separately + * even if user switches between PWA/browser modes during reconnection. + * This prevents data loss and ensures accurate analytics. + */ +const usePWAOfflineTracking = () => { + const { isOnline, networkType } = useNetworkStatusTracker(); + + const trackOfflinePageViewEvent = useCustomEventTracker({ + eventName: OFFLINE_PAGE_VIEW_EVENT_NAME, + }); + + useEffect(() => { + if (typeof window === 'undefined' || !isOnline) { + return; + } + try { + const offlineVisitFlag = localStorage.getItem(OFFLINE_VISIT_FLAG); + + if (offlineVisitFlag !== 'true') { + return; + } + trackOfflinePageViewEvent(networkType); + localStorage.removeItem(OFFLINE_VISIT_FLAG); + } catch (error) { + // eslint-disable-next-line no-console + console.error('usePWAOfflineTracking', error); + } + }, [isOnline, networkType, trackOfflinePageViewEvent]); +}; + +export default usePWAOfflineTracking; diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx index e24431f1d65..c2a55189f8b 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.test.tsx @@ -22,7 +22,7 @@ describe('OfflinePage', () => { expect( screen.getByText( - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', ), ).toBeInTheDocument(); }); @@ -48,7 +48,7 @@ describe('OfflinePage', () => { expect(screen.getByText('You are offline')).toBeInTheDocument(); expect( screen.getByText( - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page.", + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.', ), ).toBeInTheDocument(); }); diff --git a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx index bab0d21abd1..e4cf34ddb0f 100644 --- a/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx +++ b/ws-nextjs-app/pages/[service]/offline/OfflinePage.tsx @@ -2,13 +2,17 @@ import { use } from 'react'; import Helmet from 'react-helmet'; import { ServiceContext } from '#contexts/ServiceContext'; import ErrorMain from '#app/legacy/components/ErrorMain'; +import { useOfflinePageFlag } from '#app/hooks/useOfflinePageFlag'; const OfflinePage = () => { const { service, dir, script } = use(ServiceContext); + // Track offline page visit (sets flag in localStorage, PWA only) + useOfflinePageFlag(); + const title = 'You are offline'; const message = - "It seems you don't have an internet connection at the moment. Please check your connection and reload the page."; + 'Looks like you’re not online right now. Please check your network and reconnect. Once you’re back, just refresh the page to continue.'; const solutions = [ 'Check your internet connection', 'Refresh the page when your connection is restored',