@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.
@@ -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
- }, 300);
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 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}`;
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 res = await fetch(url, { signal });
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
- this.showSpinner(true);
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
- results = await this.fetchPostcodes(trimmed, signal);
432
- if (results.length < 3) {
559
+ remoteResults = await this.fetchPostcodes(trimmed, signal);
560
+ if (remoteResults.length < 3) {
433
561
  const venueResults = await this.fetchPhotonVenues(trimmed, signal);
434
- results = [...results, ...venueResults].slice(0, 6);
562
+ remoteResults = [...remoteResults, ...venueResults].slice(0, 6);
435
563
  }
436
564
  } else {
437
- results = await this.fetchPhotonVenues(trimmed, signal);
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
- this.render(results);
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
- this.close();
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
+ }