Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion app/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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:

Expand Down
106 changes: 82 additions & 24 deletions app/frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -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();
});
Expand All @@ -20,29 +27,80 @@ 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)),
);
}),
);
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));
}
});
15 changes: 14 additions & 1 deletion app/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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",
Expand Down Expand Up @@ -47,6 +53,12 @@ export const metadata: Metadata = {
},
};

export const viewport: Viewport = {
themeColor: "#0a0a0a",
width: "device-width",
initialScale: 1,
};

export default function RootLayout({
children,
}: {
Expand Down Expand Up @@ -83,6 +95,7 @@ export default function RootLayout({
</div>
</div>
</footer>
<PWAHandler />
</NotificationCenterProvider>
</body>
</html>
Expand Down
27 changes: 18 additions & 9 deletions app/frontend/src/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,50 @@ 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: [
{
name: "Generate Link",
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" }],
},
],
};
Expand Down
39 changes: 35 additions & 4 deletions app/frontend/src/components/PWAHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<BeforeInstallPromptEvent | null>(null);
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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 (
Expand Down Expand Up @@ -105,7 +136,7 @@ export function PWAHandler() {
Install Now
</button>
<button
onClick={() => setShowBanner(false)}
onClick={handleDismiss}
className="px-4 py-2.5 text-sm font-medium text-neutral-400 hover:text-white transition-colors"
>
Later
Expand Down
Loading