diff --git a/next.config.js b/next.config.js index 55d4c9b..0cf7505 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,32 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + async headers() { + return [ + { + source: "/sw.js", + headers: [ + { + key: "Cache-Control", + value: "no-cache, no-store, must-revalidate", + }, + { + key: "Service-Worker-Allowed", + value: "/", + }, + ], + }, + { + source: "/manifest.json", + headers: [ + { + key: "Content-Type", + value: "application/manifest+json", + }, + ], + }, + ]; + }, webpack: (config) => { config.resolve.fallback = { ...config.resolve.fallback, diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg new file mode 100644 index 0000000..afbbef5 --- /dev/null +++ b/public/icons/icon-192.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg new file mode 100644 index 0000000..4e76ff9 --- /dev/null +++ b/public/icons/icon-512.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..541907a --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "SoroSave", + "short_name": "SoroSave", + "description": "Decentralized rotating savings protocol on Soroban.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#16a34a", + "icons": [ + { + "src": "/icons/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..acfa2d0 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,67 @@ +const STATIC_CACHE = "sorosave-static-v1"; +const RUNTIME_CACHE = "sorosave-runtime-v1"; +const STATIC_ASSETS = ["/", "/manifest.json"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== STATIC_CACHE && key !== RUNTIME_CACHE) + .map((key) => caches.delete(key)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + + if (request.method !== "GET") { + return; + } + + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return cached || caches.match("/"); + }) + ); + return; + } + + if (["style", "script", "image", "font"].includes(request.destination)) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) { + return cached; + } + + return fetch(request).then((response) => { + if (!response || response.status !== 200 || response.type === "opaque") { + return response; + } + + const copy = response.clone(); + caches.open(RUNTIME_CACHE).then((cache) => cache.put(request, copy)); + return response; + }); + }) + ); + } +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 17200f3..dbfccf2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,19 @@ import type { Metadata } from "next"; import { Providers } from "./providers"; +import { InstallPrompt } from "@/components/InstallPrompt"; import "./globals.css"; export const metadata: Metadata = { title: "SoroSave — Decentralized Group Savings", description: "A decentralized rotating savings protocol built on Soroban. Create or join savings groups, contribute each cycle, and receive the pot when it's your turn.", + manifest: "/manifest.json", + themeColor: "#16a34a", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "SoroSave", + }, }; export default function RootLayout({ @@ -17,6 +25,7 @@ export default function RootLayout({ {children} + ); diff --git a/src/components/InstallPrompt.tsx b/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..344e8e0 --- /dev/null +++ b/src/components/InstallPrompt.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = + useState(null); + const [hidden, setHidden] = useState(false); + + useEffect(() => { + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch((error) => { + console.error("Service worker registration failed", error); + }); + }); + } + + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setDeferredPrompt(event as BeforeInstallPromptEvent); + setHidden(false); + }; + + const onAppInstalled = () => { + setDeferredPrompt(null); + setHidden(true); + }; + + window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.addEventListener("appinstalled", onAppInstalled); + + return () => { + window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.removeEventListener("appinstalled", onAppInstalled); + }; + }, []); + + if (!deferredPrompt || hidden) { + return null; + } + + const handleInstall = async () => { + await deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + if (choice.outcome === "accepted") { + setDeferredPrompt(null); + setHidden(true); + } + }; + + return ( +
+
+
+

Install SoroSave

+

+ Add SoroSave to your home screen for a faster app-like experience. +

+
+
+ + +
+
+
+ ); +}