← 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();
}