// 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 5 seconds if no prompt appeared and app not installed setTimeout(() => { if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) { console.log('PWA: No install prompt appeared, showing manual install guide'); this.showManualInstallButton(); } }, 5000); } 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...'); console.log('PWA: Current URL:', window.location.href); console.log('PWA: Display mode:', window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser'); console.log('PWA: User agent:', navigator.userAgent); } // 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', () => { const isChrome = navigator.userAgent.includes('Chrome'); const isEdge = navigator.userAgent.includes('Edge'); const isFirefox = navigator.userAgent.includes('Firefox'); let instructions = 'To install this app:\\n\\n'; if (isChrome || isEdge) { instructions += '1. Look for the install icon (⬇️) in the address bar\\n'; instructions += '2. Or click the browser menu (⋮) → "Install LittleShop Admin"\\n'; instructions += '3. Or check if there\'s an "Install app" option in the browser menu'; } else if (isFirefox) { instructions += '1. Firefox doesn\'t support PWA installation yet\\n'; instructions += '2. You can bookmark this page for easy access\\n'; instructions += '3. Or use Chrome/Edge for the full PWA experience'; } else { instructions += '1. Look for an install or "Add to Home Screen" option\\n'; instructions += '2. Check your browser menu for app installation\\n'; instructions += '3. Or bookmark this page for quick access'; } alert(instructions); }); 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 { // Get VAPID public key from server await this.getVapidPublicKey(); // Check if user is already subscribed await this.checkPushSubscription(); // Simple logic: only show prompt if user is not subscribed if (!this.pushSubscription) { // Check if we've already asked this session if (!sessionStorage.getItem('pushNotificationPromptShown')) { this.showPushNotificationSetup(); sessionStorage.setItem('pushNotificationPromptShown', 'true'); } } else { console.log('PWA: User already subscribed to push notifications'); } } 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: User has active push subscription'); } else { console.log('PWA: User is not subscribed to push notifications'); } } 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 with enhanced debugging console.log('PWA: Requesting push subscription from browser...'); console.log('PWA: VAPID public key (first 32 chars):', this.vapidPublicKey.substring(0, 32) + '...'); const subscriptionStartTime = Date.now(); let subscription; try { subscription = await Promise.race([ this.swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser push subscription timed out after 30 seconds. This may indicate an issue with your browser\'s push service connectivity.')), 30000) ) ]); const subscriptionTime = Date.now() - subscriptionStartTime; console.log(`PWA: Browser subscription completed in ${subscriptionTime}ms`); console.log('PWA: Subscription endpoint:', subscription.endpoint); } catch (subscriptionError) { console.error('PWA: Browser subscription failed:', subscriptionError); throw new Error(`Failed to subscribe with browser push service: ${subscriptionError.message}`); } // Send subscription to server with timeout console.log('PWA: Sending subscription to server...'); const serverStartTime = Date.now(); const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); console.error('PWA: Server request timeout after 15 seconds'); }, 15000); 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', signal: controller.signal }); clearTimeout(timeoutId); const serverTime = Date.now() - serverStartTime; console.log(`PWA: Server response received in ${serverTime}ms:`, response.status, response.statusText); if (response.ok) { this.pushSubscription = subscription; console.log('PWA: Successfully subscribed to push notifications'); this.hidePushNotificationSetup(); 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'); 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; `; setupDiv.innerHTML = `
Push Notifications
Get notified of new orders and updates
`; document.body.appendChild(setupDiv); // Add event listener for subscribe button const subscribeBtn = document.getElementById('subscribe-push-btn'); if (subscribeBtn) { subscribeBtn.addEventListener('click', async () => { subscribeBtn.disabled = true; subscribeBtn.innerHTML = 'Subscribing...'; try { // Add timeout to prevent infinite hanging const subscriptionPromise = this.subscribeToPushNotifications(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Push subscription timed out after 15 seconds. This may be due to network connectivity or browser push service issues.')), 15000) ); await Promise.race([subscriptionPromise, timeoutPromise]); 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.'; } else if (error.message.includes('timeout')) { userMessage = 'Push notification setup timed out. This may be due to network or browser push service issues. Please check your internet connection and try again.'; } else if (error.message.includes('push service')) { userMessage = 'Failed to connect to browser push service. This may be a temporary network issue. Please try again in a few moments.'; } else if (error.message.includes('AbortError')) { userMessage = 'Request was cancelled due to timeout. Please check your internet connection and try again.'; } console.error('PWA: Full error details:', error); alert('Failed to enable push notifications: ' + userMessage); subscribeBtn.disabled = false; subscribeBtn.innerHTML = 'Enable'; } }); } } hidePushNotificationSetup() { const setupDiv = document.getElementById('push-notification-setup'); if (setupDiv) { setupDiv.remove(); console.log('PWA: Push notification setup hidden'); } } 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();