@modernman00/shared-js-lib 1.2.82 → 1.2.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PwaInstall.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 📱 PwaInstall — Smart A2HS Banner & iOS Safari Installation Engine
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Implements:
6
+ * 1. Native beforeinstallprompt interception for Chromium
7
+ * 2. iOS Safari step-by-step walkthrough bottom-sheet
8
+ * 3. Configurable app name, icon, and brand colors
9
+ * 4. 14-day persistent dismissal window
10
+ * 5. Safe-area notch padding & 44px minimum tap target compliance
11
+ */
12
+
13
+ export class PwaInstall {
14
+ constructor(options = {}) {
15
+ this.appName = options.appName || 'Application';
16
+ this.appIcon = options.appIcon || '/public/img/favicon/apple-touch-icon.png';
17
+ this.brandColor = options.brandColor || '#2563eb';
18
+ this.dismissKey = options.dismissKey || `${this.appName.toLowerCase().replace(/[^a-z0-9]/g, '_')}_pwa_dismissed`;
19
+ this.dismissDurationMs = (options.dismissDays || 14) * 24 * 60 * 60 * 1000;
20
+ this.delayMs = options.delayMs || 3000;
21
+ this.deferredPrompt = null;
22
+ this.isInitialized = false;
23
+
24
+ if (typeof window !== 'undefined') {
25
+ this.init();
26
+ }
27
+ }
28
+
29
+ init() {
30
+ if (this.isInitialized || typeof window === 'undefined') return;
31
+ this.isInitialized = true;
32
+
33
+ // Check if running in standalone mode (already installed)
34
+ const isStandalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
35
+ || window.navigator.standalone === true;
36
+
37
+ if (isStandalone) return;
38
+
39
+ // Check if dismissed recently
40
+ const dismissedAt = localStorage.getItem(this.dismissKey);
41
+ if (dismissedAt && (Date.now() - parseInt(dismissedAt, 10) < this.dismissDurationMs)) {
42
+ return;
43
+ }
44
+
45
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
46
+ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
47
+
48
+ // 1. Chromium native beforeinstallprompt
49
+ window.addEventListener('beforeinstallprompt', (e) => {
50
+ e.preventDefault();
51
+ this.deferredPrompt = e;
52
+ this.showBanner('native');
53
+ });
54
+
55
+ // 2. iOS Safari instructions
56
+ if (isIOS && isSafari && !isStandalone) {
57
+ if (document.readyState === 'complete') {
58
+ setTimeout(() => this.showBanner('ios'), this.delayMs);
59
+ } else {
60
+ window.addEventListener('DOMContentLoaded', () => {
61
+ setTimeout(() => this.showBanner('ios'), this.delayMs);
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ showBanner(type) {
68
+ if (document.getElementById('pwaUniversalInstallBanner')) return;
69
+
70
+ const banner = document.createElement('div');
71
+ banner.id = 'pwaUniversalInstallBanner';
72
+ banner.setAttribute('role', 'dialog');
73
+ banner.setAttribute('aria-label', `Install ${this.appName}`);
74
+
75
+ // Styling with glassmorphism and safe-area compliance
76
+ banner.style.position = 'fixed';
77
+ banner.style.bottom = 'max(16px, env(safe-area-inset-bottom, 16px))';
78
+ banner.style.left = '16px';
79
+ banner.style.right = '16px';
80
+ banner.style.margin = '0 auto';
81
+ banner.style.maxWidth = '480px';
82
+ banner.style.backgroundColor = 'rgba(23, 23, 28, 0.94)';
83
+ banner.style.backdropFilter = 'blur(12px)';
84
+ banner.style.webkitBackdropFilter = 'blur(12px)';
85
+ banner.style.border = '1px solid rgba(255, 255, 255, 0.12)';
86
+ banner.style.borderRadius = '16px';
87
+ banner.style.boxShadow = '0 12px 36px rgba(0, 0, 0, 0.45)';
88
+ banner.style.padding = '14px 16px';
89
+ banner.style.display = 'flex';
90
+ banner.style.alignItems = 'center';
91
+ banner.style.justifyContent = 'space-between';
92
+ banner.style.gap = '12px';
93
+ banner.style.zIndex = '99999';
94
+ banner.style.color = '#ffffff';
95
+ banner.style.fontFamily = 'system-ui, -apple-system, sans-serif';
96
+ banner.style.transition = 'all 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
97
+
98
+ const iconHtml = `<img src="${this.appIcon}" alt="${this.appName} Logo" style="width: 44px; height: 44px; border-radius: 10px; object-fit: cover; flex-shrink: 0;">`;
99
+
100
+ if (type === 'native') {
101
+ banner.innerHTML = `
102
+ <div style="display: flex; align-items: center; gap: 12px; min-width: 0;">
103
+ ${iconHtml}
104
+ <div style="min-width: 0;">
105
+ <strong style="display: block; font-size: 15px; font-weight: 600; line-height: 1.2;">Install ${this.appName}</strong>
106
+ <span style="display: block; font-size: 12px; color: #a1a1aa; margin-top: 2px;">Add to home screen for fast, offline access</span>
107
+ </div>
108
+ </div>
109
+ <div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
110
+ <button id="btnPwaInstallAction" type="button" style="background-color: ${this.brandColor}; color: #ffffff; border: none; border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; min-height: 44px; min-width: 44px;">
111
+ Install
112
+ </button>
113
+ <button id="btnPwaDismissAction" type="button" aria-label="Dismiss" style="background: none; border: none; color: #a1a1aa; font-size: 20px; cursor: pointer; padding: 8px; min-height: 44px; min-width: 44px;">
114
+ &times;
115
+ </button>
116
+ </div>
117
+ `;
118
+ } else {
119
+ banner.innerHTML = `
120
+ <div style="display: flex; align-items: center; gap: 12px; min-width: 0;">
121
+ ${iconHtml}
122
+ <div style="min-width: 0;">
123
+ <strong style="display: block; font-size: 15px; font-weight: 600; line-height: 1.2;">Install on iPhone</strong>
124
+ <span style="display: block; font-size: 12px; color: #a1a1aa; margin-top: 2px;">Tap <strong>Share</strong>, then <strong>'Add to Home Screen'</strong></span>
125
+ </div>
126
+ </div>
127
+ <div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
128
+ <button id="btnPwaDismissAction" type="button" aria-label="Dismiss" style="background: none; border: none; color: #a1a1aa; font-size: 20px; cursor: pointer; padding: 8px; min-height: 44px; min-width: 44px;">
129
+ &times;
130
+ </button>
131
+ </div>
132
+ `;
133
+ }
134
+
135
+ document.body.appendChild(banner);
136
+
137
+ const installBtn = document.getElementById('btnPwaInstallAction');
138
+ if (installBtn && this.deferredPrompt) {
139
+ installBtn.addEventListener('click', async () => {
140
+ banner.style.display = 'none';
141
+ this.deferredPrompt.prompt();
142
+ const { outcome } = await this.deferredPrompt.userChoice;
143
+ if (outcome === 'accepted') {
144
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
145
+ window.dispatchEvent(new CustomEvent('pwa:installed', { detail: { appName: this.appName } }));
146
+ }
147
+ }
148
+ this.deferredPrompt = null;
149
+ });
150
+ }
151
+
152
+ const dismissBtn = document.getElementById('btnPwaDismissAction');
153
+ if (dismissBtn) {
154
+ dismissBtn.addEventListener('click', () => {
155
+ banner.style.opacity = '0';
156
+ banner.style.transform = 'translateY(16px)';
157
+ setTimeout(() => banner.remove(), 300);
158
+ localStorage.setItem(this.dismissKey, Date.now().toString());
159
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
160
+ window.dispatchEvent(new CustomEvent('pwa:install-dismissed', { detail: { appName: this.appName } }));
161
+ }
162
+ });
163
+ }
164
+ }
165
+ }
package/PwaManager.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * 📱 PwaManager — Universal Cross-App Service Worker & PWA Controller
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Implements:
6
+ * 1. Clean root /sw.js registration with scope '/'
7
+ * 2. Purging of legacy orphaned workers (/public/sw.js, /service-worker.js)
8
+ * 3. User-prompted updates via 'postMessage' with zero jarring auto-reloads
9
+ * 4. Safe cache purging on user logout (clears API & session caches)
10
+ * 5. Custom event dispatching for seamless Blade UI integration
11
+ */
12
+
13
+ export class PwaManager {
14
+ static registration = null;
15
+ static options = {
16
+ swUrl: '/sw.js',
17
+ scope: '/',
18
+ appName: 'App',
19
+ enableUpdateToast: true,
20
+ enableBadgingSync: true,
21
+ badgeEndpoint: '/api/notifications/unread',
22
+ onUpdateAvailable: null
23
+ };
24
+
25
+ /**
26
+ * Initializes Service Worker registration and lifecycle monitoring
27
+ */
28
+ static async init(userOptions = {}) {
29
+ if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
30
+ return null;
31
+ }
32
+
33
+ this.options = { ...this.options, ...userOptions };
34
+
35
+ window.addEventListener('load', async () => {
36
+ try {
37
+ await this.purgeLegacyWorkers();
38
+ this.registration = await navigator.serviceWorker.register(this.options.swUrl, {
39
+ scope: this.options.scope
40
+ });
41
+
42
+ console.log(`[PwaManager] ${this.options.appName} ServiceWorker active with scope:`, this.registration.scope);
43
+
44
+ // Listen for new workers entering 'installing' or 'waiting' states
45
+ this.registration.addEventListener('updatefound', () => {
46
+ const installingWorker = this.registration.installing;
47
+ if (!installingWorker) return;
48
+
49
+ installingWorker.addEventListener('statechange', () => {
50
+ if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
51
+ console.log(`[PwaManager] New version of ${this.options.appName} installed and waiting.`);
52
+ this.handleUpdateAvailable(installingWorker);
53
+ }
54
+ });
55
+ });
56
+
57
+ // Detect if a worker was already waiting prior to page load
58
+ if (this.registration.waiting && navigator.serviceWorker.controller) {
59
+ this.handleUpdateAvailable(this.registration.waiting);
60
+ }
61
+ } catch (error) {
62
+ console.error(`[PwaManager] ServiceWorker registration failed:`, error);
63
+ }
64
+ });
65
+
66
+ // Listen for controller changes (when skipWaiting activates new worker)
67
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
68
+ console.log('[PwaManager] ServiceWorker controller changed. Page ready for reload.');
69
+ });
70
+
71
+ // Automatic Home Screen Badging Synchronization
72
+ if (this.options.enableBadgingSync && typeof window !== 'undefined') {
73
+ this.syncAppBadge();
74
+
75
+ window.addEventListener('focus', () => this.syncAppBadge());
76
+ document.addEventListener('visibilitychange', () => {
77
+ if (document.visibilityState === 'visible') {
78
+ this.syncAppBadge();
79
+ }
80
+ });
81
+
82
+ navigator.serviceWorker.addEventListener('message', (event) => {
83
+ if (event.data?.type === 'ON_APP_NOTIFICATION') {
84
+ this.syncAppBadge();
85
+ }
86
+ });
87
+ }
88
+
89
+ return this.registration;
90
+ }
91
+
92
+ /**
93
+ * Drops legacy or misconfigured workers from older architecture iterations
94
+ */
95
+ static async purgeLegacyWorkers() {
96
+ try {
97
+ const registrations = await navigator.serviceWorker.getRegistrations();
98
+ for (const reg of registrations) {
99
+ const scriptURL = (reg.active || reg.waiting || reg.installing || {}).scriptURL || '';
100
+ // If worker is scoped to /public/ or named /service-worker.js, unregister it
101
+ if (scriptURL.endsWith('/public/sw.js') || scriptURL.endsWith('/service-worker.js')) {
102
+ console.warn('[PwaManager] Unregistering legacy orphaned worker:', scriptURL);
103
+ await reg.unregister();
104
+ }
105
+ }
106
+ } catch (err) {
107
+ console.warn('[PwaManager] Legacy cleanup check error:', err);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Handles update notification
113
+ */
114
+ static handleUpdateAvailable(worker) {
115
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
116
+ window.dispatchEvent(new CustomEvent('pwa:update-available', {
117
+ detail: { worker, appName: this.options.appName }
118
+ }));
119
+ }
120
+
121
+ if (typeof this.options.onUpdateAvailable === 'function') {
122
+ this.options.onUpdateAvailable(worker);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Sends skipWaiting postMessage to the waiting worker and triggers clean reload
128
+ */
129
+ static applyUpdate(worker = null) {
130
+ const targetWorker = worker || (this.registration ? this.registration.waiting : null);
131
+ if (targetWorker) {
132
+ targetWorker.postMessage({ action: 'skipWaiting' });
133
+ } else if (navigator.serviceWorker.controller) {
134
+ navigator.serviceWorker.controller.postMessage({ action: 'skipWaiting' });
135
+ }
136
+
137
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
138
+ window.location.reload();
139
+ }, { once: true });
140
+ }
141
+
142
+ /**
143
+ * Purges sensitive API and user caches from the Service Worker upon user logout
144
+ */
145
+ static async clearUserCacheOnLogout() {
146
+ if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
147
+ navigator.serviceWorker.controller.postMessage({ action: 'clearUserCache' });
148
+ }
149
+
150
+ if (typeof caches !== 'undefined') {
151
+ try {
152
+ const keys = await caches.keys();
153
+ await Promise.all(
154
+ keys.filter(k => k.includes('api-cache') || k.includes('user-cache')).map(k => caches.delete(k))
155
+ );
156
+ console.log('[PwaManager] Client-side sensitive user caches purged.');
157
+ } catch (e) {
158
+ console.warn('[PwaManager] Failed to purge user caches:', e);
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Sets app badge counter on the phone's home screen icon (Web Badging API)
165
+ *
166
+ * @param {number|string} count
167
+ * @returns {Promise<void>}
168
+ */
169
+ static setAppBadge(count) {
170
+ if (typeof navigator !== 'undefined' && 'setAppBadge' in navigator) {
171
+ const num = Math.max(0, parseInt(count, 10) || 0);
172
+ return (num > 0 ? navigator.setAppBadge(num) : navigator.clearAppBadge()).catch(() => {});
173
+ }
174
+ return Promise.resolve();
175
+ }
176
+
177
+ /**
178
+ * Clears app badge counter from the phone's home screen icon
179
+ *
180
+ * @returns {Promise<void>}
181
+ */
182
+ static clearAppBadge() {
183
+ if (typeof navigator !== 'undefined' && 'clearAppBadge' in navigator) {
184
+ return navigator.clearAppBadge().catch(() => {});
185
+ }
186
+ return Promise.resolve();
187
+ }
188
+
189
+ /**
190
+ * Synchronizes app badge with the server's unread notifications endpoint
191
+ *
192
+ * @param {string|null} endpoint
193
+ * @returns {Promise<void>}
194
+ */
195
+ static async syncAppBadge(endpoint = null) {
196
+ const url = endpoint || this.options.badgeEndpoint || '/api/notifications/unread';
197
+ if (typeof navigator === 'undefined' || !('setAppBadge' in navigator)) {
198
+ return;
199
+ }
200
+
201
+ try {
202
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
203
+ if (!res.ok) {
204
+ // Release the stream before bailing. An abandoned response body stays
205
+ // "in flight" in Chromium indefinitely, which keeps the tab from ever
206
+ // reaching network idle — it silently wedges Playwright's
207
+ // `networkidle` waits and any load-state detection for the whole page.
208
+ await res.body?.cancel();
209
+ return;
210
+ }
211
+ const data = await res.json();
212
+ if (data && data.status === 'success' && Array.isArray(data.message)) {
213
+ const count = data.message.length;
214
+ if (count > 0) {
215
+ await this.setAppBadge(count);
216
+ } else {
217
+ await this.clearAppBadge();
218
+ }
219
+ }
220
+ } catch (e) {
221
+ // Harmless no-op on network failure or offline mode
222
+ }
223
+ }
224
+ }