@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,229 @@
1
+ import { createClock } from "./clock.mjs";
2
+ import { visitKey } from "./visit.mjs";
3
+ import { itemStartTimes } from "./machine.mjs";
4
+ //#region src/core/party-slots.ts
5
+ /**
6
+ * Seating a family: which instants a whole party can actually be taken at, and
7
+ * with whom.
8
+ *
9
+ * The engine answers «when is this ONE service free?» — one row per stylist per
10
+ * instant, because `listSlots` returns a `resourceId` with every slot. Nothing
11
+ * upstream answers «when can these two children be seen back to back?», and
12
+ * nothing should: it is a question about a basket that only exists in this
13
+ * wizard.
14
+ *
15
+ * Pure, and a sibling of `wizard-machine.ts` rather than part of it, for the
16
+ * reason the machine states about itself — the search is where a family booking
17
+ * goes wrong (a stylist who can only cut one of the two, a pair of times that
18
+ * are an hour apart, the same stylist promised to both children at once), and
19
+ * every one of those is provable without rendering anything.
20
+ *
21
+ * It imports `itemStartTimes` rather than restating the seating arithmetic:
22
+ * step 4 submits with that function and the confirmation card reads it back, so
23
+ * a search that laid the children out any other way would offer a slot the
24
+ * submission then asked for at different times.
25
+ */
26
+ /** The keys a basket's availability is fetched and stored under: each distinct
27
+ * visit once, in basket order — the list the wizard asks the route for. */
28
+ function slotKeysFor(items) {
29
+ return [...new Set(items.map((item) => visitKey(item)))];
30
+ }
31
+ /** `resource@instant`, the unit both searches ask about. */
32
+ function seatKey(resourceId, startTs) {
33
+ return `${resourceId}@${startTs}`;
34
+ }
35
+ /**
36
+ * The catalogue, indexed for the only question either search asks: is this
37
+ * stylist free for this visit at this exact minute?
38
+ *
39
+ * Slots with no `resourceId` are dropped rather than treated as «anybody». The
40
+ * wire type is nullable because `medal-client.ts` types every field the way the
41
+ * serialiser emits it; a slot that arrived without one cannot be paired with a
42
+ * second slot, since there would be no way to tell whether the pair is two
43
+ * stylists or one stylist promised twice.
44
+ */
45
+ function indexAvailability(slotsByService) {
46
+ const byVisit = /* @__PURE__ */ new Map();
47
+ const resourceOrder = [];
48
+ const seen = /* @__PURE__ */ new Set();
49
+ for (const [key, slots] of Object.entries(slotsByService)) {
50
+ const keys = byVisit.get(key) ?? /* @__PURE__ */ new Set();
51
+ for (const slot of slots) {
52
+ if (slot.resourceId === null) continue;
53
+ keys.add(seatKey(slot.resourceId, slot.startTs));
54
+ if (!seen.has(slot.resourceId)) {
55
+ seen.add(slot.resourceId);
56
+ resourceOrder.push(slot.resourceId);
57
+ }
58
+ }
59
+ byVisit.set(key, keys);
60
+ }
61
+ return {
62
+ free: (key, resourceId, startTs) => byVisit.get(key)?.has(seatKey(resourceId, startTs)) ?? false,
63
+ resourceOrder
64
+ };
65
+ }
66
+ /**
67
+ * The instants the party could begin at: every time the FIRST child's visit is
68
+ * free, with anybody, in ascending order and without repeats.
69
+ *
70
+ * The first child is the right anchor for both modes — sequential seats the
71
+ * rest after them and parallel seats the rest alongside them — and starting
72
+ * anywhere else would offer a visit whose opening line was never free.
73
+ *
74
+ * Deliberately does NOT re-filter the stylist-less slots that `indexAvailability`
75
+ * drops. An instant only survives if some real stylist can be seated at it, and
76
+ * that is the index's answer to give: a second copy of the rule here would be a
77
+ * place for the two to disagree, and it would pass every test the index's own
78
+ * guard already passes.
79
+ *
80
+ * Deduplicated, because two stylists free at the same minute are one offer to a
81
+ * parent. Without the `Set` the same visit would be built once per stylist and
82
+ * step 3 would draw the identical chip twice.
83
+ */
84
+ function candidateStarts(items, slotsByService) {
85
+ const first = slotsByService[visitKey(items[0])] ?? [];
86
+ return [...new Set(first.map((slot) => slot.startTs))].sort((a, b) => a - b);
87
+ }
88
+ /**
89
+ * One stylist for the whole visit.
90
+ *
91
+ * `every`, over every line item and not just the first: the report's example is
92
+ * Sara doing Gutteklipp *and* Jenteklipp, and a search that only checked the
93
+ * service the visitor happened to tap first would offer a stylist who cannot
94
+ * cut the second child — a refusal the engine issues at submit, dressed up here
95
+ * as a slot.
96
+ *
97
+ * The times come from `itemStartTimes`, so «contiguous» is enforced rather than
98
+ * hoped for: the second child's slot has to exist at exactly the minute the
99
+ * first one's service ends. 15:00 and 16:30 are two openings, not an hour.
100
+ */
101
+ function seatSequentially(items, starts, index) {
102
+ for (const resourceId of index.resourceOrder) if (items.every((item, position) => index.free(visitKey(item), resourceId, starts[position]))) return items.map(() => resourceId);
103
+ return null;
104
+ }
105
+ /**
106
+ * A different stylist per child, all at the same minute.
107
+ *
108
+ * Backtracking rather than a greedy pass, because greedy is wrong on the case
109
+ * the salon actually has: three children where Sara is the only one who can
110
+ * take the third, and Sara is also the first name on the list for the first
111
+ * two. Handing her to child one and giving up leaves a seating that exists
112
+ * unfound. A party is small — the salon's kids' services cap it at three, and
113
+ * `maxPerBooking` caps it at whatever the salon seeds — so the search space is
114
+ * trivial and correctness is free.
115
+ *
116
+ * `taken` is what makes the stylists distinct — the rule that one person cannot
117
+ * cut two children at once, which no amount of availability data states on its
118
+ * own, since one free slot is genuinely free for either child.
119
+ */
120
+ function seatInParallel(items, starts, index) {
121
+ const taken = /* @__PURE__ */ new Set();
122
+ function assign(position) {
123
+ if (position === items.length) return [];
124
+ for (const resourceId of index.resourceOrder) {
125
+ if (taken.has(resourceId)) continue;
126
+ if (!index.free(visitKey(items[position]), resourceId, starts[position])) continue;
127
+ taken.add(resourceId);
128
+ const rest = assign(position + 1);
129
+ taken.delete(resourceId);
130
+ if (rest !== null) return [resourceId, ...rest];
131
+ }
132
+ return null;
133
+ }
134
+ return assign(0);
135
+ }
136
+ /**
137
+ * Every instant the whole party can be taken at, in the mode the visitor chose.
138
+ *
139
+ * At most one slot per instant. Two stylists who could both take the same hour
140
+ * back to back are one offer to a parent, not two identical chips — step 3 asks
141
+ * «når passer det?», and the stylist question was step 2's.
142
+ */
143
+ function findPartySlots(items, slotsByService, mode) {
144
+ if (items.length === 0) return [];
145
+ const index = indexAvailability(slotsByService);
146
+ const seat = mode === "parallel" ? seatInParallel : seatSequentially;
147
+ const found = [];
148
+ for (const startTs of candidateStarts(items, slotsByService)) {
149
+ const starts = itemStartTimes(items, startTs, mode);
150
+ const resourceIds = seat(items, starts, index);
151
+ if (resourceIds === null) continue;
152
+ found.push({
153
+ startTs,
154
+ mode,
155
+ seats: resourceIds.map((resourceId, position) => ({
156
+ startTs: starts[position],
157
+ resourceId
158
+ }))
159
+ });
160
+ }
161
+ return found;
162
+ }
163
+ /**
164
+ * The report's rescue: «Ingen ledige timer rett etter hverandre torsdag – men
165
+ * begge kan tas samtidig kl. 15:00.»
166
+ *
167
+ * Returns the earliest parallel slot on the day being looked at, and only when
168
+ * the sequential search has genuinely found nothing on that same day. Offering
169
+ * it alongside slots that do exist would be answering a question nobody asked;
170
+ * withholding it when there are none is the empty day the report is trying to
171
+ * prevent.
172
+ *
173
+ * The day is the salon's, not the viewer's — `clock.dayKey` rather than a date
174
+ * string — for the reason every other clock face in this flow is: a 00:30 slot
175
+ * belongs to the day the salon is open on, whatever UTC calls it.
176
+ */
177
+ function partyAlternative(dayKey, args) {
178
+ const day = dayKey(args.dayTs);
179
+ if (args.sequential.some((slot) => dayKey(slot.startTs) === day)) return null;
180
+ return args.parallel.reduce((earliest, slot) => dayKey(slot.startTs) === day && (earliest === null || slot.startTs < earliest.startTs) ? slot : earliest, null);
181
+ }
182
+ /**
183
+ * «Neste ledige» for a family: the earliest minute each stylist can take the
184
+ * WHOLE party back to back.
185
+ *
186
+ * The single-service answer (`firstOpeningPerResource`, and the resources
187
+ * route) is the first minute a stylist can take ONE child. Printed on a party's
188
+ * stylist card it would quote a time the search on the time step then fails to
189
+ * find for that stylist — the second child's slot need not be free straight
190
+ * after. So this runs the very search `findPartySlots` runs once the visitor
191
+ * picks the stylist (sequential, narrowed to that one person) and keeps the
192
+ * first hit: the card and the day strip under it cannot disagree.
193
+ *
194
+ * Sequential only, deliberately. The parallel mode draws no stylist list — two
195
+ * children at once are two people — so there is no card to hang an answer on.
196
+ *
197
+ * A stylist with no seating in the window gets no entry, the same «promise
198
+ * nothing we do not know» rule the single-service line follows.
199
+ */
200
+ function firstPartyStartPerResource(items, slotsByService) {
201
+ if (items.length === 0) return {};
202
+ const index = indexAvailability(slotsByService);
203
+ const earliest = {};
204
+ let left = index.resourceOrder.length;
205
+ for (const startTs of candidateStarts(items, slotsByService)) {
206
+ if (left === 0) break;
207
+ const starts = itemStartTimes(items, startTs, "sequential");
208
+ for (const resourceId of index.resourceOrder) {
209
+ if (resourceId in earliest) continue;
210
+ if (!items.every((item, position) => index.free(visitKey(item), resourceId, starts[position]))) continue;
211
+ earliest[resourceId] = startTs;
212
+ left -= 1;
213
+ }
214
+ }
215
+ return earliest;
216
+ }
217
+ /** The party search on this business's calendar. Only `partyAlternative` needs the clock. */
218
+ function createPartySlots(config) {
219
+ const clock = createClock(config);
220
+ return {
221
+ findPartySlots,
222
+ firstPartyStartPerResource,
223
+ partyAlternative: (args) => partyAlternative(clock.dayKey, args)
224
+ };
225
+ }
226
+ //#endregion
227
+ export { createPartySlots, findPartySlots, firstPartyStartPerResource, slotKeysFor };
228
+
229
+ //# sourceMappingURL=party-slots.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"party-slots.mjs","names":[],"sources":["../../src/core/party-slots.ts"],"sourcesContent":["/**\n * Seating a family: which instants a whole party can actually be taken at, and\n * with whom.\n *\n * The engine answers «when is this ONE service free?» — one row per stylist per\n * instant, because `listSlots` returns a `resourceId` with every slot. Nothing\n * upstream answers «when can these two children be seen back to back?», and\n * nothing should: it is a question about a basket that only exists in this\n * wizard.\n *\n * Pure, and a sibling of `wizard-machine.ts` rather than part of it, for the\n * reason the machine states about itself — the search is where a family booking\n * goes wrong (a stylist who can only cut one of the two, a pair of times that\n * are an hour apart, the same stylist promised to both children at once), and\n * every one of those is provable without rendering anything.\n *\n * It imports `itemStartTimes` rather than restating the seating arithmetic:\n * step 4 submits with that function and the confirmation card reads it back, so\n * a search that laid the children out any other way would offer a slot the\n * submission then asked for at different times.\n */\n\nimport { type ClockConfig, createClock } from './clock';\nimport { itemStartTimes, type WizardItem, type WizardState } from './machine';\nimport type { BookingSlotDto } from './types';\nimport { visitKey } from './visit';\n\n/** One child's place in the visit: when they sit down, and with whom. */\nexport interface PartySeat {\n startTs: number;\n /**\n * Never `null`, unlike `BookingSlotDto.resourceId`.\n *\n * «Første ledige» is an answer the engine can resolve for one booking, but a\n * party slot is a claim about *particular* people — that one stylist has a\n * clear hour, or that two different ones are free at the same minute. A seat\n * that could not name its stylist could not make either claim.\n */\n resourceId: string;\n}\n\n/** A whole family, seated. */\nexport interface PartySlot {\n /** When the visit starts — the first child sits down. */\n startTs: number;\n /** Which search produced it, so accepting the parallel alternative can also\n * switch the mode the submission is seated with. */\n mode: WizardState['partyMode'];\n /** One per line item, in basket order. */\n seats: PartySeat[];\n}\n\n/** Availability as the wizard collects it: one query per VISIT in the basket,\n * keyed by `visitKey` — a service id for a one-service visit, `a+b` for a\n * person having both. The server answers a visit as one list (spans as long as\n * the whole visit, on stylists who do every part of it), so it has to be stored\n * and read under the visit's key: the first service's own list would offer a\n * stylist who can cut but not wash. Two children sharing a visit share the\n * entry — which is exactly why the parallel search has to insist on distinct\n * stylists rather than trusting the lists to differ. */\nexport type SlotsByService = Readonly<Record<string, readonly BookingSlotDto[]>>;\n\n/** The keys a basket's availability is fetched and stored under: each distinct\n * visit once, in basket order — the list the wizard asks the route for. */\nexport function slotKeysFor(items: readonly WizardItem[]): string[] {\n return [...new Set(items.map((item) => visitKey(item)))];\n}\n\n/** `resource@instant`, the unit both searches ask about. */\nfunction seatKey(resourceId: string, startTs: number): string {\n return `${resourceId}@${startTs}`;\n}\n\n/**\n * The catalogue, indexed for the only question either search asks: is this\n * stylist free for this visit at this exact minute?\n *\n * Slots with no `resourceId` are dropped rather than treated as «anybody». The\n * wire type is nullable because `medal-client.ts` types every field the way the\n * serialiser emits it; a slot that arrived without one cannot be paired with a\n * second slot, since there would be no way to tell whether the pair is two\n * stylists or one stylist promised twice.\n */\nfunction indexAvailability(slotsByService: SlotsByService): {\n free: (key: string, resourceId: string, startTs: number) => boolean;\n resourceOrder: string[];\n} {\n const byVisit = new Map<string, Set<string>>();\n // First-encounter order across the whole catalogue: the tie-break when two\n // stylists could both take the same slot. It follows whatever order the\n // availability route returned, which is the engine's own resource order, so\n // the wizard offers the same stylist twice in a row rather than shuffling\n // under a re-render.\n const resourceOrder: string[] = [];\n const seen = new Set<string>();\n\n for (const [key, slots] of Object.entries(slotsByService)) {\n const keys = byVisit.get(key) ?? new Set<string>();\n for (const slot of slots) {\n if (slot.resourceId === null) continue;\n keys.add(seatKey(slot.resourceId, slot.startTs));\n if (!seen.has(slot.resourceId)) {\n seen.add(slot.resourceId);\n resourceOrder.push(slot.resourceId);\n }\n }\n byVisit.set(key, keys);\n }\n\n return {\n free: (key, resourceId, startTs) =>\n byVisit.get(key)?.has(seatKey(resourceId, startTs)) ?? false,\n resourceOrder,\n };\n}\n\n/**\n * The instants the party could begin at: every time the FIRST child's visit is\n * free, with anybody, in ascending order and without repeats.\n *\n * The first child is the right anchor for both modes — sequential seats the\n * rest after them and parallel seats the rest alongside them — and starting\n * anywhere else would offer a visit whose opening line was never free.\n *\n * Deliberately does NOT re-filter the stylist-less slots that `indexAvailability`\n * drops. An instant only survives if some real stylist can be seated at it, and\n * that is the index's answer to give: a second copy of the rule here would be a\n * place for the two to disagree, and it would pass every test the index's own\n * guard already passes.\n *\n * Deduplicated, because two stylists free at the same minute are one offer to a\n * parent. Without the `Set` the same visit would be built once per stylist and\n * step 3 would draw the identical chip twice.\n */\nfunction candidateStarts(items: WizardItem[], slotsByService: SlotsByService): number[] {\n const first = slotsByService[visitKey(items[0])] ?? [];\n return [...new Set(first.map((slot) => slot.startTs))].sort((a, b) => a - b);\n}\n\n/**\n * One stylist for the whole visit.\n *\n * `every`, over every line item and not just the first: the report's example is\n * Sara doing Gutteklipp *and* Jenteklipp, and a search that only checked the\n * service the visitor happened to tap first would offer a stylist who cannot\n * cut the second child — a refusal the engine issues at submit, dressed up here\n * as a slot.\n *\n * The times come from `itemStartTimes`, so «contiguous» is enforced rather than\n * hoped for: the second child's slot has to exist at exactly the minute the\n * first one's service ends. 15:00 and 16:30 are two openings, not an hour.\n */\nfunction seatSequentially(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>\n): string[] | null {\n for (const resourceId of index.resourceOrder) {\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (fits) return items.map(() => resourceId);\n }\n return null;\n}\n\n/**\n * A different stylist per child, all at the same minute.\n *\n * Backtracking rather than a greedy pass, because greedy is wrong on the case\n * the salon actually has: three children where Sara is the only one who can\n * take the third, and Sara is also the first name on the list for the first\n * two. Handing her to child one and giving up leaves a seating that exists\n * unfound. A party is small — the salon's kids' services cap it at three, and\n * `maxPerBooking` caps it at whatever the salon seeds — so the search space is\n * trivial and correctness is free.\n *\n * `taken` is what makes the stylists distinct — the rule that one person cannot\n * cut two children at once, which no amount of availability data states on its\n * own, since one free slot is genuinely free for either child.\n */\nfunction seatInParallel(\n items: WizardItem[],\n starts: number[],\n index: ReturnType<typeof indexAvailability>\n): string[] | null {\n const taken = new Set<string>();\n\n function assign(position: number): string[] | null {\n if (position === items.length) return [];\n for (const resourceId of index.resourceOrder) {\n if (taken.has(resourceId)) continue;\n if (!index.free(visitKey(items[position]), resourceId, starts[position])) continue;\n taken.add(resourceId);\n const rest = assign(position + 1);\n taken.delete(resourceId);\n if (rest !== null) return [resourceId, ...rest];\n }\n return null;\n }\n\n return assign(0);\n}\n\n/**\n * Every instant the whole party can be taken at, in the mode the visitor chose.\n *\n * At most one slot per instant. Two stylists who could both take the same hour\n * back to back are one offer to a parent, not two identical chips — step 3 asks\n * «når passer det?», and the stylist question was step 2's.\n */\nexport function findPartySlots(\n items: WizardItem[],\n slotsByService: SlotsByService,\n mode: WizardState['partyMode']\n): PartySlot[] {\n if (items.length === 0) return [];\n\n const index = indexAvailability(slotsByService);\n const seat = mode === 'parallel' ? seatInParallel : seatSequentially;\n const found: PartySlot[] = [];\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n const starts = itemStartTimes(items, startTs, mode);\n const resourceIds = seat(items, starts, index);\n if (resourceIds === null) continue;\n found.push({\n startTs,\n mode,\n seats: resourceIds.map((resourceId, position) => ({\n startTs: starts[position],\n resourceId,\n })),\n });\n }\n\n return found;\n}\n\n/**\n * The report's rescue: «Ingen ledige timer rett etter hverandre torsdag – men\n * begge kan tas samtidig kl. 15:00.»\n *\n * Returns the earliest parallel slot on the day being looked at, and only when\n * the sequential search has genuinely found nothing on that same day. Offering\n * it alongside slots that do exist would be answering a question nobody asked;\n * withholding it when there are none is the empty day the report is trying to\n * prevent.\n *\n * The day is the salon's, not the viewer's — `clock.dayKey` rather than a date\n * string — for the reason every other clock face in this flow is: a 00:30 slot\n * belongs to the day the salon is open on, whatever UTC calls it.\n */\nfunction partyAlternative(\n dayKey: (ts: number) => string,\n args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }\n): PartySlot | null {\n const day = dayKey(args.dayTs);\n if (args.sequential.some((slot) => dayKey(slot.startTs) === day)) return null;\n return args.parallel.reduce<PartySlot | null>(\n (earliest, slot) =>\n dayKey(slot.startTs) === day && (earliest === null || slot.startTs < earliest.startTs)\n ? slot\n : earliest,\n null\n );\n}\n\n/**\n * «Neste ledige» for a family: the earliest minute each stylist can take the\n * WHOLE party back to back.\n *\n * The single-service answer (`firstOpeningPerResource`, and the resources\n * route) is the first minute a stylist can take ONE child. Printed on a party's\n * stylist card it would quote a time the search on the time step then fails to\n * find for that stylist — the second child's slot need not be free straight\n * after. So this runs the very search `findPartySlots` runs once the visitor\n * picks the stylist (sequential, narrowed to that one person) and keeps the\n * first hit: the card and the day strip under it cannot disagree.\n *\n * Sequential only, deliberately. The parallel mode draws no stylist list — two\n * children at once are two people — so there is no card to hang an answer on.\n *\n * A stylist with no seating in the window gets no entry, the same «promise\n * nothing we do not know» rule the single-service line follows.\n */\nexport function firstPartyStartPerResource(\n items: WizardItem[],\n slotsByService: SlotsByService\n): Record<string, number> {\n if (items.length === 0) return {};\n\n const index = indexAvailability(slotsByService);\n const earliest: Record<string, number> = {};\n let left = index.resourceOrder.length;\n\n for (const startTs of candidateStarts(items, slotsByService)) {\n if (left === 0) break;\n const starts = itemStartTimes(items, startTs, 'sequential');\n for (const resourceId of index.resourceOrder) {\n if (resourceId in earliest) continue;\n const fits = items.every((item, position) =>\n index.free(visitKey(item), resourceId, starts[position])\n );\n if (!fits) continue;\n earliest[resourceId] = startTs;\n left -= 1;\n }\n }\n\n return earliest;\n}\n\nexport interface PartySlots {\n findPartySlots: typeof findPartySlots;\n firstPartyStartPerResource: typeof firstPartyStartPerResource;\n partyAlternative(args: {\n sequential: readonly PartySlot[];\n parallel: readonly PartySlot[];\n dayTs: number;\n }): PartySlot | null;\n}\n\n/** The party search on this business's calendar. Only `partyAlternative` needs the clock. */\nexport function createPartySlots(config: ClockConfig): PartySlots {\n const clock = createClock(config);\n return {\n findPartySlots,\n firstPartyStartPerResource,\n partyAlternative: (args) => partyAlternative(clock.dayKey, args),\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ //#region src/core/paths.d.ts
3
+ type PathsConfig = Pick<BookingConfig, 'paths'>;
4
+ export interface Paths {
5
+ managePath(token: string): string;
6
+ isManagePath(pathname: string | null | undefined): boolean;
7
+ isPortalPath(pathname: string | null | undefined): boolean;
8
+ }
9
+ export declare function createPaths(config: PathsConfig): Paths;
10
+ //#endregion
11
+ //# sourceMappingURL=paths.d.mts.map
@@ -0,0 +1,56 @@
1
+ //#region src/core/paths.ts
2
+ /** The page that manages one booking. Encoded, because the token is opaque and
3
+ * this is the only place that turns one into a URL. */
4
+ function managePath(config, token) {
5
+ return `${config.paths.manage}/${encodeURIComponent(token)}`;
6
+ }
7
+ /** A path as a regex source, so a `.` in a configured path means a dot. */
8
+ function escapeRegExp(path) {
9
+ return path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10
+ }
11
+ /** `path` and everything below it, behind an optional `/xx` or `/xx-YY` locale segment. */
12
+ function under(path) {
13
+ return new RegExp(`^(?:/[A-Za-z]{2}(?:-[A-Za-z]{2})?)?${escapeRegExp(path)}(?:/|$)`);
14
+ }
15
+ /**
16
+ * Whether a pathname is a manage page — with or without a locale segment in
17
+ * front of it.
18
+ *
19
+ * `localePrefix: 'as-needed'` means the site's one locale is unprefixed today,
20
+ * so `/bestill/administrer/…` is what actually gets served. The optional
21
+ * `/xx` or `/xx-YY` in front is for the day a second locale is added: the
22
+ * tracker staying on for the Danish copy of this page would be the same leak
23
+ * with a different first segment.
24
+ *
25
+ * `null` is what `usePathname` returns with no router above it, which cannot
26
+ * happen for a client component under the App Router. It is answered `false`
27
+ * rather than `true` so that a caller reading this as «is this the manage page»
28
+ * is not told yes about a page it knows nothing about.
29
+ */
30
+ function isManagePath(config, pathname) {
31
+ if (typeof pathname !== "string") return false;
32
+ return under(config.paths.manage).test(pathname);
33
+ }
34
+ /**
35
+ * The portal (`paths.portal`, `/min-side` by default) and everything under it. The portal pages carry no credential in
36
+ * the URL, but the dashboard renders each upcoming appointment's manage link —
37
+ * a per-booking bearer token — into the DOM, and a third-party script mounted
38
+ * on that page can read the DOM. So the analytics tracker treats the portal
39
+ * exactly like the manage page: not loaded at all, rather than loaded and
40
+ * asked not to look. Locale-prefixed forms are covered like `isManagePath`.
41
+ */
42
+ function isPortalPath(config, pathname) {
43
+ if (typeof pathname !== "string" || config.paths.portal === null) return false;
44
+ return under(config.paths.portal).test(pathname);
45
+ }
46
+ function createPaths(config) {
47
+ return {
48
+ managePath: (token) => managePath(config, token),
49
+ isManagePath: (pathname) => isManagePath(config, pathname),
50
+ isPortalPath: (pathname) => isPortalPath(config, pathname)
51
+ };
52
+ }
53
+ //#endregion
54
+ export { createPaths };
55
+
56
+ //# sourceMappingURL=paths.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.mjs","names":[],"sources":["../../src/core/paths.ts"],"sourcesContent":["/**\n * Where a manage link points, spelled once.\n *\n * The path segment after this prefix is a live bearer credential: whoever holds\n * it can move or cancel that one appointment. Several things have to agree about\n * the shape of that URL — the wizard builds it, the manage page is served at it,\n * and `Analytics` has to recognise it in order to keep the tracker off — and\n * they are in three different layers of the app, which is exactly the sort of\n * spread where one of them drifts.\n *\n * Free of React, `server-only` and fetch on purpose: the root layout's\n * `Analytics` imports it, so it has to be safe in every bundle.\n */\n\nimport type { BookingConfig } from './config';\n\ntype PathsConfig = Pick<BookingConfig, 'paths'>;\n\n/** The page that manages one booking. Encoded, because the token is opaque and\n * this is the only place that turns one into a URL. */\nfunction managePath(config: PathsConfig, token: string): string {\n return `${config.paths.manage}/${encodeURIComponent(token)}`;\n}\n\n/** A path as a regex source, so a `.` in a configured path means a dot. */\nfunction escapeRegExp(path: string): string {\n return path.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/** `path` and everything below it, behind an optional `/xx` or `/xx-YY` locale segment. */\nfunction under(path: string): RegExp {\n return new RegExp(`^(?:/[A-Za-z]{2}(?:-[A-Za-z]{2})?)?${escapeRegExp(path)}(?:/|$)`);\n}\n\n/**\n * Whether a pathname is a manage page — with or without a locale segment in\n * front of it.\n *\n * `localePrefix: 'as-needed'` means the site's one locale is unprefixed today,\n * so `/bestill/administrer/…` is what actually gets served. The optional\n * `/xx` or `/xx-YY` in front is for the day a second locale is added: the\n * tracker staying on for the Danish copy of this page would be the same leak\n * with a different first segment.\n *\n * `null` is what `usePathname` returns with no router above it, which cannot\n * happen for a client component under the App Router. It is answered `false`\n * rather than `true` so that a caller reading this as «is this the manage page»\n * is not told yes about a page it knows nothing about.\n */\nfunction isManagePath(config: PathsConfig, pathname: string | null | undefined): boolean {\n if (typeof pathname !== 'string') return false;\n return under(config.paths.manage).test(pathname);\n}\n\n/**\n * The portal (`paths.portal`, `/min-side` by default) and everything under it. The portal pages carry no credential in\n * the URL, but the dashboard renders each upcoming appointment's manage link —\n * a per-booking bearer token — into the DOM, and a third-party script mounted\n * on that page can read the DOM. So the analytics tracker treats the portal\n * exactly like the manage page: not loaded at all, rather than loaded and\n * asked not to look. Locale-prefixed forms are covered like `isManagePath`.\n */\nfunction isPortalPath(config: PathsConfig, pathname: string | null | undefined): boolean {\n if (typeof pathname !== 'string' || config.paths.portal === null) return false;\n return under(config.paths.portal).test(pathname);\n}\n\nexport interface Paths {\n managePath(token: string): string;\n isManagePath(pathname: string | null | undefined): boolean;\n isPortalPath(pathname: string | null | undefined): boolean;\n}\n\nexport function createPaths(config: PathsConfig): Paths {\n return {\n managePath: (token) => managePath(config, token),\n isManagePath: (pathname) => isManagePath(config, pathname),\n isPortalPath: (pathname) => isPortalPath(config, pathname),\n };\n}\n"],"mappings":";;;AAoBA,SAAS,WAAW,QAAqB,OAAuB;CAC9D,OAAO,GAAG,OAAO,MAAM,OAAO,GAAG,mBAAmB,KAAK;AAC3D;;AAGA,SAAS,aAAa,MAAsB;CAC1C,OAAO,KAAK,QAAQ,uBAAuB,MAAM;AACnD;;AAGA,SAAS,MAAM,MAAsB;CACnC,OAAO,IAAI,OAAO,sCAAsC,aAAa,IAAI,EAAE,QAAQ;AACrF;;;;;;;;;;;;;;;;AAiBA,SAAS,aAAa,QAAqB,UAA8C;CACvF,IAAI,OAAO,aAAa,UAAU,OAAO;CACzC,OAAO,MAAM,OAAO,MAAM,MAAM,CAAC,CAAC,KAAK,QAAQ;AACjD;;;;;;;;;AAUA,SAAS,aAAa,QAAqB,UAA8C;CACvF,IAAI,OAAO,aAAa,YAAY,OAAO,MAAM,WAAW,MAAM,OAAO;CACzE,OAAO,MAAM,OAAO,MAAM,MAAM,CAAC,CAAC,KAAK,QAAQ;AACjD;AAQA,SAAgB,YAAY,QAA4B;CACtD,OAAO;EACL,aAAa,UAAU,WAAW,QAAQ,KAAK;EAC/C,eAAe,aAAa,aAAa,QAAQ,QAAQ;EACzD,eAAe,aAAa,aAAa,QAAQ,QAAQ;CAC3D;AACF"}
@@ -0,0 +1,17 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ //#region src/core/phone.d.ts
3
+ /** `22 33 44 55` → `tel:22334455`. Spaces are typography; a phone dials digits. */
4
+ export declare function telHref(phone: string): string;
5
+ export interface Phone {
6
+ readonly country: string;
7
+ /** `strict` only for a country with a built-in rule set (`NO` in 0.x). */
8
+ readonly validate: 'strict' | 'loose';
9
+ telHref(phone: string): string;
10
+ /** What the details step submits, out of what the visitor typed. */
11
+ nationalDigits(value: string): string;
12
+ /** Whether the typed value is plausibly a number for this country. */
13
+ looksValid(value: string): boolean;
14
+ }
15
+ export declare function createPhone(config: Pick<BookingConfig, 'phone'>): Phone;
16
+ //#endregion
17
+ //# sourceMappingURL=phone.d.mts.map
@@ -0,0 +1,58 @@
1
+ //#region src/core/phone.ts
2
+ /** `22 33 44 55` → `tel:22334455`. Spaces are typography; a phone dials digits. */
3
+ function telHref(phone) {
4
+ return `tel:${phone.replace(/\s/g, "")}`;
5
+ }
6
+ /**
7
+ * Norway, the one strict rule set.
8
+ *
9
+ * The contact is deduplicated on this number, so «400 00 000» and
10
+ * «+4740000000» from one parent must not become two families in the CRM.
11
+ * Spacing goes, and so does a country code they pasted in on top of the `+47`
12
+ * the field already shows.
13
+ *
14
+ * Only `+47` and `0047` are stripped, never a bare leading `47`: `47 12 34 56`
15
+ * is an ordinary eight-digit Norwegian number, and taking the first two digits
16
+ * off it would submit somebody else's phone.
17
+ */
18
+ function norwegianDigits(value) {
19
+ return value.replace(/[\s()-]/g, "").replace(/^(?:\+47|0047)/, "");
20
+ }
21
+ /**
22
+ * Eight digits, and nothing about which digit comes first.
23
+ *
24
+ * A claim about length, so a rule about length: requiring a leading 4 or 9
25
+ * would be a *mobile* rule, and would answer a parent who typed a landline by
26
+ * telling them a digit was missing when eight were present.
27
+ */
28
+ function looksNorwegian(value) {
29
+ return /^\d{8}$/.test(norwegianDigits(value));
30
+ }
31
+ /** Anywhere else: separators go, a leading `+` stays, and 6–15 digits is E.164-ish. */
32
+ function looseDigits(value) {
33
+ return value.replace(/[\s().-]/g, "");
34
+ }
35
+ function looksE164ish(value) {
36
+ return /^\+?\d{6,15}$/.test(looseDigits(value));
37
+ }
38
+ function createPhone(config) {
39
+ const { country } = config.phone;
40
+ if (country === "NO" && config.phone.validate === "strict") return {
41
+ country,
42
+ validate: "strict",
43
+ telHref,
44
+ nationalDigits: norwegianDigits,
45
+ looksValid: looksNorwegian
46
+ };
47
+ return {
48
+ country,
49
+ validate: "loose",
50
+ telHref,
51
+ nationalDigits: looseDigits,
52
+ looksValid: looksE164ish
53
+ };
54
+ }
55
+ //#endregion
56
+ export { createPhone, telHref };
57
+
58
+ //# sourceMappingURL=phone.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phone.mjs","names":[],"sources":["../../src/core/phone.ts"],"sourcesContent":["/**\n * The booking flow's \"never a dead end\" affordances hand the visitor to the\n * telephone: a service the business does not take online, a slot inside the\n * lead time, a fully-booked day, a change asked for inside the cancellation\n * window. They all dial the same number, so they all build the link the same\n * way — one rule, in one place.\n *\n * The number itself is always a prop (`config.contact.phone`), and may be\n * missing, so every one of those sentences also has an unlinked form: a `tel:`\n * that dials nothing looks like an offer and fails in the visitor's hand.\n */\n\nimport type { BookingConfig } from './config';\n\n/** `22 33 44 55` → `tel:22334455`. Spaces are typography; a phone dials digits. */\nexport function telHref(phone: string): string {\n return `tel:${phone.replace(/\\s/g, '')}`;\n}\n\nexport interface Phone {\n readonly country: string;\n /** `strict` only for a country with a built-in rule set (`NO` in 0.x). */\n readonly validate: 'strict' | 'loose';\n telHref(phone: string): string;\n /** What the details step submits, out of what the visitor typed. */\n nationalDigits(value: string): string;\n /** Whether the typed value is plausibly a number for this country. */\n looksValid(value: string): boolean;\n}\n\n/**\n * Norway, the one strict rule set.\n *\n * The contact is deduplicated on this number, so «400 00 000» and\n * «+4740000000» from one parent must not become two families in the CRM.\n * Spacing goes, and so does a country code they pasted in on top of the `+47`\n * the field already shows.\n *\n * Only `+47` and `0047` are stripped, never a bare leading `47`: `47 12 34 56`\n * is an ordinary eight-digit Norwegian number, and taking the first two digits\n * off it would submit somebody else's phone.\n */\nfunction norwegianDigits(value: string): string {\n return value.replace(/[\\s()-]/g, '').replace(/^(?:\\+47|0047)/, '');\n}\n\n/**\n * Eight digits, and nothing about which digit comes first.\n *\n * A claim about length, so a rule about length: requiring a leading 4 or 9\n * would be a *mobile* rule, and would answer a parent who typed a landline by\n * telling them a digit was missing when eight were present.\n */\nfunction looksNorwegian(value: string): boolean {\n return /^\\d{8}$/.test(norwegianDigits(value));\n}\n\n/** Anywhere else: separators go, a leading `+` stays, and 6–15 digits is E.164-ish. */\nfunction looseDigits(value: string): string {\n return value.replace(/[\\s().-]/g, '');\n}\n\nfunction looksE164ish(value: string): boolean {\n return /^\\+?\\d{6,15}$/.test(looseDigits(value));\n}\n\nexport function createPhone(config: Pick<BookingConfig, 'phone'>): Phone {\n const { country } = config.phone;\n if (country === 'NO' && config.phone.validate === 'strict') {\n return {\n country,\n validate: 'strict',\n telHref,\n nationalDigits: norwegianDigits,\n looksValid: looksNorwegian,\n };\n }\n return {\n country,\n validate: 'loose',\n telHref,\n nationalDigits: looseDigits,\n looksValid: looksE164ish,\n };\n}\n"],"mappings":";;AAeA,SAAgB,QAAQ,OAAuB;CAC7C,OAAO,OAAO,MAAM,QAAQ,OAAO,EAAE;AACvC;;;;;;;;;;;;;AAyBA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,QAAQ,YAAY,EAAE,CAAC,CAAC,QAAQ,kBAAkB,EAAE;AACnE;;;;;;;;AASA,SAAS,eAAe,OAAwB;CAC9C,OAAO,UAAU,KAAK,gBAAgB,KAAK,CAAC;AAC9C;;AAGA,SAAS,YAAY,OAAuB;CAC1C,OAAO,MAAM,QAAQ,aAAa,EAAE;AACtC;AAEA,SAAS,aAAa,OAAwB;CAC5C,OAAO,gBAAgB,KAAK,YAAY,KAAK,CAAC;AAChD;AAEA,SAAgB,YAAY,QAA6C;CACvE,MAAM,EAAE,YAAY,OAAO;CAC3B,IAAI,YAAY,QAAQ,OAAO,MAAM,aAAa,UAChD,OAAO;EACL;EACA,UAAU;EACV;EACA,gBAAgB;EAChB,YAAY;CACd;CAEF,OAAO;EACL;EACA,UAAU;EACV;EACA,gBAAgB;EAChB,YAAY;CACd;AACF"}
@@ -0,0 +1,29 @@
1
+ //#region src/core/portal/cookies.d.ts
2
+ /**
3
+ * The cookie jar the portal helpers read and write, as an interface.
4
+ *
5
+ * `/core` imports no framework, so the helpers that touch cookies take the jar
6
+ * from a function the caller supplies. `/next` (P4) passes Next's own
7
+ * `cookies()` from `next/headers`; a test passes an in-memory jar.
8
+ */
9
+ export interface CookieAttributes {
10
+ httpOnly?: boolean;
11
+ secure?: boolean;
12
+ sameSite?: 'lax' | 'strict' | 'none';
13
+ path?: string;
14
+ maxAge?: number;
15
+ expires?: Date;
16
+ }
17
+ export interface CookieJar {
18
+ get(name: string): {
19
+ value?: string;
20
+ } | undefined;
21
+ set(name: string, value: string, options?: CookieAttributes): unknown;
22
+ delete(options: {
23
+ name: string;
24
+ } & CookieAttributes): unknown;
25
+ }
26
+ /** Where the jar for THIS request comes from — Next's `cookies`, or a test double. */
27
+ export type CookieSource = () => CookieJar | Promise<CookieJar>;
28
+ //#endregion
29
+ //# sourceMappingURL=cookies.d.mts.map
@@ -0,0 +1,140 @@
1
+ import { BookingConfig } from "../config.mjs";
2
+ import { PortalBooking, PortalBookingStatus, PortalProfile } from "@medalsocial/sdk";
3
+ //#region src/core/portal/dto.d.ts
4
+ /**
5
+ * One child on the profile — a PERSON now, not a name (SP10).
6
+ *
7
+ * `personId` is what makes a child something this site can point at: the id
8
+ * of the `contactPersons` row Medal keeps, stable across a rename through
9
+ * `/me/persons/{id}`, and the key a past booking carries as
10
+ * `booked_for_person_id`. It is `null` only against a Medal that sent neither
11
+ * a `person_id` on the family entry nor one unambiguous person of that name
12
+ * and year — the site then falls back to what it did before persons existed.
13
+ *
14
+ * `birthMonth`, `notes` («Notat til frisøren») and `preferredResourceId`
15
+ * («Fast frisør») are SP10's portal-editable details; each is `null` when
16
+ * unset AND when Medal predates them — see `personDetails` on the profile for
17
+ * telling the two apart.
18
+ */
19
+ export interface PortalFamilyMemberDto {
20
+ personId: string | null;
21
+ name: string;
22
+ birthYear: number;
23
+ birthMonth: number | null;
24
+ notes: string | null;
25
+ preferredResourceId: string | null;
26
+ }
27
+ /**
28
+ * The bransjemal preset's words for who a booking is for — «Barn» for a
29
+ * children's salon, «Familie» wherever the fallback still applies. `person` is the
30
+ * singular a future per-row label would use; only `persons` is read today.
31
+ */
32
+ export interface PortalLabels {
33
+ person: string;
34
+ persons: string;
35
+ }
36
+ export interface PortalProfileDto {
37
+ contactId: string;
38
+ email: string;
39
+ firstName: string | null;
40
+ lastName: string | null;
41
+ phone: string | null;
42
+ family: PortalFamilyMemberDto[];
43
+ /**
44
+ * Whether this Medal speaks SP10 persons — birth month, notes and preferred
45
+ * stylist on each child, and the `/me/persons` endpoints that edit them.
46
+ * Read off the wire (the fields are present, `null` or not), so the editor
47
+ * offers the extra fields only where they can be saved. `false` also for a
48
+ * profile with no persons at all, where there is nothing to read it off;
49
+ * the person actions still try the endpoints and fall back on a 404.
50
+ */
51
+ personDetails: boolean;
52
+ marketingConsent: boolean;
53
+ labels?: PortalLabels;
54
+ /**
55
+ * Whether the parent's Vipps account is linked to this profile — present
56
+ * only when Medal says (`vipps_linked`, with the «Koble til Vipps» routes).
57
+ * ABSENT against a Medal that predates them, and then Min side → Profil
58
+ * draws no Vipps row: a button whose route does not exist is a dead end.
59
+ */
60
+ vippsLinked?: boolean;
61
+ }
62
+ export interface PortalBookingDto {
63
+ bookingId: string;
64
+ status: PortalBookingStatus;
65
+ startTs: number;
66
+ endTs: number;
67
+ serviceId: string | null;
68
+ serviceName: string | null;
69
+ resourceId: string | null;
70
+ resourceName: string | null;
71
+ bookedForName: string | null;
72
+ /**
73
+ * Who the visit was for, by id — the `person_id` of one of the parent's
74
+ * children, or `null` for a visit for the parent themselves, one made before
75
+ * persons existed, or a Medal that does not send it yet (SP10). This is the
76
+ * key «Samme som sist» joins on; the name is only the fallback.
77
+ */
78
+ bookedForPersonId: string | null;
79
+ /** The birth year and month frozen onto the booking, when Medal sends them. */
80
+ bookedForBirthYear: number | null;
81
+ bookedForBirthMonth: number | null;
82
+ amountOre: number | null;
83
+ notes: string | null;
84
+ /**
85
+ * Where this booking is managed, or `null` when Medal issued no token for it
86
+ * — a cancelled or completed row, or one made over the counter. The token
87
+ * itself is deliberately not on this object; see the module comment.
88
+ */
89
+ managePath: string | null;
90
+ }
91
+ /** 1–12, or `null` for anything else — absent, `null`, or nonsense. */
92
+ export declare function monthOrNull(value: unknown): number | null;
93
+ /**
94
+ * Nulls stay nulls. The page decides what a missing surname or phone number
95
+ * reads as, and a `''` here would make «not given» and «given as blank»
96
+ * indistinguishable to the form that edits them.
97
+ */
98
+ export declare function toPortalProfileDto(profile: PortalProfile): PortalProfileDto;
99
+ /**
100
+ * What a «Bestill igjen» card carries: enough to prefill the wizard's three
101
+ * questions (`?service&stylist&who`) and the two names it shows on the card.
102
+ * No ids the wizard cannot vouch for are trusted downstream — `applyPrefill`
103
+ * ignores a service or stylist the catalogue no longer lists — so this does
104
+ * not need to check them either.
105
+ */
106
+ export interface RebookSuggestion {
107
+ serviceId: string;
108
+ serviceName: string | null;
109
+ resourceId: string | null;
110
+ resourceName: string | null;
111
+ bookedForName: string | null;
112
+ }
113
+ /**
114
+ * The haircuts worth offering again, most recent first, at most `max` of them.
115
+ *
116
+ * Distinct by (service, stylist, child): a family that books the same cut for
117
+ * the same child with the same stylist every six weeks has ONE habit, not ten,
118
+ * and ten identical cards would push the second child's cut off the list.
119
+ * «Any stylist» (`resourceId: null`) is its own key — a parent who never
120
+ * chose is offered the same non-choice, not the stylist they happened to get.
121
+ *
122
+ * Only `completed` rows: a cancelled or no-show booking is not a haircut that
123
+ * happened, and a pending or confirmed one is upcoming, not history — offering
124
+ * to book it again would be offering a duplicate. A row whose `serviceId` is
125
+ * null has nothing to link to and is skipped rather than rendered as a card
126
+ * that opens the wizard on step 1.
127
+ *
128
+ * Sorts a copy: the caller's `past` array is rendered in its own order.
129
+ */
130
+ export declare function rebookSuggestions(past: PortalBookingDto[], max?: number): RebookSuggestion[];
131
+ export interface PortalDto {
132
+ toPortalProfileDto: typeof toPortalProfileDto;
133
+ /** The booking, with its manage token turned into this site's manage PATH (`paths.manage`). */
134
+ toPortalBookingDto(booking: PortalBooking): PortalBookingDto;
135
+ rebookSuggestions: typeof rebookSuggestions;
136
+ monthOrNull: typeof monthOrNull;
137
+ }
138
+ export declare function createPortalDto(config: Pick<BookingConfig, 'paths'>): PortalDto;
139
+ //#endregion
140
+ //# sourceMappingURL=dto.d.mts.map