@medalsocial/booking 0.1.0 → 0.2.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 (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/dist/core/config.d.mts +7 -0
  4. package/dist/core/config.mjs +4 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +6 -0
  7. package/dist/core/draft-store.mjs +17 -1
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +3 -2
  10. package/dist/core/index.mjs +3 -2
  11. package/dist/core/machine.d.mts +53 -6
  12. package/dist/core/machine.mjs +162 -52
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +9 -2
  15. package/dist/core/party-slots.mjs +18 -12
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/types.d.mts +4 -0
  18. package/dist/core/visit.d.mts +26 -0
  19. package/dist/core/visit.mjs +40 -0
  20. package/dist/core/visit.mjs.map +1 -0
  21. package/dist/next/catalogue.mjs +25 -7
  22. package/dist/next/catalogue.mjs.map +1 -1
  23. package/dist/next/medal.d.mts +7 -0
  24. package/dist/next/medal.mjs +2 -1
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/catalogue-routes.mjs +18 -4
  27. package/dist/next/routes/catalogue-routes.mjs.map +1 -1
  28. package/dist/next/routes/create.mjs +47 -9
  29. package/dist/next/routes/create.mjs.map +1 -1
  30. package/dist/next/routes/manage.mjs.map +1 -1
  31. package/dist/next/routes/shared.mjs +29 -1
  32. package/dist/next/routes/shared.mjs.map +1 -1
  33. package/dist/react/BookingWizard.mjs +32 -11
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/labels/screens.d.mts +1 -1
  36. package/dist/react/labels/screens.mjs +12 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/labels/wizard.d.mts +7 -0
  39. package/dist/react/labels/wizard.mjs +2 -0
  40. package/dist/react/labels/wizard.mjs.map +1 -1
  41. package/dist/react/useBooking.d.mts +2 -0
  42. package/dist/react/useBooking.mjs +176 -50
  43. package/dist/react/useBooking.mjs.map +1 -1
  44. package/dist/react/wizard/adapters.mjs +7 -5
  45. package/dist/react/wizard/adapters.mjs.map +1 -1
  46. package/dist/react/wizard-error.mjs +29 -0
  47. package/dist/react/wizard-error.mjs.map +1 -0
  48. package/package.json +4 -4
@@ -3,6 +3,7 @@ import { fill, labelText, resolveLabels } from "./labels.mjs";
3
3
  import { createClock } from "./clock.mjs";
4
4
  import { resourceMatches } from "./deep-link.mjs";
5
5
  import { createMoney } from "./money.mjs";
6
+ import { visitKey, visitOf, visitPriceOre, visitServicesOf } from "./visit.mjs";
6
7
  //#region src/core/machine.ts
7
8
  /**
8
9
  * The wizard's brain: which step is reachable, what the sticky summary bar
@@ -49,6 +50,7 @@ function initialState() {
49
50
  step: "who",
50
51
  people: [],
51
52
  choices: [],
53
+ extras: [],
52
54
  items: [],
53
55
  resourceId: null,
54
56
  stylistAnswered: false,
@@ -69,23 +71,55 @@ function initialState() {
69
71
  };
70
72
  }
71
73
  /**
72
- * The strictest service involved sets the party limit — and the service about to
73
- * be added counts, not just the ones already chosen. Mirroring the engine rather
74
- * than inventing a ceiling: `bookings.ts` takes `Math.min` over every service in
75
- * the request and imposes no cap of its own, so the salon's real ceiling is
76
- * whatever it seeded (`maxPerBooking: 3` for `barn`, 1 for everything else). A
77
- * hardcoded number here would be a second, invisible rule that silently
78
- * disagreed with the salon's the day they changed it.
74
+ * The first service more people would take than its `maxPerBooking` allows, or
75
+ * `null` when every service is within its cap — the engine's party rule, given
76
+ * the lists as they WOULD be after the tap being judged.
79
77
  *
80
- * Counting the incoming service is what lets a strict one refuse at the tap
81
- * instead of at submit — the engine's own worked example is that a service with
82
- * `maxPerBooking: 1` cannot ride along in a party of four. It also disposes of
83
- * `Math.min()`-of-nothing: the spread always holds at least the incoming
84
- * service, so an empty basket yields that service's own limit rather than
85
- * `Infinity`, and `addService` cannot append forever.
78
+ * Per service, not per party. The engine used to cap the whole booking at the
79
+ * strictest service's number, which kept a grown-up's one-at-a-time cut out of
80
+ * any family visit; it now counts, for each service, the people taking it. So
81
+ * a «Skjegg» with `maxPerBooking: 1` rides along with two children's cuts, and
82
+ * two people on that «Skjegg» are still refused. Mirroring the engine rather
83
+ * than inventing a ceiling: a number of our own would be a second, invisible
84
+ * rule that disagreed with the salon's the day they changed it.
85
+ *
86
+ * A service counts at most once per person — a person's list never holds it
87
+ * twice, and if one did it would still be one chair.
88
+ */
89
+ function overServiceCap(lists) {
90
+ const takers = /* @__PURE__ */ new Map();
91
+ for (const list of lists) {
92
+ const once = new Map(list.map((service) => [service.id, service]));
93
+ for (const service of once.values()) {
94
+ const entry = takers.get(service.id) ?? {
95
+ service,
96
+ people: 0
97
+ };
98
+ entry.people += 1;
99
+ takers.set(service.id, entry);
100
+ }
101
+ }
102
+ for (const { service, people } of takers.values()) if (people > service.maxPerBooking) return service;
103
+ return null;
104
+ }
105
+ /**
106
+ * A person's extra services, tolerating a state that predates them. This
107
+ * package's own state always carries `extras` (`initialState`), but meda types
108
+ * the field optional and a custom shell may hold a state saved before 0.3 —
109
+ * reading `state.extras[i]` off that would throw at the first tap.
86
110
  */
87
- function partyLimit(items, incoming) {
88
- return Math.min(incoming.maxPerBooking, ...items.map((item) => item.service.maxPerBooking));
111
+ function extrasAt(state, index) {
112
+ return state.extras?.[index] ?? [];
113
+ }
114
+ /** Person `index`'s services in order: their first, then their extras. */
115
+ function servicesOf(state, index) {
116
+ const first = state.choices[index] ?? null;
117
+ return first === null ? [] : [first, ...extrasAt(state, index)];
118
+ }
119
+ /** Whether a named stylist can do every service in a list. Absent ids are
120
+ * «the caller does not know», which keeps the preference — see `stylistAfterAdd`. */
121
+ function canServeAll(resourceServiceIds, services) {
122
+ return resourceServiceIds === void 0 || services.every((service) => resourceServiceIds.includes(service.id));
89
123
  }
90
124
  /** The one stylist a seating chart names throughout, or `null` when it names
91
125
  * more than one. An empty chart has nobody, which is also `null`. */
@@ -109,7 +143,7 @@ function sharedResourceId(resourceIds) {
109
143
  * case is a caller other than the wizard rather than a race.
110
144
  */
111
145
  function stylistAfterAdd(action) {
112
- return action.resourceServiceIds === void 0 || action.resourceServiceIds.includes(action.service.id) ? {} : {
146
+ return canServeAll(action.resourceServiceIds, [action.service]) ? {} : {
113
147
  resourceId: null,
114
148
  stylistAnswered: false
115
149
  };
@@ -161,10 +195,11 @@ const clearedSlot = {
161
195
  function guestChild(n) {
162
196
  return { key: `guest:${n}` };
163
197
  }
164
- /** A person as a line item: the service, and what is known about who sits in the chair. */
165
- function itemFor(person, service) {
198
+ /** A person as a line item: the services, and what is known about who sits in the chair. */
199
+ function itemFor(person, service, extras) {
166
200
  return {
167
201
  service,
202
+ ...extras.length === 0 ? {} : { extraServices: [...extras] },
168
203
  ...person.name === void 0 ? {} : { bookedForName: person.name },
169
204
  ...person.birthYear === void 0 ? {} : { bookedForBirthYear: person.birthYear },
170
205
  ...person.personId === void 0 ? {} : { bookedForPersonId: person.personId },
@@ -172,22 +207,40 @@ function itemFor(person, service) {
172
207
  ...person.adult ? { adult: true } : {}
173
208
  };
174
209
  }
210
+ /** Every person's whole visit, so an extra added or removed reads as a changed
211
+ * basket — a Vask after the Klipp makes the visit fifteen minutes longer, and
212
+ * the slot chosen for the shorter one is no longer a slot anyone offered. */
175
213
  function basketOf(items) {
176
- return items.map((item) => item.service.id).join("|");
214
+ return items.map((item) => visitKey(item)).join("|");
177
215
  }
178
216
  /**
179
- * `people` and `choices` in, the basket out — the one place `items` is built
180
- * from them. A basket whose services changed drops the slot, for the reason
181
- * `clearedSlot` gives; one that only changed WHO (a name typed on «Bekreft»,
182
- * say) keeps it.
217
+ * `people`, `choices` and `extras` in, the basket out — the one place `items`
218
+ * is built from them. A basket whose services changed drops the slot, for the
219
+ * reason `clearedSlot` gives; one that only changed WHO (a name typed on
220
+ * «Bekreft», say) keeps it.
221
+ *
222
+ * THE ALIGNMENT RULE: `extras[i]` belongs to `people[i]`, exactly as
223
+ * `choices[i]` does. Every action that rebuilds or reorders `people` either
224
+ * carries each surviving person's extras with them — matched by `key`, the way
225
+ * `choosePeople` and `seatFamily` carry `choices` — or resets them to `[]`
226
+ * where the person's first service is replaced by a one-tap pick: `pickService`
227
+ * replacing the basket clears every extra, `pickServiceFor` clears that
228
+ * person's, because a one-tap card means «this, and only this». A person added
229
+ * by a link or a sibling chip starts on `[]`. Required rather than defaulted, so
230
+ * no caller can drop a person's extras by forgetting to mention them.
231
+ *
232
+ * A person with no first service has no extras either, whatever was passed:
233
+ * an extra never stands alone.
183
234
  */
184
- function withParty(state, people, choices) {
185
- const items = people.length > 0 && choices.every((choice) => choice !== null) ? people.map((person, index) => itemFor(person, choices[index])) : [];
235
+ function withParty(state, people, choices, extras) {
236
+ const aligned = people.map((_, index) => choices[index] ? [...extras[index]] : []);
237
+ const items = people.length > 0 && choices.every((choice) => choice !== null) ? people.map((person, index) => itemFor(person, choices[index], aligned[index])) : [];
186
238
  const changed = basketOf(items) !== basketOf(state.items);
187
239
  return {
188
240
  ...state,
189
241
  people,
190
242
  choices,
243
+ extras: aligned,
191
244
  items,
192
245
  ...changed ? clearedSlot : {}
193
246
  };
@@ -241,6 +294,7 @@ function seatFamily(state) {
241
294
  if (!state.people.some(isGuestSeat)) return state;
242
295
  const people = [];
243
296
  const choices = [];
297
+ const extras = [];
244
298
  state.people.forEach((person, index) => {
245
299
  const seat = person.key === "adult" ? {
246
300
  key: SELF_KEY,
@@ -249,9 +303,10 @@ function seatFamily(state) {
249
303
  if (isGuestSeat(seat) || people.some((other) => other.key === seat.key)) return;
250
304
  people.push(seat);
251
305
  choices.push(state.choices[index] ?? null);
306
+ extras.push(extrasAt(state, index));
252
307
  });
253
308
  return {
254
- ...withParty(state, people, choices),
309
+ ...withParty(state, people, choices, extras),
255
310
  step: people.length === 0 ? "who" : state.step,
256
311
  error: null
257
312
  };
@@ -260,21 +315,22 @@ function seatFamily(state) {
260
315
  function pickServiceFor(state, action) {
261
316
  if (!state.people[action.index] && !(action.index === 0 && state.people.length === 0)) return state;
262
317
  if (state.people.length <= 1) return {
263
- ...withParty(state, [state.people[0] ?? guestChild(1)], [action.service]),
318
+ ...withParty(state, [state.people[0] ?? guestChild(1)], [action.service], [[]]),
264
319
  ...clearedSlot,
265
320
  resourceId: null,
266
321
  stylistAnswered: false,
267
322
  step: "when",
268
323
  error: null
269
324
  };
270
- if (action.service.maxPerBooking < state.people.length) return {
325
+ if (overServiceCap(state.people.map((_, index) => index === action.index ? [action.service] : servicesOf(state, index))) !== null) return {
271
326
  ...state,
272
327
  error: "maxParty"
273
328
  };
274
329
  const choices = state.choices.map((choice, index) => index === action.index ? action.service : choice);
275
- const canServe = action.resourceServiceIds === void 0 || action.resourceServiceIds.includes(action.service.id);
330
+ const extras = state.people.map((_, index) => index === action.index ? [] : extrasAt(state, index));
331
+ const canServe = canServeAll(action.resourceServiceIds, [action.service]);
276
332
  return {
277
- ...withParty(state, state.people, choices),
333
+ ...withParty(state, state.people, choices, extras),
278
334
  ...canServe ? {} : {
279
335
  resourceId: null,
280
336
  stylistAnswered: false
@@ -347,10 +403,10 @@ function canGoToStep(state, step) {
347
403
  */
348
404
  function itemStartTimes(items, startTs, partyMode) {
349
405
  let offset = 0;
350
- return items.map(({ service }, index) => {
406
+ return items.map((item, index) => {
351
407
  if (partyMode === "sequential" && index > 0) {
352
- const previous = items[index - 1].service;
353
- offset += (previous.durationMinutes + previous.bufferAfterMinutes + service.bufferBeforeMinutes) * 6e4;
408
+ const previous = visitOf(items[index - 1]);
409
+ offset += (previous.durationMinutes + previous.bufferAfterMinutes + visitOf(item).bufferBeforeMinutes) * 6e4;
354
410
  }
355
411
  return startTs + offset;
356
412
  });
@@ -370,17 +426,20 @@ function itemStartTimes(items, startTs, partyMode) {
370
426
  * length rather than a negative one.
371
427
  */
372
428
  function visitMinutes(items, partyMode) {
373
- if (partyMode === "parallel") return Math.max(0, ...items.map(({ service }) => service.durationMinutes));
374
- return items.reduce((total, { service }, index) => total + service.durationMinutes + (index === 0 ? 0 : items[index - 1].service.bufferAfterMinutes + service.bufferBeforeMinutes), 0);
429
+ const visits = items.map((item) => visitOf(item));
430
+ if (partyMode === "parallel") return Math.max(0, ...visits.map((visit) => visit.durationMinutes));
431
+ return visits.reduce((total, visit, index) => total + visit.durationMinutes + (index === 0 ? 0 : visits[index - 1].bufferAfterMinutes + visit.bufferBeforeMinutes), 0);
375
432
  }
376
433
  /**
377
434
  * How much EARLIER a family has to start than a single first child would.
378
435
  *
379
- * The schedule endpoint answers for one service, so `lastStartTs` is «the last
380
- * start a Gutteklipp fits» — the last minute at which that service's prep,
381
- * duration and cleanup all land inside the day's window. A family needs its
382
- * whole chain to fit, so its own last start is earlier by exactly the part of
383
- * the chain that hangs off the end of the first child's busy span.
436
+ * The schedule endpoint is asked for the FIRST person's whole visit — their
437
+ * service plus its extras — so `lastStartTs` is «the last start that visit
438
+ * fits»: the last minute at which its prep, every service's minutes and its
439
+ * cleanup all land inside the day's window. A family needs its whole chain to
440
+ * fit, so its own last start is earlier by exactly the part of the chain that
441
+ * hangs off the end of the first person's whole busy span — everybody after
442
+ * them.
384
443
  *
385
444
  * NOT `visitMinutes - firstDuration`, which was the first attempt and is wrong
386
445
  * in a way that only shows on a mixed basket: `visitMinutes` deliberately
@@ -396,11 +455,14 @@ function visitMinutes(items, partyMode) {
396
455
  * has to fit is the LONGEST busy span rather than the sum.
397
456
  */
398
457
  function visitTailMinutes(items, partyMode) {
399
- const first = items[0]?.service;
458
+ const first = items[0];
400
459
  if (first === void 0 || items.length < 2) return 0;
401
- const busy = (service) => service.bufferBeforeMinutes + service.durationMinutes + service.bufferAfterMinutes;
402
- if (partyMode === "parallel") return Math.max(0, Math.max(...items.map(({ service }) => busy(service))) - busy(first));
403
- return items.slice(1).reduce((total, { service }) => total + service.bufferBeforeMinutes + service.durationMinutes + service.bufferAfterMinutes, 0);
460
+ const busy = (item) => {
461
+ const visit = visitOf(item);
462
+ return visit.bufferBeforeMinutes + visit.durationMinutes + visit.bufferAfterMinutes;
463
+ };
464
+ if (partyMode === "parallel") return Math.max(0, Math.max(...items.map(busy)) - busy(first));
465
+ return items.slice(1).reduce((total, item) => total + busy(item), 0);
404
466
  }
405
467
  /** When the family leaves. The same arithmetic as `visitMinutes`, which is why
406
468
  * it is that function and not a second copy of it: an .ics that ended before
@@ -454,6 +516,7 @@ function stylistLabel(state, resolveStylistName) {
454
516
  /** The machine for one site: its party ceiling, kids' categories, clock, currency and words. */
455
517
  function createWizard(config) {
456
518
  const maxPeople = config.party.maxPeople;
519
+ const maxServices = config.party.maxServicesPerPerson;
457
520
  const clock = createClock(config);
458
521
  const money = createMoney(config);
459
522
  const labels = resolveLabels(config.locale, config.labels);
@@ -469,6 +532,10 @@ function createWizard(config) {
469
532
  if (index !== -1) return state.choices[index] ?? null;
470
533
  return heldFor(state.pendingService, person) ?? action.services?.[position] ?? null;
471
534
  });
535
+ const extras = action.people.map((person) => {
536
+ const index = before(person);
537
+ return index === -1 ? [] : extrasAt(state, index);
538
+ });
472
539
  return {
473
540
  ...withParty(state, action.people.map((person) => {
474
541
  const index = before(person);
@@ -476,7 +543,7 @@ function createWizard(config) {
476
543
  ...state.people[index],
477
544
  ...person
478
545
  };
479
- }), choices),
546
+ }), choices, extras),
480
547
  step: action.advance ? "service" : state.step,
481
548
  error: null
482
549
  };
@@ -498,11 +565,46 @@ function createWizard(config) {
498
565
  people: [...state.people, person]
499
566
  });
500
567
  }
568
+ /** `toggleServiceFor` — see the action. */
569
+ function toggleServiceFor(state, action) {
570
+ const seated = state.people[action.index] !== void 0;
571
+ if (!seated && !(action.index === 0 && state.people.length === 0)) return state;
572
+ const people = seated ? state.people : [guestChild(1)];
573
+ const lists = people.map((_, index) => seated ? servicesOf(state, index) : []);
574
+ const current = lists[action.index];
575
+ const has = current.some((service) => service.id === action.service.id);
576
+ let next;
577
+ if (has) next = current.filter((service) => service.id !== action.service.id);
578
+ else {
579
+ if (current.length >= maxServices) return {
580
+ ...state,
581
+ error: "maxServices"
582
+ };
583
+ next = [...current, action.service];
584
+ if (overServiceCap(lists.map((list, index) => index === action.index ? next : list)) !== null) return {
585
+ ...state,
586
+ error: "maxParty"
587
+ };
588
+ }
589
+ const choices = people.map((_, index) => index === action.index ? next[0] ?? null : state.choices[index] ?? null);
590
+ const extras = people.map((_, index) => index === action.index ? next.slice(1) : extrasAt(state, index));
591
+ const canServe = canServeAll(action.resourceServiceIds, next);
592
+ return {
593
+ ...withParty(state, people, choices, extras),
594
+ ...clearedSlot,
595
+ ...canServe ? {} : {
596
+ resourceId: null,
597
+ stylistAnswered: false
598
+ },
599
+ error: null
600
+ };
601
+ }
501
602
  function reduce(state, action) {
502
603
  switch (action.type) {
503
604
  case "startOver": return initialState();
504
605
  case "choosePeople": return choosePeople(state, action);
505
606
  case "pickServiceFor": return pickServiceFor(state, action);
607
+ case "toggleServiceFor": return toggleServiceFor(state, action);
506
608
  case "seatFamily": return seatFamily(state);
507
609
  case "addPerson": return addPerson(state, action.person);
508
610
  case "holdService": return {
@@ -510,7 +612,7 @@ function createWizard(config) {
510
612
  pendingService: action.service
511
613
  };
512
614
  case "pickService": return {
513
- ...withParty(state, peopleFor(state, 1), [action.service]),
615
+ ...withParty(state, peopleFor(state, 1), [action.service], [[]]),
514
616
  ...clearedSlot,
515
617
  resourceId: null,
516
618
  stylistAnswered: false,
@@ -518,12 +620,12 @@ function createWizard(config) {
518
620
  error: null
519
621
  };
520
622
  case "addService":
521
- if (state.items.length >= partyLimit(state.items, action.service)) return {
623
+ if (state.items.length >= maxPeople || overServiceCap([...state.items.map(visitServicesOf), [action.service]]) !== null) return {
522
624
  ...state,
523
625
  error: "maxParty"
524
626
  };
525
627
  return {
526
- ...withParty(state, peopleFor(state, state.items.length + 1), [...state.items.map((item) => item.service), action.service]),
628
+ ...withParty(state, peopleFor(state, state.items.length + 1), [...state.items.map((item) => item.service), action.service], [...state.items.map((item) => item.extraServices ?? []), []]),
527
629
  ...clearedSlot,
528
630
  ...stylistAfterAdd(action),
529
631
  error: null
@@ -642,7 +744,6 @@ function createWizard(config) {
642
744
  type: "addService",
643
745
  service
644
746
  });
645
- /* v8 ignore next -- defensive: `wanted` never exceeds the service's own limit */
646
747
  if (after.items.length === next.items.length) break;
647
748
  next = after;
648
749
  }
@@ -714,7 +815,7 @@ function createWizard(config) {
714
815
  if (prefill.resourceId && next.step === "when" && !next.stylistAnswered && next.resourceId === null && next.startTs === null) {
715
816
  const wantedResource = prefill.resourceId;
716
817
  const resource = catalogue.resources.find((entry) => resourceMatches(entry, wantedResource));
717
- const basket = next.items.map((item) => item.service.id);
818
+ const basket = next.items.flatMap((item) => visitServicesOf(item).map(({ id }) => id));
718
819
  if (resource !== void 0 && basket.every((id) => resource.serviceIds.includes(id))) next = reduce(next, {
719
820
  type: "pickResource",
720
821
  resourceId: resource.id
@@ -753,6 +854,14 @@ function createWizard(config) {
753
854
  return Math.round(service.priceOre * multiplier);
754
855
  }
755
856
  /**
857
+ * What ONE person's whole visit costs — every service they chose, each priced
858
+ * by `itemPriceOre`. The line the confirmation card prints per person, and
859
+ * the unit `totalPriceOre` sums, so a line and the total cannot disagree.
860
+ */
861
+ function visitItemPriceOre(item, startTs) {
862
+ return visitPriceOre(visitServicesOf(item), (service) => itemPriceOre(service, startTs));
863
+ }
864
+ /**
756
865
  * What the visit costs, in øre, at the time it was booked for.
757
866
  *
758
867
  * Here rather than in either component that shows it, because both of them show
@@ -761,7 +870,7 @@ function createWizard(config) {
761
870
  * sort of rule that gets applied in one of two places.
762
871
  */
763
872
  function totalPriceOre(items, startTs) {
764
- return items.reduce((sum, { service }) => sum + itemPriceOre(service, startTs), 0);
873
+ return items.reduce((sum, item) => sum + visitItemPriceOre(item, startTs), 0);
765
874
  }
766
875
  /**
767
876
  * What is being booked.
@@ -776,7 +885,7 @@ function createWizard(config) {
776
885
  function serviceLabel(state) {
777
886
  const { items } = state;
778
887
  if (items.length === 0) return null;
779
- if (!showsPartyMode(state)) return items[0].service.name;
888
+ if (!showsPartyMode(state)) return visitServicesOf(items[0]).map((service) => service.name).join(" + ");
780
889
  return `${fill(labels["summary.services"], { count: items.length })}${SEPARATOR}${fill(labels["summary.totalMinutes"], { minutes: visitMinutes(items, state.partyMode) })}`;
781
890
  }
782
891
  /** «Theo, Emma», «2 barn», «Voksen» — who the visit is for, before what. */
@@ -860,6 +969,7 @@ function createWizard(config) {
860
969
  canAdvance,
861
970
  canGoToStep,
862
971
  itemPriceOre,
972
+ visitItemPriceOre,
863
973
  totalPriceOre,
864
974
  itemStartTimes,
865
975
  visitMinutes,