@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,111 @@
1
+ import { WizardItem, WizardState } from "./machine.mjs";
2
+ import { BookingSubmission } from "./types.mjs";
3
+ //#region src/core/attempt-store.d.ts
4
+ /**
5
+ * One booking attempt, kept where a page reload cannot reach it.
6
+ *
7
+ * The wizard's own state is React state, and React state does not survive a
8
+ * reload — which would be unremarkable if reloading were not the visitor's ONLY
9
+ * escape from a create request that never answers. Everything here exists for
10
+ * that one moment: the engine has committed the appointment, the response was
11
+ * lost, and the browser is now showing a spinner that will never stop.
12
+ *
13
+ * Three things have to outlive the page for that to end well:
14
+ *
15
+ * 1. The NONCE, which is half the create route's `Idempotency-Key`.
16
+ * 2. The SUBMISSION it was sent with, which is the other half. A nonce alone is
17
+ * not enough and it took a second look to see why: after the reload the
18
+ * wizard restarts empty, and if the booking did commit then the slot the
19
+ * visitor had is now occupied by their own appointment and gone from
20
+ * availability. They cannot reconstruct the body even if they want to, so
21
+ * they pick a different time — a different body, a different key, and a
22
+ * second haircut for the same child.
23
+ * 3. The CONFIRMATION, when one was received. The engine mints each manage
24
+ * token once and returns the plaintext exactly once; it stores only a hash.
25
+ * So a refresh of the confirmation screen before the visitor has saved the
26
+ * calendar file used to take every manage link with it, permanently, for a
27
+ * booking made without an e-mail address.
28
+ *
29
+ * `sessionStorage` and not `localStorage`: an attempt belongs to one tab. A
30
+ * second tab booking a sibling is a different attempt and must not share a key.
31
+ *
32
+ * WHAT THIS PUTS AT REST, said plainly: a stored confirmation contains the
33
+ * manage links, and a manage link is a bearer credential for that appointment.
34
+ * It is the same credential that is already in the visitor's address bar, in
35
+ * their e-mail, and in the calendar file they are about to download — so this
36
+ * is not a new exposure so much as a shorter-lived copy of an existing one:
37
+ * same origin, same tab, gone when the tab closes. The alternative is losing
38
+ * the appointment's only handle to a refresh.
39
+ */
40
+ /** `<namespace>:booking:attempt`. */
41
+ export declare function attemptStorageKey(namespace: string): string;
42
+ /**
43
+ * How long a stored attempt is worth restoring.
44
+ *
45
+ * The point is to survive a refresh, which happens in seconds. An hour is
46
+ * generous for that and short enough that a visitor coming back to `/bestill`
47
+ * later in the same session gets the wizard rather than last time's
48
+ * confirmation.
49
+ *
50
+ * The TTL is no longer the ONLY way out: the confirmation now carries a
51
+ * «Bestill ny time» control that clears the attempt and mints a fresh one, so a
52
+ * parent booking a sibling does not have to wait an hour or find another tab.
53
+ * The age still matters for the visitor who simply closes the screen and comes
54
+ * back — they never press anything, and an hour-old confirmation is not what
55
+ * they returned for.
56
+ */
57
+ export declare const ATTEMPT_TTL_MS: number;
58
+ /**
59
+ * The visit as it was SUBMITTED, frozen when the POST left.
60
+ *
61
+ * «Bekreft»'s child-name inputs stay editable while the request is in flight, so
62
+ * anything the confirmation reads off the live machine can describe a booking
63
+ * that was never made: correct «Jonas» to «Emma» during a slow create and
64
+ * Jonas's appointment is in Medal while the screen — and the calendar file —
65
+ * both say Emma.
66
+ */
67
+ export interface SubmittedVisit {
68
+ items: WizardItem[];
69
+ startTs: number;
70
+ partyMode: WizardState['partyMode'];
71
+ /** One resolved stylist per line, in basket order. */
72
+ resourceIds: Array<string | null>;
73
+ /**
74
+ * Their NAMES, resolved at submit time and stored beside the ids.
75
+ *
76
+ * Not derivable after a reload: the names come from the stylist catalogue,
77
+ * which the shell fetches for the basket's service — and a restored
78
+ * confirmation has no basket, so that fetch never runs and every id resolves
79
+ * to null. The refreshed card would lose the stylist from every line the
80
+ * original card named.
81
+ */
82
+ stylistNames: Array<string | null>;
83
+ }
84
+ export interface ConfirmedBooking {
85
+ id: string;
86
+ manageHref: string | null;
87
+ }
88
+ export interface BookingAttempt {
89
+ nonce: string;
90
+ /** Sent, with no answer yet seen. Replayed on the next load. */
91
+ pending?: {
92
+ submission: BookingSubmission;
93
+ submitted: SubmittedVisit;
94
+ };
95
+ /** Answered. Kept so a refresh cannot take the manage links with it. */
96
+ confirmed?: {
97
+ bookings: ConfirmedBooking[];
98
+ submitted: SubmittedVisit;
99
+ };
100
+ }
101
+ export interface AttemptStore {
102
+ readonly ATTEMPT_STORAGE_KEY: string;
103
+ readAttempt(): BookingAttempt;
104
+ rememberPending(attempt: BookingAttempt, pending: NonNullable<BookingAttempt['pending']>): void;
105
+ rememberConfirmed(attempt: BookingAttempt, confirmed: NonNullable<BookingAttempt['confirmed']>): void;
106
+ clearAttempt(): void;
107
+ }
108
+ /** The attempt store under `<namespace>:booking:attempt`. */
109
+ export declare function createAttemptStore(namespace: string): AttemptStore;
110
+ //#endregion
111
+ //# sourceMappingURL=attempt-store.d.mts.map
@@ -0,0 +1,159 @@
1
+ //#region src/core/attempt-store.ts
2
+ /**
3
+ * One booking attempt, kept where a page reload cannot reach it.
4
+ *
5
+ * The wizard's own state is React state, and React state does not survive a
6
+ * reload — which would be unremarkable if reloading were not the visitor's ONLY
7
+ * escape from a create request that never answers. Everything here exists for
8
+ * that one moment: the engine has committed the appointment, the response was
9
+ * lost, and the browser is now showing a spinner that will never stop.
10
+ *
11
+ * Three things have to outlive the page for that to end well:
12
+ *
13
+ * 1. The NONCE, which is half the create route's `Idempotency-Key`.
14
+ * 2. The SUBMISSION it was sent with, which is the other half. A nonce alone is
15
+ * not enough and it took a second look to see why: after the reload the
16
+ * wizard restarts empty, and if the booking did commit then the slot the
17
+ * visitor had is now occupied by their own appointment and gone from
18
+ * availability. They cannot reconstruct the body even if they want to, so
19
+ * they pick a different time — a different body, a different key, and a
20
+ * second haircut for the same child.
21
+ * 3. The CONFIRMATION, when one was received. The engine mints each manage
22
+ * token once and returns the plaintext exactly once; it stores only a hash.
23
+ * So a refresh of the confirmation screen before the visitor has saved the
24
+ * calendar file used to take every manage link with it, permanently, for a
25
+ * booking made without an e-mail address.
26
+ *
27
+ * `sessionStorage` and not `localStorage`: an attempt belongs to one tab. A
28
+ * second tab booking a sibling is a different attempt and must not share a key.
29
+ *
30
+ * WHAT THIS PUTS AT REST, said plainly: a stored confirmation contains the
31
+ * manage links, and a manage link is a bearer credential for that appointment.
32
+ * It is the same credential that is already in the visitor's address bar, in
33
+ * their e-mail, and in the calendar file they are about to download — so this
34
+ * is not a new exposure so much as a shorter-lived copy of an existing one:
35
+ * same origin, same tab, gone when the tab closes. The alternative is losing
36
+ * the appointment's only handle to a refresh.
37
+ */
38
+ /** `<namespace>:booking:attempt`. */
39
+ function attemptStorageKey(namespace) {
40
+ return `${namespace}:booking:attempt`;
41
+ }
42
+ /**
43
+ * How long a stored attempt is worth restoring.
44
+ *
45
+ * The point is to survive a refresh, which happens in seconds. An hour is
46
+ * generous for that and short enough that a visitor coming back to `/bestill`
47
+ * later in the same session gets the wizard rather than last time's
48
+ * confirmation.
49
+ *
50
+ * The TTL is no longer the ONLY way out: the confirmation now carries a
51
+ * «Bestill ny time» control that clears the attempt and mints a fresh one, so a
52
+ * parent booking a sibling does not have to wait an hour or find another tab.
53
+ * The age still matters for the visitor who simply closes the screen and comes
54
+ * back — they never press anything, and an hour-old confirmation is not what
55
+ * they returned for.
56
+ */
57
+ const ATTEMPT_TTL_MS = 36e5;
58
+ /** The attempt store under `<namespace>:booking:attempt`. */
59
+ function createAttemptStore(namespace) {
60
+ const STORAGE_KEY = attemptStorageKey(namespace);
61
+ /**
62
+ * Every path through this module falls back to «no stored attempt» rather than
63
+ * throwing.
64
+ *
65
+ * There is no `sessionStorage` during the server render, Safari's private mode
66
+ * has historically thrown on write, and a visitor may simply have storage
67
+ * turned off. Losing the idempotency guarantee is bad; refusing to let somebody
68
+ * book a haircut because their browser will not hold a string is worse.
69
+ */
70
+ function readStored() {
71
+ if (typeof window === "undefined") return null;
72
+ try {
73
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
74
+ if (!raw) return null;
75
+ const parsed = JSON.parse(raw);
76
+ if (typeof parsed?.nonce !== "string" || typeof parsed?.at !== "number") return null;
77
+ if (Date.now() - parsed.at > 36e5) return null;
78
+ return parsed;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ function write(attempt) {
84
+ if (typeof window === "undefined") return;
85
+ try {
86
+ const stored = {
87
+ ...attempt,
88
+ at: Date.now()
89
+ };
90
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
91
+ } catch {}
92
+ }
93
+ /**
94
+ * The attempt this tab is on — the one already stored, or a fresh one.
95
+ *
96
+ * Reading MINTS and stores when there is nothing to read, so the nonce exists
97
+ * from the moment the wizard mounts rather than from the moment somebody
98
+ * presses «Bekreft time». A nonce created at submit would be created again by
99
+ * the reload it is supposed to survive.
100
+ */
101
+ function readAttempt() {
102
+ const stored = readStored();
103
+ if (stored !== null) {
104
+ const { at: _at, ...attempt } = stored;
105
+ return attempt;
106
+ }
107
+ const fresh = { nonce: crypto.randomUUID() };
108
+ write(fresh);
109
+ return fresh;
110
+ }
111
+ /** Record what was just sent, BEFORE it is sent. A body stored after the
112
+ * response is a body that is not there when the response never comes. */
113
+ function rememberPending(attempt, pending) {
114
+ write({
115
+ nonce: attempt.nonce,
116
+ pending
117
+ });
118
+ }
119
+ /**
120
+ * Record the answer, and drop the pending submission with it.
121
+ *
122
+ * Order matters on the next load: a stored confirmation is shown, a stored
123
+ * pending submission is replayed. Leaving both would replay a booking that has
124
+ * already been confirmed — harmless, because the key makes it a replay, but a
125
+ * needless round trip on every reload of the confirmation.
126
+ */
127
+ function rememberConfirmed(attempt, confirmed) {
128
+ write({
129
+ nonce: attempt.nonce,
130
+ confirmed
131
+ });
132
+ }
133
+ /**
134
+ * Forget the attempt entirely.
135
+ *
136
+ * For the failures that are certainly NOT «we do not know»: a slot that was
137
+ * taken, or a body the engine refused. Both mean no appointment was made and
138
+ * the visitor is about to build a different one, so replaying this on the next
139
+ * load would resend a submission that has already been answered — with a
140
+ * «Bekreft time» nobody pressed.
141
+ */
142
+ function clearAttempt() {
143
+ if (typeof window === "undefined") return;
144
+ try {
145
+ window.sessionStorage.removeItem(STORAGE_KEY);
146
+ } catch {}
147
+ }
148
+ return {
149
+ ATTEMPT_STORAGE_KEY: STORAGE_KEY,
150
+ readAttempt,
151
+ rememberPending,
152
+ rememberConfirmed,
153
+ clearAttempt
154
+ };
155
+ }
156
+ //#endregion
157
+ export { ATTEMPT_TTL_MS, attemptStorageKey, createAttemptStore };
158
+
159
+ //# sourceMappingURL=attempt-store.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attempt-store.mjs","names":[],"sources":["../../src/core/attempt-store.ts"],"sourcesContent":["import type { WizardItem, WizardState } from './machine';\nimport type { BookingSubmission } from './types';\n\n/**\n * One booking attempt, kept where a page reload cannot reach it.\n *\n * The wizard's own state is React state, and React state does not survive a\n * reload — which would be unremarkable if reloading were not the visitor's ONLY\n * escape from a create request that never answers. Everything here exists for\n * that one moment: the engine has committed the appointment, the response was\n * lost, and the browser is now showing a spinner that will never stop.\n *\n * Three things have to outlive the page for that to end well:\n *\n * 1. The NONCE, which is half the create route's `Idempotency-Key`.\n * 2. The SUBMISSION it was sent with, which is the other half. A nonce alone is\n * not enough and it took a second look to see why: after the reload the\n * wizard restarts empty, and if the booking did commit then the slot the\n * visitor had is now occupied by their own appointment and gone from\n * availability. They cannot reconstruct the body even if they want to, so\n * they pick a different time — a different body, a different key, and a\n * second haircut for the same child.\n * 3. The CONFIRMATION, when one was received. The engine mints each manage\n * token once and returns the plaintext exactly once; it stores only a hash.\n * So a refresh of the confirmation screen before the visitor has saved the\n * calendar file used to take every manage link with it, permanently, for a\n * booking made without an e-mail address.\n *\n * `sessionStorage` and not `localStorage`: an attempt belongs to one tab. A\n * second tab booking a sibling is a different attempt and must not share a key.\n *\n * WHAT THIS PUTS AT REST, said plainly: a stored confirmation contains the\n * manage links, and a manage link is a bearer credential for that appointment.\n * It is the same credential that is already in the visitor's address bar, in\n * their e-mail, and in the calendar file they are about to download — so this\n * is not a new exposure so much as a shorter-lived copy of an existing one:\n * same origin, same tab, gone when the tab closes. The alternative is losing\n * the appointment's only handle to a refresh.\n */\n\n/** `<namespace>:booking:attempt`. */\nexport function attemptStorageKey(namespace: string): string {\n return `${namespace}:booking:attempt`;\n}\n\n/**\n * How long a stored attempt is worth restoring.\n *\n * The point is to survive a refresh, which happens in seconds. An hour is\n * generous for that and short enough that a visitor coming back to `/bestill`\n * later in the same session gets the wizard rather than last time's\n * confirmation.\n *\n * The TTL is no longer the ONLY way out: the confirmation now carries a\n * «Bestill ny time» control that clears the attempt and mints a fresh one, so a\n * parent booking a sibling does not have to wait an hour or find another tab.\n * The age still matters for the visitor who simply closes the screen and comes\n * back — they never press anything, and an hour-old confirmation is not what\n * they returned for.\n */\nexport const ATTEMPT_TTL_MS = 60 * 60 * 1000;\n\n/**\n * The visit as it was SUBMITTED, frozen when the POST left.\n *\n * «Bekreft»'s child-name inputs stay editable while the request is in flight, so\n * anything the confirmation reads off the live machine can describe a booking\n * that was never made: correct «Jonas» to «Emma» during a slow create and\n * Jonas's appointment is in Medal while the screen — and the calendar file —\n * both say Emma.\n */\nexport interface SubmittedVisit {\n items: WizardItem[];\n startTs: number;\n partyMode: WizardState['partyMode'];\n /** One resolved stylist per line, in basket order. */\n resourceIds: Array<string | null>;\n /**\n * Their NAMES, resolved at submit time and stored beside the ids.\n *\n * Not derivable after a reload: the names come from the stylist catalogue,\n * which the shell fetches for the basket's service — and a restored\n * confirmation has no basket, so that fetch never runs and every id resolves\n * to null. The refreshed card would lose the stylist from every line the\n * original card named.\n */\n stylistNames: Array<string | null>;\n}\n\nexport interface ConfirmedBooking {\n id: string;\n manageHref: string | null;\n}\n\nexport interface BookingAttempt {\n nonce: string;\n /** Sent, with no answer yet seen. Replayed on the next load. */\n pending?: { submission: BookingSubmission; submitted: SubmittedVisit };\n /** Answered. Kept so a refresh cannot take the manage links with it. */\n confirmed?: { bookings: ConfirmedBooking[]; submitted: SubmittedVisit };\n}\n\ninterface StoredAttempt extends BookingAttempt {\n /** When this record was last written, for `ATTEMPT_TTL_MS`. */\n at: number;\n}\n\nexport interface AttemptStore {\n readonly ATTEMPT_STORAGE_KEY: string;\n readAttempt(): BookingAttempt;\n rememberPending(attempt: BookingAttempt, pending: NonNullable<BookingAttempt['pending']>): void;\n rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void;\n clearAttempt(): void;\n}\n\n/** The attempt store under `<namespace>:booking:attempt`. */\nexport function createAttemptStore(namespace: string): AttemptStore {\n const STORAGE_KEY = attemptStorageKey(namespace);\n\n /**\n * Every path through this module falls back to «no stored attempt» rather than\n * throwing.\n *\n * There is no `sessionStorage` during the server render, Safari's private mode\n * has historically thrown on write, and a visitor may simply have storage\n * turned off. Losing the idempotency guarantee is bad; refusing to let somebody\n * book a haircut because their browser will not hold a string is worse.\n */\n function readStored(): StoredAttempt | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n const parsed = JSON.parse(raw) as StoredAttempt;\n if (typeof parsed?.nonce !== 'string' || typeof parsed?.at !== 'number') return null;\n if (Date.now() - parsed.at > ATTEMPT_TTL_MS) return null;\n return parsed;\n } catch {\n return null;\n }\n }\n\n function write(attempt: BookingAttempt): void {\n if (typeof window === 'undefined') return;\n try {\n const stored: StoredAttempt = { ...attempt, at: Date.now() };\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));\n } catch {\n // The attempt goes on without the guarantee. See the note above.\n }\n }\n\n /**\n * The attempt this tab is on — the one already stored, or a fresh one.\n *\n * Reading MINTS and stores when there is nothing to read, so the nonce exists\n * from the moment the wizard mounts rather than from the moment somebody\n * presses «Bekreft time». A nonce created at submit would be created again by\n * the reload it is supposed to survive.\n */\n function readAttempt(): BookingAttempt {\n const stored = readStored();\n if (stored !== null) {\n const { at: _at, ...attempt } = stored;\n return attempt;\n }\n const fresh: BookingAttempt = { nonce: crypto.randomUUID() };\n write(fresh);\n return fresh;\n }\n\n /** Record what was just sent, BEFORE it is sent. A body stored after the\n * response is a body that is not there when the response never comes. */\n function rememberPending(\n attempt: BookingAttempt,\n pending: NonNullable<BookingAttempt['pending']>\n ): void {\n write({ nonce: attempt.nonce, pending });\n }\n\n /**\n * Record the answer, and drop the pending submission with it.\n *\n * Order matters on the next load: a stored confirmation is shown, a stored\n * pending submission is replayed. Leaving both would replay a booking that has\n * already been confirmed — harmless, because the key makes it a replay, but a\n * needless round trip on every reload of the confirmation.\n */\n function rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void {\n write({ nonce: attempt.nonce, confirmed });\n }\n\n /**\n * Forget the attempt entirely.\n *\n * For the failures that are certainly NOT «we do not know»: a slot that was\n * taken, or a body the engine refused. Both mean no appointment was made and\n * the visitor is about to build a different one, so replaying this on the next\n * load would resend a submission that has already been answered — with a\n * «Bekreft time» nobody pressed.\n */\n function clearAttempt(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do, and nothing worth failing a booking over.\n }\n }\n\n return {\n ATTEMPT_STORAGE_KEY: STORAGE_KEY,\n readAttempt,\n rememberPending,\n rememberConfirmed,\n clearAttempt,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,kBAAkB,WAA2B;CAC3D,OAAO,GAAG,UAAU;AACtB;;;;;;;;;;;;;;;;AAiBA,MAAa,iBAAiB;;AA2D9B,SAAgB,mBAAmB,WAAiC;CAClE,MAAM,cAAc,kBAAkB,SAAS;;;;;;;;;;CAW/C,SAAS,aAAmC;EAC1C,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,MAAM,OAAO,eAAe,QAAQ,WAAW;GACrD,IAAI,CAAC,KAAK,OAAO;GACjB,MAAM,SAAS,KAAK,MAAM,GAAG;GAC7B,IAAI,OAAO,QAAQ,UAAU,YAAY,OAAO,QAAQ,OAAO,UAAU,OAAO;GAChF,IAAI,KAAK,IAAI,IAAI,OAAO,KAAA,MAAqB,OAAO;GACpD,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;CAEA,SAAS,MAAM,SAA+B;EAC5C,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,MAAM,SAAwB;IAAE,GAAG;IAAS,IAAI,KAAK,IAAI;GAAE;GAC3D,OAAO,eAAe,QAAQ,aAAa,KAAK,UAAU,MAAM,CAAC;EACnE,QAAQ,CAER;CACF;;;;;;;;;CAUA,SAAS,cAA8B;EACrC,MAAM,SAAS,WAAW;EAC1B,IAAI,WAAW,MAAM;GACnB,MAAM,EAAE,IAAI,KAAK,GAAG,YAAY;GAChC,OAAO;EACT;EACA,MAAM,QAAwB,EAAE,OAAO,OAAO,WAAW,EAAE;EAC3D,MAAM,KAAK;EACX,OAAO;CACT;;;CAIA,SAAS,gBACP,SACA,SACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAQ,CAAC;CACzC;;;;;;;;;CAUA,SAAS,kBACP,SACA,WACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAU,CAAC;CAC3C;;;;;;;;;;CAWA,SAAS,eAAqB;EAC5B,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,WAAW,WAAW;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,qBAAqB;EACrB;EACA;EACA;EACA;CACF;AACF"}
@@ -0,0 +1,27 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ //#region src/core/categories.d.ts
3
+ type CategoryConfig = Pick<BookingConfig, 'categories' | 'fallbackCategory'>;
4
+ /** The configured keys, in display order. */
5
+ export declare function categoryOrder(config: CategoryConfig): string[];
6
+ /** A raw Medal category as one of the configured keys, or the fallback. */
7
+ export declare function normaliseCategory(config: CategoryConfig, raw: string | null | undefined): string;
8
+ /** Whether a (raw) category is a kids' group. */
9
+ export declare function isChildCategory(config: CategoryConfig, raw: string | null | undefined): boolean;
10
+ /** The first kids' group, the one a link's «for a child» means. `null` when none is configured. */
11
+ export declare function childCategory(config: CategoryConfig): string | null;
12
+ /**
13
+ * The adult group a service «grows up» into, by its group's `adultEquivalent`:
14
+ * the group's own key, or the first `nameIncludes` rule the name matches.
15
+ * `null` when the group names none.
16
+ */
17
+ export declare function adultCategoryFor(config: CategoryConfig, service: {
18
+ name: string;
19
+ category: string;
20
+ }): string | null;
21
+ /** The services in the adult group `service` grows up into — empty when it names none. */
22
+ export declare function adultEquivalent<S extends {
23
+ name: string;
24
+ category: string;
25
+ }>(config: CategoryConfig, service: S, services: readonly S[]): S[];
26
+ //#endregion
27
+ //# sourceMappingURL=categories.d.mts.map
@@ -0,0 +1,42 @@
1
+ //#region src/core/categories.ts
2
+ /** The configured keys, in display order. */
3
+ function categoryOrder(config) {
4
+ return config.categories.map((category) => category.key);
5
+ }
6
+ /** A raw Medal category as one of the configured keys, or the fallback. */
7
+ function normaliseCategory(config, raw) {
8
+ return config.categories.some((category) => category.key === raw) ? raw : config.fallbackCategory;
9
+ }
10
+ function categoryFor(config, raw) {
11
+ const key = normaliseCategory(config, raw);
12
+ return config.categories.find((category) => category.key === key);
13
+ }
14
+ /** Whether a (raw) category is a kids' group. */
15
+ function isChildCategory(config, raw) {
16
+ return categoryFor(config, raw).audience === "child";
17
+ }
18
+ /** The first kids' group, the one a link's «for a child» means. `null` when none is configured. */
19
+ function childCategory(config) {
20
+ return config.categories.find((category) => category.audience === "child")?.key ?? null;
21
+ }
22
+ /**
23
+ * The adult group a service «grows up» into, by its group's `adultEquivalent`:
24
+ * the group's own key, or the first `nameIncludes` rule the name matches.
25
+ * `null` when the group names none.
26
+ */
27
+ function adultCategoryFor(config, service) {
28
+ const rule = categoryFor(config, service.category).adultEquivalent;
29
+ if (rule === void 0) return null;
30
+ if (typeof rule === "string") return rule;
31
+ const name = service.name.toLocaleLowerCase();
32
+ return rule.find((entry) => name.includes(entry.nameIncludes.toLocaleLowerCase()))?.category ?? null;
33
+ }
34
+ /** The services in the adult group `service` grows up into — empty when it names none. */
35
+ function adultEquivalent(config, service, services) {
36
+ const target = adultCategoryFor(config, service);
37
+ return target === null ? [] : services.filter((candidate) => candidate.category === target);
38
+ }
39
+ //#endregion
40
+ export { adultCategoryFor, adultEquivalent, categoryOrder, childCategory, isChildCategory, normaliseCategory };
41
+
42
+ //# sourceMappingURL=categories.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"categories.mjs","names":[],"sources":["../../src/core/categories.ts"],"sourcesContent":["/**\n * Medal's service `category` is a free string, and a business can add one in\n * the dashboard at any time. The site groups by its own configured list\n * (`config.categories`), in display order, and anything unrecognised joins the\n * catch-all (`config.fallbackCategory`) rather than dropping off the page —\n * bookable, priced, and invisible.\n *\n * `audience: 'child'` is the kids' path: what the wizard used to spell as the\n * literal `'barn'` (a guest child's seat, the held link service, `?antall=`).\n */\n\nimport type { BookingCategory, BookingConfig } from './config';\n\ntype CategoryConfig = Pick<BookingConfig, 'categories' | 'fallbackCategory'>;\n\n/** The configured keys, in display order. */\nexport function categoryOrder(config: CategoryConfig): string[] {\n return config.categories.map((category) => category.key);\n}\n\n/** A raw Medal category as one of the configured keys, or the fallback. */\nexport function normaliseCategory(config: CategoryConfig, raw: string | null | undefined): string {\n return config.categories.some((category) => category.key === raw)\n ? (raw as string)\n : config.fallbackCategory;\n}\n\nfunction categoryFor(config: CategoryConfig, raw: string | null | undefined): BookingCategory {\n const key = normaliseCategory(config, raw);\n return config.categories.find((category) => category.key === key) as BookingCategory;\n}\n\n/** Whether a (raw) category is a kids' group. */\nexport function isChildCategory(config: CategoryConfig, raw: string | null | undefined): boolean {\n return categoryFor(config, raw).audience === 'child';\n}\n\n/** The first kids' group, the one a link's «for a child» means. `null` when none is configured. */\nexport function childCategory(config: CategoryConfig): string | null {\n return config.categories.find((category) => category.audience === 'child')?.key ?? null;\n}\n\n/**\n * The adult group a service «grows up» into, by its group's `adultEquivalent`:\n * the group's own key, or the first `nameIncludes` rule the name matches.\n * `null` when the group names none.\n */\nexport function adultCategoryFor(\n config: CategoryConfig,\n service: { name: string; category: string }\n): string | null {\n const rule = categoryFor(config, service.category).adultEquivalent;\n if (rule === undefined) return null;\n if (typeof rule === 'string') return rule;\n const name = service.name.toLocaleLowerCase();\n return (\n rule.find((entry) => name.includes(entry.nameIncludes.toLocaleLowerCase()))?.category ?? null\n );\n}\n\n/** The services in the adult group `service` grows up into — empty when it names none. */\nexport function adultEquivalent<S extends { name: string; category: string }>(\n config: CategoryConfig,\n service: S,\n services: readonly S[]\n): S[] {\n const target = adultCategoryFor(config, service);\n return target === null ? [] : services.filter((candidate) => candidate.category === target);\n}\n"],"mappings":";;AAgBA,SAAgB,cAAc,QAAkC;CAC9D,OAAO,OAAO,WAAW,KAAK,aAAa,SAAS,GAAG;AACzD;;AAGA,SAAgB,kBAAkB,QAAwB,KAAwC;CAChG,OAAO,OAAO,WAAW,MAAM,aAAa,SAAS,QAAQ,GAAG,IAC3D,MACD,OAAO;AACb;AAEA,SAAS,YAAY,QAAwB,KAAiD;CAC5F,MAAM,MAAM,kBAAkB,QAAQ,GAAG;CACzC,OAAO,OAAO,WAAW,MAAM,aAAa,SAAS,QAAQ,GAAG;AAClE;;AAGA,SAAgB,gBAAgB,QAAwB,KAAyC;CAC/F,OAAO,YAAY,QAAQ,GAAG,CAAC,CAAC,aAAa;AAC/C;;AAGA,SAAgB,cAAc,QAAuC;CACnE,OAAO,OAAO,WAAW,MAAM,aAAa,SAAS,aAAa,OAAO,CAAC,EAAE,OAAO;AACrF;;;;;;AAOA,SAAgB,iBACd,QACA,SACe;CACf,MAAM,OAAO,YAAY,QAAQ,QAAQ,QAAQ,CAAC,CAAC;CACnD,IAAI,SAAS,KAAA,GAAW,OAAO;CAC/B,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,MAAM,OAAO,QAAQ,KAAK,kBAAkB;CAC5C,OACE,KAAK,MAAM,UAAU,KAAK,SAAS,MAAM,aAAa,kBAAkB,CAAC,CAAC,CAAC,EAAE,YAAY;AAE7F;;AAGA,SAAgB,gBACd,QACA,SACA,UACK;CACL,MAAM,SAAS,iBAAiB,QAAQ,OAAO;CAC/C,OAAO,WAAW,OAAO,CAAC,IAAI,SAAS,QAAQ,cAAc,UAAU,aAAa,MAAM;AAC5F"}
@@ -0,0 +1,33 @@
1
+ import { BookingLabels } from "./labels.mjs";
2
+ import { BookingConfig } from "./config.mjs";
3
+ //#region src/core/clock.d.ts
4
+ export interface Clock {
5
+ readonly timeZone: string;
6
+ /** The day part keys, in order. */
7
+ readonly dayparts: readonly string[];
8
+ dayStart(ts: number, offsetDays?: number): number;
9
+ window(fromTs: number, rangeDays: number): {
10
+ fromTs: number;
11
+ toTs: number;
12
+ days: number[];
13
+ };
14
+ formatTime(ts: number): string;
15
+ hour(ts: number): number;
16
+ dayKey(ts: number): string;
17
+ isWeekend(ts: number): boolean;
18
+ daysBetween(from: number, to: number): number;
19
+ daypartOf(ts: number): string;
20
+ daypartLabel(key: string): string;
21
+ dayLabel(ts: number, now?: number): string;
22
+ dayChip(ts: number, now?: number): string;
23
+ when(ts: number, now?: number): string;
24
+ date(ts: number): string;
25
+ dateTime(ts: number): string;
26
+ weekday(ts: number): string;
27
+ }
28
+ export type ClockConfig = Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts'> & {
29
+ labels?: Partial<BookingLabels>;
30
+ };
31
+ export declare function createClock(config: ClockConfig): Clock;
32
+ //#endregion
33
+ //# sourceMappingURL=clock.d.mts.map
@@ -0,0 +1,251 @@
1
+ import { fill, labelText, resolveLabels } from "./labels.mjs";
2
+ //#region src/core/clock.ts
3
+ /**
4
+ * Built once each: constructing a formatter is the expensive half of `Intl`,
5
+ * and the time step formats every slot in a week on each re-render.
6
+ */
7
+ const FORMATTERS = /* @__PURE__ */ new Map();
8
+ function formattersFor(timeZone, locale) {
9
+ const cacheKey = `${timeZone}\u0000${locale}`;
10
+ const cached = FORMATTERS.get(cacheKey);
11
+ if (cached !== void 0) return cached;
12
+ const built = {
13
+ time: new Intl.DateTimeFormat(locale, {
14
+ timeZone,
15
+ hour: "2-digit",
16
+ minute: "2-digit",
17
+ hourCycle: "h23"
18
+ }),
19
+ parts: new Intl.DateTimeFormat("en-US", {
20
+ timeZone,
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ hour: "2-digit",
25
+ hourCycle: "h23",
26
+ weekday: "short"
27
+ }),
28
+ weekdayLong: new Intl.DateTimeFormat(locale, {
29
+ timeZone,
30
+ weekday: "long"
31
+ }),
32
+ weekdayShort: new Intl.DateTimeFormat(locale, {
33
+ timeZone,
34
+ weekday: "short",
35
+ day: "numeric"
36
+ }),
37
+ dayMonth: new Intl.DateTimeFormat(locale, {
38
+ timeZone,
39
+ day: "numeric",
40
+ month: "short"
41
+ }),
42
+ fullDate: new Intl.DateTimeFormat(locale, {
43
+ timeZone,
44
+ weekday: "short",
45
+ day: "numeric",
46
+ month: "short"
47
+ }),
48
+ wallClock: new Intl.DateTimeFormat("en-US", {
49
+ timeZone,
50
+ year: "numeric",
51
+ month: "2-digit",
52
+ day: "2-digit",
53
+ hour: "2-digit",
54
+ minute: "2-digit",
55
+ second: "2-digit",
56
+ hourCycle: "h23"
57
+ })
58
+ };
59
+ FORMATTERS.set(cacheKey, built);
60
+ return built;
61
+ }
62
+ /** One clock per config object: every factory that needs the clock asks for it. */
63
+ const CLOCKS = /* @__PURE__ */ new WeakMap();
64
+ function createClock(config) {
65
+ const known = CLOCKS.get(config);
66
+ if (known !== void 0) return known;
67
+ const clock = buildClock(config);
68
+ CLOCKS.set(config, clock);
69
+ return clock;
70
+ }
71
+ function buildClock(config) {
72
+ const { timeZone } = config;
73
+ const f = formattersFor(timeZone, config.locale);
74
+ const labels = resolveLabels(config.locale, config.labels);
75
+ const dayparts = config.dayparts;
76
+ function partsOf(ts) {
77
+ const out = {};
78
+ for (const part of f.parts.formatToParts(ts)) out[part.type] = part.value;
79
+ return out;
80
+ }
81
+ /**
82
+ * How far ahead of UTC the business is at a given instant — `+1 h` in winter,
83
+ * `+2 h` in summer for Oslo.
84
+ *
85
+ * Read from the zone database rather than assumed, because the whole point of
86
+ * the two functions below is the two days a year when it changes.
87
+ */
88
+ function offsetMs(ts) {
89
+ const out = {};
90
+ for (const part of f.wallClock.formatToParts(ts)) out[part.type] = part.value;
91
+ return Date.UTC(Number(out.year), Number(out.month) - 1, Number(out.day), Number(out.hour), Number(out.minute), Number(out.second)) - (ts - (ts % 1e3 + 1e3) % 1e3);
92
+ }
93
+ /**
94
+ * Midnight on the business's clock, `offsetDays` calendar days after the day
95
+ * `ts` falls in.
96
+ *
97
+ * The reason this is not `ts + n * 86_400_000`: adding a fixed 24 hours assumes
98
+ * every day is 24 hours long, and two of them a year are not. Late on the
99
+ * Saturday before the clocks go forward, +24 h lands on the Monday and Sunday
100
+ * disappears; early on the Sunday they go back, +24 h lands on the same Sunday
101
+ * again and the far end of the week is lost. Either way a day strip built that
102
+ * way stops describing the week the availability query asked about.
103
+ *
104
+ * Two passes, because the offset has to be read at the answer rather than at the
105
+ * guess: `wall` is midnight on the target date treated as if the zone were UTC,
106
+ * and the offset in force at the true instant is the one that turns it back into
107
+ * one. The second read settles the case where the guess falls on the other side
108
+ * of a transition from the day it belongs to.
109
+ */
110
+ function dayStart(ts, offsetDays = 0) {
111
+ const parts = partsOf(ts);
112
+ const wall = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day) + offsetDays);
113
+ return wall - offsetMs(wall - offsetMs(wall));
114
+ }
115
+ /**
116
+ * The days the time step may offer and the instant the availability query
117
+ * stops at — derived together, so they cannot disagree.
118
+ *
119
+ * `days` is one instant inside each business day, starting with the day
120
+ * `fromTs` falls in; `toTs` is the midnight that ends the last of them. That is
121
+ * the whole contract: every opening the engine can return has a chip to sit
122
+ * on, and every chip covers a stretch the engine was actually asked about.
123
+ *
124
+ * `fromTs` is returned untouched and is NOT snapped to midnight: it is the
125
+ * moment the visitor is standing in, and a query that started at this morning's
126
+ * midnight would ask the engine about hours that are already gone.
127
+ */
128
+ function window(fromTs, rangeDays) {
129
+ return {
130
+ fromTs,
131
+ toTs: dayStart(fromTs, rangeDays),
132
+ days: Array.from({ length: rangeDays }, (_, index) => dayStart(fromTs, index))
133
+ };
134
+ }
135
+ /** `15:00` — the string on the chip, the summary bar and the confirmation. */
136
+ function formatTime(ts) {
137
+ return f.time.format(ts);
138
+ }
139
+ /** The hour 0–23 as the business reads it. */
140
+ function hour(ts) {
141
+ return Number(partsOf(ts).hour);
142
+ }
143
+ /**
144
+ * `2026-09-03` — the identity of a *business* day, which is what slots group
145
+ * by. Slicing an ISO string instead would file 00:30 on Thursday under
146
+ * Wednesday and split one opening day across two headings.
147
+ */
148
+ function dayKey(ts) {
149
+ const parts = partsOf(ts);
150
+ return `${parts.year}-${parts.month}-${parts.day}`;
151
+ }
152
+ function isWeekend(ts) {
153
+ const weekday = partsOf(ts).weekday;
154
+ return weekday === "Sat" || weekday === "Sun";
155
+ }
156
+ function utcMidnight(ts) {
157
+ const parts = partsOf(ts);
158
+ return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
159
+ }
160
+ /**
161
+ * Whole days between two instants on the business calendar, so «tomorrow»
162
+ * means the next opening day rather than "in 24 hours". Anchoring both to UTC
163
+ * midnight after the parts have already been read in the zone keeps the
164
+ * arithmetic clear of the hour the clocks change, where a naive subtraction
165
+ * is off by one.
166
+ */
167
+ function daysBetween(from, to) {
168
+ return (utcMidnight(to) - utcMidnight(from)) / 864e5;
169
+ }
170
+ /** The configured part the hour falls in — on the business's clock. */
171
+ function daypartOf(ts) {
172
+ const h = hour(ts);
173
+ return (dayparts.find((candidate) => h >= candidate.from && h < candidate.to) ?? dayparts[dayparts.length - 1]).key;
174
+ }
175
+ function daypartLabel(key) {
176
+ const label = labels[`daypart.${key}`];
177
+ return label === void 0 ? key : labelText(label);
178
+ }
179
+ /**
180
+ * `i dag` / `i morgen` / `lørdag` / `10. sep.`, mid-sentence.
181
+ *
182
+ * The weekday name stops being useful after six days — «torsdag» could then be
183
+ * either of two Thursdays — so the date takes over rather than being ambiguous
184
+ * about which week the business expects you.
185
+ */
186
+ function dayLabel(ts, now = Date.now()) {
187
+ const days = daysBetween(now, ts);
188
+ if (days === 0) return labelText(labels["clock.today"]);
189
+ if (days === 1) return labelText(labels["clock.tomorrow"]);
190
+ if (days > 1 && days < 7) return f.weekdayLong.format(ts);
191
+ return f.dayMonth.format(ts);
192
+ }
193
+ /** The same day, sized for a chip in the date strip: `I dag`, `lør. 5.`. */
194
+ function dayChip(ts, now = Date.now()) {
195
+ const days = daysBetween(now, ts);
196
+ if (days === 0) return labelText(labels["clock.todayChip"]);
197
+ if (days === 1) return labelText(labels["clock.tomorrowChip"]);
198
+ return f.weekdayShort.format(ts);
199
+ }
200
+ /** `i dag 14:15` — a day and a time in one breath, for «Neste ledige». */
201
+ function when(ts, now = Date.now()) {
202
+ return fill(labels["clock.when"], {
203
+ day: dayLabel(ts, now),
204
+ time: formatTime(ts)
205
+ });
206
+ }
207
+ /**
208
+ * `tor. 8. okt.` — the calendar date, with no reference to now.
209
+ *
210
+ * The month keeps ICU's trailing full stop (`okt.`, not `okt`), because that is
211
+ * how Norwegian abbreviates a month and this file does not second-guess the
212
+ * locale data.
213
+ */
214
+ function date(ts) {
215
+ return f.fullDate.format(ts);
216
+ }
217
+ /** `tor. 8. okt. kl. 15:00` — the whole appointment in one line, absolute in both halves. */
218
+ function dateTime(ts) {
219
+ return fill(labels["clock.dateTime"], {
220
+ date: date(ts),
221
+ time: formatTime(ts)
222
+ });
223
+ }
224
+ /** `lørdag` — the weekday's own name, for «lørdagspris». */
225
+ function weekday(ts) {
226
+ return f.weekdayLong.format(ts);
227
+ }
228
+ return {
229
+ timeZone,
230
+ dayparts: dayparts.map((part) => part.key),
231
+ dayStart,
232
+ window,
233
+ formatTime,
234
+ hour,
235
+ dayKey,
236
+ isWeekend,
237
+ daysBetween,
238
+ daypartOf,
239
+ daypartLabel,
240
+ dayLabel,
241
+ dayChip,
242
+ when,
243
+ date,
244
+ dateTime,
245
+ weekday
246
+ };
247
+ }
248
+ //#endregion
249
+ export { createClock };
250
+
251
+ //# sourceMappingURL=clock.mjs.map