← back to Watches
public/pwa-app.js
586 lines
/**
* Main PWA Application Controller
* Integrates all PWA features: offline, push notifications, camera, biometric, AR
*/
class OmegaWatchesPWA {
constructor() {
this.serviceWorkerRegistration = null;
this.scanner = null;
this.biometricAuth = null;
this.arViewer = null;
this.isOnline = navigator.onLine;
this.pushSubscription = null;
this.installPrompt = null;
}
/**
* Initialize PWA
*/
async init() {
console.log('[PWA] Initializing...');
// Register service worker
await this.registerServiceWorker();
// Setup event listeners
this.setupEventListeners();
// Check installation status
this.checkInstallStatus();
// Initialize biometric auth
await this.initBiometric();
// Request notification permission (optional)
await this.checkNotificationPermission();
// Check for updates
this.checkForUpdates();
console.log('[PWA] Initialized successfully');
}
/**
* Register Service Worker
*/
async registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('[PWA] Service Worker not supported');
return false;
}
try {
this.serviceWorkerRegistration = await navigator.serviceWorker.register('/sw-advanced.js', {
scope: '/'
});
console.log('[PWA] Service Worker registered:', this.serviceWorkerRegistration.scope);
// Handle updates
this.serviceWorkerRegistration.addEventListener('updatefound', () => {
const newWorker = this.serviceWorkerRegistration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
this.showUpdateNotification();
}
});
});
return true;
} catch (error) {
console.error('[PWA] Service Worker registration failed:', error);
return false;
}
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Online/offline status
window.addEventListener('online', () => {
this.isOnline = true;
this.showNotification('Back Online', 'You are now connected to the internet');
this.syncOfflineData();
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.showNotification('Offline Mode', 'You can continue browsing cached content');
});
// Install prompt
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this.installPrompt = e;
this.showInstallButton();
});
// App installed
window.addEventListener('appinstalled', () => {
console.log('[PWA] App installed');
this.installPrompt = null;
this.hideInstallButton();
this.trackEvent('app_installed');
});
// Visibility change (app in foreground/background)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.onAppForeground();
} else {
this.onAppBackground();
}
});
}
/**
* Check if app is installed
*/
checkInstallStatus() {
// Check if running as installed PWA
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone ||
document.referrer.includes('android-app://');
if (isStandalone) {
console.log('[PWA] Running as installed app');
document.body.classList.add('pwa-installed');
} else {
console.log('[PWA] Running in browser');
}
return isStandalone;
}
/**
* Show install button
*/
showInstallButton() {
const installBtn = document.getElementById('install-button');
if (installBtn) {
installBtn.style.display = 'block';
installBtn.addEventListener('click', () => this.installApp());
}
}
/**
* Hide install button
*/
hideInstallButton() {
const installBtn = document.getElementById('install-button');
if (installBtn) {
installBtn.style.display = 'none';
}
}
/**
* Install app
*/
async installApp() {
if (!this.installPrompt) {
console.warn('[PWA] Install prompt not available');
return false;
}
try {
this.installPrompt.prompt();
const { outcome } = await this.installPrompt.userChoice;
console.log('[PWA] Install outcome:', outcome);
if (outcome === 'accepted') {
this.trackEvent('install_accepted');
} else {
this.trackEvent('install_dismissed');
}
this.installPrompt = null;
return outcome === 'accepted';
} catch (error) {
console.error('[PWA] Install error:', error);
return false;
}
}
/**
* Initialize biometric authentication
*/
async initBiometric() {
try {
this.biometricAuth = new BiometricAuth();
await this.biometricAuth.checkAvailability();
if (this.biometricAuth.isAvailable) {
console.log('[PWA] Biometric auth available:', this.biometricAuth.getAuthType());
this.showBiometricOption();
}
} catch (error) {
console.error('[PWA] Biometric init failed:', error);
}
}
/**
* Show biometric authentication option
*/
showBiometricOption() {
const biometricBtn = document.getElementById('biometric-login');
if (biometricBtn) {
biometricBtn.style.display = 'block';
biometricBtn.textContent = `Login with ${this.biometricAuth.getAuthType()}`;
}
}
/**
* Check notification permission
*/
async checkNotificationPermission() {
if (!('Notification' in window)) {
console.warn('[PWA] Notifications not supported');
return false;
}
if (Notification.permission === 'granted') {
console.log('[PWA] Notification permission granted');
await this.subscribeToPushNotifications();
return true;
} else if (Notification.permission !== 'denied') {
console.log('[PWA] Notification permission not determined');
return false;
}
return false;
}
/**
* Request notification permission
*/
async requestNotificationPermission() {
if (!('Notification' in window)) {
return false;
}
try {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('[PWA] Notification permission granted');
await this.subscribeToPushNotifications();
this.showNotification('Notifications Enabled', 'You will receive price alerts and updates');
return true;
} else {
console.log('[PWA] Notification permission denied');
return false;
}
} catch (error) {
console.error('[PWA] Notification permission error:', error);
return false;
}
}
/**
* Subscribe to push notifications
*/
async subscribeToPushNotifications() {
if (!this.serviceWorkerRegistration) {
console.warn('[PWA] Service Worker not registered');
return false;
}
try {
// Check if already subscribed
let subscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
if (!subscription) {
// VAPID public key (in production, get from server)
const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY_HERE';
const convertedKey = this.urlBase64ToUint8Array(vapidPublicKey);
subscription = await this.serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey
});
console.log('[PWA] Push subscription created');
// Send subscription to server
await this.sendSubscriptionToServer(subscription);
} else {
console.log('[PWA] Already subscribed to push');
}
this.pushSubscription = subscription;
return true;
} catch (error) {
console.error('[PWA] Push subscription failed:', error);
return false;
}
}
/**
* Send subscription to server
*/
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
if (response.ok) {
console.log('[PWA] Subscription sent to server');
}
} catch (error) {
console.error('[PWA] Failed to send subscription:', error);
}
}
/**
* Show notification
*/
async showNotification(title, body, options = {}) {
if (Notification.permission !== 'granted') {
console.warn('[PWA] Cannot show notification - permission not granted');
return;
}
if (this.serviceWorkerRegistration) {
await this.serviceWorkerRegistration.showNotification(title, {
body: body,
icon: '/icons/icon-192x192.svg',
badge: '/icons/icon-72x72.svg',
vibrate: [200, 100, 200],
...options
});
} else {
new Notification(title, { body, ...options });
}
}
/**
* Show update notification
*/
showUpdateNotification() {
const updateBanner = document.getElementById('update-banner');
if (updateBanner) {
updateBanner.style.display = 'block';
updateBanner.querySelector('button').addEventListener('click', () => {
this.updateApp();
});
}
}
/**
* Update app
*/
async updateApp() {
if (!this.serviceWorkerRegistration) return;
const waiting = this.serviceWorkerRegistration.waiting;
if (waiting) {
waiting.postMessage({ action: 'skipWaiting' });
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
}
/**
* Check for updates
*/
async checkForUpdates() {
if (!this.serviceWorkerRegistration) return;
try {
await this.serviceWorkerRegistration.update();
console.log('[PWA] Checked for updates');
} catch (error) {
console.error('[PWA] Update check failed:', error);
}
}
/**
* Initialize camera scanner
*/
async initScanner() {
if (!this.scanner) {
this.scanner = new WatchScanner();
await this.scanner.init('scanner-video', 'scanner-canvas');
}
return this.scanner;
}
/**
* Initialize AR viewer
*/
async initAR() {
if (!this.arViewer) {
this.arViewer = new ARViewer();
await this.arViewer.checkSupport();
if (!this.arViewer.isSupported) {
throw new Error('AR not supported on this device');
}
await this.arViewer.init('ar-video', 'ar-canvas');
}
return this.arViewer;
}
/**
* Sync offline data
*/
async syncOfflineData() {
if (!this.serviceWorkerRegistration || !this.isOnline) {
return;
}
try {
if ('sync' in this.serviceWorkerRegistration) {
await this.serviceWorkerRegistration.sync.register('sync-watchlist');
await this.serviceWorkerRegistration.sync.register('sync-favorites');
await this.serviceWorkerRegistration.sync.register('sync-scanned-watches');
console.log('[PWA] Background sync registered');
}
} catch (error) {
console.error('[PWA] Sync registration failed:', error);
}
}
/**
* Register periodic sync for price updates
*/
async registerPeriodicSync() {
if (!this.serviceWorkerRegistration) return;
try {
const status = await navigator.permissions.query({
name: 'periodic-background-sync'
});
if (status.state === 'granted') {
await this.serviceWorkerRegistration.periodicSync.register('update-prices', {
minInterval: 24 * 60 * 60 * 1000 // 24 hours
});
console.log('[PWA] Periodic sync registered');
}
} catch (error) {
console.error('[PWA] Periodic sync not supported:', error);
}
}
/**
* App came to foreground
*/
onAppForeground() {
console.log('[PWA] App in foreground');
this.checkForUpdates();
}
/**
* App went to background
*/
onAppBackground() {
console.log('[PWA] App in background');
}
/**
* Clear all caches
*/
async clearCaches() {
if (!this.serviceWorkerRegistration) return;
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
resolve(event.data.success);
};
this.serviceWorkerRegistration.active.postMessage(
{ action: 'clearCache' },
[messageChannel.port2]
);
});
}
/**
* Get cache statistics
*/
async getCacheStats() {
if (!this.serviceWorkerRegistration) return null;
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
resolve(event.data);
};
this.serviceWorkerRegistration.active.postMessage(
{ action: 'getCacheStats' },
[messageChannel.port2]
);
});
}
/**
* Share watch
*/
async shareWatch(watchData) {
if (!navigator.share) {
console.warn('[PWA] Web Share API not supported');
return false;
}
try {
await navigator.share({
title: watchData.model,
text: `Check out this ${watchData.series} watch: ${watchData.model}`,
url: `${window.location.origin}/?watch=${watchData.id}`
});
console.log('[PWA] Watch shared');
this.trackEvent('watch_shared', { watchId: watchData.id });
return true;
} catch (error) {
if (error.name !== 'AbortError') {
console.error('[PWA] Share failed:', error);
}
return false;
}
}
/**
* Track event
*/
trackEvent(eventName, data = {}) {
console.log('[PWA] Event:', eventName, data);
// Send to analytics (if configured)
if (window.gtag) {
window.gtag('event', eventName, data);
}
}
/**
* Helper: 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 when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.pwaApp = new OmegaWatchesPWA();
window.pwaApp.init();
});
} else {
window.pwaApp = new OmegaWatchesPWA();
window.pwaApp.init();
}