diff --git a/frontend/index.html b/frontend/index.html index 66efdfa..e8d9b46 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,9 +7,20 @@ + +
+ diff --git a/frontend/manifest.json b/frontend/manifest.json index 7e55986..6106568 100644 --- a/frontend/manifest.json +++ b/frontend/manifest.json @@ -2,16 +2,51 @@ "name": "CosmosVote", "short_name": "CosmosVote", "description": "On-chain governance and voting app for Stellar Soroban", - "start_url": ".", + "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#4A90E2", + "categories": ["finance", "productivity", "utilities"], "icons": [ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ], + "screenshots": [ + { + "src": "/screenshots/desktop.png", + "sizes": "1280x800", + "type": "image/png", + "form_factor": "wide", + "label": "CosmosVote proposal list" + }, + { + "src": "/screenshots/mobile.png", + "sizes": "390x844", + "type": "image/png", + "form_factor": "narrow", + "label": "CosmosVote on mobile" } ] } diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..5b3c1b4 --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,50 @@ + + + + + + CosmosVote — Offline + + + +
+
📡
+

CosmosVote

+

You're offline. Check your connection and try again.

+ +
+ + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..3b9a7e5 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,40 @@ +const CACHE = 'cosmosvote-v1'; +const APP_SHELL = ['/', '/index.html', '/offline.html', '/manifest.json', '/favicon.svg']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(APP_SHELL)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + + event.respondWith( + fetch(event.request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, clone)); + return response; + }) + .catch(() => + caches.match(event.request).then( + (cached) => + cached || + (event.request.mode === 'navigate' + ? caches.match('/offline.html') + : new Response('', { status: 503 })) + ) + ) + ); +}); diff --git a/frontend/src/usePWAInstall.ts b/frontend/src/usePWAInstall.ts new file mode 100644 index 0000000..de7e9b4 --- /dev/null +++ b/frontend/src/usePWAInstall.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function usePWAInstall(): { canInstall: boolean; install: () => void } { + const [canInstall, setCanInstall] = useState(false); + const promptRef = useRef(null); + + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + promptRef.current = e as BeforeInstallPromptEvent; + setCanInstall(true); + }; + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); + + const install = () => { + if (!promptRef.current) return; + promptRef.current.prompt(); + promptRef.current.userChoice.then(() => { + promptRef.current = null; + setCanInstall(false); + }); + }; + + return { canInstall, install }; +}