@autobusal/routes-order 1.8.0 → 1.9.1

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 (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -0,0 +1,652 @@
1
+ import { TFunction } from 'i18next';
2
+ import { FoundData } from '@autobusal/providers/types/routes';
3
+
4
+ /**
5
+ * Sorting, filtering and facet counting for the results list.
6
+ *
7
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
8
+ *
9
+ * All of it runs client-side against the array the search already returned:
10
+ * every field needed is on `FoundData`, so refining the list costs no extra
11
+ * request and stays instant while the user plays with the controls.
12
+ *
13
+ * One rule runs through the whole file: **an external-provider offer must
14
+ * never be filtered out by a facet it cannot report.** Those rows carry an
15
+ * operator and a price but no local stop ids, no `stops_count` and no
16
+ * amenity list, so a naive predicate would quietly delete paid partner
17
+ * inventory from the results. Every predicate below treats "the row cannot
18
+ * answer this question" as a pass, not a fail.
19
+ */
20
+
21
+ export type SortKey = 'recommended' | 'cheapest' | 'fastest' | 'earliest' | 'latest';
22
+
23
+ export type BucketKey = '0' | '6' | '12' | '18';
24
+
25
+ export const SORTS: SortKey[] = [ 'recommended', 'cheapest', 'fastest', 'earliest', 'latest' ];
26
+
27
+ export const BUCKETS: BucketKey[] = [ '0', '6', '12', '18' ];
28
+
29
+ export interface Refinement {
30
+ sort: SortKey
31
+ operators: number[]
32
+ buckets: BucketKey[]
33
+ direct: boolean
34
+ fromStops: number[]
35
+ toStops: number[]
36
+ features: number[]
37
+ maxPrice: number | null
38
+ }
39
+
40
+ export interface FacetValue {
41
+ value: number
42
+ label: string
43
+ count: number
44
+ from: number | null
45
+ fromDisplay: string | null
46
+ }
47
+
48
+ export interface Facets {
49
+ operators: FacetValue[]
50
+ buckets: FacetValue[]
51
+ fromStops: FacetValue[]
52
+ toStops: FacetValue[]
53
+ features: FacetValue[]
54
+ direct: number
55
+ hasDirect: boolean
56
+ }
57
+
58
+ export const EMPTY: Refinement = {
59
+ sort: 'recommended',
60
+ operators: [],
61
+ buckets: [],
62
+ direct: false,
63
+ fromStops: [],
64
+ toStops: [],
65
+ features: [],
66
+ maxPrice: null
67
+ };
68
+
69
+ /**
70
+ * "HH:MM" as minutes past midnight. Anything unparseable is 0 rather than
71
+ * NaN, which would poison every comparison it touched.
72
+ */
73
+ export const minutesOf = (time?: string): number => {
74
+ const [ hours, minutes ] = (time ?? '').split(':');
75
+
76
+ const value = Number(hours) * 60 + Number(minutes);
77
+
78
+ return Number.isFinite(value) ? value : 0;
79
+ };
80
+
81
+ export const departureOf = (item: FoundData): number => (
82
+ minutesOf(item.locations.from.departure)
83
+ );
84
+
85
+ /**
86
+ * Trip length in minutes, or null when obtapi could not compute one.
87
+ *
88
+ * `Locations::duration()` emits a literal "-" when the arithmetic comes out
89
+ * negative (bad location times on the route). Parsing that as 0 would make
90
+ * a broken route the "fastest" trip on the page and float it to the top of
91
+ * the list, so it stays null and sorts last.
92
+ */
93
+ export const durationOf = (item: FoundData): number | null => {
94
+ if (!item.duration || item.duration === '-') {
95
+ return null;
96
+ }
97
+
98
+ return minutesOf(item.duration);
99
+ };
100
+
101
+ /**
102
+ * Turn obtapi's "HH:MM" trip length into something that reads as a
103
+ * duration rather than a clock time.
104
+ *
105
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
106
+ *
107
+ * "08:30" sitting between two departure times is genuinely ambiguous -
108
+ * nothing about it says "eight and a half hours" rather than "half past
109
+ * eight", and on this card it sits directly between 09:00 and 16:30. The
110
+ * unit labels come from the translation files because "h"/"m" are not
111
+ * universal; Greek and the Cyrillic locales carry their own.
112
+ *
113
+ * The "-" sentinel (obtapi could not compute a duration) passes straight
114
+ * through - it must not become "0h 0m", which would read as a real answer.
115
+ */
116
+ export const formatDuration = (duration: string, t: TFunction<'common'>): string => {
117
+ if (!duration || duration === '-') {
118
+ return duration;
119
+ }
120
+
121
+ const total = minutesOf(duration);
122
+
123
+ const hours = Math.floor(total / 60);
124
+ const minutes = total % 60;
125
+
126
+ const pad = (value: number): string => (value <= 9 ? `0${ value }` : String(value));
127
+
128
+ const parts: string[] = [];
129
+
130
+ if (hours > 0) {
131
+ parts.push(t('routes_order.step2.route.duration.hours', { value: pad(hours) }));
132
+ }
133
+
134
+ // a trip under an hour still needs a number, and so does an exact
135
+ // number of hours - but "08h 00m" is noise, so zero minutes are dropped
136
+ if (minutes > 0 || hours === 0) {
137
+ parts.push(t('routes_order.step2.route.duration.minutes', { value: pad(minutes) }));
138
+ }
139
+
140
+ return parts.join(' ');
141
+ };
142
+
143
+ /**
144
+ * Split obtapi's formatted price into its parts so the decimals can be set
145
+ * as superscript.
146
+ *
147
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
148
+ *
149
+ * obtapi sends a rendered string ("1,048.00 EUR" - number_format, so the
150
+ * thousands separator is a comma and the decimal point a dot). Splitting it
151
+ * here rather than reformatting from `price.value` keeps the label's own
152
+ * currency and formatting as the single source of truth; we only decide how
153
+ * big each piece is drawn.
154
+ *
155
+ * Anything that does not match the expected shape is returned whole, so an
156
+ * unusual currency format degrades to today's rendering instead of being
157
+ * mangled.
158
+ */
159
+ export const splitPrice = (display: string): { whole: string, decimals: string | null, currency: string | null } => {
160
+ const match = /^(.+)\.(\d{2})\s+(\S+)$/.exec(display ?? '');
161
+
162
+ if (match === null) {
163
+ return { whole: display ?? '', decimals: null, currency: null };
164
+ }
165
+
166
+ return { whole: match[1], decimals: match[2], currency: match[3] };
167
+ };
168
+
169
+ export const bucketOf = (item: FoundData): BucketKey => {
170
+ const hour = Math.floor(departureOf(item) / 60);
171
+
172
+ if (hour < 6) {
173
+ return '0';
174
+ }
175
+
176
+ if (hour < 12) {
177
+ return '6';
178
+ }
179
+
180
+ if (hour < 18) {
181
+ return '12';
182
+ }
183
+
184
+ return '18';
185
+ };
186
+
187
+ const featuresOf = (item: FoundData): number[] | null => {
188
+ const features = item.trip?.features_data;
189
+
190
+ // null = the row cannot answer, so amenity filters must not judge it
191
+ return Array.isArray(features) ? features.map(feature => feature.id) : null;
192
+ };
193
+
194
+ /**
195
+ * Read the refinement out of the URL so a filtered result set survives a
196
+ * reload and can be pasted to somebody else.
197
+ */
198
+ export const fromParams = (params: URLSearchParams): Refinement => {
199
+ const numbers = (name: string): number[] => (
200
+ (params.get(name) ?? '')
201
+ .split(',')
202
+ .map(Number)
203
+ .filter(value => Number.isFinite(value) && value > 0)
204
+ );
205
+
206
+ const sort = params.get('sort') as SortKey | null;
207
+
208
+ const maxPrice = Number(params.get('max_price'));
209
+
210
+ return {
211
+ sort: sort && SORTS.includes(sort) ? sort : 'recommended',
212
+ operators: numbers('operators'),
213
+ buckets: (params.get('times') ?? '').split(',').filter(value => BUCKETS.includes(value as BucketKey)) as BucketKey[],
214
+ direct: params.get('direct') === '1',
215
+ fromStops: numbers('from_stops'),
216
+ toStops: numbers('to_stops'),
217
+ features: numbers('features'),
218
+ maxPrice: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null
219
+ };
220
+ };
221
+
222
+ /**
223
+ * Only non-default values are written, so an untouched page keeps a clean
224
+ * URL and the search's own query parameters stay readable.
225
+ */
226
+ export const toParams = (refinement: Refinement): Record<string, string> => {
227
+ const params: Record<string, string> = {};
228
+
229
+ if (refinement.sort !== 'recommended') {
230
+ params.sort = refinement.sort;
231
+ }
232
+
233
+ if (refinement.operators.length > 0) {
234
+ params.operators = refinement.operators.join(',');
235
+ }
236
+
237
+ if (refinement.buckets.length > 0) {
238
+ params.times = refinement.buckets.join(',');
239
+ }
240
+
241
+ if (refinement.direct) {
242
+ params.direct = '1';
243
+ }
244
+
245
+ if (refinement.fromStops.length > 0) {
246
+ params.from_stops = refinement.fromStops.join(',');
247
+ }
248
+
249
+ if (refinement.toStops.length > 0) {
250
+ params.to_stops = refinement.toStops.join(',');
251
+ }
252
+
253
+ if (refinement.features.length > 0) {
254
+ params.features = refinement.features.join(',');
255
+ }
256
+
257
+ if (refinement.maxPrice !== null) {
258
+ params.max_price = String(refinement.maxPrice);
259
+ }
260
+
261
+ return params;
262
+ };
263
+
264
+ export const isRefined = (refinement: Refinement): boolean => (
265
+ Object.keys(toParams({ ...refinement, sort: 'recommended' })).length > 0
266
+ );
267
+
268
+ type Facet = 'operators' | 'buckets' | 'direct' | 'fromStops' | 'toStops' | 'features' | 'maxPrice';
269
+
270
+ /**
271
+ * Whether a row survives the refinement. `except` drops one facet from the
272
+ * test, which is how each facet's own counts are computed against the
273
+ * *other* active filters - the behaviour people expect from faceted search,
274
+ * where ticking one operator doesn't zero out every other operator's count.
275
+ */
276
+ export const matches = (item: FoundData, refinement: Refinement, except?: Facet): boolean => {
277
+ if (except !== 'operators' && refinement.operators.length > 0) {
278
+ if (!refinement.operators.includes(item.operator.id)) {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ if (except !== 'buckets' && refinement.buckets.length > 0) {
284
+ if (!refinement.buckets.includes(bucketOf(item))) {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ // undefined stops_count = an external offer that doesn't report stops
290
+ if (except !== 'direct' && refinement.direct && item.stops_count !== undefined) {
291
+ if (item.stops_count > 0) {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ if (except !== 'fromStops' && refinement.fromStops.length > 0) {
297
+ const stop = item.locations.from.stop?.id;
298
+
299
+ if (stop !== undefined && !refinement.fromStops.includes(stop)) {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ if (except !== 'toStops' && refinement.toStops.length > 0) {
305
+ const stop = item.locations.to.stop?.id;
306
+
307
+ if (stop !== undefined && !refinement.toStops.includes(stop)) {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ if (except !== 'features' && refinement.features.length > 0) {
313
+ const features = featuresOf(item);
314
+
315
+ // every ticked amenity must be present - narrowing, not widening
316
+ if (features !== null && !refinement.features.every(id => features.includes(id))) {
317
+ return false;
318
+ }
319
+ }
320
+
321
+ if (except !== 'maxPrice' && refinement.maxPrice !== null) {
322
+ if (item.price.value > refinement.maxPrice) {
323
+ return false;
324
+ }
325
+ }
326
+
327
+ return true;
328
+ };
329
+
330
+ /**
331
+ * Duration comparison that sinks routes with no computable duration to the
332
+ * bottom, rather than letting obtapi's "-" sentinel pose as a zero-minute
333
+ * trip and win "fastest".
334
+ */
335
+ const byDuration = (a: FoundData, b: FoundData): number => {
336
+ const first = durationOf(a);
337
+ const second = durationOf(b);
338
+
339
+ if (first === null && second === null) {
340
+ return 0;
341
+ }
342
+
343
+ if (first === null) {
344
+ return 1;
345
+ }
346
+
347
+ if (second === null) {
348
+ return -1;
349
+ }
350
+
351
+ return first - second;
352
+ };
353
+
354
+ /**
355
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
356
+ *
357
+ * Every sort carries a tiebreak on the other axis, because the head of the
358
+ * list is also what the quick-pick cards advertise. Without it, three trips
359
+ * tied at the same journey time meant "fastest" could headline the dearest
360
+ * of them - true, but a useless thing to show somebody.
361
+ */
362
+ const compare = (sort: SortKey) => (a: FoundData, b: FoundData): number => {
363
+ switch (sort) {
364
+ case 'cheapest':
365
+ return (a.price.value - b.price.value) || byDuration(a, b);
366
+
367
+ case 'earliest':
368
+ return (departureOf(a) - departureOf(b)) || (a.price.value - b.price.value);
369
+
370
+ case 'latest':
371
+ return (departureOf(b) - departureOf(a)) || (a.price.value - b.price.value);
372
+
373
+ case 'fastest':
374
+ return byDuration(a, b) || (a.price.value - b.price.value);
375
+
376
+ default:
377
+ return 0;
378
+ }
379
+ };
380
+
381
+ /**
382
+ * Dense rank of each item under a scoring function, best (lowest) first.
383
+ * Equal values share a rank, so ten identically priced trips are all "the
384
+ * cheapest" rather than being arbitrarily spread over ten positions.
385
+ */
386
+ const rankBy = (items: FoundData[], score: (item: FoundData) => number | null): Map<FoundData, number> => {
387
+ const known = items.filter(item => score(item) !== null);
388
+
389
+ const values = [ ...new Set(known.map(item => score(item) as number)) ].sort((a, b) => a - b);
390
+
391
+ const ranks = new Map<FoundData, number>();
392
+
393
+ items.forEach(item => {
394
+ const value = score(item);
395
+
396
+ // an unscoreable item ranks below every scoreable one
397
+ ranks.set(item, value === null ? values.length : values.indexOf(value));
398
+ });
399
+
400
+ return ranks;
401
+ };
402
+
403
+ /**
404
+ * "Recommended" ordering.
405
+ *
406
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
407
+ *
408
+ * Merit first: a trip's standing is its price rank plus its duration rank,
409
+ * both computed across the current result set. The operator's paid
410
+ * subscription priority is only a TIEBREAK between trips that are already
411
+ * equal on both - it cannot buy its way past a cheaper or faster
412
+ * competitor. That is a deliberate commercial decision (Ferjolt, 2026-08-01)
413
+ * and it is the answer if anyone asks what the word means on this page.
414
+ *
415
+ * Departure time is the final tiebreak, so the order stays stable and
416
+ * predictable rather than depending on the array's incoming arrangement.
417
+ */
418
+ const recommend = (items: FoundData[]): FoundData[] => {
419
+ const price = rankBy(items, item => item.price.value);
420
+ const duration = rankBy(items, item => durationOf(item));
421
+
422
+ const score = (item: FoundData): number => (price.get(item) ?? 0) + (duration.get(item) ?? 0);
423
+
424
+ return [ ...items ].sort((a, b) => (
425
+ score(a) - score(b)
426
+ || (b.priority ?? 0) - (a.priority ?? 0)
427
+ || departureOf(a) - departureOf(b)
428
+ ));
429
+ };
430
+
431
+ /**
432
+ * Sort a list without touching the caller's array.
433
+ */
434
+ export const sortItems = (items: FoundData[], sort: SortKey): FoundData[] => {
435
+ if (sort === 'recommended') {
436
+ return recommend(items);
437
+ }
438
+
439
+ return [ ...items ].sort(compare(sort));
440
+ };
441
+
442
+ export const filterItems = (items: FoundData[], refinement: Refinement): FoundData[] => (
443
+ items.filter(item => matches(item, refinement))
444
+ );
445
+
446
+ /**
447
+ * Collapse interchangeable departures into one entry.
448
+ *
449
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
450
+ *
451
+ * Hourly shuttle service is the dominant Albanian intercity pattern, so a
452
+ * busy pair renders a wall of rows that differ only in the departure time -
453
+ * the same operator, the same two stops, the same fare, the same journey
454
+ * length. Those are one product with a choice of time, and reading them as
455
+ * twenty separate options is what makes the page tiring.
456
+ *
457
+ * Duration and price are BOTH in the key on purpose: without them a slower
458
+ * or dearer trip would hide inside a group under a headline it doesn't
459
+ * honour. External offers are keyed by their own id so they never merge
460
+ * with anything - they carry no stop ids, and grouping on missing values
461
+ * would fuse unrelated partner inventory into a single row.
462
+ *
463
+ * Input order is preserved, so a group lands wherever its best member
464
+ * sorted to and the caller does not have to re-sort.
465
+ */
466
+ export const groupItems = (items: FoundData[]): FoundData[][] => {
467
+ const groups = new Map<string, FoundData[]>();
468
+ const order: string[] = [];
469
+
470
+ items.forEach(item => {
471
+ const key = item.external
472
+ ? `ext:${ item.id }`
473
+ : [
474
+ item.operator.id,
475
+ item.locations.from.stop?.id ?? '-',
476
+ item.locations.to.stop?.id ?? '-',
477
+ item.price.value,
478
+ item.duration
479
+ ].join('|');
480
+
481
+ if (!groups.has(key)) {
482
+ groups.set(key, []);
483
+ order.push(key);
484
+ }
485
+
486
+ groups.get(key)?.push(item);
487
+ });
488
+
489
+ return order.map(key => groups.get(key) as FoundData[]);
490
+ };
491
+
492
+ const countBy = (
493
+ items: FoundData[],
494
+ refinement: Refinement,
495
+ except: Facet,
496
+ keys: (item: FoundData) => { value: number, label: string }[]
497
+ ): FacetValue[] => {
498
+ const found = new Map<number, FacetValue>();
499
+
500
+ // Pass one establishes the value universe from the WHOLE result set, so a
501
+ // facet keeps its shape as boxes get ticked. Building it only from
502
+ // matching rows instead made options vanish mid-interaction - narrow to
503
+ // one operator and the operator group disappeared entirely, taking the
504
+ // way back out with it.
505
+ items.forEach(item => {
506
+ keys(item).forEach(({ value, label }) => {
507
+ if (!found.has(value)) {
508
+ found.set(value, { value, label, count: 0, from: null, fromDisplay: null });
509
+ }
510
+ });
511
+ });
512
+
513
+ // pass two counts against the other active filters, leaving impossible
514
+ // values at zero to be rendered disabled rather than removed
515
+ items.forEach(item => {
516
+ if (!matches(item, refinement, except)) {
517
+ return;
518
+ }
519
+
520
+ keys(item).forEach(({ value }) => {
521
+ const existing = found.get(value);
522
+
523
+ if (existing === undefined) {
524
+ return;
525
+ }
526
+
527
+ existing.count += 1;
528
+
529
+ if (existing.from === null || item.price.value < existing.from) {
530
+ existing.from = item.price.value;
531
+ existing.fromDisplay = item.price.display;
532
+ }
533
+ });
534
+ });
535
+
536
+ return [ ...found.values() ].sort((a, b) => a.label.localeCompare(b.label));
537
+ };
538
+
539
+ /**
540
+ * Every facet value present in the result set, each with how many rows it
541
+ * would leave and the cheapest fare behind it. Values are kept even at a
542
+ * count of zero so the panel doesn't reshuffle under the cursor as boxes
543
+ * get ticked - they render disabled instead of vanishing.
544
+ */
545
+ export const buildFacets = (items: FoundData[], refinement: Refinement, bucketLabel: (key: BucketKey) => string): Facets => {
546
+ const operators = countBy(items, refinement, 'operators', item => [
547
+ { value: item.operator.id, label: item.operator.company }
548
+ ]);
549
+
550
+ // same two-pass shape as countBy: a bucket is listed when the unfiltered
551
+ // result set has anything in it, and counted against the other filters
552
+ const buckets = BUCKETS
553
+ .filter(key => items.some(item => bucketOf(item) === key))
554
+ .map(key => {
555
+ const inBucket = items.filter(item => bucketOf(item) === key && matches(item, refinement, 'buckets'));
556
+
557
+ const cheapest = inBucket.reduce<FoundData | null>((best, item) => (
558
+ best === null || item.price.value < best.price.value ? item : best
559
+ ), null);
560
+
561
+ return {
562
+ value: Number(key),
563
+ label: bucketLabel(key),
564
+ count: inBucket.length,
565
+ from: cheapest?.price.value ?? null,
566
+ fromDisplay: cheapest?.price.display ?? null
567
+ };
568
+ });
569
+
570
+ const fromStops = countBy(items, refinement, 'fromStops', item => (
571
+ item.locations.from.stop ? [ { value: item.locations.from.stop.id, label: item.locations.from.stop.name } ] : []
572
+ ));
573
+
574
+ const toStops = countBy(items, refinement, 'toStops', item => (
575
+ item.locations.to.stop ? [ { value: item.locations.to.stop.id, label: item.locations.to.stop.name } ] : []
576
+ ));
577
+
578
+ /**
579
+ * Amenities are the one AND group: every ticked box must be present on
580
+ * the row, so "how many if I drop this whole group" - the right question
581
+ * for the OR groups above - would advertise 8 results next to WC while
582
+ * the page shows 1. These count what ticking the box would actually
583
+ * leave, which is the number the user is really asking for.
584
+ */
585
+ const features = (() => {
586
+ const universe = new Map<number, string>();
587
+
588
+ items.forEach(item => {
589
+ (item.trip?.features_data ?? []).forEach(feature => {
590
+ if (!universe.has(feature.id)) {
591
+ universe.set(feature.id, feature.name);
592
+ }
593
+ });
594
+ });
595
+
596
+ return [ ...universe.entries() ].map(([ value, label ]) => {
597
+ const selected = refinement.features.includes(value);
598
+
599
+ const candidate = selected ? refinement : { ...refinement, features: [ ...refinement.features, value ] };
600
+
601
+ const matching = items.filter(item => matches(item, candidate));
602
+
603
+ const cheapest = matching.reduce<FoundData | null>((best, item) => (
604
+ best === null || item.price.value < best.price.value ? item : best
605
+ ), null);
606
+
607
+ return {
608
+ value,
609
+ label,
610
+ count: matching.length,
611
+ from: cheapest?.price.value ?? null,
612
+ fromDisplay: cheapest?.price.display ?? null
613
+ };
614
+ }).sort((a, b) => a.label.localeCompare(b.label));
615
+ })();
616
+
617
+ const direct = items.filter(item => item.stops_count === 0 && matches(item, refinement, 'direct')).length;
618
+
619
+ // whether to OFFER the control at all, as opposed to what it would leave -
620
+ // the group stays put at a count of zero, like every other facet
621
+ const hasDirect = items.some(item => item.stops_count === 0);
622
+
623
+ return { operators, buckets, fromStops, toStops, features, direct, hasDirect };
624
+ };
625
+
626
+ export const priceRange = (items: FoundData[]): { min: number, max: number, minDisplay: string, maxDisplay: string } | null => {
627
+ if (items.length === 0) {
628
+ return null;
629
+ }
630
+
631
+ const sorted = [ ...items ].sort((a, b) => a.price.value - b.price.value);
632
+
633
+ const cheapest = sorted[0];
634
+ const dearest = sorted[sorted.length - 1];
635
+
636
+ return {
637
+ min: cheapest.price.value,
638
+ max: dearest.price.value,
639
+ minDisplay: cheapest.price.display,
640
+ maxDisplay: dearest.price.display
641
+ };
642
+ };
643
+
644
+ /**
645
+ * The label's currency as obtapi formats it ("50.00 EUR" -> "EUR"), taken
646
+ * from a price the server already rendered rather than re-deriving it in
647
+ * the client. Used for values the server never formatted, like the position
648
+ * of the price slider.
649
+ */
650
+ export const currencyOf = (items: FoundData[]): string => (
651
+ (items[0]?.price.display ?? '').replace(/[\d.,]/g, '').trim()
652
+ );