Skip to content

Commit 9288bfb

Browse files
authored
Merge pull request #373 from temma02/feature/issue-45-pwa
feat(pwa): convert app to Progressive Web App (#45)
2 parents 8dbfdb9 + e21ee4b commit 9288bfb

9 files changed

Lines changed: 5294 additions & 5 deletions

File tree

client/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ VITE_INDEXER_URL=http://localhost:3000
33

44
# Set to "true" to use mock API (no backend needed)
55
VITE_API_MOCK=false
6+
7+
# VAPID public key for push notifications (generate with: npx web-push generate-vapid-keys)
8+
VITE_VAPID_PUBLIC_KEY=

client/package.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
"scripts": {
77
"dev": "vite dev",
88
"build": "vite build",
9-
"preview":"vite preview","e2e":"playwright test","e2e:headed":"playwright test --headed","e2e:ui":"playwright test --ui","e2e:update":"playwright test --update-snapshots"
9+
"preview": "vite preview",
10+
"e2e": "playwright test",
11+
"e2e:headed": "playwright test --headed",
12+
"e2e:ui": "playwright test --ui",
13+
"e2e:update": "playwright test --update-snapshots"
1014
},
1115
"devDependencies": {
16+
"@playwright/test": "^1.48.0",
17+
"@stellar/stellar-sdk": "^13.2.0",
1218
"@sveltejs/adapter-auto": "^3.0.0",
1319
"@sveltejs/kit": "^2.0.0",
14-
"@stellar/stellar-sdk": "^13.2.0",
15-
"@types/node":"^20.12.0","@playwright/test":"^1.48.0","msw":"^2.4.11"
20+
"@types/node": "^20.12.0",
21+
"msw": "^2.4.11",
1622
"svelte": "^4.2.0",
1723
"svelte-check": "^3.6.3",
1824
"typescript": "~5.4.2",
19-
"vite": "^5.2.0"
25+
"vite": "^5.2.0",
26+
"vite-plugin-pwa": "^1.2.0"
2027
}
2128
}

client/pnpm-lock.yaml

