@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.
Files changed (266) hide show
  1. package/CHANGELOG.md +27 -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 +178 -0
  18. package/dist/core/config.mjs +394 -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 +89 -0
  30. package/dist/core/draft-store.mjs +131 -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 +35 -0
  39. package/dist/core/index.mjs +32 -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 +645 -0
  44. package/dist/core/machine.mjs +988 -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 +79 -0
  56. package/dist/core/party-slots.mjs +229 -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 +245 -0
  99. package/dist/core/visit.d.mts +26 -0
  100. package/dist/core/visit.mjs +40 -0
  101. package/dist/core/visit.mjs.map +1 -0
  102. package/dist/core/wire.d.mts +37 -0
  103. package/dist/next/cache/memory.d.mts +14 -0
  104. package/dist/next/cache/memory.mjs +51 -0
  105. package/dist/next/cache/memory.mjs.map +1 -0
  106. package/dist/next/cache/next-data.d.mts +6 -0
  107. package/dist/next/cache/next-data.mjs +35 -0
  108. package/dist/next/cache/next-data.mjs.map +1 -0
  109. package/dist/next/cache/noop.d.mts +6 -0
  110. package/dist/next/cache/noop.mjs +15 -0
  111. package/dist/next/cache/noop.mjs.map +1 -0
  112. package/dist/next/cache/workers.d.mts +29 -0
  113. package/dist/next/cache/workers.mjs +74 -0
  114. package/dist/next/cache/workers.mjs.map +1 -0
  115. package/dist/next/catalogue.d.mts +38 -0
  116. package/dist/next/catalogue.mjs +215 -0
  117. package/dist/next/catalogue.mjs.map +1 -0
  118. package/dist/next/handler.d.mts +11 -0
  119. package/dist/next/handler.mjs +150 -0
  120. package/dist/next/handler.mjs.map +1 -0
  121. package/dist/next/index.d.mts +17 -0
  122. package/dist/next/index.mjs +14 -0
  123. package/dist/next/loaders.d.mts +79 -0
  124. package/dist/next/loaders.mjs +195 -0
  125. package/dist/next/loaders.mjs.map +1 -0
  126. package/dist/next/manage-dto.d.mts +6 -0
  127. package/dist/next/manage-dto.mjs +28 -0
  128. package/dist/next/manage-dto.mjs.map +1 -0
  129. package/dist/next/medal.d.mts +105 -0
  130. package/dist/next/medal.mjs +155 -0
  131. package/dist/next/medal.mjs.map +1 -0
  132. package/dist/next/options.d.mts +158 -0
  133. package/dist/next/options.mjs +65 -0
  134. package/dist/next/options.mjs.map +1 -0
  135. package/dist/next/portal/guardian.d.mts +42 -0
  136. package/dist/next/portal/guardian.mjs +76 -0
  137. package/dist/next/portal/guardian.mjs.map +1 -0
  138. package/dist/next/portal/medal-portal.d.mts +161 -0
  139. package/dist/next/portal/medal-portal.mjs +825 -0
  140. package/dist/next/portal/medal-portal.mjs.map +1 -0
  141. package/dist/next/portal/session.d.mts +38 -0
  142. package/dist/next/portal/session.mjs +40 -0
  143. package/dist/next/portal/session.mjs.map +1 -0
  144. package/dist/next/portal/vipps-flash.d.mts +14 -0
  145. package/dist/next/portal/vipps-flash.mjs +41 -0
  146. package/dist/next/portal/vipps-flash.mjs.map +1 -0
  147. package/dist/next/portal.d.mts +205 -0
  148. package/dist/next/portal.mjs +469 -0
  149. package/dist/next/portal.mjs.map +1 -0
  150. package/dist/next/request.d.mts +51 -0
  151. package/dist/next/request.mjs +123 -0
  152. package/dist/next/request.mjs.map +1 -0
  153. package/dist/next/routes/catalogue-routes.mjs +344 -0
  154. package/dist/next/routes/catalogue-routes.mjs.map +1 -0
  155. package/dist/next/routes/create.mjs +460 -0
  156. package/dist/next/routes/create.mjs.map +1 -0
  157. package/dist/next/routes/manage.mjs +191 -0
  158. package/dist/next/routes/manage.mjs.map +1 -0
  159. package/dist/next/routes/portal-routes.mjs +312 -0
  160. package/dist/next/routes/portal-routes.mjs.map +1 -0
  161. package/dist/next/routes/shared.mjs +162 -0
  162. package/dist/next/routes/shared.mjs.map +1 -0
  163. package/dist/next/routes/vipps-return.mjs +166 -0
  164. package/dist/next/routes/vipps-return.mjs.map +1 -0
  165. package/dist/next/runtime.d.mts +49 -0
  166. package/dist/next/runtime.mjs +94 -0
  167. package/dist/next/runtime.mjs.map +1 -0
  168. package/dist/next/seed.d.mts +71 -0
  169. package/dist/next/seed.mjs +495 -0
  170. package/dist/next/seed.mjs.map +1 -0
  171. package/dist/next/server.d.mts +26 -0
  172. package/dist/next/server.mjs +63 -0
  173. package/dist/next/server.mjs.map +1 -0
  174. package/dist/react/BookingLink.d.mts +61 -0
  175. package/dist/react/BookingLink.mjs +102 -0
  176. package/dist/react/BookingLink.mjs.map +1 -0
  177. package/dist/react/BookingWizard.d.mts +49 -0
  178. package/dist/react/BookingWizard.mjs +598 -0
  179. package/dist/react/BookingWizard.mjs.map +1 -0
  180. package/dist/react/LoginPageQuery.d.mts +37 -0
  181. package/dist/react/LoginPageQuery.mjs +106 -0
  182. package/dist/react/LoginPageQuery.mjs.map +1 -0
  183. package/dist/react/LoginSheet.d.mts +49 -0
  184. package/dist/react/LoginSheet.mjs +98 -0
  185. package/dist/react/LoginSheet.mjs.map +1 -0
  186. package/dist/react/ManageBooking.d.mts +50 -0
  187. package/dist/react/ManageBooking.mjs +197 -0
  188. package/dist/react/ManageBooking.mjs.map +1 -0
  189. package/dist/react/PortalDashboard.d.mts +10 -0
  190. package/dist/react/Provider.d.mts +82 -0
  191. package/dist/react/Provider.mjs +132 -0
  192. package/dist/react/Provider.mjs.map +1 -0
  193. package/dist/react/actions.d.mts +133 -0
  194. package/dist/react/actions.mjs +90 -0
  195. package/dist/react/actions.mjs.map +1 -0
  196. package/dist/react/index.d.mts +28 -0
  197. package/dist/react/index.mjs +20 -0
  198. package/dist/react/kit.d.mts +54 -0
  199. package/dist/react/kit.mjs +127 -0
  200. package/dist/react/kit.mjs.map +1 -0
  201. package/dist/react/labels/index.d.mts +23 -0
  202. package/dist/react/labels/index.mjs +44 -0
  203. package/dist/react/labels/index.mjs.map +1 -0
  204. package/dist/react/labels/login.d.mts +10 -0
  205. package/dist/react/labels/login.mjs +13 -0
  206. package/dist/react/labels/login.mjs.map +1 -0
  207. package/dist/react/labels/manage.d.mts +15 -0
  208. package/dist/react/labels/manage.mjs +19 -0
  209. package/dist/react/labels/manage.mjs.map +1 -0
  210. package/dist/react/labels/portal.d.mts +25 -0
  211. package/dist/react/labels/portal.mjs +41 -0
  212. package/dist/react/labels/portal.mjs.map +1 -0
  213. package/dist/react/labels/screens.d.mts +11 -0
  214. package/dist/react/labels/screens.mjs +721 -0
  215. package/dist/react/labels/screens.mjs.map +1 -0
  216. package/dist/react/labels/wizard.d.mts +76 -0
  217. package/dist/react/labels/wizard.mjs +97 -0
  218. package/dist/react/labels/wizard.mjs.map +1 -0
  219. package/dist/react/link/index.d.mts +5 -0
  220. package/dist/react/link/index.mjs +5 -0
  221. package/dist/react/portal/SessionTouch.d.mts +19 -0
  222. package/dist/react/portal/SessionTouch.mjs +40 -0
  223. package/dist/react/portal/SessionTouch.mjs.map +1 -0
  224. package/dist/react/portal/age-prompt.d.mts +10 -0
  225. package/dist/react/portal/age-prompt.mjs +21 -0
  226. package/dist/react/portal/age-prompt.mjs.map +1 -0
  227. package/dist/react/portal/book-again.d.mts +18 -0
  228. package/dist/react/portal/book-again.mjs +21 -0
  229. package/dist/react/portal/book-again.mjs.map +1 -0
  230. package/dist/react/portal/child-summary.d.mts +17 -0
  231. package/dist/react/portal/child-summary.mjs +45 -0
  232. package/dist/react/portal/child-summary.mjs.map +1 -0
  233. package/dist/react/portal/dashboard.d.mts +74 -0
  234. package/dist/react/portal/dashboard.mjs +204 -0
  235. package/dist/react/portal/dashboard.mjs.map +1 -0
  236. package/dist/react/portal/leave.d.mts +17 -0
  237. package/dist/react/portal/leave.mjs +21 -0
  238. package/dist/react/portal/leave.mjs.map +1 -0
  239. package/dist/react/portal/sections.d.mts +12 -0
  240. package/dist/react/portal/sections.mjs +48 -0
  241. package/dist/react/portal/sections.mjs.map +1 -0
  242. package/dist/react/portal/tabs.d.mts +23 -0
  243. package/dist/react/portal/tabs.mjs +36 -0
  244. package/dist/react/portal/tabs.mjs.map +1 -0
  245. package/dist/react/portal/vipps-flash.d.mts +11 -0
  246. package/dist/react/portal/vipps-flash.mjs +18 -0
  247. package/dist/react/portal/vipps-flash.mjs.map +1 -0
  248. package/dist/react/portal/wired.mjs +202 -0
  249. package/dist/react/portal/wired.mjs.map +1 -0
  250. package/dist/react/screen-labels.mjs +14 -0
  251. package/dist/react/screen-labels.mjs.map +1 -0
  252. package/dist/react/shared.d.mts +13 -0
  253. package/dist/react/shared.mjs +7 -0
  254. package/dist/react/use-next-free.d.mts +19 -0
  255. package/dist/react/use-next-free.mjs +56 -0
  256. package/dist/react/use-next-free.mjs.map +1 -0
  257. package/dist/react/useBooking.d.mts +179 -0
  258. package/dist/react/useBooking.mjs +1331 -0
  259. package/dist/react/useBooking.mjs.map +1 -0
  260. package/dist/react/wizard/adapters.mjs +189 -0
  261. package/dist/react/wizard/adapters.mjs.map +1 -0
  262. package/dist/react/wizard/index.d.mts +11 -0
  263. package/dist/react/wizard/index.mjs +6 -0
  264. package/dist/react/wizard-error.mjs +29 -0
  265. package/dist/react/wizard-error.mjs.map +1 -0
  266. 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