@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.
- package/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- 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
|
+
};
|