assets_js_controllers_member-mobile-card-pwa-controller.js

import MobileControllerBase from "./mobile-controller-base.js";

/**
 * MemberMobileCardPWA Stimulus Controller
 * 
 * Manages Progressive Web App (PWA) functionality for mobile member cards.
 * Extends MobileControllerBase for centralized connection handling.
 * 
 * Features:
 * - Service worker registration with version management
 * - SW update detection and user notification
 * - Online/offline status detection and display
 * - URL caching for offline access
 * - Visual status indicators with Bootstrap styling
 */
class MemberMobileCardPWA extends MobileControllerBase {
    static targets = ["urlCache", "status", "refreshBtn", "updateToast"]
    
    static values = {
        swUrl: String,
        authCardUrl: String,
        isAuthCard: Boolean
    }

    initialize() {
        super.initialize();
        this.sw = null;
        this.swVersion = null;
        this.updateDismissed = false;
        this.refreshIntervalId = null;
        this.connectivityProbeId = null;
        this._visibilityHandler = this.bindHandler('visibility', this.handleVisibilityChange);
    }

    /**
     * Handle URL cache target connection
     */
    urlCacheTargetConnected() {
        this.urlCacheValue = JSON.parse(this.urlCacheTarget.textContent);
    }
    
    /**
     * Handle status target connection - ensures indicator is displayed correctly
     * This fires when the target element is connected to the DOM, which handles
     * Turbo page restoration where targets might connect after onConnect runs
     */
    statusTargetConnected() {
        // Always update display when status target connects
        this.updateStatusDisplay(this.online);
    }

    /**
     * Called when connection state changes (from base class)
     */
    onConnectionStateChanged(isOnline) {
        this.updateStatusDisplay(isOnline);
        this.notifyServiceWorker(isOnline);
        this.dispatchStatusEvent(isOnline ? 'online' : 'offline');
        
        // Auto-refresh when coming back online
        if (isOnline && this.hasRefreshBtnTarget) {
            this.refreshBtnTarget.click();
        }
    }

    /**
     * Update the status display indicator (simple circle)
     */
    updateStatusDisplay(isOnline) {
        if (!this.hasStatusTarget) return;
        
        const statusEl = this.statusTarget;
        
        if (isOnline) {
            statusEl.title = 'Online';
            statusEl.classList.remove('bg-danger');
            statusEl.classList.add('bg-success');
            if (this.hasRefreshBtnTarget) {
                this.refreshBtnTarget.hidden = false;
            }
        } else {
            statusEl.title = 'Offline';
            statusEl.classList.remove('bg-success');
            statusEl.classList.add('bg-danger');
            if (this.hasRefreshBtnTarget) {
                this.refreshBtnTarget.hidden = true;
            }
        }
    }

    async probeConnectivity() {
        let isOnline = navigator.onLine;

        // If browser thinks we're online, confirm with a network-only request
        if (isOnline) {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 2500);
            try {
                await fetch('/health', {
                    method: 'HEAD',
                    cache: 'no-store',
                    headers: {
                        'X-Requested-With': 'XMLHttpRequest',
                        'Accept': 'application/json'
                    },
                    credentials: 'same-origin',
                    signal: controller.signal
                });
                // Any HTTP response confirms network reachability.
                isOnline = true;
            } catch (error) {
                isOnline = false;
            } finally {
                clearTimeout(timeoutId);
            }
        }

