@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.
- package/AddressAutocomplete.js +473 -0
- package/CardCanvasGenerator.js +367 -0
- package/OfflineQueue.js +278 -0
- package/OtpInputManager.js +429 -0
- package/PwaInstall.js +165 -0
- package/PwaManager.js +342 -0
- package/ShareManager.js +462 -0
- package/config/version.php +1 -1
- package/index.js +9 -1
- package/package.json +12 -3
- package/release-history.md +37 -0
- package/templates/sw.template.js +239 -0
|
@@ -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
|
+
×
|
|
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
|
+
×
|
|
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
|
+
}
|