diff --git a/backend/config/cors.php b/backend/config/cors.php index a54e988..b8cfe9b 100644 --- a/backend/config/cors.php +++ b/backend/config/cors.php @@ -6,7 +6,10 @@ 'allowed_methods' => ['*'], - 'allowed_origins' => ['http://localhost:3000'], + 'allowed_origins' => [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + ], 'allowed_origins_patterns' => [], diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index cde73cf..7726638 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -20,7 +20,7 @@ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:3000,127.0.0.1:8000,::1', Sanctum::currentApplicationUrlWithPort(), // Sanctum::currentRequestHost(), ))), diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index d4112ac..514bd3a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,8 +1,11 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ToastProvider } from "@/components/providers/toast-provider"; import { LanguageProvider } from "@/components/providers/language-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; +import { PwaRegister } from "@/components/pwa/pwa-register"; +import { PwaInstallPrompt } from "@/components/pwa/pwa-install-prompt"; +import { NetworkStatusIndicator } from "@/components/pwa/network-status-indicator"; import "./globals.css"; const geistSans = Geist({ @@ -15,9 +18,37 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#1C1C1C" }, + ], + width: "device-width", + initialScale: 1, + maximumScale: 5, +}; + export const metadata: Metadata = { - title: "MealBuddy", + title: "MealBuddy | Office Lunch Management", description: "Office cafeteria lunch management and billing", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "MealBuddy | Office Lunch Management", + }, + formatDetection: { + telephone: false, + }, + icons: { + icon: [ + { url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [ + { url: "/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, + ], + }, }; const themeScript = ` @@ -52,6 +83,9 @@ export default function RootLayout({ {children} + + + diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts new file mode 100644 index 0000000..a79c613 --- /dev/null +++ b/frontend/app/manifest.ts @@ -0,0 +1,70 @@ +import type { MetadataRoute } from 'next'; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'MealBuddy | Office Lunch Management', + short_name: 'MealBuddy | Office Lunch Management', + description: 'Office cafeteria lunch management, daily meal voting, and billing', + start_url: '/', + scope: '/', + display: 'standalone', + orientation: 'portrait-primary', + background_color: '#ffffff', + theme_color: '#2E5A88', + icons: [ + { + src: '/icons/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icons/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icons/icon-512x512-maskable.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + { + src: '/icons/apple-touch-icon.png', + sizes: '180x180', + type: 'image/png', + }, + ], + shortcuts: [ + { + name: "Today's Lunch / Vote", + short_name: 'Vote', + description: 'Cast your daily lunch vote before 10 AM', + url: '/vote', + icons: [{ src: '/icons/icon-192x192.png', sizes: '192x192' }], + }, + { + name: 'My Dashboard', + short_name: 'Dashboard', + description: 'View lunch attendance and meal history', + url: '/user_dashboard', + icons: [{ src: '/icons/icon-192x192.png', sizes: '192x192' }], + }, + { + name: 'Lunch Billing', + short_name: 'Billing', + description: 'View monthly cafeteria lunch billing', + url: '/user_dashboard/billing', + icons: [{ src: '/icons/icon-192x192.png', sizes: '192x192' }], + }, + { + name: 'Chef Dashboard', + short_name: 'Chef', + description: 'Manage menus and headcount', + url: '/dashboard', + icons: [{ src: '/icons/icon-192x192.png', sizes: '192x192' }], + }, + ], + }; +} diff --git a/frontend/app/offline/page.tsx b/frontend/app/offline/page.tsx new file mode 100644 index 0000000..9b75678 --- /dev/null +++ b/frontend/app/offline/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import { WifiOff, RefreshCw, ArrowLeft } from 'lucide-react'; +import { useLanguage } from '@/components/providers/language-provider'; + +export default function OfflinePage() { + const { t } = useLanguage(); + + const handleRetry = () => { + window.location.reload(); + }; + + const handleGoBack = () => { + window.history.back(); + }; + + return ( +
+
+
+ +
+ +

+ {t('you_are_offline')} +

+ +

+ {t('internet_connection_unavailable')} +

+ +

+ {t('offline_reconnect_prompt')} +

