@medalsocial/booking 0.0.0-placeholder.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 +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -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/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +126 -5
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import { isChildCategory } from "./categories.mjs";
|
|
2
|
+
import { fill, labelText, resolveLabels } from "./labels.mjs";
|
|
3
|
+
import { createClock } from "./clock.mjs";
|
|
4
|
+
import { resourceMatches } from "./deep-link.mjs";
|
|
5
|
+
import { createMoney } from "./money.mjs";
|
|
6
|
+
import { visitKey, visitOf, visitPriceOre, visitServicesOf } from "./visit.mjs";
|
|
7
|
+
//#region src/core/machine.ts
|
|
8
|
+
/**
|
|
9
|
+
* The wizard's brain: which step is reachable, what the sticky summary bar
|
|
10
|
+
* says, and when submit is allowed. All of it lives here and nowhere else.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately pure — no React, no fetch, no DOM — so every rule is testable
|
|
13
|
+
* without rendering. `medal-client.ts` is `server-only` and must never be
|
|
14
|
+
* imported from this file; the wizard runs in the browser.
|
|
15
|
+
*
|
|
16
|
+
* The step components (meda's `/booking` screens) are meant to be
|
|
17
|
+
* presentational. If one of them would need an `if` about wizard state, that
|
|
18
|
+
* `if` belongs here instead.
|
|
19
|
+
*
|
|
20
|
+
* Most of the machine is config-free and exported as is. `createWizard(config)`
|
|
21
|
+
* binds the parts that are not: the party ceiling (`party.maxPeople`), which
|
|
22
|
+
* category is the kids' path (`categories[].audience`), the weekend on the
|
|
23
|
+
* business's clock, the currency, and the words the summary line uses.
|
|
24
|
+
*/
|
|
25
|
+
/** The logged-in parent themselves, on step 1's «Meg selv (voksen)». */
|
|
26
|
+
const SELF_KEY = "self";
|
|
27
|
+
/**
|
|
28
|
+
* A seat only a GUEST's step 1 can draw: a count chip's `guest:n`, a child
|
|
29
|
+
* named in the guest sheet (`new:…`), the «Voksen» chip's `adult`. A parent's
|
|
30
|
+
* step 1 draws cards for saved children and «Meg selv» and nothing else — so
|
|
31
|
+
* one of these under a family is a seat nobody can see or untick, counting
|
|
32
|
+
* towards the three.
|
|
33
|
+
*/
|
|
34
|
+
function isGuestSeat(person) {
|
|
35
|
+
return person.key.startsWith("guest:") || person.key.startsWith("new:") || person.key === "adult";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The engine's own bound on `booked_for_name` — `maxLength: 200` in the SDK's
|
|
39
|
+
* OpenAPI document. Step 4's input has no `maxLength`, so this is the one limit
|
|
40
|
+
* that exists, and a prefilled name is cut to it rather than being carried into
|
|
41
|
+
* a body the engine answers 400 to.
|
|
42
|
+
*/
|
|
43
|
+
const BOOKED_FOR_NAME_MAX_LENGTH = 200;
|
|
44
|
+
const SEPARATOR = " · ";
|
|
45
|
+
/** Stands in for a part the visitor has not chosen yet, so the bar keeps its
|
|
46
|
+
* shape as they fill it in rather than growing under their thumb. */
|
|
47
|
+
const UNCHOSEN = "—";
|
|
48
|
+
function initialState() {
|
|
49
|
+
return {
|
|
50
|
+
step: "who",
|
|
51
|
+
people: [],
|
|
52
|
+
choices: [],
|
|
53
|
+
extras: [],
|
|
54
|
+
items: [],
|
|
55
|
+
resourceId: null,
|
|
56
|
+
stylistAnswered: false,
|
|
57
|
+
partyMode: "sequential",
|
|
58
|
+
startTs: null,
|
|
59
|
+
resolvedResourceId: null,
|
|
60
|
+
partyResourceIds: null,
|
|
61
|
+
contact: {
|
|
62
|
+
phone: "",
|
|
63
|
+
name: "",
|
|
64
|
+
email: ""
|
|
65
|
+
},
|
|
66
|
+
notes: "",
|
|
67
|
+
consentTerms: false,
|
|
68
|
+
consentMarketing: false,
|
|
69
|
+
pendingService: null,
|
|
70
|
+
error: null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
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.
|
|
77
|
+
*
|
|
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.
|
|
110
|
+
*/
|
|
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));
|
|
123
|
+
}
|
|
124
|
+
/** The one stylist a seating chart names throughout, or `null` when it names
|
|
125
|
+
* more than one. An empty chart has nobody, which is also `null`. */
|
|
126
|
+
function sharedResourceId(resourceIds) {
|
|
127
|
+
const [first] = resourceIds;
|
|
128
|
+
if (first === void 0) return null;
|
|
129
|
+
return resourceIds.every((id) => id === first) ? first : null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* What `addService` does to the stylist preference: nothing, or release it.
|
|
133
|
+
*
|
|
134
|
+
* A patch rather than a boolean, so the case below spreads one expression and
|
|
135
|
+
* says what it means — and so the empty object is the ordinary answer, which is
|
|
136
|
+
* the point: adding a sibling is not a change of mind about Sara.
|
|
137
|
+
*
|
|
138
|
+
* `resourceServiceIds` absent is «the caller could not say», and it keeps the
|
|
139
|
+
* preference. That direction is deliberate: a wizard whose stylist catalogue has
|
|
140
|
+
* not loaded knows nothing about who does what, and discarding an answer the
|
|
141
|
+
* visitor gave on the strength of that would be a guess dressed as a safeguard.
|
|
142
|
+
* The catalogue is loaded by the time a stylist can be tapped at all, so the
|
|
143
|
+
* case is a caller other than the wizard rather than a race.
|
|
144
|
+
*/
|
|
145
|
+
function stylistAfterAdd(action) {
|
|
146
|
+
return canServeAll(action.resourceServiceIds, [action.service]) ? {} : {
|
|
147
|
+
resourceId: null,
|
|
148
|
+
stylistAnswered: false
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A chosen slot belongs to the question it was chosen under, so every action
|
|
153
|
+
* that changes that question has to drop it — the basket, the seating mode, and
|
|
154
|
+
* the stylist alike. Adding a second child doubles the duration being asked for;
|
|
155
|
+
* swapping the service changes it outright; naming a different stylist points it
|
|
156
|
+
* at somebody else's diary. Either way the time the visitor tapped is no longer
|
|
157
|
+
* a time the salon ever offered them.
|
|
158
|
+
*
|
|
159
|
+
* Left in place this stays invisible until the progress dots become tappable,
|
|
160
|
+
* and then it is a genuinely bad bug rather than an untidy one: `canGoToStep`
|
|
161
|
+
* treats any non-null `startTs` as reason enough to reach `details`, so going
|
|
162
|
+
* back, changing an answer and jumping straight forward submits against a slot
|
|
163
|
+
* that answered the old question — with the summary bar quoting the stale time
|
|
164
|
+
* as if it were confirmed.
|
|
165
|
+
*
|
|
166
|
+
* This spread deliberately does NOT touch `resourceId`, because that is a
|
|
167
|
+
* separate answer and each caller has its own view of it:
|
|
168
|
+
*
|
|
169
|
+
* - `pickResource` writes it — it *is* the new answer.
|
|
170
|
+
* - `pickService` clears it, because the basket was replaced and step 2 only
|
|
171
|
+
* offers stylists who can do every service in it.
|
|
172
|
+
* - `setPartyMode` clears it for `parallel`, which is two stylists by
|
|
173
|
+
* construction.
|
|
174
|
+
* - `addService` keeps it, unless the child being added is one the named stylist
|
|
175
|
+
* cannot take. A parent adding a sibling has not changed their mind about
|
|
176
|
+
* Sara, so clearing unconditionally would re-ask every family in the common
|
|
177
|
+
* case where she can do both — but keeping it unconditionally left step 2 with
|
|
178
|
+
* nothing selected and step 3 filtered to somebody who cannot take the visit,
|
|
179
|
+
* which is «Fullt» on every day of the week at a salon with a free chair.
|
|
180
|
+
* The catalogue that tells the two apart is a fetch result the shell holds and
|
|
181
|
+
* this file cannot reach, so it arrives on the action as
|
|
182
|
+
* `resourceServiceIds`; absent, the preference is kept.
|
|
183
|
+
*
|
|
184
|
+
* `partyResourceIds` is the other half of a chosen slot and goes with it. Left
|
|
185
|
+
* behind it would be a seating chart for a party that no longer has that many
|
|
186
|
+
* children in it, and `itemResourceIds` would hand the submission a stylist for
|
|
187
|
+
* a line item that does not exist.
|
|
188
|
+
*/
|
|
189
|
+
const clearedSlot = {
|
|
190
|
+
startTs: null,
|
|
191
|
+
resolvedResourceId: null,
|
|
192
|
+
partyResourceIds: null
|
|
193
|
+
};
|
|
194
|
+
/** The guest child in chair `n` (1-based) — a chip on step 1, or a sibling a link added. */
|
|
195
|
+
function guestChild(n) {
|
|
196
|
+
return { key: `guest:${n}` };
|
|
197
|
+
}
|
|
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) {
|
|
200
|
+
return {
|
|
201
|
+
service,
|
|
202
|
+
...extras.length === 0 ? {} : { extraServices: [...extras] },
|
|
203
|
+
...person.name === void 0 ? {} : { bookedForName: person.name },
|
|
204
|
+
...person.birthYear === void 0 ? {} : { bookedForBirthYear: person.birthYear },
|
|
205
|
+
...person.personId === void 0 ? {} : { bookedForPersonId: person.personId },
|
|
206
|
+
...person.birthMonth === void 0 ? {} : { bookedForBirthMonth: person.birthMonth },
|
|
207
|
+
...person.adult ? { adult: true } : {}
|
|
208
|
+
};
|
|
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. */
|
|
213
|
+
function basketOf(items) {
|
|
214
|
+
return items.map((item) => visitKey(item)).join("|");
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
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.
|
|
234
|
+
*/
|
|
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])) : [];
|
|
238
|
+
const changed = basketOf(items) !== basketOf(state.items);
|
|
239
|
+
return {
|
|
240
|
+
...state,
|
|
241
|
+
people,
|
|
242
|
+
choices,
|
|
243
|
+
extras: aligned,
|
|
244
|
+
items,
|
|
245
|
+
...changed ? clearedSlot : {}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** The people behind a basket built without step 1 — a link, a restored draft. */
|
|
249
|
+
function peopleFor(state, count) {
|
|
250
|
+
return Array.from({ length: count }, (_, index) => state.people[index] ?? guestChild(index + 1));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* One field of one line item, written the way the engine will have to read it:
|
|
254
|
+
* **absent**, never blank.
|
|
255
|
+
*
|
|
256
|
+
* `booked_for_name` is `z.string().trim().min(1).optional()` upstream, where
|
|
257
|
+
* `''` is a 400 rather than a shrug, and `booked_for_birth_year` is
|
|
258
|
+
* `z.number().optional()`, which refuses the `null` an emptied input serialises
|
|
259
|
+
* to. Storing the emptiness here and coalescing it at the edge would hold
|
|
260
|
+
* exactly until something other than the create route read these items — the
|
|
261
|
+
* confirmation card already does, to write «Gutteklipp for Jonas» — so the
|
|
262
|
+
* emptiness never gets in.
|
|
263
|
+
*/
|
|
264
|
+
function withItemField(item, action) {
|
|
265
|
+
const next = { ...item };
|
|
266
|
+
if (action.field === "bookedForPersonId") {
|
|
267
|
+
if (action.value !== null && action.value !== "") next.bookedForPersonId = action.value;
|
|
268
|
+
else delete next.bookedForPersonId;
|
|
269
|
+
return next;
|
|
270
|
+
}
|
|
271
|
+
if (action.field === "bookedForName") {
|
|
272
|
+
const trimmed = action.value.trim();
|
|
273
|
+
if (trimmed.length > 0) next.bookedForName = trimmed;
|
|
274
|
+
else delete next.bookedForName;
|
|
275
|
+
return next;
|
|
276
|
+
}
|
|
277
|
+
if (action.value !== null && Number.isFinite(action.value)) next.bookedForBirthYear = action.value;
|
|
278
|
+
else delete next.bookedForBirthYear;
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
281
|
+
/** The typed answers about a guest child, carried back onto their person. */
|
|
282
|
+
function withPersonFields(person, item) {
|
|
283
|
+
const { name: _name, birthYear: _year, personId: _id, ...rest } = person;
|
|
284
|
+
return {
|
|
285
|
+
...rest,
|
|
286
|
+
...item.bookedForName === void 0 ? {} : { name: item.bookedForName },
|
|
287
|
+
...item.bookedForBirthYear === void 0 ? {} : { birthYear: item.bookedForBirthYear },
|
|
288
|
+
...item.bookedForPersonId === void 0 ? {} : { personId: item.bookedForPersonId }
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** `seatFamily` — see the action. */
|
|
292
|
+
function seatFamily(state) {
|
|
293
|
+
if (state.step !== "who" && state.step !== "service") return state;
|
|
294
|
+
if (!state.people.some(isGuestSeat)) return state;
|
|
295
|
+
const people = [];
|
|
296
|
+
const choices = [];
|
|
297
|
+
const extras = [];
|
|
298
|
+
state.people.forEach((person, index) => {
|
|
299
|
+
const seat = person.key === "adult" ? {
|
|
300
|
+
key: SELF_KEY,
|
|
301
|
+
adult: true
|
|
302
|
+
} : person;
|
|
303
|
+
if (isGuestSeat(seat) || people.some((other) => other.key === seat.key)) return;
|
|
304
|
+
people.push(seat);
|
|
305
|
+
choices.push(state.choices[index] ?? null);
|
|
306
|
+
extras.push(extrasAt(state, index));
|
|
307
|
+
});
|
|
308
|
+
return {
|
|
309
|
+
...withParty(state, people, choices, extras),
|
|
310
|
+
step: people.length === 0 ? "who" : state.step,
|
|
311
|
+
error: null
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** `pickServiceFor` — see the action. */
|
|
315
|
+
function pickServiceFor(state, action) {
|
|
316
|
+
if (!state.people[action.index] && !(action.index === 0 && state.people.length === 0)) return state;
|
|
317
|
+
if (state.people.length <= 1) return {
|
|
318
|
+
...withParty(state, [state.people[0] ?? guestChild(1)], [action.service], [[]]),
|
|
319
|
+
...clearedSlot,
|
|
320
|
+
resourceId: null,
|
|
321
|
+
stylistAnswered: false,
|
|
322
|
+
step: "when",
|
|
323
|
+
error: null
|
|
324
|
+
};
|
|
325
|
+
if (overServiceCap(state.people.map((_, index) => index === action.index ? [action.service] : servicesOf(state, index))) !== null) return {
|
|
326
|
+
...state,
|
|
327
|
+
error: "maxParty"
|
|
328
|
+
};
|
|
329
|
+
const choices = state.choices.map((choice, index) => index === action.index ? action.service : choice);
|
|
330
|
+
const extras = state.people.map((_, index) => index === action.index ? [] : extrasAt(state, index));
|
|
331
|
+
const canServe = canServeAll(action.resourceServiceIds, [action.service]);
|
|
332
|
+
return {
|
|
333
|
+
...withParty(state, state.people, choices, extras),
|
|
334
|
+
...canServe ? {} : {
|
|
335
|
+
resourceId: null,
|
|
336
|
+
stylistAnswered: false
|
|
337
|
+
},
|
|
338
|
+
error: null
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** The wizard in order. Both `canGoToStep` and the progress dots read the
|
|
342
|
+
* sequence from here rather than restating it, so a fourth step cannot be added
|
|
343
|
+
* to one of them and forgotten in the other. */
|
|
344
|
+
const STEP_ORDER = [
|
|
345
|
+
"who",
|
|
346
|
+
"service",
|
|
347
|
+
"when",
|
|
348
|
+
"details"
|
|
349
|
+
];
|
|
350
|
+
/**
|
|
351
|
+
* Whether a given step has been answered. One definition, asked two ways —
|
|
352
|
+
* `canAdvance` asks it about the step the visitor is on, `canGoToStep` asks it
|
|
353
|
+
* about the ones behind the step they want. Keeping it in a single function is
|
|
354
|
+
* what stops "step 3 is done" from meaning one thing to the Neste button and
|
|
355
|
+
* another to the progress dots.
|
|
356
|
+
*/
|
|
357
|
+
function isSatisfied(state, step) {
|
|
358
|
+
switch (step) {
|
|
359
|
+
case "who": return state.people.length > 0 || state.items.length > 0;
|
|
360
|
+
case "service": return state.items.length > 0;
|
|
361
|
+
case "when": return state.startTs !== null;
|
|
362
|
+
case "details": return state.contact.phone.trim().length > 0 && state.consentTerms;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* True when the visitor may leave the current step — and on `details`, when they
|
|
367
|
+
* may submit at all. Answers about `state.step` only, so a Neste button can be
|
|
368
|
+
* `disabled={!canAdvance(state)}` and nothing else.
|
|
369
|
+
*/
|
|
370
|
+
function canAdvance(state) {
|
|
371
|
+
return isSatisfied(state, state.step);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* True when a step may be jumped to directly — the predicate behind whether a
|
|
375
|
+
* progress dot is tappable, and the guard `goToStep` applies to itself.
|
|
376
|
+
*
|
|
377
|
+
* Bounded by what is *behind* the target, never the target itself: reaching
|
|
378
|
+
* `details` requires a service and a time, but obviously not the contact
|
|
379
|
+
* details you are going there to type. Going back is therefore always allowed,
|
|
380
|
+
* because nothing precedes `service` — which is the case that matters, since
|
|
381
|
+
* back is what the dots are mostly for.
|
|
382
|
+
*/
|
|
383
|
+
function canGoToStep(state, step) {
|
|
384
|
+
const index = STEP_ORDER.indexOf(step);
|
|
385
|
+
if (index === -1) return false;
|
|
386
|
+
return STEP_ORDER.slice(0, index).every((prior) => isSatisfied(state, prior));
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* When each child in the party sits down.
|
|
390
|
+
*
|
|
391
|
+
* `sequential` is the report's default and the ordinary case — one stylist,
|
|
392
|
+
* «rett etter hverandre», so the second child starts when the first is
|
|
393
|
+
* finished. `parallel` is two stylists at once, and every line starts together.
|
|
394
|
+
*
|
|
395
|
+
* The engine takes a `start_ts` per line item rather than one for the visit, so
|
|
396
|
+
* somebody has to do this arithmetic, and it is a rule about the booking rather
|
|
397
|
+
* than about a screen. Step 4 submits with it and the confirmation card reads
|
|
398
|
+
* «15:00 Jonas / 15:30 Emma» off the same function, which is the only way those
|
|
399
|
+
* two can be made to agree.
|
|
400
|
+
*
|
|
401
|
+
* Task 9 owns the slot SEARCH for a party; this is only the seating of a slot
|
|
402
|
+
* already chosen.
|
|
403
|
+
*/
|
|
404
|
+
function itemStartTimes(items, startTs, partyMode) {
|
|
405
|
+
let offset = 0;
|
|
406
|
+
return items.map((item, index) => {
|
|
407
|
+
if (partyMode === "sequential" && index > 0) {
|
|
408
|
+
const previous = visitOf(items[index - 1]);
|
|
409
|
+
offset += (previous.durationMinutes + previous.bufferAfterMinutes + visitOf(item).bufferBeforeMinutes) * 6e4;
|
|
410
|
+
}
|
|
411
|
+
return startTs + offset;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* How long the family is in the salon — the number the summary bar quotes as
|
|
416
|
+
* «60 min totalt» and the length of the .ics entry.
|
|
417
|
+
*
|
|
418
|
+
* The two modes are two different visits: `sequential` is one chair used twice,
|
|
419
|
+
* so the minutes add up, while `parallel` is two chairs at once and the family
|
|
420
|
+
* leaves when the *longest* cut finishes. Quoting the sum for a parallel party
|
|
421
|
+
* would tell a parent to set aside an hour for a visit that takes half of one,
|
|
422
|
+
* which is the whole reason «To frisører samtidig» is worth offering.
|
|
423
|
+
*
|
|
424
|
+
* `Math.max` seeded with 0 rather than spread over the durations alone:
|
|
425
|
+
* `Math.max()` of nothing is `-Infinity`, and an empty basket is a visit of no
|
|
426
|
+
* length rather than a negative one.
|
|
427
|
+
*/
|
|
428
|
+
function visitMinutes(items, partyMode) {
|
|
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);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* How much EARLIER a family has to start than a single first child would.
|
|
435
|
+
*
|
|
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.
|
|
443
|
+
*
|
|
444
|
+
* NOT `visitMinutes - firstDuration`, which was the first attempt and is wrong
|
|
445
|
+
* in a way that only shows on a mixed basket: `visitMinutes` deliberately
|
|
446
|
+
* excludes the LAST child's cleanup — the family has left by then — so the
|
|
447
|
+
* subtraction silently keeps the FIRST child's cleanup in place of the last
|
|
448
|
+
* one's. Where the later service has the bigger `bufferAfterMinutes` the cutoff
|
|
449
|
+
* comes out too late, which is the error in the direction that matters: it says
|
|
450
|
+
* «Fullt» about an afternoon that is actually over.
|
|
451
|
+
*
|
|
452
|
+
* Sequential is the tail of the chain: every child after the first costs their
|
|
453
|
+
* prep, their duration and their cleanup, and the first child's own cleanup is
|
|
454
|
+
* already inside the number being adjusted. Parallel is one chair each, so what
|
|
455
|
+
* has to fit is the LONGEST busy span rather than the sum.
|
|
456
|
+
*/
|
|
457
|
+
function visitTailMinutes(items, partyMode) {
|
|
458
|
+
const first = items[0];
|
|
459
|
+
if (first === void 0 || items.length < 2) return 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);
|
|
466
|
+
}
|
|
467
|
+
/** When the family leaves. The same arithmetic as `visitMinutes`, which is why
|
|
468
|
+
* it is that function and not a second copy of it: an .ics that ended before
|
|
469
|
+
* `visitMinutes` said the visit did would tell a calendar the parent was free
|
|
470
|
+
* while a child was still in the chair. */
|
|
471
|
+
function visitEndTs(items, startTs, partyMode) {
|
|
472
|
+
return startTs + visitMinutes(items, partyMode) * 6e4;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Whether step 2 asks how the party is seen.
|
|
476
|
+
*
|
|
477
|
+
* One child is not a party, and «Samme frisør, rett etter hverandre» is not a
|
|
478
|
+
* choice when there is nobody to be after. The machine answers rather than the
|
|
479
|
+
* component so that the picker and the search cannot disagree about when a
|
|
480
|
+
* booking has become a family visit.
|
|
481
|
+
*/
|
|
482
|
+
function showsPartyMode(state) {
|
|
483
|
+
return state.items.length > 1;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Who is cutting whose hair, one answer per line item.
|
|
487
|
+
*
|
|
488
|
+
* The single reader for both shapes the machine can be in: a party slot names a
|
|
489
|
+
* stylist per child, and everything else has one answer for the whole visit —
|
|
490
|
+
* including `null`, which is «Første ledige» still to be resolved by the engine.
|
|
491
|
+
*
|
|
492
|
+
* Step 4's submission and the confirmation card both read it, which is the only
|
|
493
|
+
* way «hos Marcus» on the card can be guaranteed to be the `resource_id` that
|
|
494
|
+
* was actually booked for that child.
|
|
495
|
+
*/
|
|
496
|
+
function itemResourceIds(state) {
|
|
497
|
+
return state.partyResourceIds ?? state.items.map(() => state.resolvedResourceId);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The stylist, as a person rather than as a row id.
|
|
501
|
+
*
|
|
502
|
+
* The machine holds ids — that is what step 2 chose and what the availability
|
|
503
|
+
* query filters on — so naming one takes the catalogue, which is a fetch result
|
|
504
|
+
* and not a rule. Hence the resolver: the caller already has the stylists it
|
|
505
|
+
* rendered on step 2, and an id it cannot name comes back `null` rather than
|
|
506
|
+
* putting `res-7f3a…` under the visitor's thumb.
|
|
507
|
+
*
|
|
508
|
+
* `resolvedResourceId` wins where both are set, because it is who the visitor
|
|
509
|
+
* actually got. That is also what fills this part in for «Første ledige», which
|
|
510
|
+
* is a null preference and stays a dash until a slot resolves it to somebody.
|
|
511
|
+
*/
|
|
512
|
+
function stylistLabel(state, resolveStylistName) {
|
|
513
|
+
const resourceId = state.resolvedResourceId ?? state.resourceId;
|
|
514
|
+
return resourceId === null ? null : resolveStylistName(resourceId);
|
|
515
|
+
}
|
|
516
|
+
/** The machine for one site: its party ceiling, kids' categories, clock, currency and words. */
|
|
517
|
+
function createWizard(config) {
|
|
518
|
+
const maxPeople = config.party.maxPeople;
|
|
519
|
+
const maxServices = config.party.maxServicesPerPerson;
|
|
520
|
+
const clock = createClock(config);
|
|
521
|
+
const money = createMoney(config);
|
|
522
|
+
const labels = resolveLabels(config.locale, config.labels);
|
|
523
|
+
/** `choosePeople` — see the action. */
|
|
524
|
+
function choosePeople(state, action) {
|
|
525
|
+
if (action.people.length > maxPeople) return {
|
|
526
|
+
...state,
|
|
527
|
+
error: "maxParty"
|
|
528
|
+
};
|
|
529
|
+
const before = (person) => state.people.findIndex((previous) => previous.key === person.key);
|
|
530
|
+
const choices = action.people.map((person, position) => {
|
|
531
|
+
const index = before(person);
|
|
532
|
+
if (index !== -1) return state.choices[index] ?? null;
|
|
533
|
+
return heldFor(state.pendingService, person) ?? action.services?.[position] ?? null;
|
|
534
|
+
});
|
|
535
|
+
const extras = action.people.map((person) => {
|
|
536
|
+
const index = before(person);
|
|
537
|
+
return index === -1 ? [] : extrasAt(state, index);
|
|
538
|
+
});
|
|
539
|
+
return {
|
|
540
|
+
...withParty(state, action.people.map((person) => {
|
|
541
|
+
const index = before(person);
|
|
542
|
+
return index === -1 || person.personId !== void 0 ? person : {
|
|
543
|
+
...state.people[index],
|
|
544
|
+
...person
|
|
545
|
+
};
|
|
546
|
+
}), choices, extras),
|
|
547
|
+
step: action.advance ? "service" : state.step,
|
|
548
|
+
error: null
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/** The held link service, if it suits this person: kids' cuts for children only. */
|
|
552
|
+
function heldFor(service, person) {
|
|
553
|
+
if (service === null) return null;
|
|
554
|
+
return isChildCategory(config, service.category) === !person.adult ? service : null;
|
|
555
|
+
}
|
|
556
|
+
/** `addPerson` — see the action. */
|
|
557
|
+
function addPerson(state, person) {
|
|
558
|
+
if (state.people.some((other) => other.key === person.key)) return state;
|
|
559
|
+
if (state.people.length >= maxPeople) return {
|
|
560
|
+
...state,
|
|
561
|
+
error: "maxParty"
|
|
562
|
+
};
|
|
563
|
+
return choosePeople(state, {
|
|
564
|
+
type: "choosePeople",
|
|
565
|
+
people: [...state.people, person]
|
|
566
|
+
});
|
|
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
|
+
}
|
|
602
|
+
function reduce(state, action) {
|
|
603
|
+
switch (action.type) {
|
|
604
|
+
case "startOver": return initialState();
|
|
605
|
+
case "choosePeople": return choosePeople(state, action);
|
|
606
|
+
case "pickServiceFor": return pickServiceFor(state, action);
|
|
607
|
+
case "toggleServiceFor": return toggleServiceFor(state, action);
|
|
608
|
+
case "seatFamily": return seatFamily(state);
|
|
609
|
+
case "addPerson": return addPerson(state, action.person);
|
|
610
|
+
case "holdService": return {
|
|
611
|
+
...state,
|
|
612
|
+
pendingService: action.service
|
|
613
|
+
};
|
|
614
|
+
case "pickService": return {
|
|
615
|
+
...withParty(state, peopleFor(state, 1), [action.service], [[]]),
|
|
616
|
+
...clearedSlot,
|
|
617
|
+
resourceId: null,
|
|
618
|
+
stylistAnswered: false,
|
|
619
|
+
step: "when",
|
|
620
|
+
error: null
|
|
621
|
+
};
|
|
622
|
+
case "addService":
|
|
623
|
+
if (state.items.length >= maxPeople || overServiceCap([...state.items.map(visitServicesOf), [action.service]]) !== null) return {
|
|
624
|
+
...state,
|
|
625
|
+
error: "maxParty"
|
|
626
|
+
};
|
|
627
|
+
return {
|
|
628
|
+
...withParty(state, peopleFor(state, state.items.length + 1), [...state.items.map((item) => item.service), action.service], [...state.items.map((item) => item.extraServices ?? []), []]),
|
|
629
|
+
...clearedSlot,
|
|
630
|
+
...stylistAfterAdd(action),
|
|
631
|
+
error: null
|
|
632
|
+
};
|
|
633
|
+
case "pickResource":
|
|
634
|
+
if (action.resourceId === state.resourceId) return {
|
|
635
|
+
...state,
|
|
636
|
+
stylistAnswered: true,
|
|
637
|
+
step: "when",
|
|
638
|
+
error: null
|
|
639
|
+
};
|
|
640
|
+
return {
|
|
641
|
+
...state,
|
|
642
|
+
...clearedSlot,
|
|
643
|
+
resourceId: action.resourceId,
|
|
644
|
+
stylistAnswered: true,
|
|
645
|
+
step: "when",
|
|
646
|
+
error: null
|
|
647
|
+
};
|
|
648
|
+
case "pickSlot":
|
|
649
|
+
if (state.partyMode === "parallel" && state.items.length > 1) return state;
|
|
650
|
+
return {
|
|
651
|
+
...state,
|
|
652
|
+
startTs: action.startTs,
|
|
653
|
+
resolvedResourceId: action.resourceId,
|
|
654
|
+
partyResourceIds: null,
|
|
655
|
+
step: "details",
|
|
656
|
+
error: null
|
|
657
|
+
};
|
|
658
|
+
case "setPartyMode": {
|
|
659
|
+
if (action.mode === "parallel" && !config.party.allowParallel) return state;
|
|
660
|
+
const preference = action.mode === "parallel" ? {
|
|
661
|
+
resourceId: null,
|
|
662
|
+
stylistAnswered: true
|
|
663
|
+
} : {};
|
|
664
|
+
return {
|
|
665
|
+
...state,
|
|
666
|
+
...clearedSlot,
|
|
667
|
+
...preference,
|
|
668
|
+
partyMode: action.mode,
|
|
669
|
+
error: null
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
case "pickPartySlot":
|
|
673
|
+
if (action.mode === "parallel" && !config.party.allowParallel) return state;
|
|
674
|
+
if (action.resourceIds.length !== state.items.length) return state;
|
|
675
|
+
return {
|
|
676
|
+
...state,
|
|
677
|
+
startTs: action.startTs,
|
|
678
|
+
partyResourceIds: action.resourceIds,
|
|
679
|
+
partyMode: action.mode,
|
|
680
|
+
resolvedResourceId: sharedResourceId(action.resourceIds),
|
|
681
|
+
step: "details",
|
|
682
|
+
error: null
|
|
683
|
+
};
|
|
684
|
+
case "slotTaken": return {
|
|
685
|
+
...state,
|
|
686
|
+
startTs: null,
|
|
687
|
+
resolvedResourceId: null,
|
|
688
|
+
partyResourceIds: null,
|
|
689
|
+
step: "when",
|
|
690
|
+
error: "slotTaken"
|
|
691
|
+
};
|
|
692
|
+
case "submitFailed": return {
|
|
693
|
+
...state,
|
|
694
|
+
error: action.error
|
|
695
|
+
};
|
|
696
|
+
case "goToStep":
|
|
697
|
+
if (!canGoToStep(state, action.step)) return state;
|
|
698
|
+
return {
|
|
699
|
+
...state,
|
|
700
|
+
step: action.step,
|
|
701
|
+
error: null
|
|
702
|
+
};
|
|
703
|
+
case "setContact": return {
|
|
704
|
+
...state,
|
|
705
|
+
contact: {
|
|
706
|
+
...state.contact,
|
|
707
|
+
[action.field]: action.value
|
|
708
|
+
},
|
|
709
|
+
error: null
|
|
710
|
+
};
|
|
711
|
+
case "setItemField": {
|
|
712
|
+
if (!state.items[action.index]) return state;
|
|
713
|
+
const item = withItemField(state.items[action.index], action);
|
|
714
|
+
return {
|
|
715
|
+
...state,
|
|
716
|
+
items: state.items.map((current, index) => index === action.index ? item : current),
|
|
717
|
+
people: state.people.map((person, index) => index === action.index ? withPersonFields(person, item) : person),
|
|
718
|
+
error: null
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
case "setNotes": return {
|
|
722
|
+
...state,
|
|
723
|
+
notes: action.value,
|
|
724
|
+
error: null
|
|
725
|
+
};
|
|
726
|
+
case "setConsent": return {
|
|
727
|
+
...state,
|
|
728
|
+
...action.which === "terms" ? { consentTerms: action.accepted } : { consentMarketing: action.accepted },
|
|
729
|
+
error: null
|
|
730
|
+
};
|
|
731
|
+
case "prefill": return applyPrefill(state, action.prefill, action.catalogue);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** The children a family link asked for (`?antall=` also applies to a
|
|
735
|
+
* «Bestill igjen» `?service=` link, intentionally), up to what the salon allows and only
|
|
736
|
+
* on a kids' service. Through `addService`, so the party limit is the machine's
|
|
737
|
+
* own and a clamped request raises no error. */
|
|
738
|
+
function withLinkedParty(state, service, party) {
|
|
739
|
+
if (!party || !isChildCategory(config, service.category)) return state;
|
|
740
|
+
const wanted = Math.min(party, Math.max(1, service.maxPerBooking));
|
|
741
|
+
let next = state;
|
|
742
|
+
while (next.items.length < wanted) {
|
|
743
|
+
const after = reduce(next, {
|
|
744
|
+
type: "addService",
|
|
745
|
+
service
|
|
746
|
+
});
|
|
747
|
+
if (after.items.length === next.items.length) break;
|
|
748
|
+
next = after;
|
|
749
|
+
}
|
|
750
|
+
return next;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* A «Bestill igjen» link, folded into the state — so a parent re-booking from
|
|
754
|
+
* `/min-side` opens the wizard with the service, the stylist and the child
|
|
755
|
+
* already answered and only the time left to pick.
|
|
756
|
+
*
|
|
757
|
+
* FILLS BLANKS, NEVER OVERWRITES. Each of the three answers is written only
|
|
758
|
+
* where the visitor has not given one: the service into an empty basket, the
|
|
759
|
+
* name onto a line that has none, the stylist while step 2 is still the open
|
|
760
|
+
* question — nothing answered (`stylistAnswered`, because «Alle» tapped and
|
|
761
|
+
* «Alle» never touched are the same `null`), no slot chosen, and the visitor
|
|
762
|
+
* still ON that step. This is not caution for its own sake. The stylist list is fetched
|
|
763
|
+
* FOR a service, so it does not exist at the moment the link is read; the shell
|
|
764
|
+
* therefore applies the same prefill twice — once with no stylists, once when
|
|
765
|
+
* they land — and in between the visitor may have tapped anything. A second
|
|
766
|
+
* pass that only fills blanks finishes what the first began and cannot undo a
|
|
767
|
+
* tap. It also makes the function idempotent, which is what lets the shell not
|
|
768
|
+
* care whether it has already run.
|
|
769
|
+
*
|
|
770
|
+
* NOTHING IS TRUSTED BEYOND THE CATALOGUE. The link may be a year old: the
|
|
771
|
+
* stylist may have left, the service may have been re-created under a new id.
|
|
772
|
+
* An id the catalogue does not hold is ignored — silently, because there is no
|
|
773
|
+
* error a parent could act on, and the wizard they are left with is the
|
|
774
|
+
* ordinary one. A stylist is accepted only if they can do EVERY service in the
|
|
775
|
+
* basket, which is the rule `StylistStep` filters by; accepting them on the
|
|
776
|
+
* link's word alone would leave step 2 with nothing selected and step 3
|
|
777
|
+
* filtered to a diary that cannot take the visit — «Fullt» on every day of the
|
|
778
|
+
* week at a salon with a free chair, the very failure `pickService` exists to
|
|
779
|
+
* prevent.
|
|
780
|
+
*
|
|
781
|
+
* WHERE IT LANDS is the earliest step with a question still open: nothing
|
|
782
|
+
* applied → wherever the visitor was; a service → step 2, because «who» has
|
|
783
|
+
* not been answered even though «Første ledige» would be a valid default; a
|
|
784
|
+
* stylist too → step 3. Those are the steps the machine's own `pickService` and
|
|
785
|
+
* `pickResource` move to, and this function reaches them by dispatching those
|
|
786
|
+
* actions rather than by writing `step` itself — so a prefilled basket obeys
|
|
787
|
+
* every rule a tapped one does, including the slot-clearing ones.
|
|
788
|
+
*
|
|
789
|
+
* Returns the IDENTICAL object when nothing applies, for the same reason
|
|
790
|
+
* `goToStep` does: React then re-renders nothing, and a test can tell «ignored»
|
|
791
|
+
* from «rewritten to look the same».
|
|
792
|
+
*/
|
|
793
|
+
function applyPrefill(state, prefill, catalogue) {
|
|
794
|
+
let next = state;
|
|
795
|
+
if (next.items.length === 0 && prefill.serviceId) {
|
|
796
|
+
const service = catalogue.services.find((entry) => entry.id === prefill.serviceId);
|
|
797
|
+
if (service !== void 0) {
|
|
798
|
+
next = reduce(next, {
|
|
799
|
+
type: "pickService",
|
|
800
|
+
service
|
|
801
|
+
});
|
|
802
|
+
next = withLinkedParty(next, service, prefill.party);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (next.items.length === 0) return state;
|
|
806
|
+
if (prefill.who) {
|
|
807
|
+
const who = prefill.who.trim().slice(0, 200);
|
|
808
|
+
if (who.length > 0 && next.items[0].bookedForName === void 0) next = reduce(next, {
|
|
809
|
+
type: "setItemField",
|
|
810
|
+
index: 0,
|
|
811
|
+
field: "bookedForName",
|
|
812
|
+
value: who
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (prefill.resourceId && next.step === "when" && !next.stylistAnswered && next.resourceId === null && next.startTs === null) {
|
|
816
|
+
const wantedResource = prefill.resourceId;
|
|
817
|
+
const resource = catalogue.resources.find((entry) => resourceMatches(entry, wantedResource));
|
|
818
|
+
const basket = next.items.flatMap((item) => visitServicesOf(item).map(({ id }) => id));
|
|
819
|
+
if (resource !== void 0 && basket.every((id) => resource.serviceIds.includes(id))) next = reduce(next, {
|
|
820
|
+
type: "pickResource",
|
|
821
|
+
resourceId: resource.id
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
return next;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* What ONE line costs, in øre, at the time the visit was booked for.
|
|
828
|
+
*
|
|
829
|
+
* The single definition of «what this child's haircut costs», and the reason
|
|
830
|
+
* `totalPriceOre` below is a sum of it rather than a second copy of the rule:
|
|
831
|
+
* the confirmation card prints a price per child and a total underneath them,
|
|
832
|
+
* and a card whose lines were priced by one rule and whose total was priced by
|
|
833
|
+
* another read «490 kr + 490 kr = 1 078 kr» to the parent about to pay it.
|
|
834
|
+
*
|
|
835
|
+
* The surcharge is per SERVICE — Medal carries `weekend_surcharge_pct` on each
|
|
836
|
+
* one — so it is applied here, per line, and never as one percentage over a
|
|
837
|
+
* basket that may hold two different ones.
|
|
838
|
+
*
|
|
839
|
+
* `clock.isWeekend` and not `getDay()`: a Saturday 00:30 slot is still Saturday
|
|
840
|
+
* to the salon while UTC — and a viewer in London — still calls it Friday, and
|
|
841
|
+
* the surcharge follows the salon's calendar because that is who charges it.
|
|
842
|
+
*
|
|
843
|
+
* A null `startTs` means no slot has been chosen yet, which is the base price
|
|
844
|
+
* rather than a guess at a surcharge.
|
|
845
|
+
*
|
|
846
|
+
* `startTs` is the VISIT's start, not the line's own — the two differ only for
|
|
847
|
+
* a sequential party that crosses midnight into or out of a weekend, which the
|
|
848
|
+
* salon's opening hours do not permit, and pricing every line off the same
|
|
849
|
+
* instant is what makes the lines add up to the total the parent agreed to on
|
|
850
|
+
* step 4.
|
|
851
|
+
*/
|
|
852
|
+
function itemPriceOre(service, startTs) {
|
|
853
|
+
const multiplier = startTs !== null && clock.isWeekend(startTs) ? 1 + service.weekendSurchargePct / 100 : 1;
|
|
854
|
+
return Math.round(service.priceOre * multiplier);
|
|
855
|
+
}
|
|
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
|
+
/**
|
|
865
|
+
* What the visit costs, in øre, at the time it was booked for.
|
|
866
|
+
*
|
|
867
|
+
* Here rather than in either component that shows it, because both of them show
|
|
868
|
+
* it: step 4's «Bekreft time – 539 kr betales i salongen» and the confirmation
|
|
869
|
+
* card's «Totalt» have to be the same number, and the surcharge is exactly the
|
|
870
|
+
* sort of rule that gets applied in one of two places.
|
|
871
|
+
*/
|
|
872
|
+
function totalPriceOre(items, startTs) {
|
|
873
|
+
return items.reduce((sum, item) => sum + visitItemPriceOre(item, startTs), 0);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* What is being booked.
|
|
877
|
+
*
|
|
878
|
+
* One child gets the service by name, which is what they chose and what they
|
|
879
|
+
* will look for. A family gets the report's aggregate — «2 tjenester · 60 min
|
|
880
|
+
* totalt» — because three service names joined by the same separator the bar
|
|
881
|
+
* uses between its own parts is a sentence nobody can parse at the bottom of a
|
|
882
|
+
* phone, and because the minutes are the thing a parent planning the afternoon
|
|
883
|
+
* actually needs.
|
|
884
|
+
*/
|
|
885
|
+
function serviceLabel(state) {
|
|
886
|
+
const { items } = state;
|
|
887
|
+
if (items.length === 0) return null;
|
|
888
|
+
if (!showsPartyMode(state)) return visitServicesOf(items[0]).map((service) => service.name).join(" + ");
|
|
889
|
+
return `${fill(labels["summary.services"], { count: items.length })}${SEPARATOR}${fill(labels["summary.totalMinutes"], { minutes: visitMinutes(items, state.partyMode) })}`;
|
|
890
|
+
}
|
|
891
|
+
/** «Theo, Emma», «2 barn», «Voksen» — who the visit is for, before what. */
|
|
892
|
+
function peopleLabel(people) {
|
|
893
|
+
const guests = people.filter((person) => person.key.startsWith("guest:")).length;
|
|
894
|
+
const parts = people.filter((person) => !person.key.startsWith("guest:")).map((person) => person.name ?? labelText(person.key === "self" ? labels["people.self"] : labels["people.adult"]));
|
|
895
|
+
if (guests > 0) parts.unshift(fill(labels["people.children"], { count: guests }));
|
|
896
|
+
return parts.join(", ");
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* `i dag 15:00`, not `15:00`.
|
|
900
|
+
*
|
|
901
|
+
* The bar is read at the bottom of a screen the visitor has been scrolling for
|
|
902
|
+
* a minute, often after tapping between two days on step 3 — and a bare hour
|
|
903
|
+
* there is the one part of the summary they can misread without noticing. The
|
|
904
|
+
* day comes from `salon-clock.ts` along with the hour, so the bar and the chip
|
|
905
|
+
* the visitor tapped read the same; one of them being on the viewer's clock is
|
|
906
|
+
* exactly how they stop.
|
|
907
|
+
*/
|
|
908
|
+
function timeLabel(startTs, now) {
|
|
909
|
+
return startTs === null ? null : clock.when(startTs, now);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* What the visit costs so far.
|
|
913
|
+
*
|
|
914
|
+
* Never a dash: from the moment there is a service there is a price, and it is
|
|
915
|
+
* `totalPriceOre` — the same function step 4's button and the confirmation card
|
|
916
|
+
* read — so the three cannot quote different numbers. It can *rise* when the
|
|
917
|
+
* visitor picks a Saturday, which is the surcharge arriving rather than the bar
|
|
918
|
+
* changing its mind, and is why the number is recomputed here from `startTs`
|
|
919
|
+
* instead of being frozen when the service was chosen.
|
|
920
|
+
*/
|
|
921
|
+
function priceLabel(state) {
|
|
922
|
+
/* v8 ignore next -- defensive: `summaryLine` asks only once there is a basket */
|
|
923
|
+
if (state.items.length === 0) return null;
|
|
924
|
+
return money.formatMinor(totalPriceOre(state.items, state.startTs));
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The single source for the sticky bar, so the bar can never disagree with the
|
|
928
|
+
* state it is summarising. Chosen parts are joined; parts still to come render
|
|
929
|
+
* as what is left to do — «Første ledige» for a stylist nobody named, «Velg
|
|
930
|
+
* tid» for the hour — which is what makes the bar a promise of what is left
|
|
931
|
+
* rather than a receipt for what is done. A named stylist the catalogue cannot
|
|
932
|
+
* name is still `—`.
|
|
933
|
+
*
|
|
934
|
+
* `now` is a parameter rather than a `Date.now()` inside `clock.when`, because
|
|
935
|
+
* «i dag» is a claim about when the bar is being *read*: a component that wants
|
|
936
|
+
* one instant for a whole render — the confirmation card already does this — has
|
|
937
|
+
* to be able to say so, and a test that asserts «i dag» has to be able to pin it.
|
|
938
|
+
*/
|
|
939
|
+
function summaryLine(state, resolveStylistName, now = Date.now()) {
|
|
940
|
+
/**
|
|
941
|
+
* A family bar is the report's three parts — «2 tjenester · 60 min totalt ·
|
|
942
|
+
* 980 kr» — and drops the stylist and the hour rather than dashing them.
|
|
943
|
+
*
|
|
944
|
+
* Not brevity: a parallel party has two stylists and no single one to name,
|
|
945
|
+
* so that part could only ever be a dash, and a dash that can never be filled
|
|
946
|
+
* in is a promise the bar cannot keep. The when-and-with-whom is on step 3's
|
|
947
|
+
* chip while the visitor is choosing it and on the confirmation card once
|
|
948
|
+
* they have, both of which can say «15:00 (Jonas hos Marcus · Emma hos Sara)»
|
|
949
|
+
* where a one-line sticky bar cannot.
|
|
950
|
+
*/
|
|
951
|
+
if (state.items.length === 0 && state.people.length > 0) return peopleLabel(state.people);
|
|
952
|
+
const party = showsPartyMode(state);
|
|
953
|
+
const service = serviceLabel(state);
|
|
954
|
+
if (party) return [service, priceLabel(state)].filter((part) => part !== null).join(SEPARATOR);
|
|
955
|
+
if (service === null) return "";
|
|
956
|
+
const unnamedPreference = (state.resolvedResourceId ?? state.resourceId) === null;
|
|
957
|
+
return [
|
|
958
|
+
service,
|
|
959
|
+
stylistLabel(state, resolveStylistName) ?? (unnamedPreference ? labelText(labels["summary.firstAvailable"]) : UNCHOSEN),
|
|
960
|
+
timeLabel(state.startTs, now) ?? labelText(labels["summary.pickTime"]),
|
|
961
|
+
priceLabel(state) ?? UNCHOSEN
|
|
962
|
+
].join(SEPARATOR);
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
maxPeople,
|
|
966
|
+
initialState,
|
|
967
|
+
reduce,
|
|
968
|
+
applyPrefill,
|
|
969
|
+
canAdvance,
|
|
970
|
+
canGoToStep,
|
|
971
|
+
itemPriceOre,
|
|
972
|
+
visitItemPriceOre,
|
|
973
|
+
totalPriceOre,
|
|
974
|
+
itemStartTimes,
|
|
975
|
+
visitMinutes,
|
|
976
|
+
visitTailMinutes,
|
|
977
|
+
visitEndTs,
|
|
978
|
+
showsPartyMode,
|
|
979
|
+
itemResourceIds,
|
|
980
|
+
summaryLine,
|
|
981
|
+
guestChild,
|
|
982
|
+
isGuestSeat
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
986
|
+
export { BOOKED_FOR_NAME_MAX_LENGTH, SELF_KEY, canAdvance, canGoToStep, createWizard, guestChild, initialState, isGuestSeat, itemResourceIds, itemStartTimes, showsPartyMode, visitEndTs, visitMinutes, visitTailMinutes };
|
|
987
|
+
|
|
988
|
+
//# sourceMappingURL=machine.mjs.map
|