@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,1331 @@
1
+ 'use client';
2
+ import { fill, labelText } from "../core/labels.mjs";
3
+ import "../core/attempt-store.mjs";
4
+ import { stylistDisplayName } from "../core/display-name.mjs";
5
+ import { serviceMatches } from "../core/deep-link.mjs";
6
+ import "../core/draft-store.mjs";
7
+ import { visitKey, visitServicesOf } from "../core/visit.mjs";
8
+ import { firstOpeningPerResource } from "../core/next-available.mjs";
9
+ import { slotKeysFor } from "../core/party-slots.mjs";
10
+ import { stripVippsReturn, vippsConfirmFrom } from "../core/portal/vipps-return.mjs";
11
+ import { useBookingKit } from "./Provider.mjs";
12
+ import { useSearchParams } from "next/navigation";
13
+ import { useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
14
+ //#region src/react/useBooking.ts
15
+ /**
16
+ * `useBooking()` — the booking wizard without its markup.
17
+ *
18
+ * Everything `<BookingWizard>` knows lives here, and the wizard is built on
19
+ * it, so the headless path is never second-class: the machine (through
20
+ * `useReducer`), the fetches to the site's own routes under `config.paths.api`
21
+ * (availability, stylists, hours, the submission — the Medal key never leaves
22
+ * the server), the draft / attempt / rebook stores, the restore of a booking
23
+ * a reload or a Vipps login interrupted, the deep-link and «book again»
24
+ * prefill, the 409 slot-taken refresh and the idempotency nonce.
25
+ *
26
+ * What it does NOT own is the page: focus, scrolling, which screen is drawn.
27
+ * Those are the caller's (see `BookingWizard.tsx`).
28
+ *
29
+ * Moved from the source app's wizard shell with its behaviour unchanged; the
30
+ * long-form reasoning for each rule is kept beside the rule.
31
+ */
32
+ /** How long a replayed submission may take before the restore gate gives up. */
33
+ const REPLAY_TIMEOUT_MS = 15e3;
34
+ /** «hydrated» only ever flips once; `useSyncExternalStore` wants a subscription. */
35
+ function noSubscription() {
36
+ return () => {};
37
+ }
38
+ /** The `freshSlots` of a `slotTaken` answer, narrowed to the lost visits' lists. */
39
+ function freshSlotsOf(raw, lost) {
40
+ if (typeof raw !== "object" || raw === null) return {};
41
+ return Object.fromEntries(Object.entries(raw).filter((entry) => lost.has(entry[0]) && Array.isArray(entry[1])));
42
+ }
43
+ /**
44
+ * The window query for ONE visit, named by its key (`a` or `a+b`): the first
45
+ * service as `service_id`, the rest as `extra_service_ids`. The extras are
46
+ * added only when there are some, so a one-service visit asks with exactly the
47
+ * URL it always did — the slot cache and every seeded answer are keyed by it.
48
+ */
49
+ function windowQuery(key, fromTs, toTs) {
50
+ const [serviceId, ...extras] = key.split("+");
51
+ return new URLSearchParams({
52
+ service_id: serviceId,
53
+ ...extras.length === 0 ? {} : { extra_service_ids: extras.join(",") },
54
+ from_ts: String(fromTs),
55
+ to_ts: String(toTs)
56
+ });
57
+ }
58
+ /**
59
+ * The submission with each line's extras on it. meda's details screen builds
60
+ * the body from its own types, which have one service per line, so the shell
61
+ * is the only place that knows the rest of a person's visit. Index-aligned with
62
+ * the basket the screen was drawn from; a body of another length is not one
63
+ * this basket produced, and is sent as it came rather than with extras pinned
64
+ * to the wrong people.
65
+ */
66
+ function withExtraServices(submission, items) {
67
+ if (!items.some((item) => (item.extraServices ?? []).length > 0)) return submission;
68
+ if (submission.items.length !== items.length) {
69
+ console.warn("[@medalsocial/booking] The submission has %d lines for a basket of %d; not sent.", submission.items.length, items.length);
70
+ return null;
71
+ }
72
+ return {
73
+ ...submission,
74
+ items: submission.items.map((line, index) => {
75
+ const extras = items[index].extraServices ?? [];
76
+ return extras.length === 0 ? line : {
77
+ ...line,
78
+ extraServiceIds: extras.map((service) => service.id)
79
+ };
80
+ })
81
+ };
82
+ }
83
+ /** What the create route said, narrowed to what the machine can hold. */
84
+ function asSubmitFailure(code) {
85
+ switch (code) {
86
+ case "conflict":
87
+ case "invalidInput":
88
+ case "unconfigured":
89
+ case "inProgress": return code;
90
+ default: return "upstreamError";
91
+ }
92
+ }
93
+ /** What the named stylist can do; `undefined` for «nobody named» or «not loaded». */
94
+ function serviceIdsOf(resources, resourceId) {
95
+ if (resourceId === null) return void 0;
96
+ return resources.find((resource) => resource.id === resourceId)?.serviceIds;
97
+ }
98
+ /**
99
+ * Whether a create answer is a whole one: exactly one booking, with its own
100
+ * distinct id, per submitted line. Anything else — none, fewer, more, an id
101
+ * missing or repeated — is not a
102
+ * confirmation; the wizard shows it as the generic create failure
103
+ * (`upstreamError`, «we still do not know»), whose attempt survives for the
104
+ * replay under the same idempotency key.
105
+ */
106
+ function isCompleteConfirmation(bookings, itemCount) {
107
+ if (!Array.isArray(bookings) || itemCount <= 0 || bookings.length !== itemCount) return false;
108
+ const ids = bookings.map((booking) => booking?.id);
109
+ return ids.every((id) => typeof id === "string" && id.length > 0) && new Set(ids).size === ids.length;
110
+ }
111
+ /**
112
+ * The basket and the create response, zipped by INDEX — each line its OWN
113
+ * booking. Only a whole answer (`isCompleteConfirmation`) gets this far; a line
114
+ * the answer has no booking for is left out rather than handed a sibling's id,
115
+ * which would give two calendar entries one UID.
116
+ */
117
+ function confirmationLines(confirmation) {
118
+ const { submitted, bookings } = confirmation;
119
+ return submitted.items.flatMap((item, index) => {
120
+ const booking = bookings[index];
121
+ if (booking === void 0) return [];
122
+ return [{
123
+ item,
124
+ bookingId: booking.id,
125
+ stylistName: submitted.stylistNames[index] ?? null,
126
+ manageHref: booking.manageHref
127
+ }];
128
+ });
129
+ }
130
+ /** Only the stylist the visitor asked for. */
131
+ function onlyResource(slots, resourceId) {
132
+ return slots.filter((slot) => slot.resourceId === resourceId);
133
+ }
134
+ /** The catalogue a prefill is checked against: what step 2 lets the visitor TAP. */
135
+ function prefillCatalogue(services, resources) {
136
+ return {
137
+ services: services.filter((service) => service.bookableOnline),
138
+ resources
139
+ };
140
+ }
141
+ /** A stylist without a service is nothing to finish. */
142
+ function stylistLeftToFill(prefill) {
143
+ return prefill?.serviceId && prefill.resourceId ? prefill : null;
144
+ }
145
+ /** Whether the stylist list can now vouch for the pending link. */
146
+ function canFinishStylist(pending, resources, primaryVisit) {
147
+ return pending !== null && resources.length > 0 && primaryVisit === pending.serviceId;
148
+ }
149
+ /**
150
+ * A saved child as the person the machine seats: by `personId` when Medal has
151
+ * one, else by their PLACE in the profile with name and year beside it, so two
152
+ * «Emma 2018» are two seats and a reordered list matches nobody.
153
+ */
154
+ function personForChild(child, index) {
155
+ return {
156
+ key: child.personId ? `p:${child.personId}` : `n:${index}:${child.name}:${child.birthYear}`,
157
+ name: child.name,
158
+ birthYear: child.birthYear,
159
+ ...child.personId ? { personId: child.personId } : {},
160
+ ...child.birthMonth ? { birthMonth: child.birthMonth } : {}
161
+ };
162
+ }
163
+ /** The pure parts of the wizard's setup, bound to one kit. */
164
+ function setupFor(kit) {
165
+ const { config, wizard, deepLinks } = kit;
166
+ const q = config.query;
167
+ /** «Bestill igjen» (`?service=&stylist=`) or a marketing `?tjeneste=` link, or `null`. */
168
+ function prefillFromQuery(query, services) {
169
+ if (query === null) return null;
170
+ const linked = query.get(q.service);
171
+ const linkedService = linked ? services.find((service) => service.bookableOnline && serviceMatches(service, linked)) : void 0;
172
+ const serviceId = query.get(q.rebookService) || linkedService?.id || null;
173
+ const resourceId = query.get(q.rebookStylist) || (serviceId ? query.get(q.stylist) : null);
174
+ if (!serviceId && !resourceId) return null;
175
+ return {
176
+ serviceId,
177
+ resourceId,
178
+ party: serviceId ? deepLinks.parseParty(query.get(q.party)) : null
179
+ };
180
+ }
181
+ function deepLinkFromQuery(query) {
182
+ return {
183
+ category: deepLinks.parseCategory(query?.get(q.category)),
184
+ stylist: query?.get(q.stylist)?.trim() || null,
185
+ party: deepLinks.parseParty(query?.get(q.party)),
186
+ seat: deepLinks.impliedParty(query)
187
+ };
188
+ }
189
+ /** One grown-up seated, as the adult chip does. */
190
+ function withAdult() {
191
+ return wizard.reduce(wizard.initialState(), {
192
+ type: "choosePeople",
193
+ people: [{
194
+ key: "adult",
195
+ adult: true
196
+ }],
197
+ advance: true
198
+ });
199
+ }
200
+ /** `n` guest children seated, clamped to the chips there are. */
201
+ function withGuests(party) {
202
+ const count = Math.min(Math.max(1, party), wizard.maxPeople);
203
+ const people = Array.from({ length: count }, (_, index) => wizard.guestChild(index + 1));
204
+ return wizard.reduce(wizard.initialState(), {
205
+ type: "choosePeople",
206
+ people,
207
+ advance: true
208
+ });
209
+ }
210
+ /** `state` with the link's service held for step 1, when the catalogue has it. */
211
+ function heldLink(state, prefill, services, resources) {
212
+ const service = prefill?.serviceId ? prefillCatalogue(services, resources).services.find((entry) => entry.id === prefill.serviceId) : void 0;
213
+ return service === void 0 ? state : wizard.reduce(state, {
214
+ type: "holdService",
215
+ service
216
+ });
217
+ }
218
+ /**
219
+ * Where the wizard starts: empty, or with the link folded in — in the
220
+ * reducer's initialiser, so the server render already shows that step.
221
+ */
222
+ function initialWizardState(prefill, services, resources, seat = null, familyKnown = false) {
223
+ if (familyKnown) return heldLink(wizard.initialState(), prefill, services, resources);
224
+ const unasked = config.party.askWhoFirst ? null : kit.childCategory !== null ? { children: 1 } : { adult: true };
225
+ const seated = seat ?? unasked;
226
+ const base = seated !== null && "adult" in seated ? withAdult() : wizard.initialState();
227
+ if (prefill === null) {
228
+ if (seated === null) return wizard.initialState();
229
+ return "adult" in seated ? withAdult() : withGuests(seated.children);
230
+ }
231
+ return wizard.applyPrefill(base, prefill, prefillCatalogue(services, resources));
232
+ }
233
+ return {
234
+ prefillFromQuery,
235
+ deepLinkFromQuery,
236
+ heldLink,
237
+ initialWizardState
238
+ };
239
+ }
240
+ function useBooking(options) {
241
+ const { kit } = useBookingKit(options, options.contact);
242
+ const { config, labels, wizard, clock, age, phone, deepLinks } = kit;
243
+ const services = options.seed.services;
244
+ const arrivedAs = options.guardian ?? null;
245
+ const rangeDays = options.rangeDays ?? config.window.rangeDays;
246
+ const setup = useMemo(() => setupFor(kit), [kit]);
247
+ /**
248
+ * Who logged in from the login sheet — and whether anybody did, which is
249
+ * not the same question: a good code whose profile read failed is still a
250
+ * logged-in parent, who must not be offered the login again.
251
+ */
252
+ const [signedIn, setSignedIn] = useState(null);
253
+ const guardian = signedIn?.guardian ?? arrivedAs;
254
+ const query = useSearchParams();
255
+ const [prefill] = useState(() => setup.prefillFromQuery(query, services));
256
+ const [deepLink] = useState(() => setup.deepLinkFromQuery(query));
257
+ const deepLinkPending = useRef(prefill === null || prefill.serviceId == null);
258
+ const [linked] = useState(() => prefill !== null || deepLinks.hasDeepLink(query));
259
+ const [vippsResumePath] = useState(() => deepLinks.resumePath(query));
260
+ const [resuming] = useState(() => query?.get(config.query.resume) === "1");
261
+ const [vippsConfirm] = useState(() => vippsConfirmFrom(query) ?? null);
262
+ useEffect(() => {
263
+ if (vippsConfirm !== null) stripVippsReturn();
264
+ }, [vippsConfirm]);
265
+ /** `false` on the server and through hydration, `true` from the first render after it. */
266
+ const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
267
+ const [restoreExpected] = useState(() => typeof window !== "undefined" && kit.restoreGate.restorePendingInStorage(kit.attempts.ATTEMPT_STORAGE_KEY, 36e5, kit.drafts.DRAFT_STORAGE_KEY, 18e5, linked, resuming));
268
+ const [restoreSettled, setRestoreSettled] = useState(false);
269
+ const restoring = restoreExpected && !restoreSettled;
270
+ const [state, dispatch] = useReducer(wizard.reduce, prefill, (initial) => setup.initialWizardState(initial, services, options.seed.resources ?? [], deepLink.seat, arrivedAs !== null));
271
+ /** Children a logged-in parent added from step 1 during this visit. */
272
+ const [addedChildren, setAddedChildren] = useState([]);
273
+ const family = useMemo(() => guardian === null ? null : [...guardian.family, ...addedChildren], [guardian, addedChildren]);
274
+ /** Guest seats go when a family appears over them (`seatFamily`). */
275
+ const guestSeated = family !== null && state.people.some(wizard.isGuestSeat);
276
+ useEffect(() => {
277
+ if (guestSeated) dispatch({ type: "seatFamily" });
278
+ }, [guestSeated, state.step]);
279
+ /** A stylist a party link named, applied once the family reaches step 3. */
280
+ const partyStylist = useRef(arrivedAs !== null ? prefill?.resourceId ?? deepLink.stylist : null);
281
+ const childByKey = useMemo(() => new Map((family ?? []).map((child, index) => [personForChild(child, index).key, child])), [family]);
282
+ const [fromTs] = useState(() => options.seed.fromTs ?? Date.now());
283
+ const { toTs, days } = useMemo(() => clock.window(fromTs, rangeDays), [
284
+ clock,
285
+ fromTs,
286
+ rangeDays
287
+ ]);
288
+ /** The business date the ages on steps 1 and 2 are read on: the chosen day, else today. */
289
+ const ageDayKey = clock.dayKey(state.startTs ?? fromTs);
290
+ /** A child's age on the business day `dayKey`; `null` for a grown-up or an unknown year. */
291
+ function personAge(person, dayKey) {
292
+ if (person.adult || person.birthYear === void 0) return null;
293
+ return age.ageOnDay(person.birthYear, person.birthMonth, dayKey);
294
+ }
295
+ /** «Samme som sist»: the child's last visit, where it is still taken online. */
296
+ const suggestionFor = (person) => {
297
+ const serviceId = childByKey.get(person.key)?.lastVisit?.serviceId;
298
+ if (!serviceId) return null;
299
+ const bookable = services.filter((entry) => entry.bookableOnline);
300
+ const last = bookable.find((entry) => entry.id === serviceId);
301
+ if (!last) return null;
302
+ const ageNow = personAge(person, ageDayKey);
303
+ if (age.fitsAge(last, ageNow)) return { service: last };
304
+ const grownUp = age.grownUpEquivalent(last, bookable, ageNow);
305
+ if (!grownUp) return null;
306
+ return {
307
+ service: grownUp,
308
+ note: fill(labels["wizard.suggestion.outgrown"], {
309
+ last: last.name,
310
+ name: person.name ?? labelText(labels["wizard.suggestion.someone"]),
311
+ next: grownUp.name
312
+ })
313
+ };
314
+ };
315
+ const pendingStylist = useRef(stylistLeftToFill(prefill));
316
+ /**
317
+ * Unfiltered openings, keyed by VISIT (`visitKey`): a service id for a
318
+ * one-service visit — which is all the server seed ever holds — and `a+b`
319
+ * for a person having both, whose list is the server's answer for the whole
320
+ * visit rather than either service's own.
321
+ */
322
+ const [slots, setSlots] = useState(() => options.seed.slots ?? {});
323
+ const [slotsFailed, setSlotsFailed] = useState(false);
324
+ const [resources, setResources] = useState(() => options.seed.resources ?? []);
325
+ /** Whether the stylist list has been answered at all — seeded, fetched or failed. */
326
+ const [resourcesKnown, setResourcesKnown] = useState(() => options.seed.resources != null);
327
+ const [seededNextAvailable, setSeededNextAvailable] = useState(() => options.seed.resources ? options.seed.nextAvailable ?? {} : {});
328
+ const [seededSchedule] = useState(() => options.seed.schedules ?? {});
329
+ /** The last «next available» answer, and for WHICH visit (its key). */
330
+ const [fetchedNextAvailable, setFetchedNextAvailable] = useState(null);
331
+ const [submitting, setSubmitting] = useState(false);
332
+ /**
333
+ * The attempt (and its nonce), null until the browser has it: storage does
334
+ * not exist during the server render, and reading it in an initialiser
335
+ * would give the two sides different trees.
336
+ */
337
+ const [attempt, setAttempt] = useState(null);
338
+ /** The submission whose answer is still unknown; a retry resends THAT body. */
339
+ const [pendingAttempt, setPendingAttempt] = useState(null);
340
+ const submissionNonce = attempt?.nonce;
341
+ /** The instant that was taken while the visitor was filling in the form. */
342
+ const [takenSlotTs, setTakenSlotTs] = useState(null);
343
+ /** Set once, by a successful submission — one entry per line item. */
344
+ const [confirmed, setConfirmed] = useState(null);
345
+ const resumed = useRef(false);
346
+ /** The whole «book again» link, kept to apply AGAIN after a refused replay. */
347
+ const rebookLink = useRef(prefill);
348
+ const latestResources = useRef(resources);
349
+ useEffect(() => {
350
+ latestResources.current = resources;
351
+ }, [resources]);
352
+ const applyRebookWho = () => {
353
+ const who = kit.rebook.takeRebookWho();
354
+ if (who === null) return;
355
+ rebookLink.current = {
356
+ ...rebookLink.current ?? {},
357
+ who
358
+ };
359
+ const known = guardian?.family ?? [];
360
+ const named = known.filter((child) => child.name === who);
361
+ if (named.length === 1) {
362
+ const service = state.items[0]?.service ?? state.pendingService;
363
+ dispatch({
364
+ type: "choosePeople",
365
+ people: [personForChild(named[0], known.indexOf(named[0]))],
366
+ services: [service]
367
+ });
368
+ if (service !== null) dispatch({
369
+ type: "pickServiceFor",
370
+ index: 0,
371
+ service
372
+ });
373
+ return;
374
+ }
375
+ dispatch({
376
+ type: "prefill",
377
+ prefill: { who },
378
+ catalogue: prefillCatalogue(services, [])
379
+ });
380
+ };
381
+ /** Put the «book again» link back onto a wizard that has just started over, once. */
382
+ const restartFromLink = () => {
383
+ const link = rebookLink.current;
384
+ if (link === null) return;
385
+ rebookLink.current = null;
386
+ const current = latestResources.current;
387
+ if (guardian !== null) {
388
+ const held = setup.heldLink(wizard.initialState(), link, services, current);
389
+ dispatch({
390
+ type: "holdService",
391
+ service: held.pendingService
392
+ });
393
+ partyStylist.current = link.resourceId ?? null;
394
+ return;
395
+ }
396
+ dispatch({
397
+ type: "prefill",
398
+ prefill: link,
399
+ catalogue: prefillCatalogue(services, current)
400
+ });
401
+ if (current.length === 0) pendingStylist.current = stylistLeftToFill(link);
402
+ };
403
+ /** The stylists a restored slot was booked with, until the catalogue can vouch for them. */
404
+ const restoredStylists = useRef(null);
405
+ /**
406
+ * A logged-in parent's saved children back in their chairs: each draft line
407
+ * whose stored `personId` is a child of THIS guardian's family is reseated as
408
+ * that child (`choosePeople`, then its service again), so it is submitted
409
+ * with `booked_for_person_id` as it would have been without the round trip.
410
+ * An id the family does not have — another parent's, a child removed since,
411
+ * a hand-edited jar — is ignored, and its line restores as a name and a year
412
+ * exactly as a draft without ids does. Returns the reseated line indexes.
413
+ */
414
+ const reseatFamily = (draft, lines) => {
415
+ const reseated = /* @__PURE__ */ new Set();
416
+ if (family === null) return reseated;
417
+ const keys = /* @__PURE__ */ new Set();
418
+ const people = draft.items.map((item, index) => {
419
+ const built = wizard.guestChild(index + 1);
420
+ const at = item.personId ? family.findIndex((child) => child.personId === item.personId) : -1;
421
+ const child = at === -1 ? null : personForChild(family[at], at);
422
+ if (child === null || keys.has(child.key)) {
423
+ keys.add(built.key);
424
+ return built;
425
+ }
426
+ keys.add(child.key);
427
+ reseated.add(index);
428
+ return child;
429
+ });
430
+ if (reseated.size === 0) return reseated;
431
+ dispatch({
432
+ type: "choosePeople",
433
+ people
434
+ });
435
+ for (const index of reseated) dispatch({
436
+ type: "pickServiceFor",
437
+ index,
438
+ service: lines[index]
439
+ });
440
+ return reseated;
441
+ };
442
+ /**
443
+ * Each draft line's extras back on it, through `toggleServiceFor` like a tap.
444
+ * Returns whether EVERY extra came back. The machine's refusals are checked
445
+ * here first rather than read off its state, which a dispatch does not hand
446
+ * back: an id the catalogue no longer books, one already on the line (a
447
+ * toggle would take it OFF again), one past `maxServicesPerPerson`, or one
448
+ * more taker than the service's `maxPerBooking` — each is skipped, as an
449
+ * unknown main service is, and makes the answer `false`.
450
+ */
451
+ const restoreExtras = (draft, lines, bookable) => {
452
+ let whole = true;
453
+ const lists = lines.map((service) => [service]);
454
+ draft.items.forEach((item, index) => {
455
+ for (const id of item.extraServiceIds ?? []) {
456
+ const service = bookable.get(id);
457
+ const list = lists[index];
458
+ const takers = lists.filter((other) => other.some((entry) => entry.id === id)).length;
459
+ if (service === void 0 || list.some((entry) => entry.id === id) || list.length >= config.party.maxServicesPerPerson || takers + 1 > service.maxPerBooking) {
460
+ whole = false;
461
+ continue;
462
+ }
463
+ list.push(service);
464
+ dispatch({
465
+ type: "toggleServiceFor",
466
+ index,
467
+ service
468
+ });
469
+ }
470
+ });
471
+ return whole;
472
+ };
473
+ /**
474
+ * The booking a Vipps login interrupted, rebuilt through the machine's own
475
+ * actions — so it obeys every rule a tapped one does — and trusting nothing
476
+ * beyond the catalogue.
477
+ */
478
+ const restoreDraft = (draft) => {
479
+ const bookable = new Map(services.filter((service) => service.bookableOnline).map((service) => [service.id, service]));
480
+ const resolved = draft.items.map((item) => bookable.get(item.serviceId));
481
+ const [first, ...rest] = resolved;
482
+ if (first === void 0 || resolved.some((service) => service === void 0)) return;
483
+ if (family === null && draft.items.every((item) => typeof item.adult === "boolean")) dispatch({
484
+ type: "choosePeople",
485
+ people: draft.items.map((item, index) => item.adult ? {
486
+ key: "adult",
487
+ adult: true
488
+ } : wizard.guestChild(index + 1)),
489
+ advance: true
490
+ });
491
+ dispatch({
492
+ type: "pickService",
493
+ service: first
494
+ });
495
+ for (const service of rest) dispatch({
496
+ type: "addService",
497
+ service
498
+ });
499
+ const reseated = reseatFamily(draft, resolved);
500
+ const wholeVisits = restoreExtras(draft, resolved, bookable);
501
+ if (draft.partyMode === "parallel") dispatch({
502
+ type: "setPartyMode",
503
+ mode: "parallel"
504
+ });
505
+ dispatch({
506
+ type: "pickResource",
507
+ resourceId: draft.resourceId
508
+ });
509
+ draft.items.forEach((item, index) => {
510
+ if (reseated.has(index)) return;
511
+ if (item.bookedForName !== null) dispatch({
512
+ type: "setItemField",
513
+ index,
514
+ field: "bookedForName",
515
+ value: item.bookedForName
516
+ });
517
+ if (item.bookedForBirthYear !== null) dispatch({
518
+ type: "setItemField",
519
+ index,
520
+ field: "bookedForBirthYear",
521
+ value: item.bookedForBirthYear
522
+ });
523
+ });
524
+ if (draft.startTs === null || !wholeVisits) return;
525
+ if (draft.partyResourceIds !== null && draft.partyResourceIds.length === resolved.length) {
526
+ restoredStylists.current = draft.partyResourceIds;
527
+ dispatch({
528
+ type: "pickPartySlot",
529
+ startTs: draft.startTs,
530
+ resourceIds: draft.partyResourceIds,
531
+ mode: draft.partyMode
532
+ });
533
+ return;
534
+ }
535
+ restoredStylists.current = draft.resolvedResourceId === null ? null : [draft.resolvedResourceId];
536
+ dispatch({
537
+ type: "pickSlot",
538
+ startTs: draft.startTs,
539
+ resourceId: draft.resolvedResourceId
540
+ });
541
+ };
542
+ useEffect(() => {
543
+ if (resumed.current) return;
544
+ resumed.current = true;
545
+ const replay = restoreFromStorage();
546
+ if (replay === null) setRestoreSettled(true);
547
+ else replay.finally(() => setRestoreSettled(true));
548
+ }, []);
549
+ /** What the mount effect restores, in order; the replay when it started one. */
550
+ function restoreFromStorage() {
551
+ let stored = kit.attempts.readAttempt();
552
+ if (linked && stored.confirmed !== void 0 && stored.pending === void 0) {
553
+ kit.attempts.clearAttempt();
554
+ stored = kit.attempts.readAttempt();
555
+ }
556
+ if (stored.confirmed !== void 0 && !isCompleteConfirmation(stored.confirmed.bookings, stored.confirmed.submitted.items.length)) {
557
+ kit.attempts.clearAttempt();
558
+ stored = kit.attempts.readAttempt();
559
+ }
560
+ setAttempt(stored);
561
+ applyRebookWho();
562
+ if (stored.confirmed !== void 0) {
563
+ setConfirmed(stored.confirmed);
564
+ return null;
565
+ }
566
+ if (stored.pending === void 0) {
567
+ if (resuming) {
568
+ const draft = kit.drafts.takeDraft();
569
+ if (draft !== null) restoreDraft(draft);
570
+ }
571
+ return null;
572
+ }
573
+ if (rebookLink.current !== null) {
574
+ pendingStylist.current = null;
575
+ dispatch({ type: "startOver" });
576
+ }
577
+ setPendingAttempt(stored.pending);
578
+ return send(stored.pending.submission, stored.pending.submitted, { replay: true });
579
+ }
580
+ /** The draft, kept current on every answer rather than written on the Vipps tap. */
581
+ useEffect(() => {
582
+ if (confirmed !== null || state.items.length === 0) {
583
+ kit.drafts.clearDraft();
584
+ return;
585
+ }
586
+ kit.drafts.stashDraft({
587
+ items: state.items.map((item) => ({
588
+ serviceId: item.service.id,
589
+ ...item.extraServices?.length ? { extraServiceIds: item.extraServices.map((service) => service.id) } : {},
590
+ bookedForName: item.bookedForName ?? null,
591
+ bookedForBirthYear: item.bookedForBirthYear ?? null,
592
+ adult: item.adult === true,
593
+ personId: item.bookedForPersonId ?? null
594
+ })),
595
+ resourceId: state.resourceId,
596
+ partyMode: state.partyMode,
597
+ startTs: state.startTs,
598
+ resolvedResourceId: state.resolvedResourceId,
599
+ partyResourceIds: state.partyResourceIds
600
+ });
601
+ }, [
602
+ kit,
603
+ confirmed,
604
+ state.items,
605
+ state.resourceId,
606
+ state.partyMode,
607
+ state.startTs,
608
+ state.resolvedResourceId,
609
+ state.partyResourceIds
610
+ ]);
611
+ /** A live mirror of the contact fields, for the prefill below. */
612
+ const latestContact = useRef(state.contact);
613
+ useEffect(() => {
614
+ latestContact.current = state.contact;
615
+ });
616
+ /**
617
+ * The logged-in parent's details into the fields — once per parent, into
618
+ * fields still BLANK or still holding exactly what the PREVIOUS parent's
619
+ * profile put there (a shared device), never over what the visitor typed.
620
+ */
621
+ const guardianFilled = useRef(null);
622
+ useEffect(() => {
623
+ if (guardian === null || guardianFilled.current?.email === guardian.email) return;
624
+ const previous = guardianFilled.current?.wrote;
625
+ const contact = latestContact.current;
626
+ const wrote = {
627
+ name: [guardian.firstName, guardian.lastName].filter(Boolean).join(" ").trim(),
628
+ phone: guardian.phone === null ? "" : phone.nationalDigits(guardian.phone),
629
+ email: guardian.email
630
+ };
631
+ guardianFilled.current = {
632
+ email: guardian.email,
633
+ wrote
634
+ };
635
+ for (const field of [
636
+ "name",
637
+ "phone",
638
+ "email"
639
+ ]) {
640
+ const value = wrote[field];
641
+ if (value === "") continue;
642
+ const held = contact[field].trim();
643
+ if (held !== "" && held !== previous?.[field]) continue;
644
+ dispatch({
645
+ type: "setContact",
646
+ field,
647
+ value
648
+ });
649
+ }
650
+ }, [guardian, phone]);
651
+ const basketVisits = useMemo(() => slotKeysFor(state.items), [state.items]);
652
+ const basketKey = basketVisits.join("|");
653
+ const primaryServiceId = state.items[0]?.service.id ?? null;
654
+ /**
655
+ * The first person's whole visit, by key — what the stylists' «neste ledige»
656
+ * and the business's hours are asked for. Equal to `primaryServiceId` for a
657
+ * one-service visit, so the seed (keyed by service id, and only ever for one
658
+ * service) answers exactly the visits it can, and a visit with extras is
659
+ * fetched instead of being read off its first service's openings.
660
+ */
661
+ const primaryVisit = state.items[0] ? visitKey(state.items[0]) : null;
662
+ const nextAvailableTs = useMemo(() => {
663
+ if (primaryVisit === null) return {};
664
+ const seeded = seededNextAvailable[primaryVisit];
665
+ if (seeded) return seeded;
666
+ return fetchedNextAvailable?.key === primaryVisit ? fetchedNextAvailable.times : {};
667
+ }, [
668
+ primaryVisit,
669
+ seededNextAvailable,
670
+ fetchedNextAvailable
671
+ ]);
672
+ const missingKey = basketVisits.filter((key) => !(key in slots)).join("|");
673
+ /** Bumped to ask for the openings again after a failure. */
674
+ const [slotsAttempt, setSlotsAttempt] = useState(0);
675
+ const api = config.paths.api;
676
+ useEffect(() => {
677
+ if (missingKey === "") return;
678
+ const wanted = missingKey.split("|");
679
+ let cancelled = false;
680
+ setSlotsFailed(false);
681
+ (async () => {
682
+ try {
683
+ const fetched = await Promise.all(wanted.map(async (key) => {
684
+ const response = await fetch(`${api}/availability?${windowQuery(key, fromTs, toTs)}`);
685
+ if (!response.ok) throw new Error(`availability ${response.status}`);
686
+ return [key, (await response.json()).slots ?? []];
687
+ }));
688
+ if (cancelled) return;
689
+ setSlots((previous) => ({
690
+ ...previous,
691
+ ...Object.fromEntries(fetched)
692
+ }));
693
+ } catch {
694
+ if (!cancelled) setSlotsFailed(true);
695
+ }
696
+ })();
697
+ return () => {
698
+ cancelled = true;
699
+ };
700
+ }, [
701
+ api,
702
+ missingKey,
703
+ fromTs,
704
+ toTs,
705
+ slotsAttempt
706
+ ]);
707
+ useEffect(() => {
708
+ if (primaryVisit === null) return;
709
+ if (primaryVisit in seededNextAvailable) return;
710
+ let cancelled = false;
711
+ (async () => {
712
+ try {
713
+ const response = await fetch(`${api}/resources?${windowQuery(primaryVisit, fromTs, toTs)}`);
714
+ if (!response.ok) throw new Error(`resources ${response.status}`);
715
+ const body = await response.json();
716
+ if (cancelled) return;
717
+ setResources(body.resources ?? []);
718
+ setResourcesKnown(true);
719
+ setFetchedNextAvailable({
720
+ key: primaryVisit,
721
+ times: body.nextAvailableTs ?? {}
722
+ });
723
+ } catch {
724
+ if (!cancelled) {
725
+ setResourcesKnown(true);
726
+ setFetchedNextAvailable({
727
+ key: primaryVisit,
728
+ times: {}
729
+ });
730
+ }
731
+ }
732
+ })();
733
+ return () => {
734
+ cancelled = true;
735
+ };
736
+ }, [
737
+ api,
738
+ primaryVisit,
739
+ fromTs,
740
+ toTs,
741
+ seededNextAvailable
742
+ ]);
743
+ /**
744
+ * The stylist half of a «book again» link, once a list for its service has
745
+ * landed. Compared by VISIT: the link named one service, and a visitor who
746
+ * has since added a wash to it is asking a different question — the list now
747
+ * in hand answers the longer visit, not the one the link vouched for.
748
+ */
749
+ useEffect(() => {
750
+ const pending = pendingStylist.current;
751
+ if (pending && primaryVisit !== null && primaryVisit !== pending.serviceId) {
752
+ pendingStylist.current = null;
753
+ return;
754
+ }
755
+ if (!canFinishStylist(pending, resources, primaryVisit)) return;
756
+ pendingStylist.current = null;
757
+ dispatch({
758
+ type: "prefill",
759
+ prefill: pending,
760
+ catalogue: prefillCatalogue(services, resources)
761
+ });
762
+ }, [
763
+ resources,
764
+ primaryVisit,
765
+ services
766
+ ]);
767
+ /** A party link's stylist, once the family has its services and is on step 3. */
768
+ useEffect(() => {
769
+ const wanted = partyStylist.current;
770
+ if (wanted === null || state.step !== "when" || primaryServiceId === null) return;
771
+ if (resources.length === 0) return;
772
+ partyStylist.current = null;
773
+ dispatch({
774
+ type: "prefill",
775
+ prefill: {
776
+ serviceId: primaryServiceId,
777
+ resourceId: wanted
778
+ },
779
+ catalogue: prefillCatalogue(services, resources)
780
+ });
781
+ }, [
782
+ state.step,
783
+ primaryServiceId,
784
+ resources,
785
+ services
786
+ ]);
787
+ /** A restored slot whose stylist has left the roster, dropped before it is submitted. */
788
+ useEffect(() => {
789
+ const restored = restoredStylists.current;
790
+ if (restored === null || resources.length === 0 || primaryServiceId === null) return;
791
+ restoredStylists.current = null;
792
+ const known = new Set(resources.map((resource) => resource.id));
793
+ if (restored.every((id) => known.has(id))) return;
794
+ dispatch({
795
+ type: "pickResource",
796
+ resourceId: null
797
+ });
798
+ }, [resources, primaryServiceId]);
799
+ /**
800
+ * The business's open dates over the window, for the first person's VISIT
801
+ * (cutoffs are per service, and `visitTailMinutes` assumes the answer was for
802
+ * item 0's whole visit — see the machine).
803
+ */
804
+ const [fetchedSchedule, setFetchedSchedule] = useState(null);
805
+ const [scheduleFailedFor, setScheduleFailedFor] = useState(null);
806
+ const openDays = useMemo(() => {
807
+ if (primaryVisit === null) return null;
808
+ const seeded = seededSchedule[primaryVisit];
809
+ if (seeded) return seeded;
810
+ return fetchedSchedule?.key === primaryVisit ? fetchedSchedule.days : null;
811
+ }, [
812
+ primaryVisit,
813
+ seededSchedule,
814
+ fetchedSchedule
815
+ ]);
816
+ useEffect(() => {
817
+ if (primaryVisit === null) return;
818
+ if (primaryVisit in seededSchedule) return;
819
+ let cancelled = false;
820
+ (async () => {
821
+ try {
822
+ const response = await fetch(`${api}/schedule?${windowQuery(primaryVisit, fromTs, toTs)}`);
823
+ if (!response.ok) throw new Error(`schedule ${response.status}`);
824
+ const body = await response.json();
825
+ if (cancelled) return;
826
+ setFetchedSchedule({
827
+ key: primaryVisit,
828
+ days: body.days ?? []
829
+ });
830
+ } catch {
831
+ if (!cancelled) setScheduleFailedFor(primaryVisit);
832
+ }
833
+ })();
834
+ return () => {
835
+ cancelled = true;
836
+ };
837
+ }, [
838
+ api,
839
+ primaryVisit,
840
+ fromTs,
841
+ toTs,
842
+ seededSchedule
843
+ ]);
844
+ /** A stylist's display name for an id; `''` from Medal reads as none. */
845
+ const resolveStylistName = useMemo(() => (resourceId) => {
846
+ const name = resources.find((resource) => resource.id === resourceId)?.name;
847
+ return name ? stylistDisplayName(name) || null : null;
848
+ }, [resources]);
849
+ const chosenStylistName = state.resourceId === null ? null : resolveStylistName(state.resourceId);
850
+ const chosenStylistServiceIds = serviceIdsOf(resources, state.resourceId);
851
+ /**
852
+ * A NAMED stylist restored before the list landed who turns out not to cover
853
+ * this basket: «first available» instead, and said so — only on step 3, and
854
+ * only against a list that actually arrived.
855
+ */
856
+ const [stylistNotice, setStylistNotice] = useState(null);
857
+ useEffect(() => {
858
+ if (!resourcesKnown || resources.length === 0) return;
859
+ if (state.step !== "when" || state.resourceId === null) return;
860
+ const chosen = resources.find((resource) => resource.id === state.resourceId);
861
+ if (chosen !== void 0 && state.items.every((item) => visitServicesOf(item).every((service) => chosen.serviceIds.includes(service.id)))) return;
862
+ dispatch({
863
+ type: "pickResource",
864
+ resourceId: null
865
+ });
866
+ setStylistNotice(labelText(labels["wizard.stylistGone"]));
867
+ }, [
868
+ labels,
869
+ resourcesKnown,
870
+ resources,
871
+ state.step,
872
+ state.resourceId,
873
+ state.items
874
+ ]);
875
+ /** The open dates with the cutoffs pulled back to fit the WHOLE visit (the safe direction). */
876
+ const openDaysForVisit = useMemo(() => {
877
+ if (openDays === null) return null;
878
+ const extraMs = wizard.visitTailMinutes(state.items, state.partyMode) * 6e4;
879
+ if (extraMs <= 0) return openDays;
880
+ return openDays.map((day) => ({
881
+ ...day,
882
+ lastStartTs: day.lastStartTs === null ? null : day.lastStartTs - extraMs
883
+ }));
884
+ }, [
885
+ wizard,
886
+ openDays,
887
+ state.items,
888
+ state.partyMode
889
+ ]);
890
+ const party = wizard.showsPartyMode(state);
891
+ const haveEveryService = basketVisits.every((key) => key in slots);
892
+ const scheduleSettled = openDays !== null || primaryVisit !== null && scheduleFailedFor === primaryVisit;
893
+ const nextAvailableLoading = party ? !haveEveryService && !slotsFailed : primaryVisit !== null && !(primaryVisit in seededNextAvailable) && fetchedNextAvailable?.key !== primaryVisit;
894
+ /** The basket's openings, unfiltered — the parallel search needs two stylists. */
895
+ const basketSlots = useMemo(() => {
896
+ const out = {};
897
+ for (const id of basketKey === "" ? [] : basketKey.split("|")) out[id] = slots[id] ?? [];
898
+ return out;
899
+ }, [basketKey, slots]);
900
+ const preferredSlots = useMemo(() => {
901
+ const preferred = state.resourceId;
902
+ if (preferred === null) return basketSlots;
903
+ return Object.fromEntries(Object.entries(basketSlots).map(([id, list]) => [id, onlyResource(list, preferred)]));
904
+ }, [basketSlots, state.resourceId]);
905
+ const partyNextAvailableTs = useMemo(() => party && state.partyMode === "sequential" && haveEveryService ? kit.partySlots.firstPartyStartPerResource(state.items, basketSlots) : {}, [
906
+ kit,
907
+ party,
908
+ state.partyMode,
909
+ haveEveryService,
910
+ state.items,
911
+ basketSlots
912
+ ]);
913
+ const partySlots = useMemo(() => party ? kit.partySlots.findPartySlots(state.items, preferredSlots, state.partyMode) : [], [
914
+ kit,
915
+ party,
916
+ state.items,
917
+ preferredSlots,
918
+ state.partyMode
919
+ ]);
920
+ /** The parallel visits, for the day with nothing back to back — over UNFILTERED slots. */
921
+ const partyAlternatives = useMemo(() => party && state.partyMode === "sequential" ? kit.partySlots.findPartySlots(state.items, basketSlots, "parallel") : [], [
922
+ kit,
923
+ party,
924
+ state.items,
925
+ basketSlots,
926
+ state.partyMode
927
+ ]);
928
+ const singleSlots = useMemo(() => {
929
+ if (party || primaryVisit === null) return [];
930
+ const list = slots[primaryVisit] ?? [];
931
+ return state.resourceId === null ? [...list] : onlyResource(list, state.resourceId);
932
+ }, [
933
+ party,
934
+ primaryVisit,
935
+ slots,
936
+ state.resourceId
937
+ ]);
938
+ /** Create a logged-in parent's new child through the site's portal route. */
939
+ async function saveChild(child) {
940
+ const copy = {
941
+ invalid: labelText(labels["wizard.addChild.invalid"]),
942
+ session: labelText(labels["wizard.addChild.session"]),
943
+ throttled: labelText(labels["wizard.addChild.throttled"])
944
+ };
945
+ const unreachable = labelText(labels["wizard.addChild.unreachable"]);
946
+ try {
947
+ const body = await (await fetch(`${config.paths.portalApi}/persons`, {
948
+ method: "POST",
949
+ headers: { "Content-Type": "application/json" },
950
+ body: JSON.stringify(child),
951
+ cache: "no-store",
952
+ credentials: "same-origin"
953
+ })).json().catch(() => null);
954
+ if (body?.ok === true && body.child) return {
955
+ ok: true,
956
+ child: body.child
957
+ };
958
+ return {
959
+ ok: false,
960
+ message: body?.message ?? copy[body?.reason ?? ""] ?? unreachable
961
+ };
962
+ } catch {
963
+ return {
964
+ ok: false,
965
+ message: unreachable
966
+ };
967
+ }
968
+ }
969
+ /**
970
+ * Step 1's «+ add child». A logged-in parent's child is created first and
971
+ * ticked once it exists; a guest's lives in this booking.
972
+ */
973
+ async function addChild(child) {
974
+ const named = state.people.filter((person) => person.key.startsWith("new:"));
975
+ if ((guardian === null ? named : state.people).length >= wizard.maxPeople) return {
976
+ ok: false,
977
+ message: fill(labels["who.family.limit"], { max: wizard.maxPeople })
978
+ };
979
+ if (guardian === null) {
980
+ await Promise.resolve();
981
+ const person = {
982
+ key: `new:${Date.now()}:${named.length}`,
983
+ name: child.name,
984
+ birthYear: child.birthYear,
985
+ ...child.birthMonth === void 0 ? {} : { birthMonth: child.birthMonth }
986
+ };
987
+ dispatch({
988
+ type: "choosePeople",
989
+ people: [...named, person],
990
+ advance: false
991
+ });
992
+ return { ok: true };
993
+ }
994
+ const saved = await saveChild(child);
995
+ if (!saved.ok) return saved;
996
+ setAddedChildren((current) => [...current, saved.child]);
997
+ const known = family;
998
+ dispatch({
999
+ type: "addPerson",
1000
+ person: personForChild(saved.child, known.length)
1001
+ });
1002
+ return { ok: true };
1003
+ }
1004
+ async function submit(submission) {
1005
+ if (pendingAttempt !== null) {
1006
+ await send(pendingAttempt.submission, pendingAttempt.submitted, { pending: true });
1007
+ return;
1008
+ }
1009
+ const resourceIds = wizard.itemResourceIds(state);
1010
+ const body = withExtraServices(submission, state.items);
1011
+ if (body === null) {
1012
+ dispatch({
1013
+ type: "submitFailed",
1014
+ error: "invalidInput"
1015
+ });
1016
+ return;
1017
+ }
1018
+ await send(body, {
1019
+ items: state.items,
1020
+ startTs: state.startTs ?? 0,
1021
+ partyMode: state.partyMode,
1022
+ resourceIds,
1023
+ stylistNames: resourceIds.map((id) => id === null ? null : resolveStylistName(id))
1024
+ });
1025
+ }
1026
+ /** The POST, and everything that can come back from it (also the replay's). */
1027
+ async function send(submission, submitted, sendOptions) {
1028
+ setSubmitting(true);
1029
+ const current = attempt ?? kit.attempts.readAttempt();
1030
+ if (attempt === null) setAttempt(current);
1031
+ kit.attempts.rememberPending(current, {
1032
+ submission,
1033
+ submitted
1034
+ });
1035
+ setPendingAttempt({
1036
+ submission,
1037
+ submitted
1038
+ });
1039
+ if (sendOptions?.replay !== true && sendOptions?.pending !== true) rebookLink.current = null;
1040
+ try {
1041
+ const response = await fetch(`${api}/create`, {
1042
+ method: "POST",
1043
+ headers: { "Content-Type": "application/json" },
1044
+ body: JSON.stringify({
1045
+ ...submission,
1046
+ window: {
1047
+ fromTs,
1048
+ toTs
1049
+ }
1050
+ }),
1051
+ ...sendOptions?.replay === true ? { signal: AbortSignal.timeout(REPLAY_TIMEOUT_MS) } : {}
1052
+ });
1053
+ const payload = await response.json().catch(() => null);
1054
+ if (!response.ok) {
1055
+ const definite = payload?.error !== void 0 && payload.error !== "upstreamError";
1056
+ if (definite) {
1057
+ kit.attempts.clearAttempt();
1058
+ setPendingAttempt(null);
1059
+ }
1060
+ if (sendOptions?.replay === true || sendOptions?.pending === true && rebookLink.current !== null) {
1061
+ if (definite) {
1062
+ dispatch({ type: "startOver" });
1063
+ restartFromLink();
1064
+ } else dispatch({
1065
+ type: "submitFailed",
1066
+ error: "upstreamError"
1067
+ });
1068
+ return;
1069
+ }
1070
+ if (payload?.error === "slotTaken") {
1071
+ setTakenSlotTs(submitted.startTs);
1072
+ const lost = new Set(submitted.items.map((item) => visitKey(item)));
1073
+ const fresh = freshSlotsOf(payload?.freshSlots, lost);
1074
+ setSlots((previous) => {
1075
+ const next = { ...previous };
1076
+ for (const key of lost) if (key in fresh) next[key] = fresh[key];
1077
+ else delete next[key];
1078
+ return next;
1079
+ });
1080
+ const canSeed = resources.length > 0;
1081
+ setSeededNextAvailable((previous) => ({
1082
+ ...Object.fromEntries(Object.entries(previous).filter(([id]) => !lost.has(id))),
1083
+ ...canSeed ? Object.fromEntries(Object.entries(fresh).map(([id, list]) => [id, firstOpeningPerResource(list)])) : {}
1084
+ }));
1085
+ setFetchedNextAvailable((previous) => previous !== null && lost.has(previous.key) ? null : previous);
1086
+ kit.attempts.clearAttempt();
1087
+ dispatch({ type: "slotTaken" });
1088
+ return;
1089
+ }
1090
+ if (payload?.error !== void 0 && payload.error !== "upstreamError") kit.attempts.clearAttempt();
1091
+ dispatch({
1092
+ type: "submitFailed",
1093
+ error: asSubmitFailure(payload?.error)
1094
+ });
1095
+ return;
1096
+ }
1097
+ const bookings = payload?.bookings ?? [];
1098
+ if (!isCompleteConfirmation(bookings, submitted.items.length)) {
1099
+ dispatch({
1100
+ type: "submitFailed",
1101
+ error: "upstreamError"
1102
+ });
1103
+ return;
1104
+ }
1105
+ const confirmedBookings = bookings.map((booking) => ({
1106
+ id: booking.id,
1107
+ manageHref: booking.manageToken ? kit.paths.managePath(booking.manageToken) : null
1108
+ }));
1109
+ setConfirmed({
1110
+ bookings: confirmedBookings,
1111
+ submitted
1112
+ });
1113
+ kit.attempts.rememberConfirmed(current, {
1114
+ bookings: confirmedBookings,
1115
+ submitted
1116
+ });
1117
+ setPendingAttempt(null);
1118
+ } catch {
1119
+ dispatch({
1120
+ type: "submitFailed",
1121
+ error: "upstreamError"
1122
+ });
1123
+ } finally {
1124
+ setSubmitting(false);
1125
+ }
1126
+ }
1127
+ /** «Book again» on the confirmation: a clean wizard with a fresh attempt. */
1128
+ function startOver() {
1129
+ kit.attempts.clearAttempt();
1130
+ setAttempt(kit.attempts.readAttempt());
1131
+ setPendingAttempt(null);
1132
+ setSlots({});
1133
+ setSlotsFailed(false);
1134
+ setConfirmed(null);
1135
+ setTakenSlotTs(null);
1136
+ dispatch({ type: "startOver" });
1137
+ restartFromLink();
1138
+ }
1139
+ /** A service tap on step 2 for a party of one — the first carries the link's stylist. */
1140
+ function pickService(service) {
1141
+ const first = deepLinkPending.current && state.items.length === 0;
1142
+ deepLinkPending.current = false;
1143
+ if (first && deepLink.stylist && resources.length === 0) pendingStylist.current = {
1144
+ serviceId: service.id,
1145
+ resourceId: deepLink.stylist
1146
+ };
1147
+ if (!first) {
1148
+ dispatch({
1149
+ type: "pickService",
1150
+ service
1151
+ });
1152
+ return;
1153
+ }
1154
+ dispatch({
1155
+ type: "prefill",
1156
+ prefill: {
1157
+ serviceId: service.id,
1158
+ resourceId: deepLink.stylist,
1159
+ party: null
1160
+ },
1161
+ catalogue: prefillCatalogue(services, resources)
1162
+ });
1163
+ }
1164
+ /** One family member's service on step 2. */
1165
+ function pickServiceFor(index, service) {
1166
+ if (deepLinkPending.current) {
1167
+ deepLinkPending.current = false;
1168
+ partyStylist.current = deepLink.stylist;
1169
+ }
1170
+ dispatch({
1171
+ type: "pickServiceFor",
1172
+ index,
1173
+ service,
1174
+ resourceServiceIds: chosenStylistServiceIds
1175
+ });
1176
+ }
1177
+ /**
1178
+ * The multi-select card: person `index` gains `service`, or loses it if they
1179
+ * have it. Never moves the visitor — they say when their list is done with
1180
+ * `continueFromService`. The named stylist's services ride along as for
1181
+ * `pickServiceFor`, so a stylist who cannot do the longer visit is released.
1182
+ */
1183
+ function toggleServiceFor(index, service) {
1184
+ if (deepLinkPending.current) {
1185
+ deepLinkPending.current = false;
1186
+ partyStylist.current = deepLink.stylist;
1187
+ }
1188
+ dispatch({
1189
+ type: "toggleServiceFor",
1190
+ index,
1191
+ service,
1192
+ resourceServiceIds: chosenStylistServiceIds
1193
+ });
1194
+ }
1195
+ /** «Neste» under a multi-select card: on to the hour once everybody has a service. */
1196
+ function continueFromService() {
1197
+ if (state.step !== "service" || !wizard.canAdvance(state)) return;
1198
+ dispatch({
1199
+ type: "goToStep",
1200
+ step: "when"
1201
+ });
1202
+ }
1203
+ function pickResource(resourceId) {
1204
+ setStylistNotice(null);
1205
+ dispatch({
1206
+ type: "pickResource",
1207
+ resourceId
1208
+ });
1209
+ }
1210
+ function pickSlot(slot) {
1211
+ setTakenSlotTs(null);
1212
+ dispatch({
1213
+ type: "pickSlot",
1214
+ startTs: slot.startTs,
1215
+ resourceId: slot.resourceId
1216
+ });
1217
+ }
1218
+ function pickPartySlot(slot) {
1219
+ setTakenSlotTs(null);
1220
+ dispatch({
1221
+ type: "pickPartySlot",
1222
+ startTs: slot.startTs,
1223
+ resourceIds: slot.seats.map((seat) => seat.resourceId),
1224
+ mode: slot.mode
1225
+ });
1226
+ }
1227
+ /** Who a parent seats on step 1; each new child starts on «same as last time». */
1228
+ function choosePeople(people, advance) {
1229
+ dispatch({
1230
+ type: "choosePeople",
1231
+ people,
1232
+ advance,
1233
+ services: people.map((person) => suggestionFor(person)?.service ?? null)
1234
+ });
1235
+ }
1236
+ /** The step after this one, as «Next» means it. */
1237
+ function next() {
1238
+ const order = [
1239
+ "who",
1240
+ "service",
1241
+ "when",
1242
+ "details"
1243
+ ];
1244
+ const following = order[order.indexOf(state.step) + 1];
1245
+ if (following) dispatch({
1246
+ type: "goToStep",
1247
+ step: following
1248
+ });
1249
+ }
1250
+ const action = (value) => dispatch(value);
1251
+ return {
1252
+ kit,
1253
+ state,
1254
+ dispatch: action,
1255
+ /** The parent: who arrived, who logged in from the sheet, and the merged answer. */
1256
+ login: {
1257
+ arrivedAs,
1258
+ signedIn,
1259
+ guardian,
1260
+ signIn: (who) => setSignedIn({ guardian: who }),
1261
+ vippsConfirm,
1262
+ resumePath: vippsResumePath
1263
+ },
1264
+ people: {
1265
+ family,
1266
+ ageDayKey,
1267
+ personAge,
1268
+ suggestionFor,
1269
+ choosePeople,
1270
+ addChild
1271
+ },
1272
+ catalogue: {
1273
+ services,
1274
+ resources,
1275
+ resourcesKnown,
1276
+ resolveStylistName,
1277
+ chosenStylistName,
1278
+ stylistNotice,
1279
+ deepLinkCategory: deepLink.category
1280
+ },
1281
+ slots: {
1282
+ fromTs,
1283
+ toTs,
1284
+ days,
1285
+ single: singleSlots,
1286
+ party: partySlots,
1287
+ alternatives: partyAlternatives,
1288
+ failed: slotsFailed,
1289
+ ready: haveEveryService && scheduleSettled,
1290
+ takenSlotTs,
1291
+ nextAvailableTs: party ? partyNextAvailableTs : nextAvailableTs,
1292
+ nextAvailableLoading
1293
+ },
1294
+ schedule: {
1295
+ openDays: openDaysForVisit,
1296
+ settled: scheduleSettled
1297
+ },
1298
+ derived: {
1299
+ canAdvance: wizard.canAdvance(state),
1300
+ party,
1301
+ total: wizard.totalPriceOre(state.items, state.startTs),
1302
+ summary: wizard.summaryLine(state, resolveStylistName),
1303
+ visitEnd: state.startTs === null ? null : wizard.visitEndTs(state.items, state.startTs, state.partyMode)
1304
+ },
1305
+ pickService,
1306
+ pickServiceFor,
1307
+ toggleServiceFor,
1308
+ continueFromService,
1309
+ pickResource,
1310
+ pickSlot,
1311
+ pickPartySlot,
1312
+ next,
1313
+ submit,
1314
+ submitting,
1315
+ submissionNonce,
1316
+ confirmed,
1317
+ startOver,
1318
+ /** Ask for the openings again after a failure. */
1319
+ refresh: () => setSlotsAttempt((n) => n + 1),
1320
+ restore: {
1321
+ hydrated,
1322
+ restoring,
1323
+ linked,
1324
+ resuming
1325
+ }
1326
+ };
1327
+ }
1328
+ //#endregion
1329
+ export { confirmationLines, isCompleteConfirmation, personForChild, useBooking };
1330
+
1331
+ //# sourceMappingURL=useBooking.mjs.map