diff --git a/app/offline/page.tsx b/app/offline/page.tsx
new file mode 100644
index 000000000..8ddf9d934
--- /dev/null
+++ b/app/offline/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next';
+import OfflineFallback from '@/components/pwa/OfflineFallback';
+
+export const metadata: Metadata = {
+ title: 'Offline | CommitPulse',
+ description: 'Connection lost. Please check your internet connection.',
+ robots: {
+ index: false,
+ follow: false,
+ },
+};
+
+export default function OfflinePage() {
+ return ;
+}
diff --git a/app/sw.ts b/app/sw.ts
index 34c5c77fd..ebf8a60fc 100644
--- a/app/sw.ts
+++ b/app/sw.ts
@@ -1,6 +1,6 @@
import { defaultCache } from '@serwist/next/worker';
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
-import { Serwist } from 'serwist';
+import { Serwist, NetworkFirst } from 'serwist';
// Extend the ServiceWorkerGlobalScope with Serwist's injected manifest
declare global {
@@ -22,7 +22,34 @@ const serwist = new Serwist({
// Prefetch responses while the browser handles navigation
navigationPreload: true,
- runtimeCaching: defaultCache,
+ runtimeCaching: [
+ {
+ matcher({ request }) {
+ return request.mode === 'navigate';
+ },
+ handler: new NetworkFirst({
+ cacheName: 'pages',
+ plugins: [
+ {
+ async handlerDidError() {
+ return caches.match('/offline');
+ },
+ },
+ ],
+ }),
+ },
+ ...defaultCache,
+ ],
+ fallbacks: {
+ entries: [
+ {
+ url: '/offline',
+ matcher({ request }) {
+ return request.mode === 'navigate';
+ },
+ },
+ ],
+ },
});
serwist.addEventListeners();
diff --git a/components/pwa/OfflineFallback.tsx b/components/pwa/OfflineFallback.tsx
new file mode 100644
index 000000000..881d23dd5
--- /dev/null
+++ b/components/pwa/OfflineFallback.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useState } from 'react';
+import { WifiOff, RefreshCw } from 'lucide-react';
+import { useTranslation } from '@/context/TranslationContext';
+
+export default function OfflineFallback() {
+ const { t } = useTranslation();
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRetry = () => {
+ setIsRefreshing(true);
+ // Attempt reload
+ window.location.reload();
+ };
+
+ return (
+
+
+
+
+ {t('offline.title', { defaultValue: 'Connection Lost' })}
+
+
+
+ {t('offline.description', {
+ defaultValue:
+ 'You are currently offline. Check your internet connection and try refreshing the page.',
+ })}
+
+
+
+
+ );
+}
diff --git a/tests/pwa/offline-page.test.tsx b/tests/pwa/offline-page.test.tsx
new file mode 100644
index 000000000..1773a2560
--- /dev/null
+++ b/tests/pwa/offline-page.test.tsx
@@ -0,0 +1,40 @@
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+import OfflineFallback from '@/components/pwa/OfflineFallback';
+
+// Mock location.reload
+const reloadMock = vi.fn();
+Object.defineProperty(window, 'location', {
+ value: { reload: reloadMock },
+ writable: true,
+});
+
+describe('OfflineFallback Component', () => {
+ beforeEach(() => {
+ reloadMock.mockClear();
+ });
+
+ it('renders connection lost header, description, and button', () => {
+ render();
+
+ expect(screen.getByText('Connection Lost')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'You are currently offline. Check your internet connection and try refreshing the page.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
+ });
+
+ it('triggers page reload when try again button is clicked', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /try again/i });
+ fireEvent.click(button);
+
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+ expect(button).toBeDisabled();
+ });
+});