@modernman00/shared-js-lib 1.2.82 → 1.2.87

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.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * OtpInputManager - Centralized OTP & Security Verification Input Controller
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Provides cross-platform OTP handling:
6
+ * - iOS 17+ Apple Mail QuickType autofill multi-character distribution
7
+ * - Android Gmail notification copy & Gboard suggestion strip support
8
+ * - Single-keypress automatic focus progression & backspace navigation
9
+ * - Tab focus (visibilitychange) smart clipboard detection
10
+ * - 1-Click Magic Link URL query parameter extraction (?code=123456) with history sanitization
11
+ * - Eastern Arabic / Perso-Arabic numeral normalization
12
+ * - Automatic form submission upon completion
13
+ */
14
+
15
+ export class OtpInputManager {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {string|NodeList|Array} [options.inputs='.otp-input'] - Selector or element collection for OTP inputs
19
+ * @param {string} [options.hiddenInputId='code'] - ID of hidden input storing full code
20
+ * @param {string} [options.submitBtnId='button'] - ID of submit button to trigger on completion
21
+ * @param {string} [options.pasteBtnId='pasteBtn'] - Optional ID of "Paste Code from Email" button
22
+ * @param {number} [options.length=6] - Expected length of verification code
23
+ * @param {boolean} [options.autoSubmit=true] - Auto-submit form when all digits are entered
24
+ * @param {number} [options.autoSubmitDelay=150] - Delay in ms before auto-submitting
25
+ * @param {boolean} [options.checkClipboardOnFocus=true] - Check clipboard on visibilitychange / focus
26
+ * @param {string} [options.urlParamKey='code'] - URL search param to check for magic link autofill
27
+ * @param {boolean} [options.cleanUrlAfterFill=true] - Strip ?code= param from URL bar to prevent leakage
28
+ * @param {Function} [options.onComplete=null] - Callback triggered when code is complete
29
+ * @param {Function} [options.onChange=null] - Callback triggered when any digit changes
30
+ */
31
+ constructor(options = {}) {
32
+ this.options = {
33
+ inputs: '.otp-input',
34
+ hiddenInputId: 'code',
35
+ submitBtnId: 'button',
36
+ pasteBtnId: 'pasteBtn',
37
+ length: 6,
38
+ autoSubmit: true,
39
+ autoSubmitDelay: 150,
40
+ checkClipboardOnFocus: true,
41
+ urlParamKey: 'code',
42
+ cleanUrlAfterFill: true,
43
+ onComplete: null,
44
+ onChange: null,
45
+ ...options
46
+ };
47
+
48
+ this.inputs = [];
49
+ this.hiddenInput = null;
50
+ this.submitBtn = null;
51
+ this.pasteBtn = null;
52
+ this.boundHandlers = [];
53
+ this.isSubmitting = false;
54
+
55
+ this._initElements();
56
+ if (this.inputs.length > 0) {
57
+ this._bindEvents();
58
+ this._checkUrlParameter();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Normalizes Eastern Arabic / Perso-Arabic digits to standard ASCII numerals
64
+ * and strips non-alphanumeric characters.
65
+ *
66
+ * @param {string} str
67
+ * @returns {string}
68
+ */
69
+ static normalizeDigits(str) {
70
+ if (!str || typeof str !== 'string') return '';
71
+ return str
72
+ .replace(/[\u0660-\u0669]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 48))
73
+ .replace(/[\u06F0-\u06F9]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x06F0 + 48))
74
+ .replace(/[^a-zA-Z0-9]/g, '')
75
+ .toUpperCase();
76
+ }
77
+
78
+ /**
79
+ * Resolve DOM elements
80
+ * @private
81
+ */
82
+ _initElements() {
83
+ if (typeof document === 'undefined') return;
84
+
85
+ if (typeof this.options.inputs === 'string') {
86
+ this.inputs = Array.from(document.querySelectorAll(this.options.inputs));
87
+ } else if (this.options.inputs instanceof NodeList || Array.isArray(this.options.inputs)) {
88
+ this.inputs = Array.from(this.options.inputs);
89
+ }
90
+
91
+ // Single input mode fallback (e.g. LoanEasyFinance or TenantScore)
92
+ if (this.inputs.length === 0) {
93
+ const singleInput = document.getElementById(this.options.singleInputId || 'code') ||
94
+ document.querySelector('input[name="code"]');
95
+ if (singleInput) {
96
+ this.inputs = [singleInput];
97
+ }
98
+ }
99
+
100
+ if (this.options.hiddenInputId) {
101
+ this.hiddenInput = document.getElementById(this.options.hiddenInputId);
102
+ }
103
+ if (this.options.submitBtnId) {
104
+ this.submitBtn = document.getElementById(this.options.submitBtnId);
105
+ }
106
+ if (this.options.pasteBtnId) {
107
+ this.pasteBtn = document.getElementById(this.options.pasteBtnId);
108
+ }
109
+
110
+ // Ensure inputs have autocomplete and inputmode configured for mobile devices
111
+ this.inputs.forEach((input, index) => {
112
+ if (!input.getAttribute('inputmode')) {
113
+ input.setAttribute('inputmode', 'numeric');
114
+ }
115
+ // On mobile (iOS / Android), the first box receives the autofill event
116
+ if (index === 0) {
117
+ input.setAttribute('autocomplete', 'one-time-code');
118
+ // Allow up to expected length in first box so mobile OS doesn't truncate the payload
119
+ input.setAttribute('maxlength', String(this.options.length));
120
+ } else {
121
+ input.setAttribute('autocomplete', 'off');
122
+ input.setAttribute('maxlength', '1');
123
+ }
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Bind event listeners
129
+ * @private
130
+ */
131
+ _bindEvents() {
132
+ if (typeof document === 'undefined') return;
133
+
134
+ this.inputs.forEach((input, index) => {
135
+ // 1. Input event: Handles single keypress AND mobile OS multi-digit autofill (iOS 17 QuickType)
136
+ const onInput = (e) => this._handleInput(e, index);
137
+ input.addEventListener('input', onInput);
138
+ this.boundHandlers.push({ element: input, event: 'input', handler: onInput });
139
+
140
+ // 2. Keydown event: Handles Backspace, Delete, and Arrow navigation
141
+ const onKeydown = (e) => this._handleKeydown(e, index);
142
+ input.addEventListener('keydown', onKeydown);
143
+ this.boundHandlers.push({ element: input, event: 'keydown', handler: onKeydown });
144
+
145
+ // 3. Paste event: Handles explicit paste actions
146
+ const onPaste = (e) => this._handlePaste(e, index);
147
+ input.addEventListener('paste', onPaste);
148
+ this.boundHandlers.push({ element: input, event: 'paste', handler: onPaste });
149
+ });
150
+
151
+ // 4. Paste button binding
152
+ if (this.pasteBtn) {
153
+ const onPasteBtnClick = () => this.pasteFromClipboard();
154
+ this.pasteBtn.addEventListener('click', onPasteBtnClick);
155
+ this.boundHandlers.push({ element: this.pasteBtn, event: 'click', handler: onPasteBtnClick });
156
+ }
157
+
158
+ // 5. Visibility change / Tab re-focus detection (Android / iOS app switching from email)
159
+ if (this.options.checkClipboardOnFocus && typeof document.addEventListener !== 'undefined') {
160
+ const onVisibilityChange = () => {
161
+ if (document.visibilityState === 'visible') {
162
+ this._checkClipboardOnResume();
163
+ }
164
+ };
165
+ document.addEventListener('visibilitychange', onVisibilityChange);
166
+ this.boundHandlers.push({ element: document, event: 'visibilitychange', handler: onVisibilityChange });
167
+
168
+ const onWindowFocus = () => this._checkClipboardOnResume();
169
+ window.addEventListener('focus', onWindowFocus);
170
+ this.boundHandlers.push({ element: window, event: 'focus', handler: onWindowFocus });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Handle input event on an individual box
176
+ * @private
177
+ */
178
+ _handleInput(e, index) {
179
+ const rawVal = e.target.value;
180
+ const clean = OtpInputManager.normalizeDigits(rawVal);
181
+
182
+ // Single input mode (e.g. LoanEasyFinance or TenantScore)
183
+ if (this.inputs.length === 1) {
184
+ e.target.value = clean.substring(0, this.options.length);
185
+ this._updateHiddenInput();
186
+ if (clean.length >= this.options.length) {
187
+ this._checkComplete();
188
+ }
189
+ return;
190
+ }
191
+
192
+ // CASE 1: Multi-character injection (iOS 17+ Mail QuickType, Android Gboard, autofill)
193
+ if (clean.length > 1) {
194
+ this.distributeCode(clean, index);
195
+ return;
196
+ }
197
+
198
+ // CASE 2: Single character entry
199
+ if (clean.length === 1) {
200
+ e.target.value = clean;
201
+ this._updateHiddenInput();
202
+
203
+ if (index < this.inputs.length - 1) {
204
+ this.inputs[index + 1].focus();
205
+ } else {
206
+ this._checkComplete();
207
+ }
208
+ } else {
209
+ e.target.value = '';
210
+ this._updateHiddenInput();
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Handle keydown event (Backspace, Arrow keys)
216
+ * @private
217
+ */
218
+ _handleKeydown(e, index) {
219
+ if (e.key === 'Backspace') {
220
+ if (!e.target.value && index > 0) {
221
+ e.preventDefault();
222
+ this.inputs[index - 1].value = '';
223
+ this.inputs[index - 1].focus();
224
+ this._updateHiddenInput();
225
+ }
226
+ } else if (e.key === 'ArrowLeft' && index > 0) {
227
+ e.preventDefault();
228
+ this.inputs[index - 1].focus();
229
+ } else if (e.key === 'ArrowRight' && index < this.inputs.length - 1) {
230
+ e.preventDefault();
231
+ this.inputs[index + 1].focus();
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Handle explicit paste event
237
+ * @private
238
+ */
239
+ _handlePaste(e, index) {
240
+ e.preventDefault();
241
+ const pasteData = (e.clipboardData || window.clipboardData)?.getData('text') || '';
242
+ if (pasteData) {
243
+ this.distributeCode(pasteData, index);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Distributes a multi-character string across input boxes starting from startIndex
249
+ *
250
+ * @param {string} rawCode
251
+ * @param {number} [startIndex=0]
252
+ */
253
+ distributeCode(rawCode, startIndex = 0) {
254
+ const code = OtpInputManager.normalizeDigits(rawCode).substring(0, this.options.length);
255
+ if (!code) return;
256
+
257
+ if (this.inputs.length === 1) {
258
+ this.inputs[0].value = code;
259
+ this._updateHiddenInput();
260
+ this.inputs[0].focus();
261
+ if (code.length === this.options.length) {
262
+ this._checkComplete();
263
+ }
264
+ return;
265
+ }
266
+
267
+ const chars = code.split('');
268
+ chars.forEach((char, i) => {
269
+ if (this.inputs[i]) {
270
+ this.inputs[i].value = char;
271
+ }
272
+ });
273
+
274
+ this._updateHiddenInput();
275
+
276
+ const targetIndex = Math.min(chars.length - 1, this.inputs.length - 1);
277
+ if (this.inputs[targetIndex]) {
278
+ this.inputs[targetIndex].focus();
279
+ }
280
+
281
+ if (code.length === this.options.length) {
282
+ this._checkComplete();
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Reads code from system clipboard
288
+ * @returns {Promise<boolean>}
289
+ */
290
+ async pasteFromClipboard() {
291
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) {
292
+ return false;
293
+ }
294
+
295
+ try {
296
+ const text = await navigator.clipboard.readText();
297
+ const code = OtpInputManager.normalizeDigits(text.trim());
298
+ if (code.length === this.options.length) {
299
+ this.distributeCode(code, 0);
300
+ return true;
301
+ }
302
+ } catch (_) {
303
+ // Permission denied or clipboard empty
304
+ }
305
+ return false;
306
+ }
307
+
308
+ /**
309
+ * Background clipboard check on tab resume / focus
310
+ * @private
311
+ */
312
+ async _checkClipboardOnResume() {
313
+ // Only auto-fill if all inputs are currently empty
314
+ const currentCode = this.getCode();
315
+ if (currentCode.length === this.options.length) return;
316
+
317
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return;
318
+
319
+ try {
320
+ const text = await navigator.clipboard.readText();
321
+ const clean = OtpInputManager.normalizeDigits(text.trim());
322
+ // Strictly verify it matches the required code length exactly
323
+ if (clean.length === this.options.length) {
324
+ this.distributeCode(clean, 0);
325
+ }
326
+ } catch (_) {
327
+ // Silent catch: browsers without focus permission won't throw unhandled errors
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Checks URL query parameters for magic link autofill (e.g., ?code=123456)
333
+ * @private
334
+ */
335
+ _checkUrlParameter() {
336
+ if (typeof window === 'undefined') return;
337
+
338
+ try {
339
+ const search = this.options.searchQuery !== undefined ? this.options.searchQuery : window.location?.search;
340
+ if (!search) return;
341
+
342
+ const params = new URLSearchParams(search);
343
+ const urlCode = params.get(this.options.urlParamKey);
344
+
345
+ if (urlCode) {
346
+ const cleanCode = OtpInputManager.normalizeDigits(urlCode).substring(0, this.options.length);
347
+ if (cleanCode.length === this.options.length) {
348
+ this.distributeCode(cleanCode, 0);
349
+
350
+ // Strip the ?code= query parameter from URL bar to prevent token leakage in history/referrer
351
+ if (this.options.cleanUrlAfterFill && window.history?.replaceState && this.options.searchQuery === undefined) {
352
+ params.delete(this.options.urlParamKey);
353
+ const newSearch = params.toString();
354
+ const cleanUrl = (window.location?.pathname || '') + (newSearch ? `?${newSearch}` : '') + (window.location?.hash || '');
355
+ window.history.replaceState({}, document.title, cleanUrl);
356
+ }
357
+ }
358
+ }
359
+ } catch (_) {}
360
+ }
361
+
362
+ /**
363
+ * Synchronize the hidden input with the current values of all boxes
364
+ * @private
365
+ */
366
+ _updateHiddenInput() {
367
+ const code = this.getCode();
368
+ if (this.hiddenInput) {
369
+ this.hiddenInput.value = code;
370
+ }
371
+ if (typeof this.options.onChange === 'function') {
372
+ this.options.onChange(code);
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Get concatenated value of all input boxes
378
+ * @returns {string}
379
+ */
380
+ getCode() {
381
+ const raw = this.inputs.map(input => input.value).join('');
382
+ return OtpInputManager.normalizeDigits(raw);
383
+ }
384
+
385
+ /**
386
+ * Clears all inputs and hidden input
387
+ */
388
+ clear() {
389
+ this.inputs.forEach(input => {
390
+ input.value = '';
391
+ });
392
+ this._updateHiddenInput();
393
+ if (this.inputs[0]) {
394
+ this.inputs[0].focus();
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Checks if all digits are filled and triggers completion / auto-submit
400
+ * @private
401
+ */
402
+ _checkComplete() {
403
+ const code = this.getCode();
404
+ if (code.length === this.options.length) {
405
+ if (typeof this.options.onComplete === 'function') {
406
+ this.options.onComplete(code);
407
+ }
408
+
409
+ if (this.options.autoSubmit && this.submitBtn && !this.isSubmitting) {
410
+ this.isSubmitting = true;
411
+ setTimeout(() => {
412
+ if (this.submitBtn) {
413
+ this.submitBtn.click();
414
+ }
415
+ }, this.options.autoSubmitDelay);
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Cleans up all bound event listeners
422
+ */
423
+ destroy() {
424
+ this.boundHandlers.forEach(({ element, event, handler }) => {
425
+ element.removeEventListener(event, handler);
426
+ });
427
+ this.boundHandlers = [];
428
+ }
429
+ }
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
+ }