@visns-studio/visns-components 6.24.4 → 6.26.0

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.
Files changed (51) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
  8. package/src/components/callQueue/CallQueuePop.jsx +713 -90
  9. package/src/components/callQueue/CallQueueSettings.jsx +308 -146
  10. package/src/components/callQueue/callPopStatus.js +236 -0
  11. package/src/components/callQueue/callQueueHelpers.js +284 -2
  12. package/src/components/columns/ColumnRenderers.jsx +3 -46
  13. package/src/components/columns/StackedRow.jsx +186 -0
  14. package/src/components/controls/DataGridSearch.jsx +110 -2
  15. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  16. package/src/components/generic/GenericAuth.jsx +50 -18
  17. package/src/components/generic/GenericDashboard.jsx +20 -1
  18. package/src/components/generic/GenericDetail.jsx +446 -259
  19. package/src/components/mapboxSearchBox.js +640 -0
  20. package/src/components/navBadges.js +63 -1
  21. package/src/components/navDrawer.js +147 -0
  22. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  23. package/src/components/sms/smsHelpers.js +15 -0
  24. package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
  25. package/src/components/styles/CallQueuePop.module.scss +29 -0
  26. package/src/components/styles/CallQueueSettings.module.scss +93 -0
  27. package/src/components/styles/ClientAuth.module.scss +39 -0
  28. package/src/components/styles/DataGrid.module.scss +158 -5
  29. package/src/components/styles/Field.module.scss +52 -1
  30. package/src/components/styles/Form.module.scss +82 -0
  31. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  32. package/src/components/styles/GenericDashboard.module.scss +50 -0
  33. package/src/components/styles/GenericDetail.module.scss +63 -1
  34. package/src/components/styles/GenericDynamic.module.scss +23 -0
  35. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  36. package/src/components/styles/GenericIndex.module.scss +6 -1
  37. package/src/components/styles/Navigation.module.scss +460 -7
  38. package/src/components/styles/Sms.module.scss +92 -0
  39. package/src/components/styles/StackedRow.module.scss +182 -0
  40. package/src/components/styles/TicketConversation.module.scss +76 -0
  41. package/src/components/styles/Vault.module.scss +192 -0
  42. package/src/components/styles/density.css +10 -0
  43. package/src/components/styles/global-datagrid.css +163 -0
  44. package/src/components/styles/global.css +20 -0
  45. package/src/components/tickets/TicketConversation.jsx +13 -8
  46. package/src/components/utils/ConfirmDialog.js +22 -3
  47. package/src/components/utils/cardLayout.js +666 -0
  48. package/src/components/utils/contactChannels.js +130 -0
  49. package/src/components/utils/editPlacement.js +95 -0
  50. package/src/components/utils/useDensity.js +303 -7
  51. package/src/index.js +42 -0