+ +
+ + +
+
+
+ ); +} diff --git a/frontend/components/providers/language-provider.tsx b/frontend/components/providers/language-provider.tsx index 876349f..488c93d 100644 --- a/frontend/components/providers/language-provider.tsx +++ b/frontend/components/providers/language-provider.tsx @@ -311,6 +311,20 @@ const translations = { dietary_preference: 'Dietary Preference', veg: 'Vegetarian', non_veg: 'Non-Vegetarian', + + // PWA & Offline + install_mealbuddy: 'Install MealBuddy', + install_app_desc: 'Install MealBuddy on your device for quick access and a faster experience.', + add_to_home_screen: 'Add to Home Screen', + you_are_offline: "You're offline", + back_online: 'Back online', + try_again: 'Try Again', + new_version_available: 'New MealBuddy version available', + refresh: 'Refresh', + install_instructions_ios: 'To install: tap Share and select "Add to Home Screen"', + internet_connection_unavailable: 'Internet connection is unavailable. Live meal voting, menu updates, and billing cannot currently be loaded.', + offline_reconnect_prompt: 'Please reconnect to the internet and try again.', + dismiss: 'Dismiss', }, bo: { app_title: 'ཟས་མཐུན་ལས་རོགས།', @@ -616,6 +630,20 @@ const translations = { dietary_preference: 'དཀར་དམར་གྱི་འདེམས་ཀ།', veg: 'དཀར་ཟས།', non_veg: 'དམར་ཟས།', + + // PWA & Offline + install_mealbuddy: 'MealBuddy གཏེགས་འཇུག་བྱོས།', + install_app_desc: 'མགྱོགས་མྱུར་སྤྱོད་བདེའི་ཆེད་ MealBuddy ཉེར་སྤྱོད་ཁྱེད་ཀྱི་ཁ་པར་ནང་གཏེགས་འཇུག་གནང་རོགས།', + add_to_home_screen: 'གཙོ་ངོས་སུ་སྣོན་པ།', + you_are_offline: 'དྲ་རྒྱའི་སྦྲེལ་མཐུད་མེད།', + back_online: 'དྲ་རྒྱའི་སྦྲེལ་མཐུད་སླར་གསོ་བྱུང་།', + try_again: 'བསྐྱར་དུ་འབད་བརྩོན་བྱོས།', + new_version_available: 'MealBuddy པར་གཞི་གསར་པ་འདུག', + refresh: 'གསར་བཅོས་བྱོས།', + install_instructions_ios: 'གཏེགས་འཇུག་བྱེད་པར། མཉམ་སྤྱོད་ (Share) མནན་ཏེ་ "གཙོ་ངོས་སུ་སྣོན་པ་" (Add to Home Screen) འདེམས་རོགས།', + internet_connection_unavailable: 'དྲ་རྒྱའི་སྦྲེལ་མཐུད་མི་འདུག ཉིན་ཟས་འོས་བསྡུ་དང་རྩིས་ཁྲ་སོགས་དྲ་ཐོག་གི་གནས་ཚུལ་རྣམས་ལྟ་མི་ཐུབ།', + offline_reconnect_prompt: 'དྲ་རྒྱ་ལ་སླར་སྦྲེལ་མཐུད་གནང་རྗེས་བསྐྱར་དུ་འབད་བརྩོན་བྱོས།', + dismiss: 'སྣང་མེད་གཏོང་བ།', } }; @@ -666,7 +694,7 @@ export function LanguageProvider({ children }: { children: React.ReactNode }) { const t = (key: string, replacements?: Record): string => { const dict = translations[language]; - let value = (dict as any)[key] || (translations['en'] as any)[key] || key; + let value = (dict as Record)[key] || (translations['en'] as Record)[key] || key; if (replacements) { Object.entries(replacements).forEach(([k, val]) => { diff --git a/frontend/components/pwa/network-status-indicator.tsx b/frontend/components/pwa/network-status-indicator.tsx new file mode 100644 index 0000000..8dd21f8 --- /dev/null +++ b/frontend/components/pwa/network-status-indicator.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React, { useEffect, useState, useRef, useSyncExternalStore } from 'react'; +import { WifiOff, Wifi } from 'lucide-react'; +import { useLanguage } from '@/components/providers/language-provider'; + +function subscribeToOnlineStatus(callback: () => void) { + window.addEventListener('online', callback); + window.addEventListener('offline', callback); + return () => { + window.removeEventListener('online', callback); + window.removeEventListener('offline', callback); + }; +} + +function getOnlineSnapshot() { + return navigator.onLine; +} + +function getServerSnapshot() { + return true; +} + +export function NetworkStatusIndicator() { + const { t } = useLanguage(); + const isOnline = useSyncExternalStore( + subscribeToOnlineStatus, + getOnlineSnapshot, + getServerSnapshot + ); + const [showRestoredBanner, setShowRestoredBanner] = useState(false); + const prevOnlineRef = useRef(true); + + useEffect(() => { + if (!prevOnlineRef.current && isOnline) { + // Transitioned from offline to online + setShowRestoredBanner(true); + const timer = setTimeout(() => { + setShowRestoredBanner(false); + }, 3500); + return () => clearTimeout(timer); + } + prevOnlineRef.current = isOnline; + }, [isOnline]); + + // Normal online state: render nothing + if (isOnline && !showRestoredBanner) { + return null; + } + + return ( +
+ {!isOnline ? ( +
+ + {t('you_are_offline')} +
+ ) : showRestoredBanner ? ( +
+ + {t('back_online')} +
+ ) : null} +
+ ); +} diff --git a/frontend/components/pwa/pwa-install-prompt.tsx b/frontend/components/pwa/pwa-install-prompt.tsx new file mode 100644 index 0000000..167700d --- /dev/null +++ b/frontend/components/pwa/pwa-install-prompt.tsx @@ -0,0 +1,207 @@ +'use client'; + +import React, { useEffect, useState, useSyncExternalStore } from 'react'; +import { Download, Share2, PlusSquare, X } from 'lucide-react'; +import Image from 'next/image'; +import { useLanguage } from '@/components/providers/language-provider'; + +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const STORAGE_KEY = 'mealbuddy_pwa_dismissed'; + +interface NavigatorWithStandalone extends Navigator { + standalone?: boolean; +} + +interface WindowWithMSStream extends Window { + MSStream?: unknown; +} + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +} + +const emptySubscribe = () => () => {}; + +function getIsStandalone(): boolean { + if (typeof window === 'undefined') return false; + return ( + window.matchMedia('(display-mode: standalone)').matches || + (window.navigator as NavigatorWithStandalone).standalone === true || + document.referrer.includes('android-app://') + ); +} + +function getIsIosSafari(): boolean { + if (typeof window === 'undefined') return false; + const ua = window.navigator.userAgent; + const isIos = /iPhone|iPad|iPod/.test(ua) && !(window as WindowWithMSStream).MSStream; + const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua); + return isIos && isSafari; +} + +function getIsDismissedFromStorage(): boolean { + if (typeof window === 'undefined') return true; + try { + const dismissedTime = localStorage.getItem(STORAGE_KEY); + if (dismissedTime) { + const timeElapsed = Date.now() - parseInt(dismissedTime, 10); + return timeElapsed < DISMISS_DURATION_MS; + } + } catch { + return false; + } + return false; +} + +export function PwaInstallPrompt() { + const { t } = useLanguage(); + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [manuallyDismissed, setManuallyDismissed] = useState(false); + + const isStandalone = useSyncExternalStore(emptySubscribe, getIsStandalone, () => false); + const isIosSafari = useSyncExternalStore(emptySubscribe, getIsIosSafari, () => false); + const isDismissedStored = useSyncExternalStore( + emptySubscribe, + getIsDismissedFromStorage, + () => true + ); + + useEffect(() => { + if (isStandalone || isDismissedStored) { + return; + } + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, [isStandalone, isDismissedStored]); + + const handleDismiss = () => { + setManuallyDismissed(true); + try { + localStorage.setItem(STORAGE_KEY, Date.now().toString()); + } catch {} + }; + + const handleInstallClick = async () => { + if (!deferredPrompt) return; + try { + await deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + if (choice.outcome === 'accepted') { + setManuallyDismissed(true); + } + setDeferredPrompt(null); + } catch (err) { + console.error('[PWA] Error during installation prompt:', err); + } + }; + + if (isStandalone || isDismissedStored || manuallyDismissed) { + return null; + } + + // Case A: Chromium / Android / Desktop with install event available + if (deferredPrompt) { + return ( +
+
+
+ MealBuddy +
+
+

+ {t('install_mealbuddy')} +

+

+ {t('install_app_desc')} +

+
+
+ +
+ + +
+
+ ); + } + + // Case B: iOS Safari guidance + if (isIosSafari) { + return ( +
+
+
+
+ MealBuddy +
+
+

