// 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 = `