// Progressive Web App functionality with fixes for desktop and persistent prompts // Handles service worker registration and PWA features class PWAManager { constructor() { this.swRegistration = null; this.vapidPublicKey = null; this.pushSubscription = null; this.installPromptShown = false; this.pushPromptShown = false; 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(); // Loading screen is now managed by blazor-integration.js // Don't interfere with Blazor Server's loading lifecycle console.log('PWA: Initialization complete (loading screen managed by Blazor)'); } setupInstallPrompt() { let deferredPrompt; // Check if already installed on init const isInstalled = this.isInstalled(); if (isInstalled) { console.log('PWA: App is already installed'); localStorage.setItem('pwaInstalled', 'true'); this.installPromptShown = true; // Don't show prompt if already installed return; } window.addEventListener('beforeinstallprompt', (e) => { console.log('PWA: beforeinstallprompt event fired'); e.preventDefault(); deferredPrompt = e; // Only show if not already shown and not installed if (!this.installPromptShown && !this.isInstalled()) { this.showInstallButton(deferredPrompt); this.installPromptShown = true; } }); window.addEventListener('appinstalled', () => { console.log('PWA: App was installed'); localStorage.setItem('pwaInstalled', 'true'); this.hideInstallButton(); this.installPromptShown = true; }); // Only show manual button if: // 1. Not installed // 2. Not already shown // 3. User hasn't dismissed it const installDismissed = localStorage.getItem('pwaInstallDismissed'); if (!isInstalled && !this.installPromptShown && !installDismissed) { // Wait for browser prompt opportunity setTimeout(() => { if (!this.installPromptShown && !this.isInstalled()) { console.log('PWA: Showing manual install option'); this.showManualInstallButton(); this.installPromptShown = true; } }, 5000); } } showInstallButton(deferredPrompt) { // Check again before showing if (this.isInstalled() || document.getElementById('pwa-install-btn')) { return; } const installBtn = document.createElement('button'); installBtn.id = 'pwa-install-btn'; installBtn.className = 'btn btn-primary btn-sm'; installBtn.innerHTML = ' Install App'; // Detect if mobile bottom nav exists and adjust positioning const isMobileView = window.innerWidth < 768; const bottomPosition = isMobileView ? '80px' : '20px'; // 80px to clear mobile bottom nav installBtn.style.cssText = ` position: fixed; bottom: ${bottomPosition}; right: 20px; z-index: 1000; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); `; // Add close button const closeBtn = document.createElement('button'); closeBtn.className = 'btn-close btn-close-white'; closeBtn.style.cssText = ` position: absolute; top: -8px; right: -8px; background: red; border-radius: 50%; width: 20px; height: 20px; padding: 0; `; closeBtn.onclick = () => { localStorage.setItem('pwaInstallDismissed', 'true'); this.hideInstallButton(); }; const wrapper = document.createElement('div'); wrapper.id = 'pwa-install-wrapper'; wrapper.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 1000; `; wrapper.appendChild(installBtn); wrapper.appendChild(closeBtn); installBtn.addEventListener('click', async () => { if (deferredPrompt) { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log('PWA: User response to install prompt:', outcome); if (outcome === 'accepted') { localStorage.setItem('pwaInstalled', 'true'); } deferredPrompt = null; this.hideInstallButton(); } }); document.body.appendChild(wrapper); } hideInstallButton() { const wrapper = document.getElementById('pwa-install-wrapper'); const btn = document.getElementById('pwa-install-btn'); if (wrapper) wrapper.remove(); if (btn) btn.remove(); } showUpdateNotification() { 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); document.getElementById('update-btn').addEventListener('click', () => { if (this.swRegistration && this.swRegistration.waiting) { this.swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }); window.location.reload(); } }); } async setupNotifications() { if ('Notification' in window) { const permission = await this.requestNotificationPermission(); console.log('Notifications permission:', permission); } } async requestNotificationPermission() { if (Notification.permission === 'default') { return Notification.permission; } return Notification.permission; } 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 }); setTimeout(() => { notification.close(); }, 5000); return notification; } } showManualInstallButton() { // Don't show if PWA is not supported or already installed if (this.isInstalled() || document.getElementById('pwa-install-btn')) { return; } // Only show if browser supports PWA (has beforeinstallprompt event or is iOS) const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const supportsPWA = 'serviceWorker' in navigator && ('BeforeInstallPromptEvent' in window || isIOS); if (!supportsPWA) { console.log('PWA: Browser does not support PWA installation'); return; } const installBtn = document.createElement('button'); installBtn.id = 'pwa-install-btn'; installBtn.className = 'btn btn-primary btn-sm'; installBtn.innerHTML = ' Install as App'; // Detect if mobile bottom nav exists and adjust positioning const isMobileView = window.innerWidth < 768; const bottomPosition = isMobileView ? '80px' : '20px'; // 80px to clear mobile bottom nav installBtn.style.cssText = ` position: fixed; bottom: ${bottomPosition}; right: 20px; z-index: 1000; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); `; // Add close button const closeBtn = document.createElement('button'); closeBtn.className = 'btn-close btn-close-white'; closeBtn.style.cssText = ` position: absolute; top: -8px; right: -8px; background: red; border-radius: 50%; width: 20px; height: 20px; padding: 0; `; closeBtn.onclick = () => { localStorage.setItem('pwaInstallDismissed', 'true'); this.hideInstallButton(); }; const wrapper = document.createElement('div'); wrapper.id = 'pwa-install-wrapper'; wrapper.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 1000; `; wrapper.appendChild(installBtn); wrapper.appendChild(closeBtn); installBtn.addEventListener('click', () => { const isChrome = navigator.userAgent.includes('Chrome'); const isEdge = navigator.userAgent.includes('Edge'); const isFirefox = navigator.userAgent.includes('Firefox'); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; let instructions = 'To install this app:\n\n'; if (isIOS) { instructions += '1. Tap the Share button (□↑) in Safari\n'; instructions += '2. Scroll down and tap "Add to Home Screen"\n'; instructions += '3. Tap "Add" to install'; } else if (isChrome || isEdge) { instructions += '1. Look for the install icon (⬇️) in the address bar\n'; instructions += '2. Or click the browser menu (⋮) → "Install TeleShop 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); localStorage.setItem('pwaInstallDismissed', 'true'); this.hideInstallButton(); }); document.body.appendChild(wrapper); } isInstalled() { // Check multiple indicators const standalone = window.matchMedia('(display-mode: standalone)').matches; const iosStandalone = window.navigator.standalone === true; const localStorageFlag = localStorage.getItem('pwaInstalled') === 'true'; return standalone || iosStandalone || localStorageFlag; } async setupPushNotifications() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.log('PWA: Push notifications not supported'); return; } try { await this.getVapidPublicKey(); await this.checkPushSubscription(); // Only show prompt if: // 1. Not subscribed // 2. Not already shown // 3. User hasn't declined if (!this.pushSubscription && !this.pushPromptShown) { const userDeclined = localStorage.getItem('pushNotificationDeclined'); if (!userDeclined) { // Delay showing the prompt to avoid overwhelming user setTimeout(() => { if (!this.pushSubscription && !this.pushPromptShown) { this.showPushNotificationSetup(); this.pushPromptShown = true; } }, 3000); } } } 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'); localStorage.setItem('pushSubscribed', 'true'); } else { console.log('PWA: User is not subscribed to push notifications'); localStorage.removeItem('pushSubscribed'); } } 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 permission if (Notification.permission === 'denied') { throw new Error('Notification permission was denied. Please enable notifications in your browser settings.'); } // Request permission if needed let permission = Notification.permission; if (permission === 'default') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { throw new Error('Notification permission is required for push notifications.'); } console.log('PWA: Requesting push subscription...'); // Desktop Chrome workaround: Sometimes needs a small delay if (!navigator.userAgent.includes('Mobile')) { await new Promise(resolve => setTimeout(resolve, 100)); } let subscription; try { // Subscribe with shorter timeout for desktop const timeoutMs = navigator.userAgent.includes('Mobile') ? 15000 : 10000; subscription = await Promise.race([ this.swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey) }), new Promise((_, reject) => setTimeout(() => reject(new Error(`Push subscription timed out after ${timeoutMs/1000} seconds.`)), timeoutMs) ) ]); console.log('PWA: Subscription successful:', subscription.endpoint); } catch (subscriptionError) { console.error('PWA: Subscription error:', subscriptionError); // Desktop-specific error handling if (!navigator.userAgent.includes('Mobile')) { if (subscriptionError.message.includes('timeout')) { throw new Error('Push subscription timed out. This can happen with VPNs or corporate firewalls. The app will work without push notifications.'); } } throw subscriptionError; } // Send to server console.log('PWA: Sending 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; localStorage.setItem('pushSubscribed', 'true'); 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:', error); throw error; } } async unsubscribeFromPushNotifications() { if (!this.pushSubscription) { return true; } try { await this.pushSubscription.unsubscribe(); 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; localStorage.removeItem('pushSubscribed'); console.log('PWA: Successfully unsubscribed from push notifications'); return true; } catch (error) { console.error('PWA: Failed to unsubscribe:', error); throw error; } } showPushNotificationSetup() { if (document.getElementById('push-notification-setup')) { return; } const setupDiv = document.createElement('div'); setupDiv.id = 'push-notification-setup'; setupDiv.className = 'alert alert-info'; 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); // Event listeners const subscribeBtn = document.getElementById('subscribe-push-btn'); const skipBtn = document.getElementById('skip-push-btn'); const closeBtn = document.getElementById('close-push-btn'); const hideSetup = () => { localStorage.setItem('pushNotificationDeclined', 'true'); this.hidePushNotificationSetup(); }; if (subscribeBtn) { subscribeBtn.addEventListener('click', async () => { subscribeBtn.disabled = true; skipBtn.disabled = true; subscribeBtn.innerHTML = 'Enabling...'; 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: Subscription failed:', error); let userMessage = 'Failed to enable push notifications.'; if (error.message.includes('permission')) { userMessage = 'Please allow notifications when prompted.'; } else if (error.message.includes('timeout') || error.message.includes('VPN')) { userMessage = 'Connection timeout. This may be due to network restrictions. The app will work without push notifications.'; // Auto-dismiss on timeout hideSetup(); alert(userMessage); return; } alert(userMessage); subscribeBtn.disabled = false; skipBtn.disabled = false; subscribeBtn.innerHTML = 'Enable'; } }); } if (skipBtn) { skipBtn.addEventListener('click', hideSetup); } if (closeBtn) { closeBtn.addEventListener('click', hideSetup); } } hidePushNotificationSetup() { const setupDiv = document.getElementById('push-notification-setup'); if (setupDiv) { setupDiv.remove(); } } 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; } } 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 functions globally window.showNotification = (title, options) => pwaManager.showNotification(title, options); window.sendTestPushNotification = () => pwaManager.sendTestNotification(); window.subscribeToPushNotifications = () => pwaManager.subscribeToPushNotifications(); window.unsubscribeFromPushNotifications = () => pwaManager.unsubscribeFromPushNotifications(); // Handle 401 errors globally - redirect to login if (window.fetch) { const originalFetch = window.fetch; window.fetch = async function(...args) { const response = await originalFetch.apply(this, args); // Check if it's an admin area request and got 401 if (response.status === 401 && window.location.pathname.startsWith('/Admin')) { // Don't redirect if already on login page if (!window.location.pathname.includes('/Account/Login')) { window.location.href = '/Admin/Account/Login?ReturnUrl=' + encodeURIComponent(window.location.pathname); } } return response; }; } // Also handle 401 from direct navigation window.addEventListener('load', () => { // Check if we got redirected to /Admin instead of /Admin/Account/Login if (window.location.pathname === '/Admin' || window.location.pathname === '/Admin/') { // Check if user is authenticated by trying to fetch a protected resource fetch('/Admin/Dashboard', { method: 'HEAD', credentials: 'same-origin' }).then(response => { if (response.status === 401 || response.status === 302) { window.location.href = '/Admin/Account/Login'; } }).catch(() => { // Network error, do nothing }); } }); // Loading screen fallback timeout removed - now managed by blazor-integration.js // Blazor Server controls loading screen lifecycle based on SignalR connection state