+ {t('install_mealbuddy')} +

+

+ {t('install_app_desc')} +

+
+
+ +
+ +
+ + {t('install_instructions_ios')} + +
+
+ ); + } + + return null; +} diff --git a/frontend/components/pwa/pwa-register.tsx b/frontend/components/pwa/pwa-register.tsx new file mode 100644 index 0000000..aa828f4 --- /dev/null +++ b/frontend/components/pwa/pwa-register.tsx @@ -0,0 +1,103 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import { RefreshCw, Sparkles } from 'lucide-react'; +import { useLanguage } from '@/components/providers/language-provider'; + +export function PwaRegister() { + const { t } = useLanguage(); + const [updateAvailable, setUpdateAvailable] = useState(false); + const waitingWorkerRef = useRef(null); + + useEffect(() => { + if (typeof window === 'undefined' || !('serviceWorker' in navigator)) { + return; + } + + let refreshing = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + refreshing = true; + window.location.reload(); + } + }); + + const registerServiceWorker = async () => { + try { + const registration = await navigator.serviceWorker.register('/sw.js', { + scope: '/', + }); + + // 1. If an updated worker is already waiting + if (registration.waiting) { + waitingWorkerRef.current = registration.waiting; + setUpdateAvailable(true); + } + + // 2. Listen for new workers entering the waiting state + registration.addEventListener('updatefound', () => { + const installingWorker = registration.installing; + if (!installingWorker) return; + + installingWorker.addEventListener('statechange', () => { + if ( + installingWorker.state === 'installed' && + navigator.serviceWorker.controller + ) { + // New update installed and ready to be activated by user + waitingWorkerRef.current = installingWorker; + setUpdateAvailable(true); + } + }); + }); + } catch (err) { + // Graceful error logging without spamming production + if (process.env.NODE_ENV === 'development') { + console.debug('[PWA] Service worker registration omitted or failed:', err); + } + } + }; + + // Register after initial window load for fast first paint + if (document.readyState === 'complete') { + registerServiceWorker(); + } else { + window.addEventListener('load', registerServiceWorker, { once: true }); + } + }, []); + + const handleUpdate = () => { + if (waitingWorkerRef.current) { + waitingWorkerRef.current.postMessage({ type: 'SKIP_WAITING' }); + } + }; + + if (!updateAvailable) { + return null; + } + + return ( +
+
+
+ +
+

+ {t('new_version_available')} +

+
+ + +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 54cf64a..547eba7 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,15 +1,30 @@ import axios from "axios"; export const api = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_URL, + baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api", withCredentials: true, + withXSRFToken: true, headers: { Accept: "application/json", }, }); -// Interceptors removed in favor of stateful cookie auth +// Ensure X-XSRF-TOKEN header is always attached for stateful Sanctum requests +api.interceptors.request.use((config) => { + if (typeof window !== "undefined") { + const match = document.cookie.match(new RegExp("(^| )XSRF-TOKEN=([^;]+)")); + if (match && !config.headers["X-XSRF-TOKEN"]) { + config.headers["X-XSRF-TOKEN"] = decodeURIComponent(match[2]); + } + } + return config; +}); + export async function getCsrfCookie() { - const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", ""); - await axios.get(`${baseUrl}/sanctum/csrf-cookie`, { withCredentials: true }); + const rawApiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + const baseUrl = rawApiUrl.replace(/\/api\/?$/, ""); + await axios.get(`${baseUrl}/sanctum/csrf-cookie`, { + withCredentials: true, + withXSRFToken: true, + }); } \ No newline at end of file diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..9e19be0 Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/favicon-16x16.png b/frontend/public/icons/favicon-16x16.png new file mode 100644 index 0000000..c5b7656 Binary files /dev/null and b/frontend/public/icons/favicon-16x16.png differ diff --git a/frontend/public/icons/favicon-32x32.png b/frontend/public/icons/favicon-32x32.png new file mode 100644 index 0000000..ee08f63 Binary files /dev/null and b/frontend/public/icons/favicon-32x32.png differ diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/icons/icon-192x192.png new file mode 100644 index 0000000..7bc6a87 Binary files /dev/null and b/frontend/public/icons/icon-192x192.png differ diff --git a/frontend/public/icons/icon-512x512-maskable.png b/frontend/public/icons/icon-512x512-maskable.png new file mode 100644 index 0000000..9dead03 Binary files /dev/null and b/frontend/public/icons/icon-512x512-maskable.png differ diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/icons/icon-512x512.png new file mode 100644 index 0000000..7c00a5a Binary files /dev/null and b/frontend/public/icons/icon-512x512.png differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..6b40502 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,189 @@ +// MealBuddy Production Service Worker +// Cache Versioning +const VERSION = 'v1'; +const STATIC_CACHE = `mealbuddy-static-${VERSION}`; +const ASSETS_CACHE = `mealbuddy-assets-${VERSION}`; +const PAGES_CACHE = `mealbuddy-pages-${VERSION}`; + +const CURRENT_CACHES = [STATIC_CACHE, ASSETS_CACHE, PAGES_CACHE]; + +// Core App Shell & Fallback Assets to Pre-cache +const PRECACHE_ASSETS = [ + '/offline', + '/MonlamTBslim.ttf', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', + '/icons/icon-512x512-maskable.png', + '/icons/apple-touch-icon.png', + '/icons/favicon-32x32.png', + '/icons/favicon-16x16.png', +]; + +// Paths that must NEVER be cached (Dynamic / Sensitive Data) +const API_ROUTES = [ + '/api/', + '/v1/', + '/sanctum/', + '/filament/', + '/livewire/', + '/admin/', + '/storage/', +]; + +function isApiOrDynamic(url) { + return API_ROUTES.some((route) => url.pathname.startsWith(route)); +} + +// Install Event: Pre-cache core shell resources +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(STATIC_CACHE) + .then((cache) => cache.addAll(PRECACHE_ASSETS)) + .catch((err) => { + // Precache error should not prevent registration + console.warn('[SW] Pre-cache encountered an issue:', err); + }) + ); + // Do NOT blindly call self.skipWaiting() here. + // We allow a controlled update flow initiated by user interaction. +}); + +// Activate Event: Clean up outdated cache versions +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.map((key) => { + if (!CURRENT_CACHES.includes(key)) { + return caches.delete(key); + } + }) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +// Message Event: Listen for controlled skipWaiting trigger from frontend +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +// Fetch Event: Conservative, robust routing strategies +self.addEventListener('fetch', (event) => { + const { request } = event; + + // 1. Mutating requests: NEVER cache POST, PUT, PATCH, DELETE + if (request.method !== 'GET') { + return; + } + + const url = new URL(request.url); + + // 2. Cross-Origin Requests (e.g. backend API on different port/domain): NEVER intercept + if (url.origin !== self.location.origin) { + return; + } + + // 3. API & Dynamic Endpoints: NEVER cache + if (isApiOrDynamic(url)) { + // Network-only for API requests + return; + } + + // 3. Page Navigation (HTML documents) + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((networkResponse) => { + // If network succeeded, optionally update pages cache + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(PAGES_CACHE).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }) + .catch(async () => { + // Network failed: try cached page or serve offline page fallback + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + const offlineFallback = await caches.match('/offline'); + if (offlineFallback) { + return offlineFallback; + } + return new Response('Network unavailable and offline page not cached.', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/plain' }, + }); + }) + ); + return; + } + + // 4. Next.js Static Build Assets (/_next/static/*) + // Content-hashed files: Stale-While-Revalidate + if (url.pathname.startsWith('/_next/static/')) { + event.respondWith( + caches.match(request).then((cachedResponse) => { + const fetchPromise = fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(ASSETS_CACHE).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }) + .catch(() => null); + + return cachedResponse || fetchPromise; + }) + ); + return; + } + + // 5. Static Shell Resources (fonts, icons, public assets) + if ( + url.pathname.startsWith('/icons/') || + url.pathname.endsWith('.ttf') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.ico') + ) { + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + return fetch(request).then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(STATIC_CACHE).then((cache) => cache.put(request, responseClone)); + } + return networkResponse; + }); + }) + ); + return; + } + + // Default: Network with cache fallback for other GET requests + event.respondWith( + caches.match(request).then((cached) => { + return ( + cached || + fetch(request).catch(() => { + // If offline and not in cache, let it fail naturally + return new Response('', { status: 503, statusText: 'Offline' }); + }) + ); + }) + ); +}); diff --git a/nginx/default.conf b/nginx/default.conf index 44b523a..fcc76a5 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -14,6 +14,33 @@ server { ssl_certificate /etc/letsencrypt/live/meals.monlamit.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/meals.monlamit.com/privkey.pem; + location = /sw.js { + proxy_pass http://frontend:3000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + location = /manifest.webmanifest { + proxy_pass http://frontend:3000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + add_header Content-Type "application/manifest+json"; + add_header Cache-Control "public, max-age=3600"; + } + location / { proxy_pass http://frontend:3000; proxy_http_version 1.1;