littleshop/LittleShop/wwwroot/js/pwa.js
2025-09-01 06:01:05 +01:00

603 lines
23 KiB
JavaScript

// 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 = '<i class="fas fa-download"></i> 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 = `
<strong>Update Available!</strong><br>
A new version of the app is ready.
<button type="button" class="btn btn-sm btn-outline-info ms-2" id="update-btn">
Update Now
</button>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
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 = '<i class="fas fa-mobile-alt"></i> 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 = `
<div class="d-flex align-items-center">
<i class="fas fa-bell me-2"></i>
<div class="flex-grow-1">
<strong>Push Notifications</strong><br>
<small>${isSubscribed ? 'You are subscribed to notifications' : 'Get notified of new orders and updates'}</small>
</div>
<div class="ms-2">
${isSubscribed ?
'<button type="button" class="btn btn-sm btn-outline-danger" id="unsubscribe-push-btn">Turn Off</button>' :
'<div class="d-flex flex-column gap-1"><button type="button" class="btn btn-sm btn-primary" id="subscribe-push-btn">Turn On</button><button type="button" class="btn btn-sm btn-outline-secondary" id="dismiss-push-btn">Not Now</button></div>'
}
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" id="close-push-setup"></button>
`;
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 = '<i class="fas fa-spinner fa-spin me-1"></i>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 = '<i class="fas fa-spinner fa-spin me-1"></i>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();