@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,38 @@
1
+ import { BookingConfig } from "../core/config.mjs";
2
+ import { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from "../core/wire.mjs";
3
+ import { MedalSeam, RangeArgs } from "./medal.mjs";
4
+ import { BookingCacheAdapter, BookingLogger } from "./options.mjs";
5
+ //#region src/next/catalogue.d.ts
6
+ export declare const CATALOGUE_TAG = "booking-catalogue";
7
+ /** `{ fresh: true }` skips the cache for one read and writes no entry. Server
8
+ * callers only — the create route's live re-read after a `slotTaken`; no
9
+ * route lets a visitor ask for it. */
10
+ export interface ReadOptions {
11
+ fresh?: boolean;
12
+ }
13
+ export declare function slotsTag(serviceId: string): string;
14
+ /**
15
+ * Where a slot key starts: the later of the caller's `from` and now, floored to
16
+ * 30 s. Unrounded, no two visitors would share an entry; not clamped to now,
17
+ * any past instant would be a key of its own.
18
+ */
19
+ export declare function slotKeyStart(fromTs: number, now?: number): number;
20
+ export interface Catalogue {
21
+ cachedServices(): Promise<MedalService[]>;
22
+ cachedResources(): Promise<MedalResource[]>;
23
+ cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;
24
+ cachedAvailability(args: RangeArgs, options?: ReadOptions): Promise<MedalSlot[]>;
25
+ expireSlots(serviceIds: Iterable<string>): void;
26
+ }
27
+ export interface CatalogueOptions {
28
+ config: Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts'>;
29
+ cache: {
30
+ data: BookingCacheAdapter;
31
+ prefix: string;
32
+ };
33
+ logger: BookingLogger;
34
+ }
35
+ type Seam = Pick<MedalSeam, 'listServices' | 'listResources' | 'listAvailability' | 'listSchedule'>;
36
+ export declare function createCatalogue(options: CatalogueOptions, medal: Seam): Catalogue;
37
+ //#endregion
38
+ //# sourceMappingURL=catalogue.d.mts.map
@@ -0,0 +1,215 @@
1
+ import { createClock } from "../core/clock.mjs";
2
+ import { visitKeyOfIds } from "../core/visit.mjs";
3
+ import { cacheLoad } from "./options.mjs";
4
+ //#region src/next/catalogue.ts
5
+ /**
6
+ * A short-lived data cache in front of Medal's booking READS (the L2 cache).
7
+ *
8
+ * The booking page and the wizard's route handlers would otherwise ask Medal
9
+ * live on every request, which is most of a one-second TTFB and most of the
10
+ * wait after a service tap. It reads through `options.cache.data`: with the
11
+ * `next-data` adapter that is `unstable_cache` (on OpenNext, the incremental
12
+ * cache — R2 plus the Durable Object tag cache), shared across isolates and
13
+ * visitors.
14
+ *
15
+ * `unstable_cache` is stale-while-revalidate: an expired entry is served once
16
+ * more while it refills. So every key also carries TIME — the slot key a 30 s
17
+ * bucket, the catalogue keys a five-minute one — and a key that has moved on
18
+ * is a miss, not a stale hit. The resulting bounds:
19
+ *
20
+ * - free slots (tag `booking-slots:<serviceId>`, one per service in the
21
+ * visit): at most ~60 s old. The
22
+ * site's own create / move / cancel expire the tag at once (`expireSlots`);
23
+ * the bound is what covers a booking made anywhere else;
24
+ * - the catalogue (services, stylists, opening hours; tag `booking-catalogue`):
25
+ * at most ~10 min old.
26
+ *
27
+ * KEYS. Every entry is keyed `[prefix, '<read>']` plus its arguments, the
28
+ * scheme the first site on this package already uses, so its key parts stay
29
+ * the same when `prefix` does. A slot or schedule key names the VISIT
30
+ * (`visitKeyOfIds`: `a+b` for a person having both, the bare id for one
31
+ * service), so a one-service key is byte-identical to what it was before
32
+ * visits existed and a deploy does not empty the cache.
33
+ *
34
+ * A failed read throws and is not cached, so a Medal blip is not pinned.
35
+ */
36
+ const CATALOGUE_TAG = "booking-catalogue";
37
+ const CATALOGUE_TTL_S = 300;
38
+ const SLOTS_TTL_S = 30;
39
+ /** The slot key step, and the catalogue's time bucket. */
40
+ const SLOT_KEY_STEP_MS = 3e4;
41
+ const CATALOGUE_BUCKET_MS = 3e5;
42
+ /** How far ahead a cached read may reach; beyond it the read is live. Matches
43
+ * the 62-day range bound the routes put on a query string. */
44
+ const MAX_KEY_AHEAD_MS = 53568e5;
45
+ function slotsTag(serviceId) {
46
+ return `booking-slots:${serviceId}`;
47
+ }
48
+ /**
49
+ * Where a slot key starts: the later of the caller's `from` and now, floored to
50
+ * 30 s. Unrounded, no two visitors would share an entry; not clamped to now,
51
+ * any past instant would be a key of its own.
52
+ */
53
+ function slotKeyStart(fromTs, now = Date.now()) {
54
+ return Math.floor(Math.max(fromTs, now) / SLOT_KEY_STEP_MS) * SLOT_KEY_STEP_MS;
55
+ }
56
+ /** Every service in the read's visit, the first one first. */
57
+ function visitIdsOf(args) {
58
+ return [args.serviceId, ...args.extraServiceIds ?? []];
59
+ }
60
+ /** `extraServiceIds` as a spread: absent for a one-service visit, so a
61
+ * one-service read reaches the seam with exactly the arguments it always had. */
62
+ function extrasOf(args) {
63
+ return args.extraServiceIds?.length ? { extraServiceIds: args.extraServiceIds } : {};
64
+ }
65
+ function catalogueBucket(now) {
66
+ return Math.floor(now / CATALOGUE_BUCKET_MS);
67
+ }
68
+ function createCatalogue(options, medal) {
69
+ const { data } = options.cache;
70
+ const KEY_PREFIX = options.cache.prefix;
71
+ const clock = createClock(options.config);
72
+ /** `ts` if it is a local midnight, otherwise the next one. */
73
+ function roundUpToDay(ts) {
74
+ return clock.dayStart(ts) === ts ? ts : clock.dayStart(ts, 1);
75
+ }
76
+ /**
77
+ * The range a read is CACHED under, or `null` for a read that must go live.
78
+ *
79
+ * The range comes from a visitor's query string, so the key is normalised to
80
+ * a small set of values — otherwise every distinct `from_ts`/`to_ts` would be
81
+ * a Medal call and a cache write:
82
+ *
83
+ * - `from` is `slotKeyStart` (the later of it and now, floored to 30 s);
84
+ * - `to` rounded UP to a local midnight — the booking page's window already
85
+ * ends on one, so it is unchanged there;
86
+ * - a key reaching more than 62 days ahead is not cached at all;
87
+ * - a stylist-narrowed read (the manage page's) is not cached at all.
88
+ *
89
+ * The caller's real bounds are applied to the answer afterwards.
90
+ */
91
+ function keyRange(args, now) {
92
+ if (args.resourceId) return null;
93
+ const fromTs = slotKeyStart(args.fromTs, now);
94
+ const toTs = roundUpToDay(args.toTs);
95
+ const limit = now + MAX_KEY_AHEAD_MS;
96
+ if (fromTs > limit || toTs > limit || toTs <= fromTs) return null;
97
+ return {
98
+ fromTs,
99
+ toTs
100
+ };
101
+ }
102
+ function cachedServices() {
103
+ return cacheLoad(data, [KEY_PREFIX, "services"], [catalogueBucket(Date.now())], () => medal.listServices(), {
104
+ ttlSeconds: CATALOGUE_TTL_S,
105
+ tags: [CATALOGUE_TAG]
106
+ });
107
+ }
108
+ function cachedResources() {
109
+ return cacheLoad(data, [KEY_PREFIX, "resources"], [catalogueBucket(Date.now())], () => medal.listResources(), {
110
+ ttlSeconds: CATALOGUE_TTL_S,
111
+ tags: [CATALOGUE_TAG]
112
+ });
113
+ }
114
+ /**
115
+ * Opening hours per service (the cutoffs are per service) over a range.
116
+ *
117
+ * The key range can end after the one asked about, so dates outside the
118
+ * caller's real range are dropped.
119
+ */
120
+ async function cachedSchedule(args) {
121
+ const now = Date.now();
122
+ const key = keyRange(args, now);
123
+ if (key === null) return medal.listSchedule(args);
124
+ const { serviceId } = args;
125
+ const days = await cacheLoad(data, [KEY_PREFIX, "schedule"], [
126
+ visitKeyOfIds(visitIdsOf(args)),
127
+ key.fromTs,
128
+ key.toTs,
129
+ catalogueBucket(now)
130
+ ], () => medal.listSchedule({
131
+ serviceId,
132
+ ...extrasOf(args),
133
+ fromTs: key.fromTs,
134
+ toTs: key.toTs
135
+ }), {
136
+ ttlSeconds: CATALOGUE_TTL_S,
137
+ tags: [CATALOGUE_TAG]
138
+ });
139
+ const firstDay = clock.dayKey(args.fromTs);
140
+ const lastDay = clock.dayKey(args.toTs - 1);
141
+ return days.filter((day) => day.date === null || day.date >= firstDay && day.date <= lastDay);
142
+ }
143
+ /**
144
+ * Free slots for one visit — one service, or one person's several back to
145
+ * back. The entry carries the tag of EVERY service in the visit, so a write
146
+ * to any one of them (`expireSlots`) retires it; the key is the same for
147
+ * every call with the same arguments.
148
+ *
149
+ * Only slots starting inside the caller's real `[fromTs, toTs)` AND not
150
+ * before now are returned: the key reaches forward to the next local
151
+ * midnight, and an entry up to a minute old may still hold a slot that has
152
+ * since started.
153
+ */
154
+ async function cachedAvailability(args, readOptions = {}) {
155
+ const now = Date.now();
156
+ const key = readOptions.fresh ? null : keyRange(args, now);
157
+ let slots;
158
+ if (key === null) slots = await medal.listAvailability(args);
159
+ else {
160
+ const { serviceId } = args;
161
+ slots = await cacheLoad(data, [KEY_PREFIX, "availability"], [
162
+ visitKeyOfIds(visitIdsOf(args)),
163
+ key.fromTs,
164
+ key.toTs,
165
+ Math.floor(now / SLOT_KEY_STEP_MS)
166
+ ], () => medal.listAvailability({
167
+ serviceId,
168
+ ...extrasOf(args),
169
+ fromTs: key.fromTs,
170
+ toTs: key.toTs
171
+ }), {
172
+ ttlSeconds: SLOTS_TTL_S,
173
+ tags: [...new Set(visitIdsOf(args))].map(slotsTag)
174
+ });
175
+ }
176
+ const earliest = Math.max(args.fromTs, now);
177
+ return slots.filter((slot) => {
178
+ if (slot.start_ts === null) return true;
179
+ const startTs = Date.parse(slot.start_ts);
180
+ return !Number.isFinite(startTs) || startTs >= earliest && startTs < args.toTs;
181
+ });
182
+ }
183
+ /**
184
+ * Expire the cached slots of every service a write just touched, so the next
185
+ * read — this visitor's confirmation, the next visitor's time step — is live.
186
+ *
187
+ * Best effort: the write has already happened, and a cache that could not be
188
+ * expired must not turn a made booking into an error. The ~60 s bound above
189
+ * is the backstop.
190
+ */
191
+ function expireSlots(serviceIds) {
192
+ for (const serviceId of new Set(serviceIds)) {
193
+ const warn = (error) => options.logger.warn({
194
+ err: error,
195
+ serviceId
196
+ }, "Could not expire cached booking slots");
197
+ try {
198
+ data.expireTag?.(slotsTag(serviceId))?.catch(warn);
199
+ } catch (error) {
200
+ warn(error);
201
+ }
202
+ }
203
+ }
204
+ return {
205
+ cachedServices,
206
+ cachedResources,
207
+ cachedSchedule,
208
+ cachedAvailability,
209
+ expireSlots
210
+ };
211
+ }
212
+ //#endregion
213
+ export { CATALOGUE_TAG, createCatalogue, slotKeyStart, slotsTag };
214
+
215
+ //# sourceMappingURL=catalogue.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalogue.mjs","names":[],"sources":["../../src/next/catalogue.ts"],"sourcesContent":["/**\n * A short-lived data cache in front of Medal's booking READS (the L2 cache).\n *\n * The booking page and the wizard's route handlers would otherwise ask Medal\n * live on every request, which is most of a one-second TTFB and most of the\n * wait after a service tap. It reads through `options.cache.data`: with the\n * `next-data` adapter that is `unstable_cache` (on OpenNext, the incremental\n * cache — R2 plus the Durable Object tag cache), shared across isolates and\n * visitors.\n *\n * `unstable_cache` is stale-while-revalidate: an expired entry is served once\n * more while it refills. So every key also carries TIME — the slot key a 30 s\n * bucket, the catalogue keys a five-minute one — and a key that has moved on\n * is a miss, not a stale hit. The resulting bounds:\n *\n * - free slots (tag `booking-slots:<serviceId>`, one per service in the\n * visit): at most ~60 s old. The\n * site's own create / move / cancel expire the tag at once (`expireSlots`);\n * the bound is what covers a booking made anywhere else;\n * - the catalogue (services, stylists, opening hours; tag `booking-catalogue`):\n * at most ~10 min old.\n *\n * KEYS. Every entry is keyed `[prefix, '<read>']` plus its arguments, the\n * scheme the first site on this package already uses, so its key parts stay\n * the same when `prefix` does. A slot or schedule key names the VISIT\n * (`visitKeyOfIds`: `a+b` for a person having both, the bare id for one\n * service), so a one-service key is byte-identical to what it was before\n * visits existed and a deploy does not empty the cache.\n *\n * A failed read throws and is not cached, so a Medal blip is not pinned.\n */\n\nimport { createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { visitKeyOfIds } from '../core/visit';\nimport type { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from '../core/wire';\nimport type { MedalSeam, RangeArgs } from './medal';\nimport { type BookingCacheAdapter, type BookingLogger, cacheLoad } from './options';\n\nexport const CATALOGUE_TAG = 'booking-catalogue';\nconst CATALOGUE_TTL_S = 300;\nconst SLOTS_TTL_S = 30;\n\n/** The slot key step, and the catalogue's time bucket. */\nconst SLOT_KEY_STEP_MS = 30 * 1000;\nconst CATALOGUE_BUCKET_MS = 5 * 60 * 1000;\n\n/** How far ahead a cached read may reach; beyond it the read is live. Matches\n * the 62-day range bound the routes put on a query string. */\nconst MAX_KEY_AHEAD_MS = 62 * 24 * 60 * 60 * 1000;\n\n/** `{ fresh: true }` skips the cache for one read and writes no entry. Server\n * callers only — the create route's live re-read after a `slotTaken`; no\n * route lets a visitor ask for it. */\nexport interface ReadOptions {\n fresh?: boolean;\n}\n\nexport function slotsTag(serviceId: string): string {\n return `booking-slots:${serviceId}`;\n}\n\n/**\n * Where a slot key starts: the later of the caller's `from` and now, floored to\n * 30 s. Unrounded, no two visitors would share an entry; not clamped to now,\n * any past instant would be a key of its own.\n */\nexport function slotKeyStart(fromTs: number, now: number = Date.now()): number {\n return Math.floor(Math.max(fromTs, now) / SLOT_KEY_STEP_MS) * SLOT_KEY_STEP_MS;\n}\n\n/** Every service in the read's visit, the first one first. */\nfunction visitIdsOf(args: RangeArgs): string[] {\n return [args.serviceId, ...(args.extraServiceIds ?? [])];\n}\n\n/** `extraServiceIds` as a spread: absent for a one-service visit, so a\n * one-service read reaches the seam with exactly the arguments it always had. */\nfunction extrasOf(args: RangeArgs): Pick<RangeArgs, 'extraServiceIds'> {\n return args.extraServiceIds?.length ? { extraServiceIds: args.extraServiceIds } : {};\n}\n\nfunction catalogueBucket(now: number): number {\n return Math.floor(now / CATALOGUE_BUCKET_MS);\n}\n\nexport interface Catalogue {\n cachedServices(): Promise<MedalService[]>;\n cachedResources(): Promise<MedalResource[]>;\n cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;\n cachedAvailability(args: RangeArgs, options?: ReadOptions): Promise<MedalSlot[]>;\n expireSlots(serviceIds: Iterable<string>): void;\n}\n\nexport interface CatalogueOptions {\n config: Pick<BookingConfig, 'timeZone' | 'locale' | 'dayparts'>;\n cache: { data: BookingCacheAdapter; prefix: string };\n logger: BookingLogger;\n}\n\ntype Seam = Pick<MedalSeam, 'listServices' | 'listResources' | 'listAvailability' | 'listSchedule'>;\n\nexport function createCatalogue(options: CatalogueOptions, medal: Seam): Catalogue {\n const { data } = options.cache;\n const KEY_PREFIX = options.cache.prefix;\n const clock = createClock(options.config);\n\n /** `ts` if it is a local midnight, otherwise the next one. */\n function roundUpToDay(ts: number): number {\n const start = clock.dayStart(ts);\n return start === ts ? ts : clock.dayStart(ts, 1);\n }\n\n /**\n * The range a read is CACHED under, or `null` for a read that must go live.\n *\n * The range comes from a visitor's query string, so the key is normalised to\n * a small set of values — otherwise every distinct `from_ts`/`to_ts` would be\n * a Medal call and a cache write:\n *\n * - `from` is `slotKeyStart` (the later of it and now, floored to 30 s);\n * - `to` rounded UP to a local midnight — the booking page's window already\n * ends on one, so it is unchanged there;\n * - a key reaching more than 62 days ahead is not cached at all;\n * - a stylist-narrowed read (the manage page's) is not cached at all.\n *\n * The caller's real bounds are applied to the answer afterwards.\n */\n function keyRange(args: RangeArgs, now: number): { fromTs: number; toTs: number } | null {\n if (args.resourceId) return null;\n const fromTs = slotKeyStart(args.fromTs, now);\n const toTs = roundUpToDay(args.toTs);\n const limit = now + MAX_KEY_AHEAD_MS;\n if (fromTs > limit || toTs > limit || toTs <= fromTs) return null;\n return { fromTs, toTs };\n }\n\n function cachedServices(): Promise<MedalService[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'services'],\n [catalogueBucket(Date.now())],\n () => medal.listServices(),\n {\n ttlSeconds: CATALOGUE_TTL_S,\n tags: [CATALOGUE_TAG],\n }\n );\n }\n\n function cachedResources(): Promise<MedalResource[]> {\n return cacheLoad(\n data,\n [KEY_PREFIX, 'resources'],\n [catalogueBucket(Date.now())],\n () => medal.listResources(),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n }\n\n /**\n * Opening hours per service (the cutoffs are per service) over a range.\n *\n * The key range can end after the one asked about, so dates outside the\n * caller's real range are dropped.\n */\n async function cachedSchedule(args: RangeArgs): Promise<MedalScheduleDay[]> {\n const now = Date.now();\n const key = keyRange(args, now);\n if (key === null) return medal.listSchedule(args);\n const { serviceId } = args;\n const days = await cacheLoad(\n data,\n [KEY_PREFIX, 'schedule'],\n // The visit, not the first service: a visit's last start is earlier than\n // its first service's alone, so the two answers are different entries.\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, catalogueBucket(now)],\n () =>\n medal.listSchedule({ serviceId, ...extrasOf(args), fromTs: key.fromTs, toTs: key.toTs }),\n { ttlSeconds: CATALOGUE_TTL_S, tags: [CATALOGUE_TAG] }\n );\n const firstDay = clock.dayKey(args.fromTs);\n // The local day holding the last instant before `toTs` is the last one\n // asked about; a day starting at or after `toTs` was not.\n const lastDay = clock.dayKey(args.toTs - 1);\n // `date` is ISO `YYYY-MM-DD`, so string order is date order. A null date is\n // left for `toBookingDayDto`, which already drops it.\n return days.filter((day) => day.date === null || (day.date >= firstDay && day.date <= lastDay));\n }\n\n /**\n * Free slots for one visit — one service, or one person's several back to\n * back. The entry carries the tag of EVERY service in the visit, so a write\n * to any one of them (`expireSlots`) retires it; the key is the same for\n * every call with the same arguments.\n *\n * Only slots starting inside the caller's real `[fromTs, toTs)` AND not\n * before now are returned: the key reaches forward to the next local\n * midnight, and an entry up to a minute old may still hold a slot that has\n * since started.\n */\n async function cachedAvailability(\n args: RangeArgs,\n readOptions: ReadOptions = {}\n ): Promise<MedalSlot[]> {\n const now = Date.now();\n const key = readOptions.fresh ? null : keyRange(args, now);\n let slots: MedalSlot[];\n if (key === null) {\n slots = await medal.listAvailability(args);\n } else {\n const { serviceId } = args;\n // The time bucket as well as the start: a FUTURE `from` (the manage\n // page's) keys a start that never moves, and the bucket still retires it.\n slots = await cacheLoad(\n data,\n [KEY_PREFIX, 'availability'],\n [visitKeyOfIds(visitIdsOf(args)), key.fromTs, key.toTs, Math.floor(now / SLOT_KEY_STEP_MS)],\n () =>\n medal.listAvailability({\n serviceId,\n ...extrasOf(args),\n fromTs: key.fromTs,\n toTs: key.toTs,\n }),\n { ttlSeconds: SLOTS_TTL_S, tags: [...new Set(visitIdsOf(args))].map(slotsTag) }\n );\n }\n const earliest = Math.max(args.fromTs, now);\n return slots.filter((slot) => {\n // Unreadable starts are kept for `toBookingSlotDto`, which drops them.\n if (slot.start_ts === null) return true;\n const startTs = Date.parse(slot.start_ts);\n return !Number.isFinite(startTs) || (startTs >= earliest && startTs < args.toTs);\n });\n }\n\n /**\n * Expire the cached slots of every service a write just touched, so the next\n * read — this visitor's confirmation, the next visitor's time step — is live.\n *\n * Best effort: the write has already happened, and a cache that could not be\n * expired must not turn a made booking into an error. The ~60 s bound above\n * is the backstop.\n */\n function expireSlots(serviceIds: Iterable<string>): void {\n for (const serviceId of new Set(serviceIds)) {\n const warn = (error: unknown) =>\n options.logger.warn({ err: error, serviceId }, 'Could not expire cached booking slots');\n try {\n data.expireTag?.(slotsTag(serviceId))?.catch(warn);\n } catch (error) {\n warn(error);\n }\n }\n }\n\n return { cachedServices, cachedResources, cachedSchedule, cachedAvailability, expireSlots };\n}\n"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import { BookingRuntime } from "./runtime.mjs";
2
+ //#region src/next/handler.d.ts
3
+ export interface BookingHandler {
4
+ GET(request: Request): Promise<Response>;
5
+ POST(request: Request): Promise<Response>;
6
+ DELETE(request: Request): Promise<Response>;
7
+ }
8
+ /** The handler over an existing runtime (what `createBookingServer` shares). */
9
+ export declare function createBookingHandlerFor(rt: BookingRuntime): BookingHandler;
10
+ //#endregion
11
+ //# sourceMappingURL=handler.d.mts.map
@@ -0,0 +1,150 @@
1
+ import { bookingErrorResponse } from "./routes/shared.mjs";
2
+ import { availabilityRoute, avatarRoute, nextFreeRoute, resourcesRoute, scheduleRoute, servicesRoute } from "./routes/catalogue-routes.mjs";
3
+ import { createRoute } from "./routes/create.mjs";
4
+ import { manageRoute, manageSummaryRoute } from "./routes/manage.mjs";
5
+ import { loginVerifyRoute, personsRoute, sessionExpiredRoute, sessionTouchRoute, vippsFlashRoute, vippsLinkVerifyRoute } from "./routes/portal-routes.mjs";
6
+ import { NextResponse } from "next/server";
7
+ //#region src/next/handler.ts
8
+ /**
9
+ * One drop-in handler for every booking and portal API route.
10
+ *
11
+ * An app mounts it on two catch-alls and is done:
12
+ *
13
+ * ```ts
14
+ * // app/api/booking/[...path]/route.ts and app/api/portal/[...path]/route.ts
15
+ * export const { GET, POST, DELETE } = booking.handler;
16
+ * ```
17
+ *
18
+ * It dispatches on the path after `config.paths.api` / `config.paths.portalApi`:
19
+ *
20
+ * | Path (after the prefix) | Methods |
21
+ * |---|---|
22
+ * | `services`, `resources`, `availability`, `schedule`, `next-free` | GET |
23
+ * | `avatar/<resourceId>` | GET |
24
+ * | `create` | POST |
25
+ * | `manage/<token>` | GET (the booking), POST (`cancel` / `reschedule`) |
26
+ * | portal `login/verify`, `persons`, `session/touch`, `vipps/link/verify` | POST |
27
+ * | portal `session/expired` | GET |
28
+ * | portal `vipps/flash` | DELETE |
29
+ *
30
+ * Static routes in the app still win over the catch-all in Next, so a site
31
+ * keeps any route of its own beside these.
32
+ */
33
+ const BOOKING_ROUTES = {
34
+ services: { GET: (rt) => servicesRoute(rt) },
35
+ resources: { GET: (rt, request) => resourcesRoute(rt, request) },
36
+ availability: { GET: (rt, request) => availabilityRoute(rt, request) },
37
+ schedule: { GET: (rt, request) => scheduleRoute(rt, request) },
38
+ "next-free": { GET: (rt) => nextFreeRoute(rt) },
39
+ create: { POST: (rt, request) => createRoute(rt, request) }
40
+ };
41
+ const BOOKING_PARAM_ROUTES = {
42
+ avatar: { GET: (rt, request, segment) => avatarRoute(rt, request, segment) },
43
+ manage: {
44
+ GET: (rt, request, segment) => manageSummaryRoute(rt, request, segment),
45
+ POST: (rt, request, segment) => manageRoute(rt, request, segment)
46
+ }
47
+ };
48
+ const PORTAL_ROUTES = {
49
+ "login/verify": { POST: (rt, request) => loginVerifyRoute(rt, request) },
50
+ persons: { POST: (rt, request) => personsRoute(rt, request) },
51
+ "session/touch": { POST: (rt) => sessionTouchRoute(rt) },
52
+ "session/expired": { GET: (rt, request) => sessionExpiredRoute(rt, request) },
53
+ "vipps/flash": { DELETE: (rt, request) => vippsFlashRoute(rt, request) },
54
+ "vipps/link/verify": { POST: (rt, request) => vippsLinkVerifyRoute(rt, request) }
55
+ };
56
+ /** The rest of `pathname` after `prefix`, or `null` when it is not under it. */
57
+ function under(pathname, prefix) {
58
+ const base = prefix.replace(/\/+$/, "");
59
+ if (!pathname.startsWith(`${base}/`)) return null;
60
+ return pathname.slice(base.length + 1).replace(/\/+$/, "");
61
+ }
62
+ function decoded(segment) {
63
+ try {
64
+ return decodeURIComponent(segment);
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+ function matchBooking(rest) {
70
+ const fixed = BOOKING_ROUTES[rest];
71
+ if (fixed) return {
72
+ scope: `booking/${rest}`,
73
+ routes: fixed,
74
+ segment: ""
75
+ };
76
+ const slash = rest.indexOf("/");
77
+ if (slash > 0) {
78
+ const head = rest.slice(0, slash);
79
+ const tail = rest.slice(slash + 1);
80
+ const routes = BOOKING_PARAM_ROUTES[head];
81
+ if (routes && tail !== "" && !tail.includes("/")) {
82
+ const segment = decoded(tail);
83
+ if (segment === null) return "malformed";
84
+ return {
85
+ scope: `booking/${head}`,
86
+ routes,
87
+ segment
88
+ };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ function matchPortal(rest) {
94
+ const routes = PORTAL_ROUTES[rest];
95
+ return routes ? {
96
+ scope: `portal/${rest}`,
97
+ routes,
98
+ segment: ""
99
+ } : null;
100
+ }
101
+ /**
102
+ * The route a path names. The LONGER prefix is tried first, so a portal API
103
+ * nested under the booking API (`/api` and `/api/portal`) still reaches the
104
+ * portal routes, and the other way round.
105
+ */
106
+ function match(rt, pathname) {
107
+ const { api, portalApi } = rt.config.paths;
108
+ const prefixes = [{
109
+ prefix: api,
110
+ find: matchBooking
111
+ }, {
112
+ prefix: portalApi,
113
+ find: matchPortal
114
+ }].sort((a, b) => b.prefix.replace(/\/+$/, "").length - a.prefix.replace(/\/+$/, "").length);
115
+ for (const { prefix, find } of prefixes) {
116
+ const rest = under(pathname, prefix);
117
+ if (rest === null) continue;
118
+ const found = find(rest);
119
+ if (found !== null) return found;
120
+ }
121
+ return null;
122
+ }
123
+ /** The handler over an existing runtime (what `createBookingServer` shares). */
124
+ function createBookingHandlerFor(rt) {
125
+ async function handle(method, request) {
126
+ const found = match(rt, new URL(request.url).pathname);
127
+ if (found === "malformed") return bookingErrorResponse("invalidInput", 400, "path is malformed");
128
+ if (found === null) return bookingErrorResponse("notFound", 404);
129
+ const route = found.routes[method];
130
+ if (!route) {
131
+ const response = bookingErrorResponse("methodNotAllowed", 405);
132
+ response.headers.set("Allow", Object.keys(found.routes).join(", "));
133
+ return response;
134
+ }
135
+ if (rt.options.rateLimit && await rt.options.rateLimit(found.scope, request)) return NextResponse.json({ error: "rateLimited" }, {
136
+ status: 429,
137
+ headers: { "Cache-Control": "no-store" }
138
+ });
139
+ return route(rt, request, found.segment);
140
+ }
141
+ return {
142
+ GET: (request) => handle("GET", request),
143
+ POST: (request) => handle("POST", request),
144
+ DELETE: (request) => handle("DELETE", request)
145
+ };
146
+ }
147
+ //#endregion
148
+ export { createBookingHandlerFor };
149
+
150
+ //# sourceMappingURL=handler.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.mjs","names":[],"sources":["../../src/next/handler.ts"],"sourcesContent":["/**\n * One drop-in handler for every booking and portal API route.\n *\n * An app mounts it on two catch-alls and is done:\n *\n * ```ts\n * // app/api/booking/[...path]/route.ts and app/api/portal/[...path]/route.ts\n * export const { GET, POST, DELETE } = booking.handler;\n * ```\n *\n * It dispatches on the path after `config.paths.api` / `config.paths.portalApi`:\n *\n * | Path (after the prefix) | Methods |\n * |---|---|\n * | `services`, `resources`, `availability`, `schedule`, `next-free` | GET |\n * | `avatar/<resourceId>` | GET |\n * | `create` | POST |\n * | `manage/<token>` | GET (the booking), POST (`cancel` / `reschedule`) |\n * | portal `login/verify`, `persons`, `session/touch`, `vipps/link/verify` | POST |\n * | portal `session/expired` | GET |\n * | portal `vipps/flash` | DELETE |\n *\n * Static routes in the app still win over the catch-all in Next, so a site\n * keeps any route of its own beside these.\n */\n\nimport { NextResponse } from 'next/server';\nimport {\n availabilityRoute,\n avatarRoute,\n nextFreeRoute,\n resourcesRoute,\n scheduleRoute,\n servicesRoute,\n} from './routes/catalogue-routes';\nimport { createRoute } from './routes/create';\nimport { manageRoute, manageSummaryRoute } from './routes/manage';\nimport {\n loginVerifyRoute,\n personsRoute,\n sessionExpiredRoute,\n sessionTouchRoute,\n vippsFlashRoute,\n vippsLinkVerifyRoute,\n} from './routes/portal-routes';\nimport { bookingErrorResponse } from './routes/shared';\nimport type { BookingRuntime } from './runtime';\n\ntype Method = 'GET' | 'POST' | 'DELETE';\n\ntype Route = (rt: BookingRuntime, request: Request, segment: string) => Promise<Response>;\n\ninterface Match {\n scope: string;\n routes: Partial<Record<Method, Route>>;\n /** The decoded trailing segment, for `avatar/<id>` and `manage/<token>`. */\n segment: string;\n}\n\nconst BOOKING_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n services: { GET: (rt) => servicesRoute(rt) },\n resources: { GET: (rt, request) => resourcesRoute(rt, request) },\n availability: { GET: (rt, request) => availabilityRoute(rt, request) },\n schedule: { GET: (rt, request) => scheduleRoute(rt, request) },\n 'next-free': { GET: (rt) => nextFreeRoute(rt) },\n create: { POST: (rt, request) => createRoute(rt, request) },\n};\n\nconst BOOKING_PARAM_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n avatar: { GET: (rt, request, segment) => avatarRoute(rt, request, segment) },\n manage: {\n GET: (rt, request, segment) => manageSummaryRoute(rt, request, segment),\n POST: (rt, request, segment) => manageRoute(rt, request, segment),\n },\n};\n\nconst PORTAL_ROUTES: Record<string, Partial<Record<Method, Route>>> = {\n 'login/verify': { POST: (rt, request) => loginVerifyRoute(rt, request) },\n persons: { POST: (rt, request) => personsRoute(rt, request) },\n 'session/touch': { POST: (rt) => sessionTouchRoute(rt) },\n 'session/expired': { GET: (rt, request) => sessionExpiredRoute(rt, request) },\n 'vipps/flash': { DELETE: (rt, request) => vippsFlashRoute(rt, request) },\n 'vipps/link/verify': { POST: (rt, request) => vippsLinkVerifyRoute(rt, request) },\n};\n\n/** The rest of `pathname` after `prefix`, or `null` when it is not under it. */\nfunction under(pathname: string, prefix: string): string | null {\n const base = prefix.replace(/\\/+$/, '');\n if (!pathname.startsWith(`${base}/`)) return null;\n return pathname.slice(base.length + 1).replace(/\\/+$/, '');\n}\n\nfunction decoded(segment: string): string | null {\n try {\n return decodeURIComponent(segment);\n } catch {\n return null;\n }\n}\n\nfunction matchBooking(rest: string): Match | 'malformed' | null {\n const fixed = BOOKING_ROUTES[rest];\n if (fixed) return { scope: `booking/${rest}`, routes: fixed, segment: '' };\n const slash = rest.indexOf('/');\n if (slash > 0) {\n const head = rest.slice(0, slash);\n const tail = rest.slice(slash + 1);\n const routes = BOOKING_PARAM_ROUTES[head];\n if (routes && tail !== '' && !tail.includes('/')) {\n const segment = decoded(tail);\n if (segment === null) return 'malformed';\n return { scope: `booking/${head}`, routes, segment };\n }\n }\n return null;\n}\n\nfunction matchPortal(rest: string): Match | null {\n const routes = PORTAL_ROUTES[rest];\n return routes ? { scope: `portal/${rest}`, routes, segment: '' } : null;\n}\n\n/**\n * The route a path names. The LONGER prefix is tried first, so a portal API\n * nested under the booking API (`/api` and `/api/portal`) still reaches the\n * portal routes, and the other way round.\n */\nfunction match(rt: BookingRuntime, pathname: string): Match | 'malformed' | null {\n const { api, portalApi } = rt.config.paths;\n const prefixes = [\n { prefix: api, find: matchBooking },\n { prefix: portalApi, find: matchPortal },\n ].sort((a, b) => b.prefix.replace(/\\/+$/, '').length - a.prefix.replace(/\\/+$/, '').length);\n for (const { prefix, find } of prefixes) {\n const rest = under(pathname, prefix);\n if (rest === null) continue;\n const found = find(rest);\n if (found !== null) return found;\n }\n return null;\n}\n\nexport interface BookingHandler {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n DELETE(request: Request): Promise<Response>;\n}\n\n/** The handler over an existing runtime (what `createBookingServer` shares). */\nexport function createBookingHandlerFor(rt: BookingRuntime): BookingHandler {\n async function handle(method: Method, request: Request): Promise<Response> {\n const found = match(rt, new URL(request.url).pathname);\n if (found === 'malformed')\n return bookingErrorResponse('invalidInput', 400, 'path is malformed');\n if (found === null) return bookingErrorResponse('notFound', 404);\n const route = found.routes[method];\n if (!route) {\n const response = bookingErrorResponse('methodNotAllowed', 405);\n response.headers.set('Allow', Object.keys(found.routes).join(', '));\n return response;\n }\n if (rt.options.rateLimit && (await rt.options.rateLimit(found.scope, request))) {\n return NextResponse.json(\n { error: 'rateLimited' },\n { status: 429, headers: { 'Cache-Control': 'no-store' } }\n );\n }\n return route(rt, request, found.segment);\n }\n\n return {\n GET: (request) => handle('GET', request),\n POST: (request) => handle('POST', request),\n DELETE: (request) => handle('DELETE', request),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,MAAM,iBAAiE;CACrE,UAAU,EAAE,MAAM,OAAO,cAAc,EAAE,EAAE;CAC3C,WAAW,EAAE,MAAM,IAAI,YAAY,eAAe,IAAI,OAAO,EAAE;CAC/D,cAAc,EAAE,MAAM,IAAI,YAAY,kBAAkB,IAAI,OAAO,EAAE;CACrE,UAAU,EAAE,MAAM,IAAI,YAAY,cAAc,IAAI,OAAO,EAAE;CAC7D,aAAa,EAAE,MAAM,OAAO,cAAc,EAAE,EAAE;CAC9C,QAAQ,EAAE,OAAO,IAAI,YAAY,YAAY,IAAI,OAAO,EAAE;AAC5D;AAEA,MAAM,uBAAuE;CAC3E,QAAQ,EAAE,MAAM,IAAI,SAAS,YAAY,YAAY,IAAI,SAAS,OAAO,EAAE;CAC3E,QAAQ;EACN,MAAM,IAAI,SAAS,YAAY,mBAAmB,IAAI,SAAS,OAAO;EACtE,OAAO,IAAI,SAAS,YAAY,YAAY,IAAI,SAAS,OAAO;CAClE;AACF;AAEA,MAAM,gBAAgE;CACpE,gBAAgB,EAAE,OAAO,IAAI,YAAY,iBAAiB,IAAI,OAAO,EAAE;CACvE,SAAS,EAAE,OAAO,IAAI,YAAY,aAAa,IAAI,OAAO,EAAE;CAC5D,iBAAiB,EAAE,OAAO,OAAO,kBAAkB,EAAE,EAAE;CACvD,mBAAmB,EAAE,MAAM,IAAI,YAAY,oBAAoB,IAAI,OAAO,EAAE;CAC5E,eAAe,EAAE,SAAS,IAAI,YAAY,gBAAgB,IAAI,OAAO,EAAE;CACvE,qBAAqB,EAAE,OAAO,IAAI,YAAY,qBAAqB,IAAI,OAAO,EAAE;AAClF;;AAGA,SAAS,MAAM,UAAkB,QAA+B;CAC9D,MAAM,OAAO,OAAO,QAAQ,QAAQ,EAAE;CACtC,IAAI,CAAC,SAAS,WAAW,GAAG,KAAK,EAAE,GAAG,OAAO;CAC7C,OAAO,SAAS,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,QAAQ,EAAE;AAC3D;AAEA,SAAS,QAAQ,SAAgC;CAC/C,IAAI;EACF,OAAO,mBAAmB,OAAO;CACnC,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,aAAa,MAA0C;CAC9D,MAAM,QAAQ,eAAe;CAC7B,IAAI,OAAO,OAAO;EAAE,OAAO,WAAW;EAAQ,QAAQ;EAAO,SAAS;CAAG;CACzE,MAAM,QAAQ,KAAK,QAAQ,GAAG;CAC9B,IAAI,QAAQ,GAAG;EACb,MAAM,OAAO,KAAK,MAAM,GAAG,KAAK;EAChC,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC;EACjC,MAAM,SAAS,qBAAqB;EACpC,IAAI,UAAU,SAAS,MAAM,CAAC,KAAK,SAAS,GAAG,GAAG;GAChD,MAAM,UAAU,QAAQ,IAAI;GAC5B,IAAI,YAAY,MAAM,OAAO;GAC7B,OAAO;IAAE,OAAO,WAAW;IAAQ;IAAQ;GAAQ;EACrD;CACF;CACA,OAAO;AACT;AAEA,SAAS,YAAY,MAA4B;CAC/C,MAAM,SAAS,cAAc;CAC7B,OAAO,SAAS;EAAE,OAAO,UAAU;EAAQ;EAAQ,SAAS;CAAG,IAAI;AACrE;;;;;;AAOA,SAAS,MAAM,IAAoB,UAA8C;CAC/E,MAAM,EAAE,KAAK,cAAc,GAAG,OAAO;CACrC,MAAM,WAAW,CACf;EAAE,QAAQ;EAAK,MAAM;CAAa,GAClC;EAAE,QAAQ;EAAW,MAAM;CAAY,CACzC,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,QAAQ,QAAQ,EAAE,CAAC,CAAC,SAAS,EAAE,OAAO,QAAQ,QAAQ,EAAE,CAAC,CAAC,MAAM;CAC1F,KAAK,MAAM,EAAE,QAAQ,UAAU,UAAU;EACvC,MAAM,OAAO,MAAM,UAAU,MAAM;EACnC,IAAI,SAAS,MAAM;EACnB,MAAM,QAAQ,KAAK,IAAI;EACvB,IAAI,UAAU,MAAM,OAAO;CAC7B;CACA,OAAO;AACT;;AASA,SAAgB,wBAAwB,IAAoC;CAC1E,eAAe,OAAO,QAAgB,SAAqC;EACzE,MAAM,QAAQ,MAAM,IAAI,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,QAAQ;EACrD,IAAI,UAAU,aACZ,OAAO,qBAAqB,gBAAgB,KAAK,mBAAmB;EACtE,IAAI,UAAU,MAAM,OAAO,qBAAqB,YAAY,GAAG;EAC/D,MAAM,QAAQ,MAAM,OAAO;EAC3B,IAAI,CAAC,OAAO;GACV,MAAM,WAAW,qBAAqB,oBAAoB,GAAG;GAC7D,SAAS,QAAQ,IAAI,SAAS,OAAO,KAAK,MAAM,MAAM,CAAC,CAAC,KAAK,IAAI,CAAC;GAClE,OAAO;EACT;EACA,IAAI,GAAG,QAAQ,aAAc,MAAM,GAAG,QAAQ,UAAU,MAAM,OAAO,OAAO,GAC1E,OAAO,aAAa,KAClB,EAAE,OAAO,cAAc,GACvB;GAAE,QAAQ;GAAK,SAAS,EAAE,iBAAiB,WAAW;EAAE,CAC1D;EAEF,OAAO,MAAM,IAAI,SAAS,MAAM,OAAO;CACzC;CAEA,OAAO;EACL,MAAM,YAAY,OAAO,OAAO,OAAO;EACvC,OAAO,YAAY,OAAO,QAAQ,OAAO;EACzC,SAAS,YAAY,OAAO,UAAU,OAAO;CAC/C;AACF"}
@@ -0,0 +1,17 @@
1
+ import { CreateBookingBody, CreateBookingResult, DEFAULT_MEDAL_ENDPOINT, MedalApiError, MedalConfigError, MedalSeam, MedalSeamOptions, RangeArgs, RecordConsentBody, RescheduleResult, createMedalSeam, looksLikePlaceholderKey } from "./medal.mjs";
2
+ import { BookingCacheAdapter, BookingLogger, BookingPortalServerOptions, BookingRateLimit, BookingServerOptions, BookingTiming, BookingTimingPhase, DEFAULT_AVATAR_HOSTS, DEFAULT_EDGE_PREFIX, DEFAULT_PORTAL_MESSAGES, PortalMessages } from "./options.mjs";
3
+ import { CATALOGUE_TAG, Catalogue, ReadOptions, createCatalogue, slotKeyStart, slotsTag } from "./catalogue.mjs";
4
+ import { PersonTarget, PortalPersonInput, PortalPersonResult, PortalSeam, PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError, VippsLinkCompletion } from "./portal/medal-portal.mjs";
5
+ import { toBookingFamilyMember } from "./portal/guardian.mjs";
6
+ import { PortalSession, RENEWED_SESSION_LIFETIME_MS } from "./portal/session.mjs";
7
+ import { VIPPS_LINK_FLASHES, VippsLinkFlash, vippsLinkFlash } from "./portal/vipps-flash.mjs";
8
+ import { BookingCatalogue, BookingSeed, SEED_RECHECK_MS, SEED_STALE_MAX_MS, Seed, SeedState, createSeed } from "./seed.mjs";
9
+ import { BookingRuntime, createBookingRuntime } from "./runtime.mjs";
10
+ import { BookingHandler } from "./handler.mjs";
11
+ import { BookingContact, BookingPageResult, ManagePageResult, PageSwitches, PortalPageResult } from "./loaders.mjs";
12
+ import { toManageDto } from "./manage-dto.mjs";
13
+ import { InvalidInput, PersonActionResult, Portal, PortalActions, PortalSchemas, SessionFailure, UpdateProfileResult, VerifyLoginResult, VippsLinkStartState, VippsStartState } from "./portal.mjs";
14
+ import { isCrossOriginRequest, originFromHeaders, readBoundedText } from "./request.mjs";
15
+ import { BookingServer, createBookingHandler, createBookingServer, createPortal, loadBookingPage, loadManagePage, loadPortalPage } from "./server.mjs";
16
+ import "server-only";
17
+ export { type BookingCacheAdapter, type BookingCatalogue, type BookingContact, type BookingHandler, type BookingLogger, type BookingPageResult, type BookingPortalServerOptions, type BookingRateLimit, type BookingRuntime, type BookingSeed, type BookingServer, type BookingServerOptions, type BookingTiming, type BookingTimingPhase, CATALOGUE_TAG, type Catalogue, type CreateBookingBody, type CreateBookingResult, DEFAULT_AVATAR_HOSTS, DEFAULT_EDGE_PREFIX, DEFAULT_MEDAL_ENDPOINT, DEFAULT_PORTAL_MESSAGES, type InvalidInput, type ManagePageResult, MedalApiError, MedalConfigError, type MedalSeam, type MedalSeamOptions, type PageSwitches, type PersonActionResult, type PersonTarget, type Portal, type PortalActions, type PortalMessages, type PortalPageResult, type PortalPersonInput, type PortalPersonResult, type PortalSchemas, type PortalSeam, type PortalSession, PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError, RENEWED_SESSION_LIFETIME_MS, type RangeArgs, type ReadOptions, type RecordConsentBody, type RescheduleResult, SEED_RECHECK_MS, SEED_STALE_MAX_MS, type Seed, type SeedState, type SessionFailure, type UpdateProfileResult, VIPPS_LINK_FLASHES, type VerifyLoginResult, type VippsLinkCompletion, type VippsLinkFlash, type VippsLinkStartState, type VippsStartState, createBookingHandler, createBookingRuntime, createBookingServer, createCatalogue, createMedalSeam, createPortal, createSeed, isCrossOriginRequest, loadBookingPage, loadManagePage, loadPortalPage, looksLikePlaceholderKey, originFromHeaders, readBoundedText, slotKeyStart, slotsTag, toBookingFamilyMember, toManageDto, vippsLinkFlash };
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_AVATAR_HOSTS, DEFAULT_EDGE_PREFIX, DEFAULT_PORTAL_MESSAGES } from "./options.mjs";
2
+ import { CATALOGUE_TAG, createCatalogue, slotKeyStart, slotsTag } from "./catalogue.mjs";
3
+ import { toManageDto } from "./manage-dto.mjs";
4
+ import { DEFAULT_MEDAL_ENDPOINT, MedalApiError, MedalConfigError, createMedalSeam, looksLikePlaceholderKey } from "./medal.mjs";
5
+ import { toBookingFamilyMember } from "./portal/guardian.mjs";
6
+ import { PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError } from "./portal/medal-portal.mjs";
7
+ import { RENEWED_SESSION_LIFETIME_MS } from "./portal/session.mjs";
8
+ import { VIPPS_LINK_FLASHES, vippsLinkFlash } from "./portal/vipps-flash.mjs";
9
+ import { isCrossOriginRequest, originFromHeaders, readBoundedText } from "./request.mjs";
10
+ import { SEED_RECHECK_MS, SEED_STALE_MAX_MS, createSeed } from "./seed.mjs";
11
+ import { createBookingRuntime } from "./runtime.mjs";
12
+ import { createBookingHandler, createBookingServer, createPortal, loadBookingPage, loadManagePage, loadPortalPage } from "./server.mjs";
13
+ import "server-only";
14
+ export { CATALOGUE_TAG, DEFAULT_AVATAR_HOSTS, DEFAULT_EDGE_PREFIX, DEFAULT_MEDAL_ENDPOINT, DEFAULT_PORTAL_MESSAGES, MedalApiError, MedalConfigError, PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError, RENEWED_SESSION_LIFETIME_MS, SEED_RECHECK_MS, SEED_STALE_MAX_MS, VIPPS_LINK_FLASHES, createBookingHandler, createBookingRuntime, createBookingServer, createCatalogue, createMedalSeam, createPortal, createSeed, isCrossOriginRequest, loadBookingPage, loadManagePage, loadPortalPage, looksLikePlaceholderKey, originFromHeaders, readBoundedText, slotKeyStart, slotsTag, toBookingFamilyMember, toManageDto, vippsLinkFlash };
@@ -0,0 +1,79 @@
1
+ import { BookingConfig } from "../core/config.mjs";
2
+ import { BookingGuardian, BookingManageDto, BookingServiceDto } from "../core/types.mjs";
3
+ import { PortalBookingDto, PortalProfileDto } from "../core/portal/dto.mjs";
4
+ import { VippsLinkFlash } from "./portal/vipps-flash.mjs";
5
+ import { BookingSeed } from "./seed.mjs";
6
+ import "./runtime.mjs";
7
+ //#region src/next/loaders.d.ts
8
+ /** The business's own contact, for «call us» and the unavailable screens. */
9
+ export interface BookingContact {
10
+ phone: string | null;
11
+ address: string | null;
12
+ }
13
+ /** The per-request switches a site resolves itself (from a CMS, say). */
14
+ export interface PageSwitches {
15
+ /** Set = the booking page hands off here. Default `config.handoffUrl`. */
16
+ handoffUrl?: string | null;
17
+ /** Default `config.contact`. */
18
+ contact?: BookingContact;
19
+ }
20
+ export type BookingPageResult = {
21
+ kind: 'redirect';
22
+ href: string;
23
+ } | {
24
+ kind: 'unavailable';
25
+ contact: BookingContact;
26
+ } | {
27
+ kind: 'ready';
28
+ seed: BookingSeed;
29
+ guardian: BookingGuardian | null;
30
+ config: Readonly<BookingConfig>;
31
+ contact: BookingContact;
32
+ /** The window the seed describes; the wizard's day strip must use the same. */
33
+ rangeDays: number;
34
+ };
35
+ export type ManagePageResult = {
36
+ kind: 'unreachable';
37
+ retryHref: string;
38
+ contact: BookingContact;
39
+ } | {
40
+ kind: 'unknown';
41
+ bookingHref: string;
42
+ contact: BookingContact;
43
+ } | {
44
+ kind: 'ready';
45
+ booking: BookingManageDto;
46
+ service: BookingServiceDto | null;
47
+ /** Where the page POSTs cancel / reschedule. */
48
+ actionPath: string;
49
+ /** The page's own URL, for the calendar entry's «change or cancel» line. */
50
+ selfManagePath: string;
51
+ bookingHref: string;
52
+ contact: BookingContact;
53
+ };
54
+ export type PortalPageResult = {
55
+ kind: 'disabled';
56
+ } | {
57
+ kind: 'redirect';
58
+ href: string;
59
+ } | {
60
+ kind: 'unreachable';
61
+ contact: BookingContact;
62
+ } | {
63
+ kind: 'ready';
64
+ profile: PortalProfileDto;
65
+ bookings: {
66
+ upcoming: PortalBookingDto[];
67
+ past: PortalBookingDto[];
68
+ };
69
+ stylists: Array<{
70
+ id: string;
71
+ name: string;
72
+ }>;
73
+ /** How a «link Vipps» attempt just ended, for the profile section to say. */
74
+ vippsFlash: VippsLinkFlash | null;
75
+ bookingHref: string;
76
+ contact: BookingContact;
77
+ };
78
+ //#endregion
79
+ //# sourceMappingURL=loaders.d.mts.map