@@ -0,0 +1,640 @@
1
+ /**
2
+ * The address autocomplete's Mapbox layer: URLs, session tokens, and the
3
+ * adapter that turns a Search Box record back into the Geocoding v5 feature
4
+ * shape every consuming form already reads.
5
+ *
6
+ * Kept React-free so `node --test` can import it. Everything worth pinning
7
+ * about this feature is in here — the component around it is a text box, a
8
+ * debounce and a list.
9
+ *
10
+ * WHY THE MOVE. `geocoding/v5/mapbox.places` is deprecated, and its POI half
11
+ * is worse than deprecated: "epiq perth" — a real business at Level 9/16 St
12
+ * Georges Terrace, Perth 6000 — came back as a handful of Queensland streets.
13
+ * The same token against `search/searchbox/v1/suggest` returns
14
+ * `Epiq | Perth 6000 | poi` as the second suggestion, because Search Box POIs
15
+ * are Foursquare-backed. Businesses by name work now; they did not before.
16
+ *
17
+ * WHY AN ADAPTER RATHER THAN A NEW CONTRACT. Every `"address"` field in every
18
+ * consuming app hands its pick to `Form.jsx`'s `autocompleteSelect`, which
19
+ * reads v5 keys — `address`, `text`, and a `context` ARRAY whose entries are
20
+ * typed by a dotted `id` (`postcode.123`, `region.456`). Search Box returns a
21
+ * context OBJECT keyed by type instead. Rather than edit that consumer and
22
+ * every form config behind it, the two-step suggest/retrieve result is
23
+ * translated back into the old shape here, once. Nothing downstream changes.
24
+ */
25
+
26
+ const SEARCH_BOX_BASE = 'https://api.mapbox.com/search/searchbox/v1';
27
+
28
+ export const SUGGEST_URL = `${SEARCH_BOX_BASE}/suggest`;
29
+ export const RETRIEVE_URL = `${SEARCH_BOX_BASE}/retrieve`;
30
+
31
+ /**
32
+ * What the dropdown asks for. `address` is the bread and butter; `poi` is the
33
+ * reason for the upgrade. Both are valid `types` values on suggest — but see
34
+ * `isTypesParamRejection` below, which exists because that is Mapbox's word
35
+ * and not ours to assume forever.
36
+ */
37
+ export const DEFAULT_TYPES = 'address,poi';
38
+
39
+ /** Six rows is what the dropdown can show without scrolling. Suggest caps at 10. */
40
+ export const DEFAULT_LIMIT = 6;
41
+
42
+ /* --------------------------------------------------------------- session */
43
+
44
+ let fallbackTokenCounter = 0;
45
+
46
+ /**
47
+ * A session token for one typing session.
48
+ *
49
+ * THIS IS MAPBOX'S BILLING UNIT. Every suggest call carrying the same token,
50
+ * plus the retrieve that ends it, bills as a single session. Mint one per
51
+ * keystroke and a nine-letter search bills as nine sessions instead of one.
52
+ *
53
+ * `crypto.randomUUID` where it exists (every browser this library targets),
54
+ * then `crypto.getRandomValues` shaped into a v4 UUID, and only then a clock
55
+ * plus a counter. No `Math.random` at any level — a token that repeats across
56
+ * two users' sessions is a billing and a correlation problem, and `Math.random`
57
+ * is the one source here with no guarantee it will not.
58
+ */
59
+ export const newSessionToken = () => {
60
+ const source = typeof globalThis === 'undefined' ? undefined : globalThis.crypto;
61
+
62
+ if (typeof source?.randomUUID === 'function') {
63
+ return source.randomUUID();
64
+ }
65
+
66
+ if (typeof source?.getRandomValues === 'function') {
67
+ const bytes = source.getRandomValues(new Uint8Array(16));
68
+
69
+ // RFC 4122 version and variant bits, so the value is a real v4 UUID
70
+ // rather than 32 hex characters that merely look like one.
71
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
72
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
73
+
74
+ const hex = Array.from(bytes)
75
+ .map((byte) => byte.toString(16).padStart(2, '0'))
76
+ .join('');
77
+
78
+ return [
79
+ hex.slice(0, 8),
80
+ hex.slice(8, 12),
81
+ hex.slice(12, 16),
82
+ hex.slice(16, 20),
83
+ hex.slice(20),
84
+ ].join('-');
85
+ }
86
+
87
+ fallbackTokenCounter += 1;
88
+
89
+ return `visns-${Date.now().toString(16)}-${fallbackTokenCounter.toString(16)}`;
90
+ };
91
+
92
+ /**
93
+ * Holds the current session token.
94
+ *
95
+ * `current()` mints on first use and then returns the SAME value for every
96
+ * later keystroke; `consume()` hands it to the retrieve and clears it, so the
97
+ * next thing typed opens a new session. `reset()` is for abandoning a session
98
+ * that never reached a retrieve (the field was cleared, autocomplete switched
99
+ * off) — dropping the token costs nothing, keeping a stale one bills the next
100
+ * user's search against the previous session.
101
+ */
102
+ export const createSessionTokenManager = (mint = newSessionToken) => {
103
+ let token = null;
104
+
105
+ return {
106
+ current() {
107
+ if (!token) {
108
+ token = mint();
109
+ }
110
+
111
+ return token;
112
+ },
113
+ /** The token without minting one — for tests and for logging. */
114
+ peek() {
115
+ return token;
116
+ },
117
+ consume() {
118
+ const current = token || mint();
119
+ token = null;
120
+
121
+ return current;
122
+ },
123
+ reset() {
124
+ token = null;
125
+ },
126
+ };
127
+ };
128
+
129
+ /* ------------------------------------------------------------------ urls */
130
+
131
+ const appendParam = (params, key, value) => {
132
+ if (value === undefined || value === null || value === '') {
133
+ return;
134
+ }
135
+
136
+ params.set(key, String(value));
137
+ };
138
+
139
+ /**
140
+ * `GET /search/searchbox/v1/suggest`.
141
+ *
142
+ * `proximity` and `bbox` come off the `mapbox` prop exactly as they did under
143
+ * v5 — same names, same "lon,lat" and "minLon,minLat,maxLon,maxLat" strings —
144
+ * so a consuming app that biases results towards its own city keeps doing so
145
+ * without touching its config.
146
+ */
147
+ export const buildSuggestUrl = ({
148
+ query,
149
+ accessToken,
150
+ sessionToken,
151
+ country,
152
+ proximity,
153
+ bbox,
154
+ limit = DEFAULT_LIMIT,
155
+ types = DEFAULT_TYPES,
156
+ } = {}) => {
157
+ if (!query || !accessToken) {
158
+ return null;
159
+ }
160
+
161
+ const params = new URLSearchParams();
162
+
163
+ // Unlike v5 the query is a query STRING parameter, not a path segment, so
164
+ // "5/12 Smith St" no longer needs the escaping v5 demanded to stop the
165
+ // slash splitting the path. URLSearchParams handles it either way.
166
+ appendParam(params, 'q', query);
167
+ appendParam(params, 'access_token', accessToken);
168
+ appendParam(params, 'session_token', sessionToken);
169
+ appendParam(params, 'country', country);
170
+ appendParam(params, 'proximity', proximity);
171
+ appendParam(params, 'bbox', bbox);
172
+ appendParam(params, 'limit', limit);
173
+ appendParam(params, 'types', types);
174
+
175
+ return `${SUGGEST_URL}?${params.toString()}`;
176
+ };
177
+
178
+ /**
179
+ * `GET /search/searchbox/v1/retrieve/{mapbox_id}`.
180
+ *
181
+ * MUST carry the same `session_token` the suggest calls used — that is what
182
+ * closes the session and makes the whole exchange one billable unit.
183
+ */
184
+ export const buildRetrieveUrl = ({ mapboxId, accessToken, sessionToken } = {}) => {
185
+ if (!mapboxId || !accessToken) {
186
+ return null;
187
+ }
188
+
189
+ const params = new URLSearchParams();
190
+
191
+ appendParam(params, 'access_token', accessToken);
192
+ appendParam(params, 'session_token', sessionToken);
193
+
194
+ return `${RETRIEVE_URL}/${encodeURIComponent(mapboxId)}?${params.toString()}`;
195
+ };
196
+
197
+ /* -------------------------------------------------------------- payloads */
198
+
199
+ /**
200
+ * Mapbox answers JSON, but the old code guarded against a body arriving as a
201
+ * string anyway and that guard has earned its keep — keep it.
202
+ */
203
+ export const parsePayload = (payload) => {
204
+ if (typeof payload !== 'string') {
205
+ return payload || null;
206
+ }
207
+
208
+ try {
209
+ return JSON.parse(payload);
210
+ } catch {
211
+ return null;
212
+ }
213
+ };
214
+
215
+ /**
216
+ * Does this error read as Mapbox refusing the `types` parameter?
217
+ *
218
+ * The parameter name is `types` per the documentation, but this is the one
219
+ * thing in the request that is a guess about a moving API rather than a
220
+ * shape we control — so read the refusal rather than assume, and let the
221
+ * caller retry once without it. A dropdown that returns addresses and no
222
+ * businesses beats a dropdown that returns an error.
223
+ */
224
+ export const isTypesParamRejection = (error) => {
225
+ const body = error?.response?.data;
226
+ const message =
227
+ typeof body === 'string'
228
+ ? body
229
+ : body?.message || body?.error || error?.message || '';
230
+
231
+ return /\btypes?\b/i.test(String(message));
232
+ };
233
+
234
+ /* --------------------------------------------------------------- helpers */
235
+
236
+ const text = (value) =>
237
+ typeof value === 'string' && value.trim() ? value.trim() : '';
238
+
239
+ /**
240
+ * Split a street line into its leading number and the rest.
241
+ *
242
+ * Australian addresses arrive as "16", "16A", "5/12" and "12-14" and all four
243
+ * are the number half. Only used when Mapbox did not break the line up for us.
244
+ */
245
+ const splitStreetLine = (line) => {
246
+ const match = text(line).match(
247
+ /^(\d+[a-zA-Z]?(?:\s*[/-]\s*\d+[a-zA-Z]?)?)\s+(.+)$/
248
+ );
249
+
250
+ if (!match) {
251
+ return { number: '', street: text(line) };
252
+ }
253
+
254
+ return { number: match[1].replace(/\s+/g, ''), street: match[2].trim() };
255
+ };
256
+
257
+ /**
258
+ * The context entries the legacy consumer looks for, narrowest first — the
259
+ * order v5 used, which is also the order the dropdown's secondary line reads
260
+ * best in.
261
+ */
262
+ const CONTEXT_ORDER = [
263
+ 'neighborhood',
264
+ 'locality',
265
+ 'postcode',
266
+ 'place',
267
+ 'district',
268
+ 'region',
269
+ 'country',
270
+ ];
271
+
272
+ /**
273
+ * Search Box's context OBJECT → v5's context ARRAY.
274
+ *
275
+ * The dotted `id` is the load-bearing part: `Form.jsx` types each entry by
276
+ * `entry.id.split('.')[0]`, so the type has to be the prefix. Mapbox's own
277
+ * ids are opaque urns with no dot, so they go after ours; when an entry has
278
+ * no id at all the index keeps the React keys unique.
279
+ */
280
+ const adaptContext = (context) => {
281
+ if (!context || typeof context !== 'object') {
282
+ return [];
283
+ }
284
+
285
+ const entries = [];
286
+
287
+ CONTEXT_ORDER.forEach((type) => {
288
+ const entry = context[type];
289
+ const name = text(entry?.name);
290
+
291
+ if (!name) {
292
+ return;
293
+ }
294
+
295
+ const adapted = {
296
+ id: `${type}.${text(entry.id) || entries.length}`,
297
+ text: name,
298
+ };
299
+
300
+ if (type === 'region') {
301
+ // v5 gave ISO 3166-2 ("AU-WA"); `Form.jsx` takes the last segment
302
+ // as the state's initials, so either of Mapbox's two region codes
303
+ // survives that split intact.
304
+ const shortCode =
305
+ text(entry.region_code_full) || text(entry.region_code);
306
+
307
+ if (shortCode) {
308
+ adapted.short_code = shortCode;
309
+ }
310
+ }
311
+
312
+ if (type === 'country') {
313
+ // v5 wrote country short codes lowercase.
314
+ const shortCode = text(entry.country_code);
315
+
316
+ if (shortCode) {
317
+ adapted.short_code = shortCode.toLowerCase();
318
+ }
319
+ }
320
+
321
+ if (text(entry.wikidata_id)) {
322
+ adapted.wikidata = text(entry.wikidata_id);
323
+ }
324
+
325
+ entries.push(adapted);
326
+ });
327
+
328
+ // AUSTRALIAN POIs OFTEN ARRIVE WITH NO `region` AT ALL — Epiq in Perth
329
+ // came back with country/postcode/place/address and nothing naming the
330
+ // state, which left the form's required State dropdown unfilled (Form.jsx
331
+ // resolves `state_id` by matching the region's name or initials, so no
332
+ // region means no match). Australian postcodes map to states
333
+ // deterministically, so when the country says AU and a postcode is
334
+ // present, the state is synthesised from it rather than left blank.
335
+ // AU-only on purpose: no other country's postcodes carry this guarantee.
336
+ const hasRegion = entries.some((entry) => entry.id.startsWith('region.'));
337
+ const isAu =
338
+ text(context.country?.country_code).toUpperCase() === 'AU';
339
+ const postcodeName = text(context.postcode?.name);
340
+
341
+ if (!hasRegion && isAu && postcodeName) {
342
+ const state = auStateFromPostcode(postcodeName);
343
+
344
+ if (state) {
345
+ const countryIndex = entries.findIndex((entry) =>
346
+ entry.id.startsWith('country.')
347
+ );
348
+ const synthesised = {
349
+ id: `region.postcode-derived`,
350
+ text: state.name,
351
+ short_code: `AU-${state.code}`,
352
+ };
353
+
354
+ // In CONTEXT_ORDER's slot: just before country.
355
+ if (countryIndex === -1) {
356
+ entries.push(synthesised);
357
+ } else {
358
+ entries.splice(countryIndex, 0, synthesised);
359
+ }
360
+ }
361
+ }
362
+
363
+ return entries;
364
+ };
365
+
366
+ /**
367
+ * The state an Australian postcode belongs to, or null off the map.
368
+ *
369
+ * The ranges are stable public knowledge (Australia Post's allocations,
370
+ * including the x000/x800 PO-box bands), and the point of hardcoding them is
371
+ * that they need no network call at pick time. `9/16 St Georges Terrace,
372
+ * Perth 6000` is Western Australia whether or not Mapbox says so.
373
+ */
374
+ const auStateFromPostcode = (postcode) => {
375
+ const value = Number.parseInt(String(postcode).trim(), 10);
376
+
377
+ if (!Number.isFinite(value)) {
378
+ return null;
379
+ }
380
+
381
+ const inAny = (ranges) =>
382
+ ranges.some(([lo, hi]) => value >= lo && value <= hi);
383
+
384
+ if (inAny([[2600, 2618], [2900, 2920], [200, 299]])) {
385
+ return { code: 'ACT', name: 'Australian Capital Territory' };
386
+ }
387
+
388
+ if (inAny([[1000, 2599], [2619, 2899], [2921, 2999]])) {
389
+ return { code: 'NSW', name: 'New South Wales' };
390
+ }
391
+
392
+ if (inAny([[800, 999]])) {
393
+ return { code: 'NT', name: 'Northern Territory' };
394
+ }
395
+
396
+ if (inAny([[4000, 4999], [9000, 9999]])) {
397
+ return { code: 'QLD', name: 'Queensland' };
398
+ }
399
+
400
+ if (inAny([[5000, 5999]])) {
401
+ return { code: 'SA', name: 'South Australia' };
402
+ }
403
+
404
+ if (inAny([[7000, 7999]])) {
405
+ return { code: 'TAS', name: 'Tasmania' };
406
+ }
407
+
408
+ if (inAny([[3000, 3999], [8000, 8999]])) {
409
+ return { code: 'VIC', name: 'Victoria' };
410
+ }
411
+
412
+ if (inAny([[6000, 6999]])) {
413
+ return { code: 'WA', name: 'Western Australia' };
414
+ }
415
+
416
+ return null;
417
+ };
418
+
419
+ /* --------------------------------------------------------------- adapter */
420
+
421
+ /**
422
+ * One Search Box record → one Geocoding v5 feature.
423
+ *
424
+ * Takes either a retrieve feature's `properties` (with its geometry folded in
425
+ * by `featureFromRetrieve`) or a raw suggestion — the two carry the same
426
+ * fields apart from coordinates, which is exactly what makes a suggestion a
427
+ * usable fallback when a retrieve fails.
428
+ *
429
+ * FIELD BY FIELD, and who reads each:
430
+ *
431
+ * | legacy key | built from | read by |
432
+ * | --------------- | -------------------------------------------- | ------- |
433
+ * | `id` | `mapbox_id` | the dropdown's React key |
434
+ * | `place_name` | `full_address`, else `name` + `place_formatted` | `Autocomplete` standalone display value |
435
+ * | `text` | the street NAME (or the POI name when there is no street) | `Form.autocompleteSelect` → `address1` |
436
+ * | `address` | the street NUMBER | `Form.autocompleteSelect` → `address1` |
437
+ * | `context[]` | the `context` object, typed by dotted id | `Form.autocompleteSelect` → suburb, postcode, state, `state_id`, country; the dropdown's secondary line |
438
+ * | `center` | `[longitude, latitude]` | nothing today — v5 parity for consumers that map a pick |
439
+ * | `geometry` | the retrieve's Point | as above |
440
+ * | `place_type[]` | `[feature_type]` | as above |
441
+ * | `properties` | passed through, with `address` as the street line | v5 POI parity |
442
+ *
443
+ * ONE DELIBERATE DIFFERENCE FROM v5. For a POI, v5 put the business name in
444
+ * `text` — so picking one filed "Epiq" as the street line. Here the street
445
+ * parts go in `address`/`text` and the name goes in `place_name` and the
446
+ * dropdown row, so picking a business fills the address field with its
447
+ * ADDRESS. That was academic under v5, whose POI results never matched
448
+ * anything anyway; it is the point of the upgrade. A POI Mapbox has no street
449
+ * for still falls back to the name, which is v5's behaviour exactly.
450
+ */
451
+ export const adaptToLegacyFeature = (source) => {
452
+ if (!source || typeof source !== 'object') {
453
+ return null;
454
+ }
455
+
456
+ const featureType = text(source.feature_type);
457
+ const name = text(source.name_preferred) || text(source.name);
458
+ const context = source.context && typeof source.context === 'object'
459
+ ? source.context
460
+ : {};
461
+
462
+ // The street line, best-cased copy first: `context.address.name` is
463
+ // "16 St Georges Terrace" where `context.street.name` is "st georges
464
+ // terrace". An address feature with no context at all still has its line
465
+ // sitting in `name`.
466
+ const line =
467
+ text(context.address?.name) ||
468
+ text(source.address) ||
469
+ (featureType === 'address' ? name : '');
470
+
471
+ const split = splitStreetLine(line);
472
+ let number = text(context.address?.address_number) || split.number;
473
+
474
+ let street = '';
475
+
476
+ if (line) {
477
+ if (number && line.toLowerCase().startsWith(number.toLowerCase())) {
478
+ // Strip the number Mapbox gave us off the front of the line
479
+ // rather than trusting `street.name`'s casing.
480
+ street = line.slice(number.length).trim();
481
+ } else if (number && !line.toLowerCase().includes(number.toLowerCase())) {
482
+ street = split.street || line;
483
+ } else {
484
+ // The number Mapbox reports sits somewhere INSIDE the line, not
485
+ // at its head — "Ste A, Level 9/16 St Georges Terrace" carries
486
+ // address_number "9/16". The line is already the complete local
487
+ // address; re-emitting the number separately made the form print
488
+ // "9/16 Ste A, Level 9/16 St Geor…". The whole line is the
489
+ // street text and the number field stays empty — the consumer
490
+ // concatenates `${address} ${text}`, so empty-plus-line is the
491
+ // line verbatim.
492
+ street = line;
493
+ number = '';
494
+ }
495
+ }
496
+
497
+ if (!street) {
498
+ street =
499
+ text(context.street?.name) ||
500
+ text(context.address?.street_name) ||
501
+ // No street anywhere: a POI keeps its name in `text`, which is
502
+ // what v5 did and what leaves the form with something to show.
503
+ name;
504
+ }
505
+
506
+ const coordinates = (() => {
507
+ const geometry = source.geometry;
508
+
509
+ if (Array.isArray(geometry?.coordinates) && geometry.coordinates.length === 2) {
510
+ return [geometry.coordinates[0], geometry.coordinates[1]];
511
+ }
512
+
513
+ const point = source.coordinates;
514
+
515
+ if (
516
+ typeof point?.longitude === 'number' &&
517
+ typeof point?.latitude === 'number'
518
+ ) {
519
+ return [point.longitude, point.latitude];
520
+ }
521
+
522
+ return null;
523
+ })();
524
+
525
+ const placeName = (() => {
526
+ const full = text(source.full_address);
527
+ const formatted = text(source.place_formatted);
528
+
529
+ if (full) {
530
+ // A POI's full_address is its street address with no business
531
+ // name on the front — v5's place_name carried both.
532
+ if (
533
+ name &&
534
+ featureType !== 'address' &&
535
+ !full.toLowerCase().startsWith(name.toLowerCase())
536
+ ) {
537
+ return `${name}, ${full}`;
538
+ }
539
+
540
+ return full;
541
+ }
542
+
543
+ if (name && formatted) {
544
+ return `${name}, ${formatted}`;
545
+ }
546
+
547
+ return name || formatted || '';
548
+ })();
549
+
550
+ const feature = {
551
+ id: text(source.mapbox_id) || (name ? `mapbox-${name}` : 'mapbox-result'),
552
+ type: 'Feature',
553
+ place_type: featureType ? [featureType] : [],
554
+ place_name: placeName,
555
+ text: street,
556
+ address: number,
557
+ context: adaptContext(context),
558
+ properties: {
559
+ ...(source.properties && typeof source.properties === 'object'
560
+ ? source.properties
561
+ : {}),
562
+ address: line || undefined,
563
+ mapbox_id: text(source.mapbox_id) || undefined,
564
+ feature_type: featureType || undefined,
565
+ },
566
+ };
567
+
568
+ if (coordinates) {
569
+ feature.center = coordinates;
570
+ feature.geometry = { type: 'Point', coordinates };
571
+ }
572
+
573
+ return feature;
574
+ };
575
+
576
+ /**
577
+ * A retrieve response → one legacy feature. Retrieve answers a GeoJSON
578
+ * FeatureCollection with a single feature; the record lives in `properties`
579
+ * and only the coordinates live on the geometry, so they are folded together
580
+ * before adapting.
581
+ */
582
+ export const featureFromRetrieve = (payload) => {
583
+ const data = parsePayload(payload);
584
+ const feature = Array.isArray(data?.features) ? data.features[0] : null;
585
+
586
+ if (!feature) {
587
+ return null;
588
+ }
589
+
590
+ return adaptToLegacyFeature({
591
+ ...(feature.properties || {}),
592
+ geometry: feature.geometry,
593
+ });
594
+ };
595
+
596
+ /**
597
+ * A suggest response → the dropdown's rows.
598
+ *
599
+ * Rows are NOT features: a suggestion has no coordinates and is not a pick
600
+ * yet. It carries the `mapbox_id` the retrieve needs, the two strings the row
601
+ * renders — `name` over `place_formatted`, so "Epiq" sits above "Perth 6000,
602
+ * Australia" — and the suggestion itself, kept so a failed retrieve can still
603
+ * be adapted into a usable feature rather than dropping the user's click.
604
+ */
605
+ export const adaptSuggestions = (payload) => {
606
+ const data = parsePayload(payload);
607
+ const suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
608
+
609
+ return suggestions.map((suggestion, index) => ({
610
+ id: text(suggestion?.mapbox_id) || `suggestion-${index}`,
611
+ mapbox_id: text(suggestion?.mapbox_id),
612
+ name:
613
+ text(suggestion?.name_preferred) ||
614
+ text(suggestion?.name) ||
615
+ text(suggestion?.full_address),
616
+ place_formatted:
617
+ text(suggestion?.place_formatted) || text(suggestion?.full_address),
618
+ feature_type: text(suggestion?.feature_type),
619
+ suggestion,
620
+ }));
621
+ };
622
+
623
+ /* --------------------------------------------------------------- logging */
624
+
625
+ const warned = new Set();
626
+
627
+ /**
628
+ * `console.warn`, but once per reason per page.
629
+ *
630
+ * A dropdown that degrades quietly is the goal; a dropdown that degrades
631
+ * quietly and fills the console with the same line on every keystroke is not.
632
+ */
633
+ export const warnOnce = (key, ...args) => {
634
+ if (warned.has(key)) {
635
+ return;
636
+ }
637
+
638
+ warned.add(key);
639
+ console.warn(...args);
640
+ };