localgreenchain/public/sw.js
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
This implements the mobile optimization agent (P3 - Enhancement) with:

PWA Configuration:
- Add next-pwa integration with offline caching strategies
- Create web app manifest for installability
- Add service worker with background sync support
- Create offline fallback page

Mobile Components:
- BottomNav: Touch-friendly bottom navigation bar
- MobileHeader: Responsive header with back navigation
- InstallPrompt: Smart PWA install prompt (iOS & Android)
- SwipeableCard: Gesture-based swipeable cards
- PullToRefresh: Native-like pull to refresh
- QRScanner: Camera-based QR code scanning

Mobile Library:
- camera.ts: Camera access and photo capture utilities
- offline.ts: IndexedDB-based offline storage and sync
- gestures.ts: Touch gesture detection (swipe, pinch, tap)
- pwa.ts: PWA status, install prompts, service worker management

Mobile Pages:
- /m: Mobile dashboard with quick actions and stats
- /m/scan: QR code scanner for plant lookup
- /m/quick-add: Streamlined plant registration form
- /m/profile: User profile with offline status

Dependencies added: next-pwa, idb
2025-11-23 03:56:30 +00:00

272 lines
7.1 KiB
JavaScript

// LocalGreenChain Service Worker
// Version: 1.0.0
const CACHE_NAME = 'lgc-cache-v1';
const OFFLINE_URL = '/offline.html';
// Core files to cache for offline access
const PRECACHE_ASSETS = [
'/',
'/offline.html',
'/manifest.json',
'/favicon.ico',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
// Install event - cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Cache offline page first
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
// Cache other assets
await cache.addAll(PRECACHE_ASSETS);
// Skip waiting to activate immediately
self.skipWaiting();
})()
);
});
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Claim all clients immediately
await self.clients.claim();
// Remove old caches
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})()
);
});
// Fetch event - serve from cache or network
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
// Skip API requests - always fetch from network
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request).catch(() => {
return new Response(
JSON.stringify({ error: 'Offline', offline: true }),
{
headers: { 'Content-Type': 'application/json' },
status: 503
}
);
})
);
return;
}
// For navigation requests, try network first
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
// Try network first
const networkResponse = await fetch(event.request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// If offline, try cache
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// Fallback to offline page
return caches.match(OFFLINE_URL);
}
})()
);
return;
}
// For other requests, try cache first, then network
event.respondWith(
(async () => {
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
// Return cached response and update cache in background
event.waitUntil(
(async () => {
try {
const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse);
}
} catch (e) {
// Network failed, cached version will be used
}
})()
);
return cachedResponse;
}
try {
const networkResponse = await fetch(event.request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// For images, return a placeholder
if (event.request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="#f3f4f6" width="100" height="100"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af" font-size="12">Offline</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
throw error;
}
})()
);
});
// Handle push notifications
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: data.id || 1,
url: data.url || '/'
},
actions: data.actions || [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title || 'LocalGreenChain', options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') {
return;
}
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Check if there's already a window open
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-plants') {
event.waitUntil(syncPlants());
} else if (event.tag === 'sync-transport') {
event.waitUntil(syncTransport());
}
});
async function syncPlants() {
// Get pending plant registrations from IndexedDB and sync them
try {
const pendingPlants = await getPendingFromIDB('pending-plants');
for (const plant of pendingPlants) {
await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plant)
});
await removeFromIDB('pending-plants', plant.id);
}
} catch (error) {
console.error('Plant sync failed:', error);
}
}
async function syncTransport() {
// Get pending transport events from IndexedDB and sync them
try {
const pendingEvents = await getPendingFromIDB('pending-transport');
for (const event of pendingEvents) {
await fetch('/api/transport/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
await removeFromIDB('pending-transport', event.id);
}
} catch (error) {
console.error('Transport sync failed:', error);
}
}
// IndexedDB helpers (simplified)
function getPendingFromIDB(storeName) {
return new Promise((resolve) => {
// In production, implement proper IndexedDB operations
resolve([]);
});
}
function removeFromIDB(storeName, id) {
return new Promise((resolve) => {
// In production, implement proper IndexedDB operations
resolve();
});
}