// Progressive Web App functionality // Handles service worker registration and PWA features class PWAManager { constructor() { this.swRegistration = null; this.vapidPublicKey = null; this.pushSubscription = null; this.init(); } async init() { console.log('PWA: Initializing PWA Manager...'); if ('serviceWorker' in navigator) { try { this.swRegistration = await navigator.serviceWorker.register('/sw.js'); console.log('SW: Service Worker registered successfully'); // Listen for updates this.swRegistration.addEventListener('updatefound', () => { console.log('SW: New version available'); this.showUpdateNotification(); }); } catch (error) { console.log('SW: Service Worker registration failed:', error); } } // Setup PWA install prompt this.setupInstallPrompt(); // Setup notifications (if enabled) this.setupNotifications(); // Setup push notifications this.setupPushNotifications(); // Show manual install option after 3 seconds if no prompt appeared and app not installed setTimeout(() => { if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) { this.showManualInstallButton(); } }, 3000); } setupInstallPrompt() { let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { console.log('PWA: beforeinstallprompt event fired'); // Prevent Chrome 67 and earlier from automatically showing the prompt e.preventDefault(); deferredPrompt = e; // Show custom install button this.showInstallButton(deferredPrompt); }); window.addEventListener('appinstalled', () => { console.log('PWA: App was installed'); this.hideInstallButton(); }); // Debug: Check if app is already installed if (this.isInstalled()) { console.log('PWA: App is already installed (standalone mode)'); // Hide any existing install buttons this.hideInstallButton(); } else { console.log('PWA: App is not installed, waiting for install prompt...'); } // Periodically check if app becomes installed (for cases where user installs via browser menu) setInterval(() => { if (this.isInstalled()) { this.hideInstallButton(); } }, 2000); } showInstallButton(deferredPrompt) { // Don't show install button if app is already installed if (this.isInstalled()) { console.log('PWA: App already installed, skipping install button'); return; } // Create install button const installBtn = document.createElement('button'); installBtn.id = 'pwa-install-btn'; installBtn.className = 'btn btn-primary btn-sm'; installBtn.innerHTML = ' Install App'; installBtn.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 1000; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); `; installBtn.addEventListener('click', async () => { if (deferredPrompt) { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log('PWA: User response to install prompt:', outcome); deferredPrompt = null; this.hideInstallButton(); } }); document.body.appendChild(installBtn); } hideInstallButton() { const installBtn = document.getElementById('pwa-install-btn'); if (installBtn) { installBtn.remove(); } } showUpdateNotification() { // Show update available notification const notification = document.createElement('div'); notification.className = 'alert alert-info alert-dismissible'; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 1050; max-width: 300px; `; notification.innerHTML = ` Update Available!
A new version of the app is ready. `; document.body.appendChild(notification); // Handle update document.getElementById('update-btn').addEventListener('click', () => { if (this.swRegistration && this.swRegistration.waiting) { this.swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); window.location.reload(); } }); } async setupNotifications() { // Check if notifications are supported and get permission if ('Notification' in window) { const permission = await this.requestNotificationPermission(); console.log('Notifications permission:', permission); } } async requestNotificationPermission() { if (Notification.permission === 'default') { // Only request permission when user interacts with a relevant feature // For now, just return the current status return Notification.permission; } return Notification.permission; } // Show notification (if permission granted) showNotification(title, options = {}) { if (Notification.permission === 'granted') { const notification = new Notification(title, { icon: '/icons/icon-192x192.png', badge: '/icons/icon-72x72.png', tag: 'littleshop-admin', ...options }); // Auto-close after 5 seconds setTimeout(() => { notification.close(); }, 5000); return notification; } } // Show manual install button for browsers that don't auto-prompt showManualInstallButton() { // Don't show install button if app is already installed if (this.isInstalled()) { console.log('PWA: App already installed, skipping manual install button'); return; } console.log('PWA: Showing manual install button'); const installBtn = document.createElement('button'); installBtn.id = 'pwa-install-btn'; installBtn.className = 'btn btn-primary btn-sm'; installBtn.innerHTML = ' Install as App'; installBtn.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 1000; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); `; installBtn.addEventListener('click', () => { alert('To install this app:\\n\\n1. Click the browser menu (⋮)\\n2. Select "Install LittleShop Admin"\\n\\nOr look for the install icon in the address bar!'); }); document.body.appendChild(installBtn); } // Check if app is installed isInstalled() { return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; } // Setup push notifications async setupPushNotifications() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.log('PWA: Push notifications not supported'); return; } try { // Check if user has dismissed push notifications recently const dismissedUntil = localStorage.getItem('pushNotificationsDismissedUntil'); if (dismissedUntil && new Date() < new Date(dismissedUntil)) { console.log('PWA: Push notifications dismissed by user, skipping setup'); return; } // Get VAPID public key from server await this.getVapidPublicKey(); // Check if user is already subscribed await this.checkPushSubscription(); // Only show setup UI if not subscribed and not recently dismissed const isSubscribedFromCache = localStorage.getItem('pushNotificationsSubscribed') === 'true'; if (!this.pushSubscription && !isSubscribedFromCache) { this.showPushNotificationSetup(); } else { console.log('PWA: User already subscribed to push notifications, skipping setup UI'); } } catch (error) { console.error('PWA: Failed to setup push notifications:', error); } } async getVapidPublicKey() { try { const response = await fetch('/api/push/vapid-key'); if (response.ok) { const data = await response.json(); this.vapidPublicKey = data.publicKey; console.log('PWA: VAPID public key retrieved'); } else { throw new Error('Failed to get VAPID public key'); } } catch (error) { console.error('PWA: Error getting VAPID public key:', error); throw error; } } async checkPushSubscription() { if (!this.swRegistration) { return; } try { this.pushSubscription = await this.swRegistration.pushManager.getSubscription(); if (this.pushSubscription) { console.log('PWA: Browser has push subscription'); // Verify server-side subscription still exists by trying to send a test try { const response = await fetch('/api/push/subscriptions', { credentials: 'same-origin' }); if (response.ok) { const subscriptions = await response.json(); const hasServerSubscription = subscriptions.some(sub => sub.endpoint && this.pushSubscription.endpoint.includes(sub.endpoint.substring(0, 50)) ); if (!hasServerSubscription) { console.log('PWA: Server subscription missing, will re-subscribe on next attempt'); localStorage.removeItem('pushNotificationsSubscribed'); } else { console.log('PWA: Server subscription confirmed'); localStorage.setItem('pushNotificationsSubscribed', 'true'); } } } catch (error) { console.log('PWA: Could not verify server subscription:', error.message); } } else { console.log('PWA: User is not subscribed to push notifications'); localStorage.removeItem('pushNotificationsSubscribed'); } } catch (error) { console.error('PWA: Error checking push subscription:', error); } } async subscribeToPushNotifications() { if (!this.swRegistration || !this.vapidPublicKey) { throw new Error('Service worker or VAPID key not available'); } try { // Check current permission status if (Notification.permission === 'denied') { throw new Error('Notification permission was denied. Please enable notifications in your browser settings.'); } // Request notification permission if not already granted let permission = Notification.permission; if (permission === 'default') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { throw new Error('Notification permission is required for push notifications. Please allow notifications and try again.'); } // Subscribe to push notifications const subscription = await this.swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }); // Send subscription to server const response = await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ endpoint: subscription.endpoint, p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')))), auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth')))) }), credentials: 'same-origin' }); if (response.ok) { this.pushSubscription = subscription; console.log('PWA: Successfully subscribed to push notifications'); // Cache subscription state localStorage.setItem('pushNotificationsSubscribed', 'true'); localStorage.setItem('pushNotificationsSubscribedAt', new Date().toISOString()); this.updatePushNotificationUI(); return true; } else { throw new Error('Failed to save push subscription to server'); } } catch (error) { console.error('PWA: Failed to subscribe to push notifications:', error); throw error; } } async unsubscribeFromPushNotifications() { if (!this.pushSubscription) { return true; } try { // Unsubscribe from push manager await this.pushSubscription.unsubscribe(); // Notify server await fetch('/api/push/unsubscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ endpoint: this.pushSubscription.endpoint }), credentials: 'same-origin' }); this.pushSubscription = null; console.log('PWA: Successfully unsubscribed from push notifications'); // Clear subscription cache localStorage.removeItem('pushNotificationsSubscribed'); localStorage.removeItem('pushNotificationsSubscribedAt'); this.updatePushNotificationUI(); return true; } catch (error) { console.error('PWA: Failed to unsubscribe from push notifications:', error); throw error; } } showPushNotificationSetup() { // Check if setup UI already exists if (document.getElementById('push-notification-setup')) { return; } const setupDiv = document.createElement('div'); setupDiv.id = 'push-notification-setup'; setupDiv.className = 'alert alert-info alert-dismissible'; setupDiv.style.cssText = ` position: fixed; top: 80px; right: 20px; z-index: 1050; max-width: 350px; `; const isSubscribed = !!this.pushSubscription; setupDiv.innerHTML = `
Push Notifications
${isSubscribed ? 'You are subscribed to notifications' : 'Get notified of new orders and updates'}
${isSubscribed ? '' : '
' }
`; document.body.appendChild(setupDiv); // Add event listeners const subscribeBtn = document.getElementById('subscribe-push-btn'); const unsubscribeBtn = document.getElementById('unsubscribe-push-btn'); const dismissBtn = document.getElementById('dismiss-push-btn'); const closeBtn = document.getElementById('close-push-setup'); if (subscribeBtn) { subscribeBtn.addEventListener('click', async () => { subscribeBtn.disabled = true; subscribeBtn.innerHTML = 'Subscribing...'; try { await this.subscribeToPushNotifications(); this.showNotification('Push notifications enabled!', { body: 'You will now receive notifications for new orders and updates.' }); } catch (error) { console.error('PWA: Push subscription failed:', error); // Provide user-friendly error messages let userMessage = error.message; if (error.message.includes('permission')) { userMessage = 'Please allow notifications when your browser asks, then try again.'; } alert('Failed to enable push notifications: ' + userMessage); subscribeBtn.disabled = false; subscribeBtn.innerHTML = 'Turn On'; } }); } if (unsubscribeBtn) { unsubscribeBtn.addEventListener('click', async () => { unsubscribeBtn.disabled = true; unsubscribeBtn.innerHTML = 'Disabling...'; try { await this.unsubscribeFromPushNotifications(); this.showNotification('Push notifications disabled', { body: 'You will no longer receive push notifications.' }); } catch (error) { alert('Failed to disable push notifications: ' + error.message); unsubscribeBtn.disabled = false; unsubscribeBtn.innerHTML = 'Turn Off'; } }); } // Handle dismiss button if (dismissBtn) { dismissBtn.addEventListener('click', () => { // Dismiss for 24 hours const dismissUntil = new Date(); dismissUntil.setHours(dismissUntil.getHours() + 24); localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString()); const element = document.getElementById('push-notification-setup'); if (element) { element.remove(); } console.log('PWA: Push notifications dismissed for 24 hours'); }); } // Handle close button if (closeBtn) { closeBtn.addEventListener('click', () => { // Dismiss for 1 hour const dismissUntil = new Date(); dismissUntil.setHours(dismissUntil.getHours() + 1); localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString()); console.log('PWA: Push notifications dismissed for 1 hour'); }); } // Auto-hide after 15 seconds setTimeout(() => { const element = document.getElementById('push-notification-setup'); if (element) { element.remove(); // Auto-dismiss for 2 hours if user ignores const dismissUntil = new Date(); dismissUntil.setHours(dismissUntil.getHours() + 2); localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString()); console.log('PWA: Push notifications auto-dismissed for 2 hours'); } }, 15000); } updatePushNotificationUI() { const setupDiv = document.getElementById('push-notification-setup'); if (setupDiv) { setupDiv.remove(); this.showPushNotificationSetup(); } } async sendTestNotification() { try { const response = await fetch('/api/push/test', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Test Notification', body: 'This is a test push notification from LittleShop Admin!' }), credentials: 'same-origin' }); const result = await response.json(); if (response.ok) { console.log('PWA: Test notification sent successfully'); return true; } else { throw new Error(result.error || 'Failed to send test notification'); } } catch (error) { console.error('PWA: Failed to send test notification:', error); throw error; } } // Helper function to convert VAPID key urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } } // Initialize PWA Manager const pwaManager = new PWAManager(); window.pwaManager = pwaManager; // Expose notification functions globally window.showNotification = (title, options) => pwaManager.showNotification(title, options); window.sendTestPushNotification = () => pwaManager.sendTestNotification(); window.subscribeToPushNotifications = () => pwaManager.subscribeToPushNotifications(); window.unsubscribeFromPushNotifications = () => pwaManager.unsubscribeFromPushNotifications();