diff --git a/app/frontend/README.md b/app/frontend/README.md index b1cefee71..55b6dcc03 100644 --- a/app/frontend/README.md +++ b/app/frontend/README.md @@ -17,6 +17,7 @@ Users can: * Manage rewards and certifications * Connect Stellar wallets * Track XP, streaks, and achievements +* Install as an app (PWA) and keep working offline --- @@ -164,7 +165,51 @@ Receive NFT Certificate --- -## Deployment +## PWA & Offline Support + +RustAcademy Web is an installable Progressive Web App with offline-first caching. + +### How it works + +| Piece | File | Role | +| --- | --- | --- | +| Web manifest | `src/app/manifest.ts` | App name, icons, theme, standalone display, and app shortcuts (Generate Link, Dashboard). Served at `/manifest.webmanifest`. | +| Service worker | `public/sw.js` | Precaches the app shell and handles runtime caching (see strategy below). | +| Install/update UI | `src/components/PWAHandler.tsx` | Registers the service worker, shows the install banner, and prompts to refresh when a new version is deployed. Mounted globally in `src/app/layout.tsx`. | +| Offline fallback | `src/app/offline/page.tsx` | Shown for navigations when the network is unreachable and no cached copy exists. | + +### Caching strategy + +* **Navigations (HTML)** — network-first. Fresh pages when online; the last cached copy (or `/offline`) when the connection drops. +* **Static assets** (`/_next/static`, images, fonts, styles, scripts) — cache-first with runtime population. Hashed Next.js assets are immutable, so cache hits are always safe. +* **Never cached** — `/api/*` routes and cross-origin requests. Payment data is always fetched live. + +Bump the `VERSION` constant in `public/sw.js` when changing cache behavior; old caches are cleaned up on activation. + +### Install flow + +1. On first eligible visit, the browser fires `beforeinstallprompt`; `PWAHandler` shows an install banner (bottom-right on desktop). +2. "Install Now" triggers the native install prompt. "Later" hides the banner for 7 days (stored in `localStorage`). +3. Installed users (standalone display mode, including iOS "Add to Home Screen") never see the banner. +4. When a new service worker is deployed, users get a refresh prompt on their next visit. + +### Testing locally + +Service workers require a secure context. `localhost` counts, so: + +```bash +pnpm build && pnpm start +``` + +Then in Chrome DevTools → **Application**: + +* **Manifest** — verify name, icons, and installability. +* **Service workers** — confirm `sw.js` is activated. +* **Network → Offline** — reload to see cached pages / the offline fallback. + +> Note: the dev server (`pnpm dev`) serves `sw.js`, but caching behavior is only meaningful against a production build. + +--- Recommended: diff --git a/app/frontend/public/sw.js b/app/frontend/public/sw.js index 19dca82e1..fed120ae0 100644 --- a/app/frontend/public/sw.js +++ b/app/frontend/public/sw.js @@ -1,16 +1,23 @@ -const CACHE_NAME = " RustAcademy-v1"; +const VERSION = "v2"; +const PRECACHE = `rustacademy-precache-${VERSION}`; +const RUNTIME = `rustacademy-runtime-${VERSION}`; const OFFLINE_URL = "/offline"; -const ASSETS_TO_CACHE = ["/", "/offline", "/icon.png", "/favicon.ico"]; +const PRECACHE_ASSETS = [ + "/", + "/offline", + "/icon-192.png", + "/icon-512.png", + "/favicon.ico", + "/manifest.webmanifest", +]; self.addEventListener("install", (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - // It's okay if /offline fails during install, but we should try to cache it - return cache - .addAll(ASSETS_TO_CACHE) - .catch((err) => console.warn("Offline cache failed", err)); - }), + caches + .open(PRECACHE) + .then((cache) => cache.addAll(PRECACHE_ASSETS)) + .catch((err) => console.warn("Precache failed", err)), ); self.skipWaiting(); }); @@ -20,7 +27,7 @@ self.addEventListener("activate", (event) => { caches.keys().then((cacheNames) => { return Promise.all( cacheNames - .filter((name) => name !== CACHE_NAME) + .filter((name) => name !== PRECACHE && name !== RUNTIME) .map((name) => caches.delete(name)), ); }), @@ -28,21 +35,72 @@ self.addEventListener("activate", (event) => { self.clients.claim(); }); +// Network-first for page navigations: fresh content when online, +// last-seen copy (or /offline) when the network is down. +async function handleNavigation(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + return cached || caches.match(OFFLINE_URL); + } +} + +// Cache-first for static assets. Hashed _next/static files are immutable, +// so serving from cache is always safe. +async function handleAsset(request) { + const cached = await caches.match(request); + if (cached) return cached; + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(RUNTIME); + cache.put(request, response.clone()); + } + return response; + } catch { + // Offline and not cached — return a real Response so the rejection + // doesn't escape the fetch handler. + return new Response("Offline", { + status: 408, + statusText: "Request Timeout", + headers: { "Content-Type": "text/plain" }, + }); + } +} + self.addEventListener("fetch", (event) => { - // Only handle GET requests - if (event.request.method !== "GET") return; - - if (event.request.mode === "navigate") { - event.respondWith( - fetch(event.request).catch(() => { - return caches.match(OFFLINE_URL); - }), - ); - } else { - event.respondWith( - caches.match(event.request).then((response) => { - return response || fetch(event.request); - }), - ); + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + + // Never cache cross-origin requests or API calls — payment data must be live. + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/")) return; + + if (request.mode === "navigate") { + event.respondWith(handleNavigation(request)); + return; + } + + const isStaticAsset = + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/_next/image") || + url.pathname === "/manifest.webmanifest" || + url.pathname === "/favicon.ico" || + request.destination === "manifest" || + request.destination === "style" || + request.destination === "script" || + request.destination === "image" || + request.destination === "font"; + + if (isStaticAsset) { + event.respondWith(handleAsset(request)); } }); diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx index dea5905bc..b0f688d23 100644 --- a/app/frontend/src/app/layout.tsx +++ b/app/frontend/src/app/layout.tsx @@ -1,7 +1,8 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Header } from "@/components/Header"; import { NotificationCenterProvider } from "@/components/NotificationCenterProvider"; import { ErrorReportingShell } from "@/components/ErrorReportingShell"; +import { PWAHandler } from "@/components/PWAHandler"; import "./globals.css"; const siteUrl = @@ -16,6 +17,11 @@ export const metadata: Metadata = { }, description: "Privacy-focused payments on Stellar", applicationName: " RustAcademy", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "RustAcademy", + }, keywords: ["Stellar", "payments", "crypto", "XLM", "USDC", "payment link"], authors: [{ name: "Pulsefy" }], creator: "Pulsefy", @@ -47,6 +53,12 @@ export const metadata: Metadata = { }, }; +export const viewport: Viewport = { + themeColor: "#0a0a0a", + width: "device-width", + initialScale: 1, +}; + export default function RootLayout({ children, }: { @@ -83,6 +95,7 @@ export default function RootLayout({ + diff --git a/app/frontend/src/app/manifest.ts b/app/frontend/src/app/manifest.ts index 0e4b09e60..b7a8fdc15 100644 --- a/app/frontend/src/app/manifest.ts +++ b/app/frontend/src/app/manifest.ts @@ -2,26 +2,35 @@ import { MetadataRoute } from "next"; export default function manifest(): MetadataRoute.Manifest { return { - name: " RustAcademy", - short_name: " RustAcademy", + id: "/", + name: "RustAcademy", + short_name: "RustAcademy", description: "Privacy-focused payments on Stellar", start_url: "/", + scope: "/", display: "standalone", + orientation: "portrait-primary", background_color: "#0a0a0a", theme_color: "#0a0a0a", icons: [ { - src: "/icon.png", - sizes: "512x512", + src: "/icon-192.png", + sizes: "192x192", type: "image/png", - purpose: "maskable", + purpose: "any", }, { - src: "/icon.png", - sizes: "192x192", + src: "/icon-512.png", + sizes: "512x512", type: "image/png", purpose: "any", }, + { + src: "/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, ], shortcuts: [ { @@ -29,14 +38,14 @@ export default function manifest(): MetadataRoute.Manifest { short_name: "Generate", description: "Generate a new payment link", url: "/generator", - icons: [{ src: "/icon.png", sizes: "192x192" }], + icons: [{ src: "/icon-192.png", sizes: "192x192" }], }, { name: "Dashboard", short_name: "Dashboard", description: "View your dashboard", url: "/dashboard", - icons: [{ src: "/icon.png", sizes: "192x192" }], + icons: [{ src: "/icon-192.png", sizes: "192x192" }], }, ], }; diff --git a/app/frontend/src/components/PWAHandler.tsx b/app/frontend/src/components/PWAHandler.tsx index dc1b3536f..8797dac80 100644 --- a/app/frontend/src/components/PWAHandler.tsx +++ b/app/frontend/src/components/PWAHandler.tsx @@ -8,6 +8,26 @@ interface BeforeInstallPromptEvent extends Event { userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } +const DISMISSED_KEY = "pwa-install-dismissed-at"; +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // re-offer after 7 days + +function wasRecentlyDismissed(): boolean { + try { + const dismissedAt = Number(localStorage.getItem(DISMISSED_KEY)); + return !!dismissedAt && Date.now() - dismissedAt < DISMISS_COOLDOWN_MS; + } catch { + return false; + } +} + +function isStandalone(): boolean { + return ( + window.matchMedia("(display-mode: standalone)").matches || + // iOS Safari + (navigator as { standalone?: boolean }).standalone === true + ); +} + export function PWAHandler() { const [installPrompt, setInstallPrompt] = useState(null); @@ -32,7 +52,7 @@ export function PWAHandler() { // New content is available; please refresh. if ( confirm( - "A new version of RustAcademy is available. Refresh now?", + "A new version of RustAcademy is available. Refresh now?", ) ) { window.location.reload(); @@ -45,14 +65,16 @@ export function PWAHandler() { } // Check if already installed - if (window.matchMedia("(display-mode: standalone)").matches) { + if (isStandalone()) { setIsInstalled(true); } const handler = (e: Event) => { e.preventDefault(); setInstallPrompt(e as BeforeInstallPromptEvent); - setShowBanner(true); + if (!wasRecentlyDismissed()) { + setShowBanner(true); + } }; window.addEventListener("beforeinstallprompt", handler); @@ -75,6 +97,15 @@ export function PWAHandler() { } }; + const handleDismiss = () => { + setShowBanner(false); + try { + localStorage.setItem(DISMISSED_KEY, String(Date.now())); + } catch { + // localStorage unavailable (private mode) — banner just reappears next visit + } + }; + if (!showBanner || isInstalled) return null; return ( @@ -105,7 +136,7 @@ export function PWAHandler() { Install Now