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