Lines changed: 5146 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/app.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
<meta charset="utf-8" />
55
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
66
<meta name="viewport" content="width=device-width" />
7+
<meta name="theme-color" content="#6366f1" />
8+
<meta name="mobile-web-app-capable" content="yes" />
9+
<meta name="apple-mobile-web-app-capable" content="yes" />
10+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
11+
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
712
%sveltekit.head%
813
</head>
914
<body data-sveltekit-preload-data="hover" class="text-gray-900 antialiased">
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script lang="ts">
2+
import { isOnline, pushPermission, requestPushPermission } from '$lib/pwa';
3+
import { useRegisterSW } from 'virtual:pwa-register/svelte';
4+
5+
const { needRefresh, updateServiceWorker } = useRegisterSW();
6+
7+
const VAPID_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY ?? '';
8+
</script>
9+
10+
<!-- Offline banner -->
11+
{#if !$isOnline}
12+
<div role="alert" class="fixed bottom-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-gray-900 text-white text-sm py-2 px-4">
13+
<span>📡</span> You're offline — showing cached data
14+
</div>
15+
{/if}
16+
17+
<!-- SW update prompt -->
18+
{#if $needRefresh}
19+
<div role="alert" class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg p-4 flex items-center gap-3 text-sm">
20+
<span>🔄 New version available</span>
21+
<button
22+
on:click={() => updateServiceWorker(true)}
23+
class="px-3 py-1 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
24+
>Update</button>
25+
</div>
26+
{/if}
27+
28+
<!-- Push notification opt-in (shown once, only when granted is not yet set) -->
29+
{#if $pushPermission === 'default' && $isOnline && VAPID_KEY}
30+
<div class="fixed bottom-4 left-4 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg p-4 text-sm max-w-xs">
31+
<p class="font-semibold mb-1 dark:text-white">Stay updated</p>
32+
<p class="text-gray-500 dark:text-gray-400 mb-3">Get notified when your trades change status.</p>
33+
<div class="flex gap-2">
34+
<button
35+
on:click={requestPushPermission}
36+
class="flex-1 px-3 py-1.5 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 text-xs font-medium"
37+
>Enable</button>
38+
<button
39+
on:click={() => pushPermission.set('denied')}
40+
class="px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-xs"
41+
>Not now</button>
42+
</div>
43+
</div>
44+
{/if}

client/src/lib/pwa.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { writable, readable } from 'svelte/store';
2+
import { browser } from '$app/environment';
3+
4+
/** Reactive online/offline status */
5+
export const isOnline = readable(true, (set) => {
6+
if (!browser) return;
7+
set(navigator.onLine);
8+
const on = () => set(true);
9+
const off = () => set(false);
10+
window.addEventListener('online', on);
11+
window.addEventListener('offline', off);
12+
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
13+
});
14+
15+
/** Push notification permission state */
16+
export const pushPermission = writable<NotificationPermission>('default');
17+
18+
export async function requestPushPermission(): Promise<boolean> {
19+
if (!browser || !('Notification' in window)) return false;
20+
const result = await Notification.requestPermission();
21+
pushPermission.set(result);
22+
return result === 'granted';
23+
}
24+
25+
/** Subscribe to push notifications via the service worker */
26+
export async function subscribePush(vapidPublicKey: string): Promise<PushSubscription | null> {
27+
if (!browser || !('serviceWorker' in navigator) || !('PushManager' in window)) return null;
28+
const reg = await navigator.serviceWorker.ready;
29+
const existing = await reg.pushManager.getSubscription();
30+
if (existing) return existing;
31+
return reg.pushManager.subscribe({
32+
userVisibleOnly: true,
33+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
34+
});
35+
}
36+
37+
function urlBase64ToUint8Array(base64: string): Uint8Array {
38+
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
39+
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
40+
const raw = atob(b64);
41+
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
42+
}

client/src/routes/+layout.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { themeStore } from '$lib/theme';
55
import ThemeToggle from '$lib/ThemeToggle.svelte';
66
import SearchBar from '$lib/SearchBar.svelte';
7+
import OfflineIndicator from '$lib/OfflineIndicator.svelte';
78
89
onMount(() => themeStore.init());
910
</script>
@@ -17,6 +18,7 @@
1718
<main>
1819
<slot />
1920
</main>
21+
<OfflineIndicator />
2022
</div>
2123

2224
<style global lang="postcss">

client/static/icon.svg

Lines changed: 4 additions & 0 deletions
Loading

client/vite.config.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
import { sveltekit } from '@sveltejs/kit/vite';
22
import { defineConfig } from 'vite';
3+
import { VitePWA } from 'vite-plugin-pwa';
4+
35
export default defineConfig({
4-
plugins: [sveltekit()]
6+
plugins: [
7+
sveltekit(),
8+
VitePWA({
9+
registerType: 'autoUpdate',
10+
devOptions: { enabled: true },
11+
manifest: {
12+
name: 'StellarEscrow',
13+
short_name: 'StellarEscrow',
14+
description: 'Decentralized escrow on Stellar/Soroban',
15+
theme_color: '#6366f1',
16+
background_color: '#f9fafb',
17+
display: 'standalone',
18+
start_url: '/',
19+
icons: [
20+
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
21+
]
22+
},
23+
workbox: {
24+
// Cache app shell + static assets
25+
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
26+
runtimeCaching: [
27+
{
28+
// Cache API responses (indexer) with network-first strategy
29+
urlPattern: ({ url }) => url.pathname.startsWith('/events') || url.pathname.startsWith('/search'),
30+
handler: 'NetworkFirst',
31+
options: {
32+
cacheName: 'api-cache',
33+
networkTimeoutSeconds: 5,
34+
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
35+
}
36+
}
37+
]
38+
}
39+
})
40+
]
541
});

0 commit comments

Comments
 (0)