@modernman00/shared-js-lib 1.2.83 โ 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/OtpInputManager.js +429 -0
- package/PwaManager.js +128 -10
- package/config/version.php +1 -1
- package/index.js +4 -1
- package/package.json +4 -2
- package/release-history.md +22 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address & Venue Autocomplete Engine
|
|
3
|
+
* Zero-cost hybrid geocoding using OpenStreetMap (Photon) & Postcodes.io
|
|
4
|
+
*
|
|
5
|
+
* Part of @modernman00/shared-js-lib
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const UK_POSTCODE_REGEX = /^[A-Z]{1,2}[0-9][A-Z0-9]?(\s*[0-9][A-Z]{2})?$/i;
|
|
9
|
+
const UK_FULL_POSTCODE_REGEX = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i;
|
|
10
|
+
|
|
11
|
+
export class AddressAutocomplete {
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {string|HTMLElement} options.input - Target input element or selector
|
|
15
|
+
* @param {string|HTMLElement} [options.dropdown] - Dropdown container element or selector (optional)
|
|
16
|
+
* @param {string|HTMLElement} [options.spinner] - Spinner element or selector (optional)
|
|
17
|
+
* @param {string|HTMLElement} [options.errorElement] - Error container to clear on select (optional)
|
|
18
|
+
* @param {string} [options.themeColor='#7b03fc'] - Primary highlight color
|
|
19
|
+
* @param {string} [options.country='uk'] - Target country ('uk' prioritizes UK PAF/postcode formats)
|
|
20
|
+
* @param {Function} [options.onSelect] - Callback when an address is selected
|
|
21
|
+
* @param {boolean} [options.injectStyles=true] - Auto-inject component CSS
|
|
22
|
+
*/
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.options = Object.assign({
|
|
25
|
+
themeColor: '#7b03fc',
|
|
26
|
+
country: 'uk',
|
|
27
|
+
injectStyles: true,
|
|
28
|
+
onSelect: null,
|
|
29
|
+
errorElement: null,
|
|
30
|
+
}, options);
|
|
31
|
+
|
|
32
|
+
this.input = typeof this.options.input === 'string'
|
|
33
|
+
? document.querySelector(this.options.input)
|
|
34
|
+
: this.options.input;
|
|
35
|
+
|
|
36
|
+
if (!this.input) {
|
|
37
|
+
console.warn('[AddressAutocomplete] Target input element not found.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.dropdown = this.options.dropdown
|
|
42
|
+
? (typeof this.options.dropdown === 'string' ? document.querySelector(this.options.dropdown) : this.options.dropdown)
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
this.spinner = this.options.spinner
|
|
46
|
+
? (typeof this.options.spinner === 'string' ? document.querySelector(this.options.spinner) : this.options.spinner)
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
this.errorElement = this.options.errorElement
|
|
50
|
+
? (typeof this.options.errorElement === 'string' ? document.querySelector(this.options.errorElement) : this.options.errorElement)
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
this.debounceTimer = null;
|
|
54
|
+
this.abortController = null;
|
|
55
|
+
this.activeIndex = -1;
|
|
56
|
+
this.currentItems = [];
|
|
57
|
+
|
|
58
|
+
this._boundInput = this.handleInput.bind(this);
|
|
59
|
+
this._boundKeydown = this.handleKeydown.bind(this);
|
|
60
|
+
this._boundDocClick = this.handleDocumentClick.bind(this);
|
|
61
|
+
|
|
62
|
+
if (this.options.injectStyles) {
|
|
63
|
+
this.injectStyles();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.setupDropdown();
|
|
67
|
+
this.bindEvents();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
injectStyles() {
|
|
71
|
+
if (typeof document === 'undefined') return;
|
|
72
|
+
if (document.getElementById('shared-address-autocomplete-styles')) return;
|
|
73
|
+
|
|
74
|
+
const style = document.createElement('style');
|
|
75
|
+
style.id = 'shared-address-autocomplete-styles';
|
|
76
|
+
style.textContent = `
|
|
77
|
+
.shared-loc-autocomplete-menu {
|
|
78
|
+
position: absolute;
|
|
79
|
+
top: calc(100% + 6px);
|
|
80
|
+
left: 0;
|
|
81
|
+
right: 0;
|
|
82
|
+
background: rgba(255, 255, 255, 0.98);
|
|
83
|
+
backdrop-filter: blur(16px);
|
|
84
|
+
-webkit-backdrop-filter: blur(16px);
|
|
85
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
86
|
+
border-radius: 18px;
|
|
87
|
+
box-shadow: 0 20px 45px -10px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.05);
|
|
88
|
+
z-index: 1050;
|
|
89
|
+
max-height: 290px;
|
|
90
|
+
overflow-y: auto;
|
|
91
|
+
padding: 0.5rem;
|
|
92
|
+
margin: 0;
|
|
93
|
+
list-style: none;
|
|
94
|
+
animation: sharedFadeInLoc 0.2s ease-out;
|
|
95
|
+
}
|
|
96
|
+
@keyframes sharedFadeInLoc {
|
|
97
|
+
from { opacity: 0; transform: translateY(-6px); }
|
|
98
|
+
to { opacity: 1; transform: translateY(0); }
|
|
99
|
+
}
|
|
100
|
+
.shared-loc-item {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: center;
|
|
103
|
+
gap: 0.75rem;
|
|
104
|
+
padding: 0.7rem 0.95rem;
|
|
105
|
+
border-radius: 12px;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: all 0.15s ease;
|
|
108
|
+
border: 1px solid transparent;
|
|
109
|
+
}
|
|
110
|
+
.shared-loc-item:hover,
|
|
111
|
+
.shared-loc-item.active {
|
|
112
|
+
background: rgba(123, 3, 252, 0.08);
|
|
113
|
+
border-color: rgba(123, 3, 252, 0.2);
|
|
114
|
+
}
|
|
115
|
+
.shared-loc-item .shared-loc-icon {
|
|
116
|
+
width: 32px;
|
|
117
|
+
height: 32px;
|
|
118
|
+
border-radius: 10px;
|
|
119
|
+
background: #f1f5f9;
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
justify-content: center;
|
|
123
|
+
font-size: 1.05rem;
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
}
|
|
126
|
+
.shared-loc-item .shared-loc-text {
|
|
127
|
+
flex-grow: 1;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
}
|
|
130
|
+
.shared-loc-item .shared-loc-title {
|
|
131
|
+
font-weight: 600;
|
|
132
|
+
font-size: 0.92rem;
|
|
133
|
+
color: #0f172a;
|
|
134
|
+
white-space: nowrap;
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
text-overflow: ellipsis;
|
|
137
|
+
}
|
|
138
|
+
.shared-loc-item .shared-loc-sub {
|
|
139
|
+
font-size: 0.78rem;
|
|
140
|
+
color: #64748b;
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
overflow: hidden;
|
|
143
|
+
text-overflow: ellipsis;
|
|
144
|
+
}
|
|
145
|
+
.shared-loc-footer {
|
|
146
|
+
display: flex;
|
|
147
|
+
justify-content: space-between;
|
|
148
|
+
align-items: center;
|
|
149
|
+
padding: 0.45rem 0.75rem 0.2rem;
|
|
150
|
+
font-size: 0.72rem;
|
|
151
|
+
color: #94a3b8;
|
|
152
|
+
border-top: 1px solid #f1f5f9;
|
|
153
|
+
margin-top: 0.35rem;
|
|
154
|
+
}
|
|
155
|
+
`;
|
|
156
|
+
document.head.appendChild(style);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setupDropdown() {
|
|
160
|
+
if (!this.dropdown && this.input.parentElement) {
|
|
161
|
+
const dd = document.createElement('div');
|
|
162
|
+
dd.className = 'shared-loc-autocomplete-menu';
|
|
163
|
+
dd.style.display = 'none';
|
|
164
|
+
dd.setAttribute('role', 'listbox');
|
|
165
|
+
dd.setAttribute('aria-label', 'Address suggestions');
|
|
166
|
+
this.input.parentElement.appendChild(dd);
|
|
167
|
+
this.dropdown = dd;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
bindEvents() {
|
|
172
|
+
this.input.setAttribute('autocomplete', 'off');
|
|
173
|
+
this.input.addEventListener('input', this._boundInput);
|
|
174
|
+
this.input.addEventListener('keydown', this._boundKeydown);
|
|
175
|
+
document.addEventListener('click', this._boundDocClick);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
showSpinner(show) {
|
|
179
|
+
if (this.spinner) {
|
|
180
|
+
this.spinner.style.display = show ? 'block' : 'none';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
close() {
|
|
185
|
+
if (this.dropdown) {
|
|
186
|
+
this.dropdown.style.display = 'none';
|
|
187
|
+
this.dropdown.innerHTML = '';
|
|
188
|
+
}
|
|
189
|
+
this.currentItems = [];
|
|
190
|
+
this.activeIndex = -1;
|
|
191
|
+
this.showSpinner(false);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleInput() {
|
|
195
|
+
clearTimeout(this.debounceTimer);
|
|
196
|
+
const query = this.input.value;
|
|
197
|
+
this.debounceTimer = setTimeout(() => {
|
|
198
|
+
this.search(query);
|
|
199
|
+
}, 300);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
handleKeydown(e) {
|
|
203
|
+
if (!this.dropdown || this.dropdown.style.display === 'none' || this.currentItems.length === 0) return;
|
|
204
|
+
|
|
205
|
+
if (e.key === 'ArrowDown') {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
this.activeIndex = (this.activeIndex + 1) % this.currentItems.length;
|
|
208
|
+
this.updateActiveSelection();
|
|
209
|
+
} else if (e.key === 'ArrowUp') {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
this.activeIndex = (this.activeIndex - 1 + this.currentItems.length) % this.currentItems.length;
|
|
212
|
+
this.updateActiveSelection();
|
|
213
|
+
} else if (e.key === 'Enter') {
|
|
214
|
+
if (this.activeIndex >= 0 && this.activeIndex < this.currentItems.length) {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
e.stopPropagation();
|
|
217
|
+
this.selectItem(this.currentItems[this.activeIndex]);
|
|
218
|
+
}
|
|
219
|
+
} else if (e.key === 'Escape') {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
this.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
handleDocumentClick(e) {
|
|
226
|
+
if (!this.input.contains(e.target) && this.dropdown && !this.dropdown.contains(e.target)) {
|
|
227
|
+
this.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
updateActiveSelection() {
|
|
232
|
+
if (!this.dropdown) return;
|
|
233
|
+
const rows = this.dropdown.querySelectorAll('.shared-loc-item, .location-autocomplete-item');
|
|
234
|
+
rows.forEach((r, idx) => {
|
|
235
|
+
if (idx === this.activeIndex) {
|
|
236
|
+
r.classList.add('active');
|
|
237
|
+
if (typeof r.scrollIntoView === 'function') {
|
|
238
|
+
r.scrollIntoView({ block: 'nearest' });
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
r.classList.remove('active');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
selectItem(item) {
|
|
247
|
+
if (!item) return;
|
|
248
|
+
this.input.value = item.fullAddress;
|
|
249
|
+
this.close();
|
|
250
|
+
|
|
251
|
+
if (this.errorElement) {
|
|
252
|
+
this.errorElement.style.display = 'none';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (typeof this.options.onSelect === 'function') {
|
|
256
|
+
this.options.onSelect(item);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Dispatch native change & input events for frameworks
|
|
260
|
+
this.input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
render(items) {
|
|
264
|
+
if (!this.dropdown) return;
|
|
265
|
+
this.dropdown.innerHTML = '';
|
|
266
|
+
this.currentItems = items || [];
|
|
267
|
+
this.activeIndex = -1;
|
|
268
|
+
|
|
269
|
+
if (!items || items.length === 0) {
|
|
270
|
+
this.close();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
items.forEach((item, index) => {
|
|
275
|
+
const row = document.createElement('div');
|
|
276
|
+
row.className = 'shared-loc-item location-autocomplete-item';
|
|
277
|
+
row.setAttribute('role', 'option');
|
|
278
|
+
row.dataset.index = String(index);
|
|
279
|
+
|
|
280
|
+
const iconDiv = document.createElement('div');
|
|
281
|
+
iconDiv.className = 'shared-loc-icon loc-icon';
|
|
282
|
+
iconDiv.textContent = item.icon || '๐';
|
|
283
|
+
|
|
284
|
+
const textDiv = document.createElement('div');
|
|
285
|
+
textDiv.className = 'shared-loc-text loc-text';
|
|
286
|
+
|
|
287
|
+
const titleDiv = document.createElement('div');
|
|
288
|
+
titleDiv.className = 'shared-loc-title loc-title';
|
|
289
|
+
titleDiv.textContent = item.title;
|
|
290
|
+
|
|
291
|
+
const subDiv = document.createElement('div');
|
|
292
|
+
subDiv.className = 'shared-loc-sub loc-sub';
|
|
293
|
+
subDiv.textContent = item.subtitle;
|
|
294
|
+
|
|
295
|
+
textDiv.appendChild(titleDiv);
|
|
296
|
+
textDiv.appendChild(subDiv);
|
|
297
|
+
row.appendChild(iconDiv);
|
|
298
|
+
row.appendChild(textDiv);
|
|
299
|
+
|
|
300
|
+
row.addEventListener('click', () => {
|
|
301
|
+
this.selectItem(item);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
this.dropdown.appendChild(row);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const footer = document.createElement('div');
|
|
308
|
+
footer.className = 'shared-loc-footer location-autocomplete-footer';
|
|
309
|
+
|
|
310
|
+
const leftBadge = document.createElement('span');
|
|
311
|
+
leftBadge.textContent = 'โจ Address & Venue Finder';
|
|
312
|
+
const rightBadge = document.createElement('span');
|
|
313
|
+
rightBadge.textContent = 'Free ยท OpenStreetMap & Postcodes.io';
|
|
314
|
+
|
|
315
|
+
footer.appendChild(leftBadge);
|
|
316
|
+
footer.appendChild(rightBadge);
|
|
317
|
+
this.dropdown.appendChild(footer);
|
|
318
|
+
|
|
319
|
+
this.dropdown.style.display = 'block';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async fetchPostcodes(query, signal) {
|
|
323
|
+
const cleanQuery = query.trim().toUpperCase().replace(/\s+/g, '');
|
|
324
|
+
if (UK_FULL_POSTCODE_REGEX.test(query.trim())) {
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanQuery)}`, { signal });
|
|
327
|
+
if (res.ok) {
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
if (data.status === 200 && data.result) {
|
|
330
|
+
const r = data.result;
|
|
331
|
+
const subParts = [r.admin_district, r.region, 'United Kingdom'].filter(Boolean);
|
|
332
|
+
const fullParts = [r.postcode, r.admin_ward, r.admin_district, r.region, 'UK'].filter(Boolean);
|
|
333
|
+
return [{
|
|
334
|
+
icon: '๐ฎ',
|
|
335
|
+
title: r.postcode,
|
|
336
|
+
subtitle: subParts.join(', '),
|
|
337
|
+
fullAddress: fullParts.join(', '),
|
|
338
|
+
postcode: r.postcode,
|
|
339
|
+
city: r.admin_district || '',
|
|
340
|
+
country: 'UK',
|
|
341
|
+
raw: r
|
|
342
|
+
}];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// Ignored or aborted
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const autoRes = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanQuery)}/autocomplete`, { signal });
|
|
352
|
+
if (autoRes.ok) {
|
|
353
|
+
const autoData = await autoRes.json();
|
|
354
|
+
if (autoData.status === 200 && Array.isArray(autoData.result)) {
|
|
355
|
+
return autoData.result.slice(0, 5).map(pc => ({
|
|
356
|
+
icon: '๐ฎ',
|
|
357
|
+
title: pc,
|
|
358
|
+
subtitle: 'UK Postcode',
|
|
359
|
+
fullAddress: pc + ', UK',
|
|
360
|
+
postcode: pc,
|
|
361
|
+
city: '',
|
|
362
|
+
country: 'UK',
|
|
363
|
+
raw: { postcode: pc }
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch (e) {
|
|
368
|
+
// Ignored or aborted
|
|
369
|
+
}
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async fetchPhotonVenues(query, signal) {
|
|
374
|
+
const bbox = '-7.57,49.96,1.68,58.64';
|
|
375
|
+
const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(query)}&limit=6&lang=en&bbox=${bbox}`;
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch(url, { signal });
|
|
378
|
+
if (!res.ok) return [];
|
|
379
|
+
const data = await res.json();
|
|
380
|
+
if (!data.features || !Array.isArray(data.features)) return [];
|
|
381
|
+
|
|
382
|
+
return data.features.map(f => {
|
|
383
|
+
const p = f.properties || {};
|
|
384
|
+
const name = p.name || p.street || p.city || query;
|
|
385
|
+
const subParts = [
|
|
386
|
+
p.housenumber ? p.housenumber + ' ' + (p.street || '') : p.street,
|
|
387
|
+
p.district,
|
|
388
|
+
p.city || p.town || p.village,
|
|
389
|
+
p.postcode,
|
|
390
|
+
p.country || 'UK'
|
|
391
|
+
].filter(Boolean);
|
|
392
|
+
|
|
393
|
+
const isVenue = !p.housenumber && (p.osm_value === 'amenity' || p.osm_value === 'tourism' || p.osm_value === 'leisure' || p.osm_key === 'amenity');
|
|
394
|
+
const fullParts = [name, ...subParts.filter(s => s !== name)];
|
|
395
|
+
const fullAddress = fullParts.filter(Boolean).join(', ');
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
icon: isVenue ? '๐๏ธ' : '๐',
|
|
399
|
+
title: name,
|
|
400
|
+
subtitle: subParts.join(', '),
|
|
401
|
+
fullAddress: fullAddress,
|
|
402
|
+
postcode: p.postcode || '',
|
|
403
|
+
city: p.city || p.town || '',
|
|
404
|
+
country: p.country || 'UK',
|
|
405
|
+
raw: f
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
} catch (e) {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async search(query) {
|
|
414
|
+
const trimmed = (query || '').trim();
|
|
415
|
+
if (trimmed.length < 2) {
|
|
416
|
+
this.close();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.abortController) {
|
|
421
|
+
this.abortController.abort();
|
|
422
|
+
}
|
|
423
|
+
this.abortController = new AbortController();
|
|
424
|
+
const signal = this.abortController.signal;
|
|
425
|
+
|
|
426
|
+
this.showSpinner(true);
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
let results = [];
|
|
430
|
+
if (this.options.country === 'uk' && UK_POSTCODE_REGEX.test(trimmed)) {
|
|
431
|
+
results = await this.fetchPostcodes(trimmed, signal);
|
|
432
|
+
if (results.length < 3) {
|
|
433
|
+
const venueResults = await this.fetchPhotonVenues(trimmed, signal);
|
|
434
|
+
results = [...results, ...venueResults].slice(0, 6);
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
results = await this.fetchPhotonVenues(trimmed, signal);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.showSpinner(false);
|
|
441
|
+
this.render(results);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (err.name !== 'AbortError') {
|
|
444
|
+
this.showSpinner(false);
|
|
445
|
+
this.close();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
destroy() {
|
|
451
|
+
clearTimeout(this.debounceTimer);
|
|
452
|
+
if (this.abortController) {
|
|
453
|
+
this.abortController.abort();
|
|
454
|
+
}
|
|
455
|
+
if (this.input) {
|
|
456
|
+
this.input.removeEventListener('input', this._boundInput);
|
|
457
|
+
this.input.removeEventListener('keydown', this._boundKeydown);
|
|
458
|
+
}
|
|
459
|
+
document.removeEventListener('click', this._boundDocClick);
|
|
460
|
+
if (this.dropdown && this.dropdown.parentElement) {
|
|
461
|
+
this.dropdown.remove();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Functional helper for easy one-line attachment
|
|
468
|
+
* @param {Object} options
|
|
469
|
+
* @returns {AddressAutocomplete}
|
|
470
|
+
*/
|
|
471
|
+
export function setupAddressAutocomplete(options) {
|
|
472
|
+
return new AddressAutocomplete(options);
|
|
473
|
+
}
|
|
@@ -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/PwaManager.js
CHANGED
|
@@ -82,6 +82,9 @@ export class PwaManager {
|
|
|
82
82
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
83
83
|
if (event.data?.type === 'ON_APP_NOTIFICATION') {
|
|
84
84
|
this.syncAppBadge();
|
|
85
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
86
|
+
window.dispatchEvent(new CustomEvent('pwa:notification-received', { detail: event.data.payload }));
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
});
|
|
87
90
|
}
|
|
@@ -118,11 +121,109 @@ export class PwaManager {
|
|
|
118
121
|
}));
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
if (this.options.enableUpdateToast) {
|
|
125
|
+
this.showUpdateToast(worker);
|
|
126
|
+
}
|
|
127
|
+
|
|
121
128
|
if (typeof this.options.onUpdateAvailable === 'function') {
|
|
122
129
|
this.options.onUpdateAvailable(worker);
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Renders a discreet, glassmorphic floating update pill to prompt user refresh
|
|
135
|
+
*/
|
|
136
|
+
static showUpdateToast(worker) {
|
|
137
|
+
if (typeof document === 'undefined' || document.getElementById('pwaUniversalUpdateToast')) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toast = document.createElement('div');
|
|
142
|
+
toast.id = 'pwaUniversalUpdateToast';
|
|
143
|
+
toast.setAttribute('role', 'status');
|
|
144
|
+
toast.setAttribute('aria-live', 'polite');
|
|
145
|
+
toast.setAttribute('aria-label', `${this.options.appName || 'App'} update available`);
|
|
146
|
+
|
|
147
|
+
toast.style.position = 'fixed';
|
|
148
|
+
toast.style.bottom = 'max(16px, env(safe-area-inset-bottom, 16px))';
|
|
149
|
+
toast.style.left = '50%';
|
|
150
|
+
toast.style.transform = 'translateX(-50%)';
|
|
151
|
+
toast.style.zIndex = '99999';
|
|
152
|
+
toast.style.display = 'flex';
|
|
153
|
+
toast.style.alignItems = 'center';
|
|
154
|
+
toast.style.gap = '12px';
|
|
155
|
+
toast.style.padding = '8px 14px 8px 16px';
|
|
156
|
+
toast.style.backgroundColor = 'rgba(15, 23, 42, 0.94)';
|
|
157
|
+
toast.style.backdropFilter = 'blur(12px)';
|
|
158
|
+
toast.style.webkitBackdropFilter = 'blur(12px)';
|
|
159
|
+
toast.style.border = '1px solid rgba(255, 255, 255, 0.12)';
|
|
160
|
+
toast.style.borderRadius = '9999px';
|
|
161
|
+
toast.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.45)';
|
|
162
|
+
toast.style.color = '#ffffff';
|
|
163
|
+
toast.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
164
|
+
toast.style.fontSize = '14px';
|
|
165
|
+
toast.style.lineHeight = '1.4';
|
|
166
|
+
toast.style.maxWidth = 'calc(100vw - 32px)';
|
|
167
|
+
toast.style.boxSizing = 'border-box';
|
|
168
|
+
toast.style.transition = 'all 0.25s cubic-bezier(0.16, 1, 0.3, 1)';
|
|
169
|
+
|
|
170
|
+
const appTitle = this.options.appName || 'App';
|
|
171
|
+
|
|
172
|
+
toast.innerHTML = `
|
|
173
|
+
<div style="display: flex; align-items: center; gap: 8px; white-space: nowrap;">
|
|
174
|
+
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: #10b981; box-shadow: 0 0 8px #10b981;"></span>
|
|
175
|
+
<span style="font-weight: 500;">${appTitle} updated</span>
|
|
176
|
+
</div>
|
|
177
|
+
<button id="pwaUpdateActionBtn" type="button" style="
|
|
178
|
+
background: #2563eb;
|
|
179
|
+
color: #ffffff;
|
|
180
|
+
border: none;
|
|
181
|
+
padding: 6px 14px;
|
|
182
|
+
border-radius: 9999px;
|
|
183
|
+
font-size: 13px;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
display: inline-flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: center;
|
|
189
|
+
min-height: 36px;
|
|
190
|
+
transition: background 0.2s;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
">Refresh</button>
|
|
193
|
+
<button id="pwaUpdateDismissBtn" type="button" style="
|
|
194
|
+
background: transparent;
|
|
195
|
+
color: #94a3b8;
|
|
196
|
+
border: none;
|
|
197
|
+
font-size: 16px;
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
padding: 6px;
|
|
200
|
+
min-width: 36px;
|
|
201
|
+
min-height: 36px;
|
|
202
|
+
display: inline-flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
justify-content: center;
|
|
205
|
+
line-height: 1;
|
|
206
|
+
" aria-label="Dismiss">โ</button>
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
document.body.appendChild(toast);
|
|
210
|
+
|
|
211
|
+
const updateBtn = document.getElementById('pwaUpdateActionBtn');
|
|
212
|
+
if (updateBtn) {
|
|
213
|
+
updateBtn.addEventListener('click', () => {
|
|
214
|
+
toast.remove();
|
|
215
|
+
this.applyUpdate(worker);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const dismissBtn = document.getElementById('pwaUpdateDismissBtn');
|
|
220
|
+
if (dismissBtn) {
|
|
221
|
+
dismissBtn.addEventListener('click', () => {
|
|
222
|
+
toast.remove();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
126
227
|
/**
|
|
127
228
|
* Sends skipWaiting postMessage to the waiting worker and triggers clean reload
|
|
128
229
|
*/
|
|
@@ -167,8 +268,11 @@ export class PwaManager {
|
|
|
167
268
|
* @returns {Promise<void>}
|
|
168
269
|
*/
|
|
169
270
|
static setAppBadge(count) {
|
|
271
|
+
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
272
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
273
|
+
window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: num } }));
|
|
274
|
+
}
|
|
170
275
|
if (typeof navigator !== 'undefined' && 'setAppBadge' in navigator) {
|
|
171
|
-
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
172
276
|
return (num > 0 ? navigator.setAppBadge(num) : navigator.clearAppBadge()).catch(() => {});
|
|
173
277
|
}
|
|
174
278
|
return Promise.resolve();
|
|
@@ -180,6 +284,9 @@ export class PwaManager {
|
|
|
180
284
|
* @returns {Promise<void>}
|
|
181
285
|
*/
|
|
182
286
|
static clearAppBadge() {
|
|
287
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
288
|
+
window.dispatchEvent(new CustomEvent('pwa:badge-updated', { detail: { count: 0 } }));
|
|
289
|
+
}
|
|
183
290
|
if (typeof navigator !== 'undefined' && 'clearAppBadge' in navigator) {
|
|
184
291
|
return navigator.clearAppBadge().catch(() => {});
|
|
185
292
|
}
|
|
@@ -194,9 +301,6 @@ export class PwaManager {
|
|
|
194
301
|
*/
|
|
195
302
|
static async syncAppBadge(endpoint = null) {
|
|
196
303
|
const url = endpoint || this.options.badgeEndpoint || '/api/notifications/unread';
|
|
197
|
-
if (typeof navigator === 'undefined' || !('setAppBadge' in navigator)) {
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
304
|
|
|
201
305
|
try {
|
|
202
306
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
@@ -209,14 +313,28 @@ export class PwaManager {
|
|
|
209
313
|
return;
|
|
210
314
|
}
|
|
211
315
|
const data = await res.json();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
} else {
|
|
217
|
-
|
|
316
|
+
let count = 0;
|
|
317
|
+
if (data) {
|
|
318
|
+
if (typeof data.unread_count === 'number') {
|
|
319
|
+
count = data.unread_count;
|
|
320
|
+
} else if (typeof data.count === 'number') {
|
|
321
|
+
count = data.count;
|
|
322
|
+
} else if (typeof data.unreadCount === 'number') {
|
|
323
|
+
count = data.unreadCount;
|
|
324
|
+
} else if (data.status === 'success' && Array.isArray(data.message)) {
|
|
325
|
+
count = data.message.length;
|
|
326
|
+
} else if (Array.isArray(data.data)) {
|
|
327
|
+
count = data.data.length;
|
|
328
|
+
} else if (Array.isArray(data.notifications)) {
|
|
329
|
+
count = data.notifications.length;
|
|
218
330
|
}
|
|
219
331
|
}
|
|
332
|
+
const num = Math.max(0, parseInt(count, 10) || 0);
|
|
333
|
+
if (num > 0) {
|
|
334
|
+
await this.setAppBadge(num);
|
|
335
|
+
} else {
|
|
336
|
+
await this.clearAppBadge();
|
|
337
|
+
}
|
|
220
338
|
} catch (e) {
|
|
221
339
|
// Harmless no-op on network failure or offline mode
|
|
222
340
|
}
|
package/config/version.php
CHANGED
package/index.js
CHANGED
|
@@ -28,4 +28,7 @@ export * from './AcctMgt/recaptchaToken.js';
|
|
|
28
28
|
export * from './AcctMgt/AdminAuthForm.js';
|
|
29
29
|
export * from './general.js';
|
|
30
30
|
export * from './CardCanvasGenerator.js';
|
|
31
|
-
export * from './ShareManager.js';
|
|
31
|
+
export * from './ShareManager.js';
|
|
32
|
+
export * from './WebAuthnClient.js';
|
|
33
|
+
export * from './OtpInputManager.js';
|
|
34
|
+
export * from './AddressAutocomplete.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modernman00/shared-js-lib",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.87",
|
|
4
4
|
"description": "Reusable JS utilities for numerous js problems",
|
|
5
5
|
"homepage": "https://github.com/modernman00/shared-js-lib#readme",
|
|
6
6
|
"keywords": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"url": "git+https://github.com/modernman00/shared-js-lib.git"
|
|
18
18
|
},
|
|
19
19
|
"license": "MIT",
|
|
20
|
-
"author": "
|
|
20
|
+
"author": "Core Platform Engineering Team",
|
|
21
21
|
"main": "index.js",
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "jest"
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
"PwaInstall.js",
|
|
63
63
|
"CardCanvasGenerator.js",
|
|
64
64
|
"ShareManager.js",
|
|
65
|
+
"OtpInputManager.js",
|
|
66
|
+
"AddressAutocomplete.js",
|
|
65
67
|
"templates/"
|
|
66
68
|
],
|
|
67
69
|
"devDependencies": {
|
package/release-history.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
|
|
2
|
+
## v1.2.86 โ 2026-10-12 22:38
|
|
3
|
+
### Changelog for v1.2.86
|
|
4
|
+
- ๐ feat: Add AddressAutocomplete class and setupAddressAutocomplete helper for zero-cost UK geocoding (Photon OSM + Postcodes.io)
|
|
5
|
+
- ๐ก๏ธ Add keyboard navigation, defensive textContent escaping (XSS immune), AbortController race protection, and full Jest test suite
|
|
6
|
+
|
|
2
7
|
## v1.2.15 โ 2025-08-18 21:56
|
|
3
8
|
### Changelog for v1.2.15
|
|
4
9
|
|
|
@@ -136,3 +141,20 @@
|
|
|
136
141
|
- ๐ Bump version to 1.2.83 _(by modernman00)_
|
|
137
142
|
- feat(pwa): centralize resilient OfflineQueue, PwaManager and standardized Service Worker template _(by modernman00)_
|
|
138
143
|
- feat: enhance release script with interactive npm login and improved workflow _(by modernman00)_
|
|
144
|
+
|
|
145
|
+
## v1.2.84 โ 2026-10-09 16:10
|
|
146
|
+
### Changelog for v1.2.84
|
|
147
|
+
|
|
148
|
+
- ๐งน Auto-commit before release _(by modernman00)_
|
|
149
|
+
- feat(otp): add OtpInputManager supporting iOS QuickType autofill and magic link auto-submit _(by modernman00)_
|
|
150
|
+
|
|
151
|
+
## v1.2.87 โ 2026-10-12 23:28
|
|
152
|
+
### Changelog for v1.2.87
|
|
153
|
+
|
|
154
|
+
- chore(release): 1.2.87 _(by github-actions)_
|
|
155
|
+
- fix(ci): add permissions contents write and continue-on-error for artifact upload _(by modernman00)_
|
|
156
|
+
- fix(ci): convert babel config to cjs and upgrade github actions to v4 with node 20 _(by modernman00)_
|
|
157
|
+
- feat: add AddressAutocomplete component (Photon + Postcodes.io) v1.2.86 _(by modernman00)_
|
|
158
|
+
- feat(pwa): harden PwaManager badging sync, support diverse envelopes, and dispatch pwa:badge-updated event _(by modernman00)_
|
|
159
|
+
- ๐ Release v1.2.84 _(by modernman00)_
|
|
160
|
+
- ๐งน Auto-commit before release _(by modernman00)_
|