@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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/dist/core/config.d.mts +7 -0
- package/dist/core/config.mjs +4 -2
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +6 -0
- package/dist/core/draft-store.mjs +17 -1
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/index.d.mts +3 -2
- package/dist/core/index.mjs +3 -2
- package/dist/core/machine.d.mts +53 -6
- package/dist/core/machine.mjs +162 -52
- package/dist/core/machine.mjs.map +1 -1
- package/dist/core/party-slots.d.mts +9 -2
- package/dist/core/party-slots.mjs +18 -12
- package/dist/core/party-slots.mjs.map +1 -1
- package/dist/core/types.d.mts +4 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/next/catalogue.mjs +25 -7
- package/dist/next/catalogue.mjs.map +1 -1
- package/dist/next/medal.d.mts +7 -0
- package/dist/next/medal.mjs +2 -1
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/routes/catalogue-routes.mjs +18 -4
- package/dist/next/routes/catalogue-routes.mjs.map +1 -1
- package/dist/next/routes/create.mjs +47 -9
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/manage.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +29 -1
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +32 -11
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/labels/screens.d.mts +1 -1
- package/dist/react/labels/screens.mjs +12 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/labels/wizard.d.mts +7 -0
- package/dist/react/labels/wizard.mjs +2 -0
- package/dist/react/labels/wizard.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +2 -0
- package/dist/react/useBooking.mjs +176 -50
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +7 -5
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +4 -4
package/dist/core/machine.mjs
CHANGED
|
@@ -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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
88
|
-
return
|
|
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
|
|
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
|
|
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
|
|
214
|
+
return items.map((item) => visitKey(item)).join("|");
|
|
177
215
|
}
|
|
178
216
|
/**
|
|
179
|
-
* `people` and `
|
|
180
|
-
* from them. A basket whose services changed drops the slot, for the
|
|
181
|
-
* `clearedSlot` gives; one that only changed WHO (a name typed on
|
|
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
|
|
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
|
|
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
|
|
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((
|
|
406
|
+
return items.map((item, index) => {
|
|
351
407
|
if (partyMode === "sequential" && index > 0) {
|
|
352
|
-
const previous = items[index - 1]
|
|
353
|
-
offset += (previous.durationMinutes + previous.bufferAfterMinutes +
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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]
|
|
458
|
+
const first = items[0];
|
|
400
459
|
if (first === void 0 || items.length < 2) return 0;
|
|
401
|
-
const busy = (
|
|
402
|
-
|
|
403
|
-
|
|
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 >=
|
|
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.
|
|
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,
|
|
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,
|