Skip to content
Open
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
70 changes: 65 additions & 5 deletions harvest-finance/frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
// Simplified Next.js config without next-intl plugin
import type { NextConfig } from "next";
import withPWA from "next-pwa";

const nextConfig: NextConfig = {
output: 'standalone',
output: "standalone",
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
protocol: "https",
hostname: "images.unsplash.com",
},
],
formats: ['image/avif', 'image/webp'],
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
Expand All @@ -19,4 +20,63 @@ const nextConfig: NextConfig = {
reactStrictMode: true,
};

export default nextConfig;
export default withPWA({
dest: "public",
disable: process.env.NODE_ENV === "development",
register: true,
skipWaiting: true,
// We already register our own SW in ServiceWorkerRegistration.
// But next-pwa will still generate/update /public/sw.js.
// (acceptance criteria: caches + offline fallback)
runtimeCaching: [
{
// Next/static + build assets
urlPattern: ({ request }) => {
const url = new URL(request.url);
return (
url.pathname.startsWith("/_next/static") ||
url.pathname.startsWith("/static/") ||
url.pathname.endsWith(".js") ||
url.pathname.endsWith(".css") ||
url.pathname.endsWith(".png") ||
url.pathname.endsWith(".svg")
);
},
handler: "StaleWhileRevalidate",
options: {
cacheName: "harvest-static",
expiration: {
maxEntries: 512,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
{
// Offline vault list: cache the last successful response.
urlPattern: ({ url }) => {
const pathname = url.pathname;
return (
pathname.startsWith("/api/v1/") &&
(pathname.includes("/farm-vaults") || pathname.includes("/vaults"))
);
},
handler: "NetworkFirst",
options: {
cacheName: "harvest-api-vault-list",
networkTimeoutSeconds: 3,
expiration: {
maxEntries: 64,
maxAgeSeconds: 60 * 60 * 24 * 7,
},
},
},
],

// Offline fallback page.
// next-pwa will ensure this gets used for navigation requests.
// (We also keep a manual SW already present; this config is the source of truth.)
fallbacks: {
document: "/offline.html",
},
});

80 changes: 80 additions & 0 deletions harvest-finance/frontend/public/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Offline</title>
<style>
:root {
--brand: #2f7a42;
--bg: #f4f8f0;
--text: #0b1b0f;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica,
Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
width: 100%;
max-width: 520px;
background: white;
border: 1px solid rgba(47, 122, 66, 0.18);
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
}
h1 {
margin: 0 0 8px;
font-size: 20px;
}
p {
margin: 0 0 16px;
opacity: 0.85;
line-height: 1.5;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(47, 122, 66, 0.3);
background: var(--brand);
color: #fff;
text-decoration: none;
font-weight: 700;
}
.muted {
font-size: 12px;
opacity: 0.7;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card" role="main">
<h1>You’re offline</h1>
<p>
Harvest Finance can’t reach the server right now. You can still view
cached content (like your last viewed vault list) and queue actions
that will sync automatically when you’re back online.
</p>
<a class="btn" href="/dashboard">Go to Dashboard</a>
<div class="muted">Tip: enable mobile data or Wi‑Fi to refresh.</div>
</div>
</div>
</body>
</html>

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import { useEffect } from 'react';

const VISIT_COUNT_KEY = "harvest-pwa-visit-count-v1";

const INSTALL_PROMPT_SHOWN_KEY = "harvest-pwa-install-prompt-shown-v1";

export function ServiceWorkerRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
Expand All @@ -27,11 +31,55 @@ export function ServiceWorkerRegistration() {

let deferredPrompt: BeforeInstallPromptEvent | null = null;

// Track visits after app load, independent of beforeinstallprompt event.
const incrementVisitCount = () => {
const shown = window.localStorage.getItem(INSTALL_PROMPT_SHOWN_KEY) === "1";
if (shown) return;

const raw = window.localStorage.getItem(VISIT_COUNT_KEY);
const count = raw ? Number(raw) : 0;
const next = count + 1;
window.localStorage.setItem(VISIT_COUNT_KEY, String(next));
if (next >= 3 && deferredPrompt) {
window.localStorage.setItem(INSTALL_PROMPT_SHOWN_KEY, "1");
void deferredPrompt.prompt().then(() => deferredPrompt?.userChoice).catch(() => {});
}
};


const incrementAndMaybePrompt = async () => {
try {
const shown = window.localStorage.getItem(INSTALL_PROMPT_SHOWN_KEY) === "1";
if (shown) return;

const raw = window.localStorage.getItem(VISIT_COUNT_KEY);
const count = raw ? Number(raw) : 0;
const next = count + 1;
window.localStorage.setItem(VISIT_COUNT_KEY, String(next));

// Acceptance criteria: show after 3 visits to eligible users.
if (next >= 3 && deferredPrompt) {
window.localStorage.setItem(INSTALL_PROMPT_SHOWN_KEY, "1");
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
}
} catch {
// no-op
}
};

window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;

// If user already reached eligibility, show prompt now.
incrementVisitCount();
});

// Increment visit count immediately on app load.
incrementVisitCount();


navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'SYNC_TRIGGERED') {
window.dispatchEvent(new CustomEvent('harvest-sync-triggered'));
Expand All @@ -43,6 +91,7 @@ export function ServiceWorkerRegistration() {
return null;
}


interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
Expand Down