diff --git a/public/sw.js b/public/sw.js
index b847b2ca1a2..b01a9477db3 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,3 +1,4 @@
+/* eslint-disable no-restricted-syntax */
/* eslint-disable no-useless-return */
/* eslint-disable import/prefer-default-export */
/* eslint-disable no-unused-vars */
@@ -10,14 +11,86 @@ const cacheName = 'simorghCache_v4';
const pwaClients = new Map();
let isPWADeviceOffline = false;
+// --- IndexedDB helpers ---
+const DB_NAME = 'simorghOfflineDB';
+const STORE_NAME = 'cachedArticles';
+const MAX_ARTICLE_AGE_MS = 72 * 60 * 60 * 1000; // 72 hours
+const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+const openDB = () =>
+ new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = e => {
+ const db = e.target.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME, { keyPath: 'url' });
+ }
+ if (!db.objectStoreNames.contains('meta')) {
+ db.createObjectStore('meta', { keyPath: 'key' });
+ }
+ };
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+
+const dbGet = async (store, key) => {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(store, 'readonly');
+ const req = tx.objectStore(store).get(key);
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+};
+
+const dbPut = async (store, value) => {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(store, 'readwrite');
+ const req = tx.objectStore(store).put(value);
+ req.onsuccess = () => resolve();
+ req.onerror = e => reject(e.target.error);
+ });
+};
+
+const dbGetAll = async store => {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(store, 'readonly');
+ const req = tx.objectStore(store).getAll();
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+};
+
+const dbDelete = async (store, key) => {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(store, 'readwrite');
+ const req = tx.objectStore(store).delete(key);
+ req.onsuccess = () => resolve();
+ req.onerror = e => reject(e.target.error);
+ });
+};
+
// --------------------
// Helper Functions
// --------------------
+const loggerEnabled = true;
+const generatedTimestamp = new Date().toISOString();
+
+const logger = (...args) => {
+ if (!loggerEnabled) return;
+ // eslint-disable-next-line no-console
+ console.log(`[SW ${version}]`, ...args);
+};
+
const getServiceFromUrl = url => new URL(url).pathname.split('/')[1];
const getOfflinePageUrl = service => `/${service}/offline`;
const cacheResource = async (cache, url) => {
+ logger('cacheResource', { url });
try {
const response = await fetch(url);
if (response.ok) await cache.put(url, response.clone());
@@ -27,16 +100,10 @@ const cacheResource = async (cache, url) => {
}
};
-const cacheOfflinePageAndResources = async service => {
- const cache = await caches.open(cacheName);
- const offlinePageUrl = new URL(
- getOfflinePageUrl(service),
- self.location.origin,
- ).href;
-
- if (await cache.match(offlinePageUrl)) return;
+const cachePageAndResources = async (cache, url, forceRefresh = false) => {
+ if (!forceRefresh && (await cache.match(url))) return;
- const resp = await cacheResource(cache, offlinePageUrl);
+ const resp = await cacheResource(cache, url);
if (!resp || !resp.ok) return;
const html = await resp.text();
@@ -48,7 +115,104 @@ const cacheOfflinePageAndResources = async service => {
);
const resources = [...scriptSrcs, ...linkHrefs].filter(Boolean);
- await Promise.allSettled(resources.map(url => cacheResource(cache, url)));
+ await Promise.allSettled(resources.map(r => cacheResource(cache, r)));
+};
+
+const cacheOfflinePageAndResources = async (service, forceRefresh = false) => {
+ const cache = await caches.open(cacheName);
+ const offlinePageUrl = new URL(
+ getOfflinePageUrl(service),
+ self.location.origin,
+ ).href;
+
+ await cachePageAndResources(cache, offlinePageUrl, forceRefresh);
+};
+
+const getMostReadDataFromOfflinePage = async service => {
+ const offlinePageUrl = new URL(
+ getOfflinePageUrl(service),
+ self.location.origin,
+ ).href;
+
+ const cache = await caches.open(cacheName);
+ const cachedResponse = await cache.match(offlinePageUrl);
+ if (!cachedResponse) return null;
+
+ const html = await cachedResponse.text();
+
+ const match = html.match(
+ /
+ )}
-
+
+ {mostReadOfflineData?.items?.length ? (
+
+
+ {title}
+
+
+ {message}
+
+
+ Most read articles
+
+
+
+ ) : (
+
+ )}
>
);
};
diff --git a/ws-nextjs-app/pages/[service]/offline/index.page.tsx b/ws-nextjs-app/pages/[service]/offline/index.page.tsx
index 8df456fa8fb..4ad6b050ad5 100644
--- a/ws-nextjs-app/pages/[service]/offline/index.page.tsx
+++ b/ws-nextjs-app/pages/[service]/offline/index.page.tsx
@@ -1,15 +1,20 @@
import dynamic from 'next/dynamic';
import { GetServerSideProps } from 'next';
-import { OFFLINE_PAGE } from '#app/routes/utils/pageTypes';
+import { OFFLINE_PAGE, MOST_READ_PAGE } from '#app/routes/utils/pageTypes';
import PageDataParams from '#app/models/types/pageDataParams';
import deriveVariant from '#nextjs/utilities/deriveVariant';
-import extractHeaders from '#server/utilities/extractHeaders';
import logResponseTime from '#server/utilities/logResponseTime';
+import getPageData from '#nextjs/utilities/pageRequests/getPageData';
+import extractHeaders from '#src/server/utilities/extractHeaders';
const OfflinePage = dynamic(() => import('./OfflinePage'));
export const getServerSideProps: GetServerSideProps = async context => {
- const { service, variant: variantFromUrl } = context.query as PageDataParams;
+ const {
+ service,
+ variant: variantFromUrl,
+ renderer_env: rendererEnv,
+ } = context.query as PageDataParams;
const variant = deriveVariant(variantFromUrl);
logResponseTime({ path: context.resolvedUrl }, context.res, () => null);
@@ -19,16 +24,35 @@ export const getServerSideProps: GetServerSideProps = async context => {
'public, max-age=300, stale-while-revalidate=600, stale-if-error=3600',
);
+ let mostReadData = null;
+
+ try {
+ const { data } = await getPageData({
+ pageType: MOST_READ_PAGE,
+ id: `/${service}/popular/read`,
+ resolvedUrl: `/${service}/popular/read`,
+ service,
+ variant: variant || undefined,
+ rendererEnv: rendererEnv || 'live',
+ });
+
+ mostReadData = data?.pageData ?? null;
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to fetch most read data:', err);
+ }
+
return {
props: {
- service,
- variant,
pageType: OFFLINE_PAGE,
+ service,
status: 200,
+ variant: variant || null,
timeOnServer: Date.now(),
pathname: `/${service}/offline`,
...extractHeaders(context.req.headers),
pageData: {
+ mostReadData,
metadata: {
type: OFFLINE_PAGE,
},
diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx
index a9d32bfb211..1cb596a8ba7 100644
--- a/ws-nextjs-app/pages/_app.page.tsx
+++ b/ws-nextjs-app/pages/_app.page.tsx
@@ -31,6 +31,7 @@ import { AccountProvider } from '#app/contexts/AccountContext';
import getIdctaConfig from '#app/lib/idcta/getIdctaConfig';
import { IdctaConfig } from '#app/models/types/account';
import fetchConfig from '#app/lib/utilities/fetchConfig';
+import isOffline from '#app/lib/utilities/isOfflineMode';
interface Props {
pageProps: {
@@ -62,6 +63,7 @@ interface Props {
isUK?: boolean;
country?: string | null;
idctaConfig: IdctaConfig | null;
+ isOfflineMode: boolean;
};
}
@@ -72,6 +74,7 @@ export default class CustomApp extends App {
const { asPath = '' } = ctx;
const { isApp, isAmp, isLite } = getPathExtension(asPath);
+ const isOfflineMode = isOffline(asPath);
const { service, variant } = parseRoute(asPath) as {
service: Services;
@@ -119,6 +122,7 @@ export default class CustomApp extends App {
isApp,
isAmp,
isLite,
+ isOfflineMode,
isNextJs: true,
serverSideExperiments,
toggles,
@@ -155,6 +159,7 @@ export default class CustomApp extends App {
country,
idctaConfig = null,
navItems,
+ isOfflineMode,
} = pageProps;
const { metadata: { atiAnalytics = undefined } = {} } = pageData ?? {};
@@ -179,6 +184,7 @@ export default class CustomApp extends App {
isAmp={isAmp}
isApp={isApp}
isLite={isLite}
+ isOfflineMode={isOfflineMode}
pageType={pageType}
service={service}
statusCode={status}