@modernman00/shared-js-lib 1.2.90 → 1.6.2
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 +164 -15
- package/AiBillSplitter.js +230 -0
- package/AiEntertainmentPrompt.js +130 -0
- package/AiFlyerStudio.js +215 -0
- package/AiMediaGuard.js +95 -0
- package/AiPrivacyGuard.js +63 -0
- package/AiSemanticSearch.js +107 -0
- package/AiVoiceRecorder.js +196 -0
- package/CanvasPhysicsEngine.js +568 -0
- package/FileUpload.js +182 -1
- package/HyperReality3DEngine.js +404 -0
- package/Loader.js +6 -2
- package/PwaInstall.js +20 -2
- package/README.md +78 -0
- package/SovereignBroadcasterClient.js +325 -0
- package/VisualSupremacyRuntime.js +312 -0
- package/WebAuthnClient.js +346 -1
- package/WebPushClient.js +308 -0
- package/axiosWrapper.js +1 -0
- package/index.js +13 -1
- package/package.json +13 -1
- package/templates/sw.template.js +105 -57
package/AddressAutocomplete.js
CHANGED
|
@@ -8,6 +8,84 @@
|
|
|
8
8
|
const UK_POSTCODE_REGEX = /^[A-Z]{1,2}[0-9][A-Z0-9]?(\s*[0-9][A-Z]{2})?$/i;
|
|
9
9
|
const UK_FULL_POSTCODE_REGEX = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i;
|
|
10
10
|
|
|
11
|
+
// ⚡ High-speed internal address, venue and postal district index (< 1ms instant matching)
|
|
12
|
+
export const INTERNAL_VENUE_INDEX = [
|
|
13
|
+
// Iconic Event & Celebration Venues / Hotels
|
|
14
|
+
{ title: "Hilton London Park Lane", subtitle: "22 Park Lane, Mayfair, London W1K 1BE", fullAddress: "Hilton London Park Lane, 22 Park Lane, Mayfair, London, W1K 1BE, UK", icon: "🏛️" },
|
|
15
|
+
{ title: "Hilton London Metropole", subtitle: "225 Edgware Rd, Paddington, London W2 1JU", fullAddress: "Hilton London Metropole, 225 Edgware Rd, London, W2 1JU, UK", icon: "🏛️" },
|
|
16
|
+
{ title: "Hilton Manchester Deansgate", subtitle: "303 Deansgate, Manchester M3 4LQ", fullAddress: "Hilton Manchester Deansgate, 303 Deansgate, Manchester, M3 4LQ, UK", icon: "🏛️" },
|
|
17
|
+
{ title: "Hilton Birmingham Metropole", subtitle: "NEC, Pendigo Way, Birmingham B40 1PP", fullAddress: "Hilton Birmingham Metropole, Pendigo Way, Birmingham, B40 1PP, UK", icon: "🏛️" },
|
|
18
|
+
{ title: "The Savoy London", subtitle: "Strand, Covent Garden, London WC2R 0EZ", fullAddress: "The Savoy, Strand, London, WC2R 0EZ, UK", icon: "✨" },
|
|
19
|
+
{ title: "The Dorchester", subtitle: "53 Park Lane, Mayfair, London W1K 1QA", fullAddress: "The Dorchester, 53 Park Lane, Mayfair, London, W1K 1QA, UK", icon: "✨" },
|
|
20
|
+
{ title: "The Ritz London", subtitle: "150 Piccadilly, St. James's, London W1J 9BR", fullAddress: "The Ritz London, 150 Piccadilly, London, W1J 9BR, UK", icon: "✨" },
|
|
21
|
+
{ title: "Claridge's", subtitle: "Brook Street, Mayfair, London W1K 4HR", fullAddress: "Claridge's, Brook Street, Mayfair, London, W1K 4HR, UK", icon: "✨" },
|
|
22
|
+
{ title: "Shangri-La The Shard", subtitle: "31 St Thomas St, London Bridge, London SE1 9QU", fullAddress: "Shangri-La The Shard, 31 St Thomas St, London, SE1 9QU, UK", icon: "🏛️" },
|
|
23
|
+
{ title: "Park Plaza Westminster Bridge", subtitle: "200 Westminster Bridge Rd, London SE1 7UT", fullAddress: "Park Plaza Westminster Bridge, 200 Westminster Bridge Rd, London, SE1 7UT, UK", icon: "🏛️" },
|
|
24
|
+
{ title: "London Marriott Hotel Grosvenor Square", subtitle: "Grosvenor Square, Mayfair, London W1K 6JP", fullAddress: "London Marriott Hotel Grosvenor Square, London, W1K 6JP, UK", icon: "🏛️" },
|
|
25
|
+
{ title: "London Marriott Hotel Marble Arch", subtitle: "134 George St, London W1H 5DN", fullAddress: "London Marriott Hotel Marble Arch, 134 George St, London, W1H 5DN, UK", icon: "🏛️" },
|
|
26
|
+
{ title: "Kimpton Fitzroy London", subtitle: "1-8 Russell Square, Bloomsbury, London WC1B 5BE", fullAddress: "Kimpton Fitzroy London, 1-8 Russell Square, London, WC1B 5BE, UK", icon: "🏛️" },
|
|
27
|
+
{ title: "St. Pancras Renaissance Hotel", subtitle: "Euston Rd, King's Cross, London NW1 2AR", fullAddress: "St. Pancras Renaissance Hotel, Euston Rd, London, NW1 2AR, UK", icon: "🏛️" },
|
|
28
|
+
{ title: "JW Marriott Grosvenor House", subtitle: "86-90 Park Ln, Mayfair, London W1K 7TN", fullAddress: "JW Marriott Grosvenor House, 86-90 Park Ln, London, W1K 7TN, UK", icon: "🏛️" },
|
|
29
|
+
{ title: "The Ned London", subtitle: "27 Poultry, City of London EC2R 8AJ", fullAddress: "The Ned, 27 Poultry, London, EC2R 8AJ, UK", icon: "🏛️" },
|
|
30
|
+
{ title: "Natural History Museum", subtitle: "Cromwell Rd, South Kensington, London SW7 5BD", fullAddress: "Natural History Museum, Cromwell Rd, South Kensington, London, SW7 5BD, UK", icon: "🏛️" },
|
|
31
|
+
{ title: "Victoria and Albert Museum", subtitle: "Cromwell Rd, South Kensington, London SW7 2RL", fullAddress: "Victoria and Albert Museum, Cromwell Rd, London, SW7 2RL, UK", icon: "🏛️" },
|
|
32
|
+
{ title: "Alexandra Palace", subtitle: "Alexandra Palace Way, London N22 7AY", fullAddress: "Alexandra Palace, Alexandra Palace Way, London, N22 7AY, UK", icon: "🏛️" },
|
|
33
|
+
{ title: "Royal Horticultural Halls (Lindley Hall)", subtitle: "Elverton St, Westminster, London SW1P 2PB", fullAddress: "Lindley Hall, Royal Horticultural Halls, Elverton St, London, SW1P 2PB, UK", icon: "🏛️" },
|
|
34
|
+
{ title: "The Brewery London", subtitle: "52 Chiswell St, London EC1Y 4SD", fullAddress: "The Brewery, 52 Chiswell St, London, EC1Y 4SD, UK", icon: "🏛️" },
|
|
35
|
+
{ title: "One Marylebone", subtitle: "1 Marylebone Rd, London NW1 4AQ", fullAddress: "One Marylebone, 1 Marylebone Rd, London, NW1 4AQ, UK", icon: "🏛️" },
|
|
36
|
+
{ title: "8 Northumberland Avenue", subtitle: "Northumberland Ave, Trafalgar Square, London WC2N 5BY", fullAddress: "8 Northumberland Avenue, London, WC2N 5BY, UK", icon: "🏛️" },
|
|
37
|
+
{ title: "Tobacco Dock", subtitle: "Tobacco Quay, Wapping Lane, London E1W 2SF", fullAddress: "Tobacco Dock, Wapping Lane, London, E1W 2SF, UK", icon: "🏛️" },
|
|
38
|
+
{ title: "Blenheim Palace", subtitle: "Woodstock, Oxfordshire OX20 1PS", fullAddress: "Blenheim Palace, Woodstock, Oxfordshire, OX20 1PS, UK", icon: "🏛️" },
|
|
39
|
+
{ title: "Celtic Manor Resort", subtitle: "Coldra Woods, Newport NP18 1HQ", fullAddress: "Celtic Manor Resort, Coldra Woods, Newport, NP18 1HQ, UK", icon: "🏛️" },
|
|
40
|
+
{ title: "The Balmoral Hotel", subtitle: "1 Princes St, Edinburgh EH2 2EQ", fullAddress: "The Balmoral Hotel, 1 Princes St, Edinburgh, EH2 2EQ, UK", icon: "🏛️" },
|
|
41
|
+
{ title: "The Grand Hotel Birmingham", subtitle: "Colmore Row, Birmingham B3 2BS", fullAddress: "The Grand Hotel Birmingham, Colmore Row, Birmingham, B3 2BS, UK", icon: "🏛️" },
|
|
42
|
+
{ title: "Midland Hotel Manchester", subtitle: "16 Peter St, Manchester M60 2DS", fullAddress: "Midland Hotel, 16 Peter St, Manchester, M60 2DS, UK", icon: "🏛️" },
|
|
43
|
+
{ title: "Radisson Blu Hotel Manchester Airport", subtitle: "Chicago Ave, Manchester M90 3RA", fullAddress: "Radisson Blu Hotel, Manchester Airport, M90 3RA, UK", icon: "🏛️" },
|
|
44
|
+
{ title: "Kew Gardens (Temperate House)", subtitle: "Kew, Richmond, London TW9 3AE", fullAddress: "Kew Gardens, Kew, Richmond, TW9 3AE, UK", icon: "🌿" },
|
|
45
|
+
|
|
46
|
+
// Major UK Cities & Towns
|
|
47
|
+
{ title: "London", subtitle: "Greater London, United Kingdom", fullAddress: "London, Greater London, United Kingdom", icon: "📍" },
|
|
48
|
+
{ title: "Manchester", subtitle: "Greater Manchester, North West England, UK", fullAddress: "Manchester, Greater Manchester, United Kingdom", icon: "📍" },
|
|
49
|
+
{ title: "Birmingham", subtitle: "West Midlands, United Kingdom", fullAddress: "Birmingham, West Midlands, United Kingdom", icon: "📍" },
|
|
50
|
+
{ title: "Leeds", subtitle: "West Yorkshire, United Kingdom", fullAddress: "Leeds, West Yorkshire, United Kingdom", icon: "📍" },
|
|
51
|
+
{ title: "Glasgow", subtitle: "Scotland, United Kingdom", fullAddress: "Glasgow, Scotland, United Kingdom", icon: "📍" },
|
|
52
|
+
{ title: "Edinburgh", subtitle: "City of Edinburgh, Scotland, UK", fullAddress: "Edinburgh, City of Edinburgh, Scotland, UK", icon: "📍" },
|
|
53
|
+
{ title: "Liverpool", subtitle: "Merseyside, North West England, UK", fullAddress: "Liverpool, Merseyside, United Kingdom", icon: "📍" },
|
|
54
|
+
{ title: "Bristol", subtitle: "South West England, United Kingdom", fullAddress: "Bristol, South West England, United Kingdom", icon: "📍" },
|
|
55
|
+
{ title: "Newcastle upon Tyne", subtitle: "Tyne and Wear, North East England, UK", fullAddress: "Newcastle upon Tyne, Tyne and Wear, United Kingdom", icon: "📍" },
|
|
56
|
+
{ title: "Sheffield", subtitle: "South Yorkshire, United Kingdom", fullAddress: "Sheffield, South Yorkshire, United Kingdom", icon: "📍" },
|
|
57
|
+
{ title: "Cardiff", subtitle: "Wales, United Kingdom", fullAddress: "Cardiff, Wales, United Kingdom", icon: "📍" },
|
|
58
|
+
{ title: "Belfast", subtitle: "Northern Ireland, United Kingdom", fullAddress: "Belfast, Northern Ireland, United Kingdom", icon: "📍" },
|
|
59
|
+
{ title: "Nottingham", subtitle: "Nottinghamshire, East Midlands, UK", fullAddress: "Nottingham, Nottinghamshire, United Kingdom", icon: "📍" },
|
|
60
|
+
{ title: "Southampton", subtitle: "Hampshire, South East England, UK", fullAddress: "Southampton, Hampshire, United Kingdom", icon: "📍" },
|
|
61
|
+
{ title: "Brighton and Hove", subtitle: "East Sussex, South East England, UK", fullAddress: "Brighton and Hove, East Sussex, United Kingdom", icon: "📍" },
|
|
62
|
+
{ title: "Leicester", subtitle: "Leicestershire, East Midlands, UK", fullAddress: "Leicester, Leicestershire, United Kingdom", icon: "📍" },
|
|
63
|
+
{ title: "Coventry", subtitle: "West Midlands, United Kingdom", fullAddress: "Coventry, West Midlands, United Kingdom", icon: "📍" },
|
|
64
|
+
{ title: "Oxford", subtitle: "Oxfordshire, South East England, UK", fullAddress: "Oxford, Oxfordshire, United Kingdom", icon: "📍" },
|
|
65
|
+
{ title: "Cambridge", subtitle: "Cambridgeshire, East of England, UK", fullAddress: "Cambridge, Cambridgeshire, United Kingdom", icon: "📍" },
|
|
66
|
+
{ title: "York", subtitle: "North Yorkshire, United Kingdom", fullAddress: "York, North Yorkshire, United Kingdom", icon: "📍" },
|
|
67
|
+
{ title: "Bath", subtitle: "Somerset, South West England, UK", fullAddress: "Bath, Somerset, United Kingdom", icon: "📍" },
|
|
68
|
+
{ title: "Aberdeen", subtitle: "Scotland, United Kingdom", fullAddress: "Aberdeen, Scotland, United Kingdom", icon: "📍" },
|
|
69
|
+
{ title: "Milton Keynes", subtitle: "Buckinghamshire, South East England, UK", fullAddress: "Milton Keynes, Buckinghamshire, United Kingdom", icon: "📍" },
|
|
70
|
+
{ title: "Reading", subtitle: "Berkshire, South East England, UK", fullAddress: "Reading, Berkshire, United Kingdom", icon: "📍" },
|
|
71
|
+
|
|
72
|
+
// Key Postcode Areas & Landmark Postal Sectors
|
|
73
|
+
{ title: "SW1A 1AA", subtitle: "Buckingham Palace, City of Westminster, London", fullAddress: "Buckingham Palace, London, SW1A 1AA, UK", icon: "📮" },
|
|
74
|
+
{ title: "SW1", subtitle: "Westminster, Victoria, Belgravia, London", fullAddress: "SW1, City of Westminster, London, UK", icon: "📮" },
|
|
75
|
+
{ title: "W1", subtitle: "Mayfair, Marylebone, Soho, Fitzrovia, London", fullAddress: "W1, West End, London, UK", icon: "📮" },
|
|
76
|
+
{ title: "WC2", subtitle: "Covent Garden, Holborn, Strand, London", fullAddress: "WC2, London, UK", icon: "📮" },
|
|
77
|
+
{ title: "EC1", subtitle: "Clerkenwell, Farringdon, Finsbury, London", fullAddress: "EC1, Central London, UK", icon: "📮" },
|
|
78
|
+
{ title: "EC2", subtitle: "City of London, Shoreditch, Liverpool St, London", fullAddress: "EC2, City of London, UK", icon: "📮" },
|
|
79
|
+
{ title: "SE1", subtitle: "South Bank, London Bridge, Waterloo, London", fullAddress: "SE1, Southwark & Lambeth, London, UK", icon: "📮" },
|
|
80
|
+
{ title: "E14", subtitle: "Canary Wharf, Isle of Dogs, Poplar, London", fullAddress: "E14, Tower Hamlets, London, UK", icon: "📮" },
|
|
81
|
+
{ title: "M1", subtitle: "Manchester City Centre, Piccadilly, Manchester", fullAddress: "M1, Manchester, UK", icon: "📮" },
|
|
82
|
+
{ title: "B1", subtitle: "Birmingham City Centre, New Street, Birmingham", fullAddress: "B1, Birmingham, UK", icon: "📮" },
|
|
83
|
+
{ title: "BS1", subtitle: "Bristol City Centre, Harbourside, Bristol", fullAddress: "BS1, Bristol, UK", icon: "📮" },
|
|
84
|
+
{ title: "LS1", subtitle: "Leeds City Centre, City Square, Leeds", fullAddress: "LS1, Leeds, UK", icon: "📮" },
|
|
85
|
+
{ title: "G1", subtitle: "Glasgow City Centre, George Square, Glasgow", fullAddress: "G1, Glasgow, UK", icon: "📮" },
|
|
86
|
+
{ title: "EH1", subtitle: "Edinburgh Old Town, Royal Mile, Edinburgh", fullAddress: "EH1, Edinburgh, UK", icon: "📮" }
|
|
87
|
+
];
|
|
88
|
+
|
|
11
89
|
export class AddressAutocomplete {
|
|
12
90
|
/**
|
|
13
91
|
* @param {Object} options
|
|
@@ -54,6 +132,7 @@ export class AddressAutocomplete {
|
|
|
54
132
|
this.abortController = null;
|
|
55
133
|
this.activeIndex = -1;
|
|
56
134
|
this.currentItems = [];
|
|
135
|
+
this.queryCache = new Map();
|
|
57
136
|
|
|
58
137
|
this._boundInput = this.handleInput.bind(this);
|
|
59
138
|
this._boundKeydown = this.handleKeydown.bind(this);
|
|
@@ -67,6 +146,17 @@ export class AddressAutocomplete {
|
|
|
67
146
|
this.bindEvents();
|
|
68
147
|
}
|
|
69
148
|
|
|
149
|
+
findInternalMatches(query) {
|
|
150
|
+
if (!query || query.trim().length < 2) return [];
|
|
151
|
+
const q = query.trim().toLowerCase();
|
|
152
|
+
const qParts = q.split(/\s+/).filter(Boolean);
|
|
153
|
+
|
|
154
|
+
return INTERNAL_VENUE_INDEX.filter(item => {
|
|
155
|
+
const text = (item.title + ' ' + item.subtitle + ' ' + item.fullAddress).toLowerCase();
|
|
156
|
+
return qParts.every(part => text.includes(part));
|
|
157
|
+
}).slice(0, 6);
|
|
158
|
+
}
|
|
159
|
+
|
|
70
160
|
injectStyles() {
|
|
71
161
|
if (typeof document === 'undefined') return;
|
|
72
162
|
if (document.getElementById('shared-address-autocomplete-styles')) return;
|
|
@@ -193,10 +283,30 @@ export class AddressAutocomplete {
|
|
|
193
283
|
|
|
194
284
|
handleInput() {
|
|
195
285
|
clearTimeout(this.debounceTimer);
|
|
196
|
-
const query = this.input.value;
|
|
286
|
+
const query = (this.input.value || '').trim();
|
|
287
|
+
|
|
288
|
+
if (query.length < 2) {
|
|
289
|
+
this.close();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ⚡ 1. In-memory query cache check (0ms instant response)
|
|
294
|
+
const cacheKey = query.toLowerCase();
|
|
295
|
+
if (this.queryCache && this.queryCache.has(cacheKey)) {
|
|
296
|
+
this.render(this.queryCache.get(cacheKey));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ⚡ 2. Instant internal algorithm match (0ms instant response)
|
|
301
|
+
const localMatches = this.findInternalMatches(query);
|
|
302
|
+
if (localMatches.length > 0) {
|
|
303
|
+
this.render(localMatches);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ⚡ 3. Short 100ms debounce for background remote enrichment
|
|
197
307
|
this.debounceTimer = setTimeout(() => {
|
|
198
|
-
this.search(query);
|
|
199
|
-
},
|
|
308
|
+
this.search(query, localMatches);
|
|
309
|
+
}, 100);
|
|
200
310
|
}
|
|
201
311
|
|
|
202
312
|
handleKeydown(e) {
|
|
@@ -371,10 +481,14 @@ export class AddressAutocomplete {
|
|
|
371
481
|
}
|
|
372
482
|
|
|
373
483
|
async fetchPhotonVenues(query, signal) {
|
|
374
|
-
const
|
|
375
|
-
const
|
|
484
|
+
const isUkPostcode = UK_POSTCODE_REGEX.test((query || '').trim());
|
|
485
|
+
const bboxParam = isUkPostcode ? '&bbox=-7.57,49.96,1.68,58.64' : '';
|
|
486
|
+
const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(query)}&limit=6&lang=en${bboxParam}`;
|
|
376
487
|
try {
|
|
377
|
-
const
|
|
488
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
489
|
+
setTimeout(() => reject(new Error('Timeout')), 1500)
|
|
490
|
+
);
|
|
491
|
+
const res = await Promise.race([fetch(url, { signal }), timeoutPromise]);
|
|
378
492
|
if (!res.ok) return [];
|
|
379
493
|
const data = await res.json();
|
|
380
494
|
if (!data.features || !Array.isArray(data.features)) return [];
|
|
@@ -410,39 +524,74 @@ export class AddressAutocomplete {
|
|
|
410
524
|
}
|
|
411
525
|
}
|
|
412
526
|
|
|
413
|
-
async search(query) {
|
|
527
|
+
async search(query, initialMatches = []) {
|
|
414
528
|
const trimmed = (query || '').trim();
|
|
415
529
|
if (trimmed.length < 2) {
|
|
416
530
|
this.close();
|
|
417
531
|
return;
|
|
418
532
|
}
|
|
419
533
|
|
|
534
|
+
const cacheKey = trimmed.toLowerCase();
|
|
535
|
+
if (this.queryCache && this.queryCache.has(cacheKey)) {
|
|
536
|
+
this.render(this.queryCache.get(cacheKey));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
420
540
|
if (this.abortController) {
|
|
421
541
|
this.abortController.abort();
|
|
422
542
|
}
|
|
423
543
|
this.abortController = new AbortController();
|
|
424
544
|
const signal = this.abortController.signal;
|
|
425
545
|
|
|
426
|
-
|
|
546
|
+
const baseMatches = initialMatches && initialMatches.length > 0
|
|
547
|
+
? [...initialMatches]
|
|
548
|
+
: this.findInternalMatches(trimmed);
|
|
549
|
+
|
|
550
|
+
// Only show spinner if no instant matches are already rendered
|
|
551
|
+
if (baseMatches.length === 0) {
|
|
552
|
+
this.showSpinner(true);
|
|
553
|
+
}
|
|
427
554
|
|
|
428
555
|
try {
|
|
429
|
-
let results = [];
|
|
556
|
+
let results = [...baseMatches];
|
|
557
|
+
let remoteResults = [];
|
|
430
558
|
if (this.options.country === 'uk' && UK_POSTCODE_REGEX.test(trimmed)) {
|
|
431
|
-
|
|
432
|
-
if (
|
|
559
|
+
remoteResults = await this.fetchPostcodes(trimmed, signal);
|
|
560
|
+
if (remoteResults.length < 3) {
|
|
433
561
|
const venueResults = await this.fetchPhotonVenues(trimmed, signal);
|
|
434
|
-
|
|
562
|
+
remoteResults = [...remoteResults, ...venueResults].slice(0, 6);
|
|
435
563
|
}
|
|
436
564
|
} else {
|
|
437
|
-
|
|
565
|
+
remoteResults = await this.fetchPhotonVenues(trimmed, signal);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Merge and deduplicate by title + subtitle
|
|
569
|
+
const seen = new Set(results.map(r => (r.title + '|' + (r.subtitle || '')).toLowerCase()));
|
|
570
|
+
remoteResults.forEach(r => {
|
|
571
|
+
const key = (r.title + '|' + (r.subtitle || '')).toLowerCase();
|
|
572
|
+
if (!seen.has(key)) {
|
|
573
|
+
seen.add(key);
|
|
574
|
+
results.push(r);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
results = results.slice(0, 6);
|
|
579
|
+
if (this.queryCache) {
|
|
580
|
+
this.queryCache.set(cacheKey, results);
|
|
438
581
|
}
|
|
439
582
|
|
|
440
583
|
this.showSpinner(false);
|
|
441
|
-
|
|
584
|
+
if (results.length > 0) {
|
|
585
|
+
this.render(results);
|
|
586
|
+
} else {
|
|
587
|
+
this.close();
|
|
588
|
+
}
|
|
442
589
|
} catch (err) {
|
|
443
590
|
if (err.name !== 'AbortError') {
|
|
444
591
|
this.showSpinner(false);
|
|
445
|
-
|
|
592
|
+
if (baseMatches.length === 0) {
|
|
593
|
+
this.close();
|
|
594
|
+
}
|
|
446
595
|
}
|
|
447
596
|
}
|
|
448
597
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { axiosWrapper } from './axiosWrapper.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AiBillSplitter
|
|
5
|
+
*
|
|
6
|
+
* Orchestrates receipt scanning via Multimodal Vision AI with client-side image pre-compression,
|
|
7
|
+
* itemized split calculations, guest assignments, and dynamic tip allocation.
|
|
8
|
+
*/
|
|
9
|
+
export class AiBillSplitter {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} [options.uploadUrl='/api/ai/scan-receipt']
|
|
13
|
+
* @param {number} [options.defaultTipPercent=10]
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.uploadUrl = options.uploadUrl || '/api/ai/scan-receipt';
|
|
17
|
+
this.defaultTipPercent = options.defaultTipPercent !== undefined ? options.defaultTipPercent : 10;
|
|
18
|
+
this.items = [];
|
|
19
|
+
this.guests = []; // { id: string, name: string }
|
|
20
|
+
this.assignments = {}; // { [itemIndex: number]: string[] (guestIds) }
|
|
21
|
+
this.currency = 'GBP';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Client-side compression to eliminate VLM timeout and slash upload payload.
|
|
26
|
+
*
|
|
27
|
+
* @param {File|Blob} file
|
|
28
|
+
* @param {number} [maxDimension=1200]
|
|
29
|
+
* @param {number} [quality=0.8]
|
|
30
|
+
* @returns {Promise<Blob>}
|
|
31
|
+
*/
|
|
32
|
+
static async compressImage(file, maxDimension = 1200, quality = 0.8) {
|
|
33
|
+
if (typeof document === 'undefined' || !file) {
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const img = new Image();
|
|
39
|
+
const reader = new FileReader();
|
|
40
|
+
|
|
41
|
+
reader.onload = (e) => {
|
|
42
|
+
img.onload = () => {
|
|
43
|
+
let { width, height } = img;
|
|
44
|
+
if (width > maxDimension || height > maxDimension) {
|
|
45
|
+
if (width > height) {
|
|
46
|
+
height = Math.round((height * maxDimension) / width);
|
|
47
|
+
width = maxDimension;
|
|
48
|
+
} else {
|
|
49
|
+
width = Math.round((width * maxDimension) / height);
|
|
50
|
+
height = maxDimension;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const canvas = document.createElement('canvas');
|
|
55
|
+
canvas.width = width;
|
|
56
|
+
canvas.height = height;
|
|
57
|
+
const ctx = canvas.getContext('2d');
|
|
58
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
59
|
+
|
|
60
|
+
canvas.toBlob(
|
|
61
|
+
(blob) => resolve(blob || file),
|
|
62
|
+
'image/jpeg',
|
|
63
|
+
quality
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
img.src = e.target.result;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
reader.onerror = () => resolve(file);
|
|
70
|
+
reader.readAsDataURL(file);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Upload receipt photo and retrieve parsed line items.
|
|
76
|
+
*
|
|
77
|
+
* @param {File|Blob} file
|
|
78
|
+
* @returns {Promise<{success: boolean, items: Array, total: number, currency: string, error?: string}>}
|
|
79
|
+
*/
|
|
80
|
+
async scanReceipt(file) {
|
|
81
|
+
if (!file) {
|
|
82
|
+
return { success: false, items: [], total: 0, currency: this.currency, error: 'No file provided.' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const compressedBlob = await AiBillSplitter.compressImage(file);
|
|
87
|
+
const formData = new FormData();
|
|
88
|
+
formData.append('receipt', compressedBlob, 'receipt.jpg');
|
|
89
|
+
|
|
90
|
+
const response = await axiosWrapper.post(this.uploadUrl, formData, {
|
|
91
|
+
headers: { 'Content-Type': 'multipart/form-data' }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const data = response.data || {};
|
|
95
|
+
if (data.success && Array.isArray(data.items)) {
|
|
96
|
+
this.items = data.items.map((it, idx) => ({
|
|
97
|
+
index: idx,
|
|
98
|
+
name: it.name || 'Item',
|
|
99
|
+
price: Number(it.price) || 0,
|
|
100
|
+
quantity: Number(it.quantity) || 1
|
|
101
|
+
}));
|
|
102
|
+
this.currency = data.currency || 'GBP';
|
|
103
|
+
this.assignments = {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return data;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
items: [],
|
|
111
|
+
total: 0,
|
|
112
|
+
currency: this.currency,
|
|
113
|
+
error: err.response?.data?.message || err.message || 'Receipt scan failed.'
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Add a guest to the split roster
|
|
120
|
+
* @param {string} id
|
|
121
|
+
* @param {string} name
|
|
122
|
+
*/
|
|
123
|
+
addGuest(id, name) {
|
|
124
|
+
if (!id || !name) return;
|
|
125
|
+
if (!this.guests.some(g => g.id === id)) {
|
|
126
|
+
this.guests.push({ id, name });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove a guest
|
|
132
|
+
* @param {string} id
|
|
133
|
+
*/
|
|
134
|
+
removeGuest(id) {
|
|
135
|
+
this.guests = this.guests.filter(g => g.id !== id);
|
|
136
|
+
// Unassign from all items
|
|
137
|
+
for (const idx in this.assignments) {
|
|
138
|
+
this.assignments[idx] = this.assignments[idx].filter(gId => gId !== id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Assign an item to a guest (can be split across multiple guests)
|
|
144
|
+
* @param {number} itemIndex
|
|
145
|
+
* @param {string} guestId
|
|
146
|
+
*/
|
|
147
|
+
assignItem(itemIndex, guestId) {
|
|
148
|
+
if (!this.assignments[itemIndex]) {
|
|
149
|
+
this.assignments[itemIndex] = [];
|
|
150
|
+
}
|
|
151
|
+
if (!this.assignments[itemIndex].includes(guestId)) {
|
|
152
|
+
this.assignments[itemIndex].push(guestId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Unassign an item from a guest
|
|
158
|
+
* @param {number} itemIndex
|
|
159
|
+
* @param {string} guestId
|
|
160
|
+
*/
|
|
161
|
+
unassignItem(itemIndex, guestId) {
|
|
162
|
+
if (this.assignments[itemIndex]) {
|
|
163
|
+
this.assignments[itemIndex] = this.assignments[itemIndex].filter(gId => gId !== guestId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Calculate final amounts per guest with tip
|
|
169
|
+
* @param {number} [tipPercent]
|
|
170
|
+
* @returns {Object}
|
|
171
|
+
*/
|
|
172
|
+
calculateShares(tipPercent) {
|
|
173
|
+
const tip = tipPercent !== undefined ? tipPercent : this.defaultTipPercent;
|
|
174
|
+
const guestTotals = {};
|
|
175
|
+
this.guests.forEach(g => {
|
|
176
|
+
guestTotals[g.id] = { id: g.id, name: g.name, subtotal: 0, items: [] };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let assignedSubtotal = 0;
|
|
180
|
+
const unassignedItems = [];
|
|
181
|
+
|
|
182
|
+
this.items.forEach((item, idx) => {
|
|
183
|
+
const assignedGuests = this.assignments[idx] || [];
|
|
184
|
+
const totalItemCost = item.price * item.quantity;
|
|
185
|
+
|
|
186
|
+
if (assignedGuests.length === 0) {
|
|
187
|
+
unassignedItems.push(item);
|
|
188
|
+
} else {
|
|
189
|
+
const splitCost = totalItemCost / assignedGuests.length;
|
|
190
|
+
assignedGuests.forEach(gId => {
|
|
191
|
+
if (guestTotals[gId]) {
|
|
192
|
+
guestTotals[gId].subtotal += splitCost;
|
|
193
|
+
guestTotals[gId].items.push({
|
|
194
|
+
name: item.name,
|
|
195
|
+
fullPrice: totalItemCost,
|
|
196
|
+
portionCost: Math.round(splitCost * 100) / 100,
|
|
197
|
+
splitCount: assignedGuests.length
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
assignedSubtotal += totalItemCost;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const shares = Object.values(guestTotals).map(g => {
|
|
206
|
+
const sub = Math.round(g.subtotal * 100) / 100;
|
|
207
|
+
const guestTip = Math.round((sub * (tip / 100)) * 100) / 100;
|
|
208
|
+
return {
|
|
209
|
+
id: g.id,
|
|
210
|
+
name: g.name,
|
|
211
|
+
subtotal: sub,
|
|
212
|
+
tip: guestTip,
|
|
213
|
+
total: Math.round((sub + guestTip) * 100) / 100,
|
|
214
|
+
items: g.items
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const totalTip = shares.reduce((acc, s) => acc + s.tip, 0);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
currency: this.currency,
|
|
222
|
+
tipPercentage: tip,
|
|
223
|
+
assignedSubtotal: Math.round(assignedSubtotal * 100) / 100,
|
|
224
|
+
totalTip: Math.round(totalTip * 100) / 100,
|
|
225
|
+
grandTotal: Math.round((assignedSubtotal + totalTip) * 100) / 100,
|
|
226
|
+
guestShares: shares,
|
|
227
|
+
unassignedItems
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { axiosWrapper } from './axiosWrapper.js';
|
|
2
|
+
import { AiPrivacyGuard } from './AiPrivacyGuard.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AiEntertainmentPrompt
|
|
6
|
+
*
|
|
7
|
+
* Client interface for creative social AI features:
|
|
8
|
+
* - AI Speechwriter & Roast Master
|
|
9
|
+
* - AI Household Dispute & Chore Arbiter
|
|
10
|
+
*/
|
|
11
|
+
export class AiEntertainmentPrompt {
|
|
12
|
+
/**
|
|
13
|
+
* Request an AI celebration speech or comedic roast.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} params
|
|
16
|
+
* @param {string} params.honoureeName
|
|
17
|
+
* @param {string} params.occasion
|
|
18
|
+
* @param {string[]} [params.anecdotes=[]]
|
|
19
|
+
* @param {'humorous_roast'|'heartfelt'|'peaky_blinders'|'concise_card'} [params.tone='humorous_roast']
|
|
20
|
+
* @param {string} [params.endpoint='/api/ai/speech']
|
|
21
|
+
* @returns {Promise<{success: boolean, title: string, speechText: string, toast: string, tips: string[], error?: string}>}
|
|
22
|
+
*/
|
|
23
|
+
static async requestSpeech({
|
|
24
|
+
honoureeName,
|
|
25
|
+
occasion,
|
|
26
|
+
anecdotes = [],
|
|
27
|
+
tone = 'humorous_roast',
|
|
28
|
+
endpoint = '/api/ai/speech'
|
|
29
|
+
}) {
|
|
30
|
+
if (!honoureeName || !occasion) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
title: '',
|
|
34
|
+
speechText: '',
|
|
35
|
+
toast: '',
|
|
36
|
+
tips: [],
|
|
37
|
+
error: 'Honouree name and occasion are required.'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const response = await axiosWrapper.post(endpoint, {
|
|
43
|
+
honouree_name: honoureeName,
|
|
44
|
+
occasion,
|
|
45
|
+
anecdotes: anecdotes.map(a => AiPrivacyGuard.scrubText(a)),
|
|
46
|
+
tone
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const d = response.data || {};
|
|
50
|
+
return {
|
|
51
|
+
success: d.success !== false,
|
|
52
|
+
title: d.title || `A Toast to ${honoureeName}`,
|
|
53
|
+
speechText: d.speech_text || d.speechText || '',
|
|
54
|
+
toast: d.suggested_toast || d.toast || `To ${honoureeName}!`,
|
|
55
|
+
tips: d.delivery_tips || d.tips || [],
|
|
56
|
+
error: d.error
|
|
57
|
+
};
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
title: '',
|
|
62
|
+
speechText: '',
|
|
63
|
+
toast: '',
|
|
64
|
+
tips: [],
|
|
65
|
+
error: err.response?.data?.message || err.message || 'Speech generation failed.'
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Request an impartial and humorous household dispute arbitration.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} params
|
|
74
|
+
* @param {string} params.partyAName
|
|
75
|
+
* @param {string} params.partyAClaim
|
|
76
|
+
* @param {string} params.partyBName
|
|
77
|
+
* @param {string} params.partyBClaim
|
|
78
|
+
* @param {string} [params.context='household_chores']
|
|
79
|
+
* @param {string} [params.endpoint='/api/ai/arbitrate-dispute']
|
|
80
|
+
* @returns {Promise<{success: boolean, verdict: string, plan: Object, penalty: string, quip: string, error?: string}>}
|
|
81
|
+
*/
|
|
82
|
+
static async arbitrateDispute({
|
|
83
|
+
partyAName,
|
|
84
|
+
partyAClaim,
|
|
85
|
+
partyBName,
|
|
86
|
+
partyBClaim,
|
|
87
|
+
context = 'household_chores',
|
|
88
|
+
endpoint = '/api/ai/arbitrate-dispute'
|
|
89
|
+
}) {
|
|
90
|
+
if (!partyAName || !partyAClaim || !partyBName || !partyBClaim) {
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
verdict: '',
|
|
94
|
+
plan: {},
|
|
95
|
+
penalty: '',
|
|
96
|
+
quip: '',
|
|
97
|
+
error: 'Both parties must submit their names and arguments.'
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const response = await axiosWrapper.post(endpoint, {
|
|
103
|
+
party_a_name: partyAName,
|
|
104
|
+
party_a_claim: AiPrivacyGuard.scrubText(partyAClaim),
|
|
105
|
+
party_b_name: partyBName,
|
|
106
|
+
party_b_claim: AiPrivacyGuard.scrubText(partyBClaim),
|
|
107
|
+
context
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const d = response.data || {};
|
|
111
|
+
return {
|
|
112
|
+
success: d.success !== false,
|
|
113
|
+
verdict: d.verdict || 'The court has spoken.',
|
|
114
|
+
plan: d.compromise_action_plan || d.plan || {},
|
|
115
|
+
penalty: d.penalty_or_chore || d.penalty || '',
|
|
116
|
+
quip: d.closing_quip || d.quip || '',
|
|
117
|
+
error: d.error
|
|
118
|
+
};
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
verdict: '',
|
|
123
|
+
plan: {},
|
|
124
|
+
penalty: '',
|
|
125
|
+
quip: '',
|
|
126
|
+
error: err.response?.data?.message || err.message || 'Dispute arbitration failed.'
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|