        const previous = this.online;
        MobileControllerBase.setOnlineState(isOnline, true);
        this.updateStatusDisplay(isOnline);
        if (previous !== isOnline) {
            this.dispatchStatusEvent(isOnline ? 'online' : 'offline');
        }
    }

    handleVisibilityChange() {
        if (document.visibilityState === 'visible') {
            this.probeConnectivity();
        }
    }

    /**
     * Notify service worker of connection state
     */
    notifyServiceWorker(isOnline) {
        if (this.sw && this.sw.active) {
            this.sw.active.postMessage({
                type: isOnline ? 'ONLINE' : 'OFFLINE'
            });
        }
    }

    /**
     * Dispatch connection status event for other controllers
     */
    dispatchStatusEvent(status) {
        const event = new CustomEvent('connection-status-changed', {
            bubbles: true,
            detail: {
                status: status,
                isOnline: status === 'online',
                isAuthCard: this.hasIsAuthCardValue && this.isAuthCardValue,
                authCardUrl: this.hasAuthCardUrlValue ? this.authCardUrlValue : null
            }
        });
        this.element.dispatchEvent(event);
    }

    /**
     * Handle service worker messages (e.g., SW_UPDATED)
     */
    handleSwMessage(event) {
        if (!event.data) return;
        
        switch (event.data.type) {
            case 'SW_UPDATED':
                console.log('[PWA] Service worker updated to version:', event.data.version);
                this.swVersion = event.data.version;
                if (!this.updateDismissed) {
                    this.showUpdateToast();
                }
                break;
        }
    }

    /**
     * Show update available toast
     */
    showUpdateToast() {
        // Create toast if target doesn't exist
        if (!this.hasUpdateToastTarget) {
            this.createUpdateToast();
        }
        
        if (this.hasUpdateToastTarget) {
            this.updateToastTarget.hidden = false;
            
            // Auto-dismiss after 10 seconds
            setTimeout(() => {
                if (this.hasUpdateToastTarget) {
                    this.updateToastTarget.hidden = true;
                    this.updateDismissed = true;
                }
            }, 10000);
        }
    }

    /**
     * Create update toast element
     */
    createUpdateToast() {
        const toast = document.createElement('div');
        toast.className = 'mobile-update-toast alert alert-info alert-dismissible fade show position-fixed bottom-0 start-50 translate-middle-x mb-3';
        toast.style.zIndex = '9999';
        toast.setAttribute('data-member-mobile-card-pwa-target', 'updateToast');
        toast.innerHTML = `
            <i class="bi bi-arrow-repeat me-2"></i>
            Update available - tap to refresh
            <button type="button" class="btn-close" data-action="click->member-mobile-card-pwa#dismissUpdate"></button>
        `;
        toast.addEventListener('click', (e) => {
            if (!e.target.classList.contains('btn-close')) {
                this.applyUpdate();
            }
        });
        this.element.appendChild(toast);
    }

    /**
     * Apply the service worker update
     */
    applyUpdate() {
        window.location.reload();
    }

    /**
     * Dismiss update toast
     */
    dismissUpdate(event) {
        event.stopPropagation();
        if (this.hasUpdateToastTarget) {
            this.updateToastTarget.hidden = true;
        }
        this.updateDismissed = true;
    }

    /**
     * Register service worker and set up message handling
     */
    async registerServiceWorker() {
        try {
            const registration = await navigator.serviceWorker.register(this.swUrlValue);
            this.sw = registration;
            
            // Listen for messages from service worker
            navigator.serviceWorker.addEventListener('message', this.bindHandler('swMessage', this.handleSwMessage));
            
            // Wait for service worker to be active
            await this.waitForActive(registration);
            
            // Send URLs to cache
            if (this.urlCacheValue && registration.active) {
                registration.active.postMessage({
                    type: 'CACHE_URLS',
                    payload: this.urlCacheValue
                });
            }
            
            // Dispatch PWA ready event
            const event = new CustomEvent('pwa-ready', { bubbles: true });
            this.element.dispatchEvent(event);
            
            console.log('[PWA] Service worker registered successfully');
        } catch (error) {
            console.error('[PWA] Service worker registration failed:', error);
            // Still dispatch ready event so app works without SW
            const event = new CustomEvent('pwa-ready', { bubbles: true });
            this.element.dispatchEvent(event);
        }
    }

    /**
     * Wait for service worker to become active
     */
    waitForActive(registration) {
        return new Promise((resolve) => {
            if (registration.active) {
                resolve();
                return;
            }
            
            const worker = registration.installing || registration.waiting;
            if (worker) {
                worker.addEventListener('statechange', function handler(e) {
                    if (e.target.state === 'activated') {
                        worker.removeEventListener('statechange', handler);
                        resolve();
                    }
                });
            } else {
                // Fallback - resolve after short delay
                setTimeout(resolve, 100);
            }
        });
    }

    /**
     * Refresh page if online
     */
    refreshPageIfOnline() {
        if (this.online) {
            window.location.reload();
        }
    }

    /**
     * Connect controller - called after base class connect
     */
    onConnect() {
        // Initialize status display
        this.updateStatusDisplay(this.online);
        this.dispatchStatusEvent(this.online ? 'online' : 'offline');
        this.probeConnectivity();
        
        if ('serviceWorker' in navigator) {
            // Register service worker
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', () => this.registerServiceWorker(), { once: true });
            } else {
                this.registerServiceWorker();
            }
        } else {
            console.warn('[PWA] Service Workers not available');
            // Dispatch ready event even without SW
            setTimeout(() => {
                const event = new CustomEvent('pwa-ready', { bubbles: true });
                this.element.dispatchEvent(event);
            }, 100);
        }
        
        // Set up periodic refresh (5 minutes)
        this.refreshIntervalId = setInterval(() => this.refreshPageIfOnline(), 300000);
        this.connectivityProbeId = setInterval(() => this.probeConnectivity(), 10000);
        document.addEventListener('visibilitychange', this._visibilityHandler);
    }

    /**
     * Disconnect controller - called after base class disconnect
     */
    onDisconnect() {
        if (this.refreshIntervalId) {
            clearInterval(this.refreshIntervalId);
            this.refreshIntervalId = null;
        }
        if (this.connectivityProbeId) {
            clearInterval(this.connectivityProbeId);
            this.connectivityProbeId = null;
        }
        document.removeEventListener('visibilitychange', this._visibilityHandler);
        
        // Remove SW message listener
        const swMessageHandler = this.getHandler('swMessage');
        if (swMessageHandler) {
            navigator.serviceWorker?.removeEventListener('message', swMessageHandler);
        }
    }
}

if (!window.Controllers) {
    window.Controllers = {};
}
window.Controllers["member-mobile-card-pwa"] = MemberMobileCardPWA;