@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,123 @@
1
+ //#region src/next/request.ts
2
+ function isLocalhost(url) {
3
+ let hostname = "";
4
+ try {
5
+ hostname = new URL(url).hostname.toLowerCase();
6
+ } catch {
7
+ return false;
8
+ }
9
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0";
10
+ }
11
+ /**
12
+ * The host the browser asked for: `x-forwarded-host` when present, else `host`.
13
+ *
14
+ * Behind a proxy the bare `host` is the last hop, not what was typed. Only the
15
+ * first value of a comma-joined list is used — the rest are upstream hops.
16
+ */
17
+ function forwardedHost(headers) {
18
+ return (headers.get("x-forwarded-host") ?? headers.get("host"))?.split(",")[0]?.trim();
19
+ }
20
+ /**
21
+ * The origin a request arrived on, from its headers, or `fallback`.
22
+ *
23
+ * For code that runs inside a Server Action, which has no `Request` to read
24
+ * `url` from the way a route handler does. `x-forwarded-host` and
25
+ * `x-forwarded-proto` win over `host` because on Cloudflare and behind Next's
26
+ * own proxying they name the host the browser typed; a bare `host` is the
27
+ * last hop. HTTPS is assumed whenever the proto header is missing and the
28
+ * host is not local — an absolute URL handed to a third party should not
29
+ * guess `http`.
30
+ */
31
+ function originFromHeaders(headers, fallback) {
32
+ const host = forwardedHost(headers);
33
+ if (!host) return fallback;
34
+ const origin = `${headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || (isLocalhost(`http://${host}`) ? "http" : "https")}://${host}`;
35
+ try {
36
+ return new URL(origin).origin;
37
+ } catch {
38
+ return fallback;
39
+ }
40
+ }
41
+ /**
42
+ * Whether the submission came from a page on somebody else's origin.
43
+ *
44
+ * The threat this answers is a page on another origin POSTing here on a
45
+ * visitor's behalf — for which comparing the declared origin to this site's
46
+ * own is sufficient, because a browser sets `Origin` on a cross-site POST and
47
+ * will not let the page lie about it.
48
+ *
49
+ * A request that declares NEITHER `Origin` nor `Referer` is let through here;
50
+ * the routes that mint a session refuse it on their own (`declaresNoOrigin`).
51
+ *
52
+ * An origin that will not parse — `null` from a sandboxed frame, or a garbled
53
+ * one — is treated as somebody else's. It is not this site's.
54
+ */
55
+ function isCrossOriginRequest(request) {
56
+ const declared = request.headers.get("origin") ?? request.headers.get("referer");
57
+ if (!declared) return false;
58
+ let declaredOrigin;
59
+ try {
60
+ declaredOrigin = new URL(declared).origin;
61
+ } catch {
62
+ return true;
63
+ }
64
+ let ownOrigin;
65
+ try {
66
+ ownOrigin = new URL(request.url).origin;
67
+ } catch {
68
+ return true;
69
+ }
70
+ return declaredOrigin !== originFromHeaders(request.headers, ownOrigin);
71
+ }
72
+ /** Neither `Origin` nor `Referer`: every browser sends `Origin` on a `fetch` POST. */
73
+ function declaresNoOrigin(request) {
74
+ return !request.headers.get("origin") && !request.headers.get("referer");
75
+ }
76
+ /**
77
+ * The request body as text, or `null` if it is over the ceiling.
78
+ *
79
+ * Read in pieces and counted in BYTES, which is the whole point of both
80
+ * halves. `request.text()` buffers everything before anything can judge it,
81
+ * and a decoded string's `.length` is UTF-16 code units, not bytes — so
82
+ * measuring the decoded string against a byte limit lets a multibyte body
83
+ * several times the ceiling straight through.
84
+ *
85
+ * The stream is cancelled the moment the count passes the ceiling, so the
86
+ * remainder is never pulled across.
87
+ */
88
+ async function readBoundedText(request, maxBytes) {
89
+ const body = request.body;
90
+ if (!body) {
91
+ const fallback = await request.text();
92
+ return new TextEncoder().encode(fallback).byteLength > maxBytes ? null : fallback;
93
+ }
94
+ const reader = body.getReader();
95
+ const chunks = [];
96
+ let total = 0;
97
+ try {
98
+ while (true) {
99
+ const { done, value } = await reader.read();
100
+ if (done) break;
101
+ if (!value) continue;
102
+ total += value.byteLength;
103
+ if (total > maxBytes) {
104
+ await reader.cancel();
105
+ return null;
106
+ }
107
+ chunks.push(value);
108
+ }
109
+ } finally {
110
+ reader.releaseLock();
111
+ }
112
+ const joined = new Uint8Array(total);
113
+ let at = 0;
114
+ for (const chunk of chunks) {
115
+ joined.set(chunk, at);
116
+ at += chunk.byteLength;
117
+ }
118
+ return new TextDecoder().decode(joined);
119
+ }
120
+ //#endregion
121
+ export { declaresNoOrigin, isCrossOriginRequest, originFromHeaders, readBoundedText };
122
+
123
+ //# sourceMappingURL=request.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.mjs","names":[],"sources":["../../src/next/request.ts"],"sourcesContent":["/**\n * The request guards the portal routes share: same-origin checks, a bounded\n * body read and the origin a request arrived on.\n */\n\n/** The subset of `Headers` these helpers read. Keeps them callable from tests. */\nexport type RequestHeaders = { get(name: string): string | null };\n\nfunction isLocalhost(url: string): boolean {\n let hostname = '';\n try {\n hostname = new URL(url).hostname.toLowerCase();\n } catch {\n return false;\n }\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';\n}\n\n/**\n * The host the browser asked for: `x-forwarded-host` when present, else `host`.\n *\n * Behind a proxy the bare `host` is the last hop, not what was typed. Only the\n * first value of a comma-joined list is used — the rest are upstream hops.\n */\nfunction forwardedHost(headers: RequestHeaders): string | undefined {\n return (headers.get('x-forwarded-host') ?? headers.get('host'))?.split(',')[0]?.trim();\n}\n\n/**\n * The origin a request arrived on, from its headers, or `fallback`.\n *\n * For code that runs inside a Server Action, which has no `Request` to read\n * `url` from the way a route handler does. `x-forwarded-host` and\n * `x-forwarded-proto` win over `host` because on Cloudflare and behind Next's\n * own proxying they name the host the browser typed; a bare `host` is the\n * last hop. HTTPS is assumed whenever the proto header is missing and the\n * host is not local — an absolute URL handed to a third party should not\n * guess `http`.\n */\nexport function originFromHeaders(headers: RequestHeaders, fallback: string): string {\n const host = forwardedHost(headers);\n if (!host) return fallback;\n const forwardedProto = headers.get('x-forwarded-proto')?.split(',')[0]?.trim();\n const proto = forwardedProto || (isLocalhost(`http://${host}`) ? 'http' : 'https');\n const origin = `${proto}://${host}`;\n try {\n return new URL(origin).origin;\n } catch {\n return fallback;\n }\n}\n\n/**\n * Whether the submission came from a page on somebody else's origin.\n *\n * The threat this answers is a page on another origin POSTing here on a\n * visitor's behalf — for which comparing the declared origin to this site's\n * own is sufficient, because a browser sets `Origin` on a cross-site POST and\n * will not let the page lie about it.\n *\n * A request that declares NEITHER `Origin` nor `Referer` is let through here;\n * the routes that mint a session refuse it on their own (`declaresNoOrigin`).\n *\n * An origin that will not parse — `null` from a sandboxed frame, or a garbled\n * one — is treated as somebody else's. It is not this site's.\n */\nexport function isCrossOriginRequest(request: Request): boolean {\n const declared = request.headers.get('origin') ?? request.headers.get('referer');\n if (!declared) return false;\n\n let declaredOrigin: string;\n try {\n declaredOrigin = new URL(declared).origin;\n } catch {\n return true;\n }\n\n // The host the browser typed, not the last hop: on Cloudflare the Worker's\n // own `request.url` can name an internal hostname the `Origin` never will.\n let ownOrigin: string;\n try {\n ownOrigin = new URL(request.url).origin;\n } catch {\n return true;\n }\n return declaredOrigin !== originFromHeaders(request.headers, ownOrigin);\n}\n\n/** Neither `Origin` nor `Referer`: every browser sends `Origin` on a `fetch` POST. */\nexport function declaresNoOrigin(request: Request): boolean {\n return !request.headers.get('origin') && !request.headers.get('referer');\n}\n\n/**\n * The request body as text, or `null` if it is over the ceiling.\n *\n * Read in pieces and counted in BYTES, which is the whole point of both\n * halves. `request.text()` buffers everything before anything can judge it,\n * and a decoded string's `.length` is UTF-16 code units, not bytes — so\n * measuring the decoded string against a byte limit lets a multibyte body\n * several times the ceiling straight through.\n *\n * The stream is cancelled the moment the count passes the ceiling, so the\n * remainder is never pulled across.\n */\nexport async function readBoundedText(request: Request, maxBytes: number): Promise<string | null> {\n const body = request.body;\n // No stream to read — an empty body, or a runtime that does not expose one.\n // `text()` is bounded by the same ceiling rather than trusted.\n if (!body) {\n const fallback = await request.text();\n return new TextEncoder().encode(fallback).byteLength > maxBytes ? null : fallback;\n }\n\n const reader = body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (!value) continue;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return null;\n }\n chunks.push(value);\n }\n } finally {\n reader.releaseLock();\n }\n\n const joined = new Uint8Array(total);\n let at = 0;\n for (const chunk of chunks) {\n joined.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(joined);\n}\n"],"mappings":";AAQA,SAAS,YAAY,KAAsB;CACzC,IAAI,WAAW;CACf,IAAI;EACF,WAAW,IAAI,IAAI,GAAG,CAAC,CAAC,SAAS,YAAY;CAC/C,QAAQ;EACN,OAAO;CACT;CACA,OAAO,aAAa,eAAe,aAAa,eAAe,aAAa;AAC9E;;;;;;;AAQA,SAAS,cAAc,SAA6C;CAClE,QAAQ,QAAQ,IAAI,kBAAkB,KAAK,QAAQ,IAAI,MAAM,EAAA,EAAI,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;AACvF;;;;;;;;;;;;AAaA,SAAgB,kBAAkB,SAAyB,UAA0B;CACnF,MAAM,OAAO,cAAc,OAAO;CAClC,IAAI,CAAC,MAAM,OAAO;CAGlB,MAAM,SAAS,GAFQ,QAAQ,IAAI,mBAAmB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,MAC5C,YAAY,UAAU,MAAM,IAAI,SAAS,SAClD,KAAK;CAC7B,IAAI;EACF,OAAO,IAAI,IAAI,MAAM,CAAC,CAAC;CACzB,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,qBAAqB,SAA2B;CAC9D,MAAM,WAAW,QAAQ,QAAQ,IAAI,QAAQ,KAAK,QAAQ,QAAQ,IAAI,SAAS;CAC/E,IAAI,CAAC,UAAU,OAAO;CAEtB,IAAI;CACJ,IAAI;EACF,iBAAiB,IAAI,IAAI,QAAQ,CAAC,CAAC;CACrC,QAAQ;EACN,OAAO;CACT;CAIA,IAAI;CACJ,IAAI;EACF,YAAY,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;CACnC,QAAQ;EACN,OAAO;CACT;CACA,OAAO,mBAAmB,kBAAkB,QAAQ,SAAS,SAAS;AACxE;;AAGA,SAAgB,iBAAiB,SAA2B;CAC1D,OAAO,CAAC,QAAQ,QAAQ,IAAI,QAAQ,KAAK,CAAC,QAAQ,QAAQ,IAAI,SAAS;AACzE;;;;;;;;;;;;;AAcA,eAAsB,gBAAgB,SAAkB,UAA0C;CAChG,MAAM,OAAO,QAAQ;CAGrB,IAAI,CAAC,MAAM;EACT,MAAM,WAAW,MAAM,QAAQ,KAAK;EACpC,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,QAAQ,CAAC,CAAC,aAAa,WAAW,OAAO;CAC3E;CAEA,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,SAAuB,CAAC;CAC9B,IAAI,QAAQ;CACZ,IAAI;EACF,OAAO,MAAM;GACX,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;GAC1C,IAAI,MAAM;GACV,IAAI,CAAC,OAAO;GACZ,SAAS,MAAM;GACf,IAAI,QAAQ,UAAU;IACpB,MAAM,OAAO,OAAO;IACpB,OAAO;GACT;GACA,OAAO,KAAK,KAAK;EACnB;CACF,UAAU;EACR,OAAO,YAAY;CACrB;CAEA,MAAM,SAAS,IAAI,WAAW,KAAK;CACnC,IAAI,KAAK;CACT,KAAK,MAAM,SAAS,QAAQ;EAC1B,OAAO,IAAI,OAAO,EAAE;EACpB,MAAM,MAAM;CACd;CACA,OAAO,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM;AACxC"}
@@ -0,0 +1,344 @@
1
+ import { RESOURCE_ID_SHAPE } from "../../core/dto.mjs";
2
+ import { earliestOpening } from "../../core/next-available.mjs";
3
+ import { DEFAULT_AVATAR_HOSTS } from "../options.mjs";
4
+ import { bookingErrorResponse, catalogueErrorResponse, knownExtraServiceIds, knownServiceId, parseRange } from "./shared.mjs";
5
+ import { NextResponse } from "next/server";
6
+ //#region src/next/routes/catalogue-routes.ts
7
+ /**
8
+ * The read routes the wizard asks while it is on screen: the price list, the
9
+ * stylists (with each one's next opening), free slots, opening hours, the
10
+ * next free start and a stylist's photo.
11
+ *
12
+ * Every guard answers before Medal is called, and every read goes through the
13
+ * data cache (`catalogue.ts`), which the site's own writes expire. There is
14
+ * deliberately no way for a visitor to skip it.
15
+ */
16
+ /**
17
+ * The full price list, in the order Medal returns it. Nothing is filtered: a
18
+ * service with `bookable_online: false` is still a service the business sells,
19
+ * and hiding it here would leave the printed price list and the website
20
+ * disagreeing. Read through the catalogue cache (at most ~10 min old).
21
+ */
22
+ async function servicesRoute(rt) {
23
+ try {
24
+ const services = await rt.catalogue.cachedServices();
25
+ return NextResponse.json({ services: services.map(rt.dto.toBookingServiceDto) });
26
+ } catch (error) {
27
+ return catalogueErrorResponse(rt, error, "services");
28
+ }
29
+ }
30
+ /**
31
+ * The window the caller asked about, never one this route invents: «next
32
+ * free» has to agree with the day strip on the time step, and the wizard owns
33
+ * that horizon.
34
+ *
35
+ * Best effort, and never fatal. A stylist list with no «next free» lines is a
36
+ * working step; a 502 here would be a screen with nobody on it.
37
+ */
38
+ async function firstOpeningPerResource(rt, serviceId, extraServiceIds, range) {
39
+ try {
40
+ const slots = await rt.catalogue.cachedAvailability({
41
+ serviceId,
42
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
43
+ ...range
44
+ });
45
+ const earliest = {};
46
+ for (const slot of slots) {
47
+ if (slot.resource_id === null || slot.start_ts === null) continue;
48
+ const startTs = Date.parse(slot.start_ts);
49
+ if (!Number.isFinite(startTs)) continue;
50
+ const seen = earliest[slot.resource_id];
51
+ if (seen === void 0 || startTs < seen) earliest[slot.resource_id] = startTs;
52
+ }
53
+ return earliest;
54
+ } catch (error) {
55
+ rt.logger.warn({
56
+ err: error,
57
+ serviceId
58
+ }, "Could not read next-available times for step 2");
59
+ return {};
60
+ }
61
+ }
62
+ /**
63
+ * The stylists step 2 offers, and — when asked about a service — the first
64
+ * minute each of them is free. Two answers from one round trip, because step 2
65
+ * is one screen. The availability read is the SAME cached entry the
66
+ * availability route and the booking page use for this service and window.
67
+ */
68
+ async function resourcesRoute(rt, request) {
69
+ const { searchParams } = new URL(request.url);
70
+ const rawServiceId = searchParams.get("service_id")?.trim();
71
+ let range = null;
72
+ let serviceId = null;
73
+ let extraServiceIds = [];
74
+ if (rawServiceId) {
75
+ const parsed = parseRange(searchParams);
76
+ if ("error" in parsed) return parsed.error;
77
+ range = parsed;
78
+ const known = await knownServiceId(rt, rawServiceId);
79
+ if ("error" in known) return known.error;
80
+ serviceId = known.serviceId;
81
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
82
+ if ("error" in extras) return extras.error;
83
+ extraServiceIds = extras.extraServiceIds;
84
+ }
85
+ const nextAvailable = serviceId !== null && range !== null ? firstOpeningPerResource(rt, serviceId, extraServiceIds, range) : Promise.resolve({});
86
+ let resources;
87
+ try {
88
+ resources = await rt.catalogue.cachedResources();
89
+ } catch (error) {
90
+ return catalogueErrorResponse(rt, error, "resources");
91
+ }
92
+ return NextResponse.json({
93
+ resources: resources.map(rt.dto.toBookingResourceDto),
94
+ nextAvailableTs: await nextAvailable
95
+ });
96
+ }
97
+ /**
98
+ * Free slots for one visit over one range — one service, or one person's
99
+ * several (`extra_service_ids`) back to back — optionally narrowed to the
100
+ * stylist chosen on step 2. Read through the slot cache (at most ~60 s old),
101
+ * which the site's own create / move / cancel routes expire on success.
102
+ */
103
+ async function availabilityRoute(rt, request) {
104
+ const { searchParams } = new URL(request.url);
105
+ const rawServiceId = searchParams.get("service_id")?.trim();
106
+ if (!rawServiceId) return bookingErrorResponse("invalidInput", 400, "service_id is required");
107
+ const range = parseRange(searchParams);
108
+ if ("error" in range) return range.error;
109
+ const known = await knownServiceId(rt, rawServiceId);
110
+ if ("error" in known) return known.error;
111
+ const { serviceId } = known;
112
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
113
+ if ("error" in extras) return extras.error;
114
+ const { extraServiceIds } = extras;
115
+ const resourceId = searchParams.get("resource_id")?.trim();
116
+ try {
117
+ const slots = await rt.catalogue.cachedAvailability({
118
+ serviceId,
119
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
120
+ ...resourceId ? { resourceId } : {},
121
+ fromTs: range.fromTs,
122
+ toTs: range.toTs
123
+ });
124
+ return NextResponse.json({ slots: slots.flatMap(rt.dto.toBookingSlotDto) });
125
+ } catch (error) {
126
+ return catalogueErrorResponse(rt, error, "availability");
127
+ }
128
+ }
129
+ /**
130
+ * The open dates over one range, optionally narrowed to one stylist — the half
131
+ * availability cannot answer: it returns free slots and nothing else, so a
132
+ * closed Sunday, an evening after closing and a booked-out Thursday all arrive
133
+ * as the same empty array. A date ABSENT from the response is one the business
134
+ * keeps no hours on. Opening hours are per stylist, so the filter matters here
135
+ * as much as on availability.
136
+ */
137
+ async function scheduleRoute(rt, request) {
138
+ const { searchParams } = new URL(request.url);
139
+ const rawServiceId = searchParams.get("service_id")?.trim();
140
+ if (!rawServiceId) return bookingErrorResponse("invalidInput", 400, "service_id is required");
141
+ const range = parseRange(searchParams);
142
+ if ("error" in range) return range.error;
143
+ const known = await knownServiceId(rt, rawServiceId);
144
+ if ("error" in known) return known.error;
145
+ const { serviceId } = known;
146
+ const extras = await knownExtraServiceIds(rt, searchParams.get("extra_service_ids"), serviceId);
147
+ if ("error" in extras) return extras.error;
148
+ const { extraServiceIds } = extras;
149
+ const resourceId = searchParams.get("resource_id")?.trim();
150
+ try {
151
+ const days = await rt.catalogue.cachedSchedule({
152
+ ...resourceId ? { resourceId } : {},
153
+ serviceId,
154
+ ...extraServiceIds.length > 0 ? { extraServiceIds } : {},
155
+ fromTs: range.fromTs,
156
+ toTs: range.toTs
157
+ });
158
+ return NextResponse.json({ days: days.flatMap(rt.dto.toBookingDayDto) });
159
+ } catch (error) {
160
+ return catalogueErrorResponse(rt, error, "schedule");
161
+ }
162
+ }
163
+ /**
164
+ * «Next free: today 11:00» — the next free start for the default prefetch
165
+ * set, for a sticky booking bar or a hero. Read from the booking seed the
166
+ * booking page itself renders from, so it costs no Medal call of its own and
167
+ * cannot name a time the wizard will not show. `startTs: null` when nothing is
168
+ * free or the seed cannot be read: the callers then simply say nothing.
169
+ *
170
+ * The label is formatted here, on the business's clock, so a visitor abroad
171
+ * and the server agree on what «today» means.
172
+ */
173
+ async function nextFreeRoute(rt) {
174
+ const now = Date.now();
175
+ try {
176
+ const seed = await rt.seed.loadBookingSeed(void 0, now);
177
+ const startTs = earliestOpening(seed.slots, now);
178
+ return NextResponse.json({
179
+ startTs,
180
+ label: startTs === null ? null : rt.clock.when(startTs, now)
181
+ }, { headers: { "Cache-Control": "private, max-age=30" } });
182
+ } catch (error) {
183
+ rt.logger.warn({ err: error }, "next-free: booking seed unavailable");
184
+ return NextResponse.json({
185
+ startTs: null,
186
+ label: null
187
+ }, { headers: { "Cache-Control": "no-store" } });
188
+ }
189
+ }
190
+ const AVATAR_CACHE_CONTROL = "public, max-age=86400, stale-while-revalidate=604800";
191
+ function uncached(status) {
192
+ return new NextResponse(null, {
193
+ status,
194
+ headers: { "Cache-Control": "no-store" }
195
+ });
196
+ }
197
+ /** How long the photo host gets before the card falls back to initials: a hung
198
+ * read must not hold a request open. */
199
+ const UPSTREAM_TIMEOUT_MS = 5e3;
200
+ /** FNV-1a, 32-bit, twice with different seeds — synchronous and plenty for a
201
+ * cache validator; nothing here is a secret. */
202
+ function etagFor(objectKey) {
203
+ const hash = (seed) => {
204
+ let h = seed;
205
+ for (let i = 0; i < objectKey.length; i += 1) {
206
+ h ^= objectKey.charCodeAt(i);
207
+ h = Math.imul(h, 16777619);
208
+ }
209
+ return (h >>> 0).toString(16).padStart(8, "0");
210
+ };
211
+ return `"${hash(2166136261)}${hash(16777619)}"`;
212
+ }
213
+ /**
214
+ * Names that never leave the machine or the private network, whatever a site
215
+ * lists: a photo fetched from one of them would be this server probing its own
216
+ * neighbourhood on behalf of whoever can edit a stylist's photo.
217
+ */
218
+ const INTERNAL_NAME = /(^|\.)(localhost|local|internal|intranet|lan|home\.arpa)$/;
219
+ /** WHATWG URL normalises every IPv4 spelling (`2130706433`, `0x7f.1`) to dotted quads. */
220
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
221
+ /** Whether `hostname` (already lower-cased by `URL`) is one the site allows. */
222
+ function allowedPhotoHost(hostname, allowed) {
223
+ if (hostname.startsWith("[") || IPV4.test(hostname) || !hostname.includes(".")) return false;
224
+ if (INTERNAL_NAME.test(hostname)) return false;
225
+ return allowed.some((entry) => {
226
+ const pattern = entry.trim().toLowerCase();
227
+ if (pattern.startsWith("*.")) {
228
+ const suffix = pattern.slice(1);
229
+ return suffix.lastIndexOf(".") > 0 && hostname.endsWith(suffix);
230
+ }
231
+ return pattern.length > 0 && hostname === pattern;
232
+ });
233
+ }
234
+ /**
235
+ * The object, without the signature: origin and path. `null` for a URL this
236
+ * server will not fetch — anything but `https:` on the default port, with no
237
+ * credentials, on a host the site allows (`options.avatarHosts`).
238
+ *
239
+ * ASSUMPTION: the ETag is only a content validator because Medal stores each
240
+ * uploaded avatar under a fresh key, so a new photo is a new path.
241
+ */
242
+ function objectKeyOf(photoUrl, allowed) {
243
+ let url;
244
+ try {
245
+ url = new URL(photoUrl);
246
+ } catch {
247
+ return null;
248
+ }
249
+ if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "") return null;
250
+ return allowedPhotoHost(url.hostname, allowed) ? `${url.origin}${url.pathname}` : null;
251
+ }
252
+ /**
253
+ * The raster formats a stylist photo may be. Anything else — SVG above all,
254
+ * and HTML or a missing type — is refused rather than served from this
255
+ * origin, where it would be the site's own active content.
256
+ */
257
+ const RASTER_TYPES = /* @__PURE__ */ new Set([
258
+ "image/avif",
259
+ "image/gif",
260
+ "image/jpeg",
261
+ "image/png",
262
+ "image/webp"
263
+ ]);
264
+ /** `Image/PNG; charset=…` → `image/png`; `null` for a type that is not on the list. */
265
+ function rasterType(header) {
266
+ const type = (header ?? "").split(";")[0].trim().toLowerCase();
267
+ return RASTER_TYPES.has(type) ? type : null;
268
+ }
269
+ function etagMatches(header, etag) {
270
+ if (header === null) return false;
271
+ return header.split(",").map((value) => value.trim().replace(/^W\//, "")).some((value) => value === etag || value === "*");
272
+ }
273
+ /**
274
+ * A stylist's photo, from a URL the browser can cache.
275
+ *
276
+ * Medal returns `photo_url` as a presigned URL that is signed afresh on every
277
+ * read, so the wizard used to download every photo on every visit. This route
278
+ * looks the stylist up in the cached catalogue, fetches the object server-side
279
+ * and hands it back under one stable address with a day of browser cache and
280
+ * an ETag keyed to the OBJECT (the URL without its signature).
281
+ */
282
+ async function avatarRoute(rt, request, resourceId) {
283
+ if (!RESOURCE_ID_SHAPE.test(resourceId)) return uncached(400);
284
+ let photoUrl;
285
+ try {
286
+ photoUrl = (await rt.catalogue.cachedResources()).find((resource) => resource.id === resourceId)?.photo_url;
287
+ } catch (error) {
288
+ rt.logger.warn({
289
+ err: error,
290
+ resourceId
291
+ }, "Could not read the stylists for an avatar");
292
+ return uncached(502);
293
+ }
294
+ const objectKey = photoUrl ? objectKeyOf(photoUrl, rt.options.avatarHosts ?? DEFAULT_AVATAR_HOSTS) : null;
295
+ if (!photoUrl || objectKey === null) {
296
+ if (photoUrl) rt.logger.warn({ resourceId }, "Stylist photo is on a host this site does not allow");
297
+ return uncached(404);
298
+ }
299
+ const etag = etagFor(objectKey);
300
+ if (etagMatches(request.headers.get("If-None-Match"), etag)) return new NextResponse(null, {
301
+ status: 304,
302
+ headers: {
303
+ ETag: etag,
304
+ "Cache-Control": AVATAR_CACHE_CONTROL
305
+ }
306
+ });
307
+ let upstream;
308
+ try {
309
+ upstream = await fetch(photoUrl, {
310
+ cache: "no-store",
311
+ redirect: "manual",
312
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
313
+ });
314
+ } catch (error) {
315
+ rt.logger.warn({
316
+ err: error,
317
+ resourceId
318
+ }, "Could not fetch a stylist photo");
319
+ return uncached(502);
320
+ }
321
+ const contentType = rasterType(upstream.headers.get("Content-Type"));
322
+ if (!upstream.ok || contentType === null) {
323
+ if (upstream.ok) rt.logger.warn({
324
+ resourceId,
325
+ contentType: upstream.headers.get("Content-Type")
326
+ }, "Stylist photo is not a raster image");
327
+ await upstream.body?.cancel();
328
+ return uncached(502);
329
+ }
330
+ return new NextResponse(upstream.body, {
331
+ status: 200,
332
+ headers: {
333
+ "Content-Type": contentType,
334
+ "Cache-Control": AVATAR_CACHE_CONTROL,
335
+ ETag: etag,
336
+ "X-Content-Type-Options": "nosniff",
337
+ "Content-Security-Policy": "default-src 'none'; sandbox"
338
+ }
339
+ });
340
+ }
341
+ //#endregion
342
+ export { availabilityRoute, avatarRoute, nextFreeRoute, resourcesRoute, scheduleRoute, servicesRoute };
343
+
344
+ //# sourceMappingURL=catalogue-routes.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalogue-routes.mjs","names":[],"sources":["../../../src/next/routes/catalogue-routes.ts"],"sourcesContent":["/**\n * The read routes the wizard asks while it is on screen: the price list, the\n * stylists (with each one's next opening), free slots, opening hours, the\n * next free start and a stylist's photo.\n *\n * Every guard answers before Medal is called, and every read goes through the\n * data cache (`catalogue.ts`), which the site's own writes expire. There is\n * deliberately no way for a visitor to skip it.\n */\n\nimport { NextResponse } from 'next/server';\nimport { RESOURCE_ID_SHAPE } from '../../core/dto';\nimport { earliestOpening } from '../../core/next-available';\nimport type { MedalResource } from '../../core/wire';\nimport { DEFAULT_AVATAR_HOSTS } from '../options';\nimport type { BookingRuntime } from '../runtime';\nimport {\n bookingErrorResponse,\n catalogueErrorResponse,\n knownExtraServiceIds,\n knownServiceId,\n parseRange,\n} from './shared';\n\n/**\n * The full price list, in the order Medal returns it. Nothing is filtered: a\n * service with `bookable_online: false` is still a service the business sells,\n * and hiding it here would leave the printed price list and the website\n * disagreeing. Read through the catalogue cache (at most ~10 min old).\n */\nexport async function servicesRoute(rt: BookingRuntime): Promise<Response> {\n try {\n const services = await rt.catalogue.cachedServices();\n return NextResponse.json({ services: services.map(rt.dto.toBookingServiceDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'services');\n }\n}\n\n/**\n * The window the caller asked about, never one this route invents: «next\n * free» has to agree with the day strip on the time step, and the wizard owns\n * that horizon.\n *\n * Best effort, and never fatal. A stylist list with no «next free» lines is a\n * working step; a 502 here would be a screen with nobody on it.\n */\nasync function firstOpeningPerResource(\n rt: BookingRuntime,\n serviceId: string,\n extraServiceIds: string[],\n range: { fromTs: number; toTs: number }\n): Promise<Record<string, number>> {\n try {\n // Unfiltered by resource, deliberately: the whole point is one entry per\n // stylist, and `resource_id` would collapse the answer to that one. The\n // whole visit, too: a stylist free for the cut but not the cut and the\n // wash is not free for this person.\n const slots = await rt.catalogue.cachedAvailability({\n serviceId,\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n ...range,\n });\n const earliest: Record<string, number> = {};\n for (const slot of slots) {\n // A slot with no stylist cannot fill anybody's line, and an unparseable\n // start would render «Invalid Date».\n if (slot.resource_id === null || slot.start_ts === null) continue;\n const startTs = Date.parse(slot.start_ts);\n if (!Number.isFinite(startTs)) continue;\n const seen = earliest[slot.resource_id];\n if (seen === undefined || startTs < seen) earliest[slot.resource_id] = startTs;\n }\n return earliest;\n } catch (error) {\n rt.logger.warn({ err: error, serviceId }, 'Could not read next-available times for step 2');\n return {};\n }\n}\n\n/**\n * The stylists step 2 offers, and — when asked about a service — the first\n * minute each of them is free. Two answers from one round trip, because step 2\n * is one screen. The availability read is the SAME cached entry the\n * availability route and the booking page use for this service and window.\n */\nexport async function resourcesRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const rawServiceId = searchParams.get('service_id')?.trim();\n\n // The range is only meaningful alongside a service to ask about. Without one\n // this route is the catalogue alone, and a stray `from_ts` is not worth a 400.\n let range: { fromTs: number; toTs: number } | null = null;\n let serviceId: string | null = null;\n let extraServiceIds: string[] = [];\n if (rawServiceId) {\n const parsed = parseRange(searchParams);\n if ('error' in parsed) return parsed.error;\n range = parsed;\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n serviceId = known.serviceId;\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n extraServiceIds = extras.extraServiceIds;\n }\n\n // Started before the stylist list is awaited, so the two reads overlap.\n // `firstOpeningPerResource` never rejects.\n const nextAvailable =\n serviceId !== null && range !== null\n ? firstOpeningPerResource(rt, serviceId, extraServiceIds, range)\n : Promise.resolve({});\n\n let resources: MedalResource[];\n try {\n resources = await rt.catalogue.cachedResources();\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'resources');\n }\n\n return NextResponse.json({\n // Unsorted, in the order Medal returned it: step 2 sorts by `sortOrder`\n // itself, and the party seating uses first-encounter order as its\n // tie-break.\n resources: resources.map(rt.dto.toBookingResourceDto),\n nextAvailableTs: await nextAvailable,\n });\n}\n\n/**\n * Free slots for one visit over one range — one service, or one person's\n * several (`extra_service_ids`) back to back — optionally narrowed to the\n * stylist chosen on step 2. Read through the slot cache (at most ~60 s old),\n * which the site's own create / move / cancel routes expire on success.\n */\nexport async function availabilityRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n const { extraServiceIds } = extras;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const slots = await rt.catalogue.cachedAvailability({\n serviceId,\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n // Omitted rather than passed as an empty string: `?resource_id=` with\n // nothing after it means «first available».\n ...(resourceId ? { resourceId } : {}),\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ slots: slots.flatMap(rt.dto.toBookingSlotDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'availability');\n }\n}\n\n/**\n * The open dates over one range, optionally narrowed to one stylist — the half\n * availability cannot answer: it returns free slots and nothing else, so a\n * closed Sunday, an evening after closing and a booked-out Thursday all arrive\n * as the same empty array. A date ABSENT from the response is one the business\n * keeps no hours on. Opening hours are per stylist, so the filter matters here\n * as much as on availability.\n */\nexport async function scheduleRoute(rt: BookingRuntime, request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n\n const rawServiceId = searchParams.get('service_id')?.trim();\n if (!rawServiceId) {\n return bookingErrorResponse('invalidInput', 400, 'service_id is required');\n }\n\n const range = parseRange(searchParams);\n if ('error' in range) return range.error;\n\n const known = await knownServiceId(rt, rawServiceId);\n if ('error' in known) return known.error;\n const { serviceId } = known;\n\n const extras = await knownExtraServiceIds(rt, searchParams.get('extra_service_ids'), serviceId);\n if ('error' in extras) return extras.error;\n const { extraServiceIds } = extras;\n\n const resourceId = searchParams.get('resource_id')?.trim();\n\n try {\n const days = await rt.catalogue.cachedSchedule({\n ...(resourceId ? { resourceId } : {}),\n serviceId,\n // The whole visit's hours: its last start leaves room for every service.\n ...(extraServiceIds.length > 0 ? { extraServiceIds } : {}),\n fromTs: range.fromTs,\n toTs: range.toTs,\n });\n return NextResponse.json({ days: days.flatMap(rt.dto.toBookingDayDto) });\n } catch (error) {\n return catalogueErrorResponse(rt, error, 'schedule');\n }\n}\n\n/**\n * «Next free: today 11:00» — the next free start for the default prefetch\n * set, for a sticky booking bar or a hero. Read from the booking seed the\n * booking page itself renders from, so it costs no Medal call of its own and\n * cannot name a time the wizard will not show. `startTs: null` when nothing is\n * free or the seed cannot be read: the callers then simply say nothing.\n *\n * The label is formatted here, on the business's clock, so a visitor abroad\n * and the server agree on what «today» means.\n */\nexport async function nextFreeRoute(rt: BookingRuntime): Promise<Response> {\n const now = Date.now();\n try {\n const seed = await rt.seed.loadBookingSeed(undefined, now);\n const startTs = earliestOpening(seed.slots, now);\n return NextResponse.json(\n { startTs, label: startTs === null ? null : rt.clock.when(startTs, now) },\n // Short and private: the answer turns over with the seed's 30 s bucket.\n { headers: { 'Cache-Control': 'private, max-age=30' } }\n );\n } catch (error) {\n rt.logger.warn({ err: error }, 'next-free: booking seed unavailable');\n return NextResponse.json(\n { startTs: null, label: null },\n { headers: { 'Cache-Control': 'no-store' } }\n );\n }\n}\n\nconst AVATAR_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';\n\nfunction uncached(status: number): NextResponse {\n return new NextResponse(null, { status, headers: { 'Cache-Control': 'no-store' } });\n}\n\n/** How long the photo host gets before the card falls back to initials: a hung\n * read must not hold a request open. */\nconst UPSTREAM_TIMEOUT_MS = 5000;\n\n/** FNV-1a, 32-bit, twice with different seeds — synchronous and plenty for a\n * cache validator; nothing here is a secret. */\nfunction etagFor(objectKey: string): string {\n const hash = (seed: number) => {\n let h = seed;\n for (let i = 0; i < objectKey.length; i += 1) {\n h ^= objectKey.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (h >>> 0).toString(16).padStart(8, '0');\n };\n return `\"${hash(0x811c9dc5)}${hash(0x01000193)}\"`;\n}\n\n/**\n * Names that never leave the machine or the private network, whatever a site\n * lists: a photo fetched from one of them would be this server probing its own\n * neighbourhood on behalf of whoever can edit a stylist's photo.\n */\nconst INTERNAL_NAME = /(^|\\.)(localhost|local|internal|intranet|lan|home\\.arpa)$/;\n/** WHATWG URL normalises every IPv4 spelling (`2130706433`, `0x7f.1`) to dotted quads. */\nconst IPV4 = /^\\d{1,3}(\\.\\d{1,3}){3}$/;\n\n/** Whether `hostname` (already lower-cased by `URL`) is one the site allows. */\nfunction allowedPhotoHost(hostname: string, allowed: readonly string[]): boolean {\n // An IPv6 literal, an IPv4 one, a single label or an internal name: never.\n if (hostname.startsWith('[') || IPV4.test(hostname) || !hostname.includes('.')) return false;\n if (INTERNAL_NAME.test(hostname)) return false;\n return allowed.some((entry) => {\n const pattern = entry.trim().toLowerCase();\n if (pattern.startsWith('*.')) {\n const suffix = pattern.slice(1);\n // `*.` alone, or `*.com`, would be every host there is.\n return suffix.lastIndexOf('.') > 0 && hostname.endsWith(suffix);\n }\n return pattern.length > 0 && hostname === pattern;\n });\n}\n\n/**\n * The object, without the signature: origin and path. `null` for a URL this\n * server will not fetch — anything but `https:` on the default port, with no\n * credentials, on a host the site allows (`options.avatarHosts`).\n *\n * ASSUMPTION: the ETag is only a content validator because Medal stores each\n * uploaded avatar under a fresh key, so a new photo is a new path.\n */\nfunction objectKeyOf(photoUrl: string, allowed: readonly string[]): string | null {\n let url: URL;\n try {\n url = new URL(photoUrl);\n } catch {\n return null;\n }\n if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '') {\n return null;\n }\n return allowedPhotoHost(url.hostname, allowed) ? `${url.origin}${url.pathname}` : null;\n}\n\n/**\n * The raster formats a stylist photo may be. Anything else — SVG above all,\n * and HTML or a missing type — is refused rather than served from this\n * origin, where it would be the site's own active content.\n */\nconst RASTER_TYPES = new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp']);\n\n/** `Image/PNG; charset=…` → `image/png`; `null` for a type that is not on the list. */\nfunction rasterType(header: string | null): string | null {\n const type = (header ?? '').split(';')[0].trim().toLowerCase();\n return RASTER_TYPES.has(type) ? type : null;\n}\n\nfunction etagMatches(header: string | null, etag: string): boolean {\n if (header === null) return false;\n return header\n .split(',')\n .map((value) => value.trim().replace(/^W\\//, ''))\n .some((value) => value === etag || value === '*');\n}\n\n/**\n * A stylist's photo, from a URL the browser can cache.\n *\n * Medal returns `photo_url` as a presigned URL that is signed afresh on every\n * read, so the wizard used to download every photo on every visit. This route\n * looks the stylist up in the cached catalogue, fetches the object server-side\n * and hands it back under one stable address with a day of browser cache and\n * an ETag keyed to the OBJECT (the URL without its signature).\n */\nexport async function avatarRoute(\n rt: BookingRuntime,\n request: Request,\n resourceId: string\n): Promise<Response> {\n if (!RESOURCE_ID_SHAPE.test(resourceId)) return uncached(400);\n\n let photoUrl: string | null | undefined;\n try {\n photoUrl = (await rt.catalogue.cachedResources()).find(\n (resource) => resource.id === resourceId\n )?.photo_url;\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not read the stylists for an avatar');\n return uncached(502);\n }\n const objectKey = photoUrl\n ? objectKeyOf(photoUrl, rt.options.avatarHosts ?? DEFAULT_AVATAR_HOSTS)\n : null;\n if (!photoUrl || objectKey === null) {\n if (photoUrl)\n rt.logger.warn({ resourceId }, 'Stylist photo is on a host this site does not allow');\n return uncached(404);\n }\n\n const etag = etagFor(objectKey);\n if (etagMatches(request.headers.get('If-None-Match'), etag)) {\n return new NextResponse(null, {\n status: 304,\n headers: { ETag: etag, 'Cache-Control': AVATAR_CACHE_CONTROL },\n });\n }\n\n let upstream: Response;\n try {\n upstream = await fetch(photoUrl, {\n cache: 'no-store',\n // A redirect is answered, never followed: its target was not checked\n // against the allowed hosts. A 3xx is not `ok`, so it is a 502 below.\n redirect: 'manual',\n signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),\n });\n } catch (error) {\n rt.logger.warn({ err: error, resourceId }, 'Could not fetch a stylist photo');\n return uncached(502);\n }\n const contentType = rasterType(upstream.headers.get('Content-Type'));\n // Only raster images are relayed. Whatever the bucket answers is served from\n // THIS origin, and an HTML or SVG body here would be script on the site.\n if (!upstream.ok || contentType === null) {\n if (upstream.ok) {\n rt.logger.warn(\n { resourceId, contentType: upstream.headers.get('Content-Type') },\n 'Stylist photo is not a raster image'\n );\n }\n await upstream.body?.cancel();\n return uncached(502);\n }\n\n return new NextResponse(upstream.body, {\n status: 200,\n headers: {\n 'Content-Type': contentType,\n 'Cache-Control': AVATAR_CACHE_CONTROL,\n ETag: etag,\n 'X-Content-Type-Options': 'nosniff',\n // Belt and braces for a body opened as a document: no script, no\n // subresource, a unique origin.\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA8BA,eAAsB,cAAc,IAAuC;CACzE,IAAI;EACF,MAAM,WAAW,MAAM,GAAG,UAAU,eAAe;EACnD,OAAO,aAAa,KAAK,EAAE,UAAU,SAAS,IAAI,GAAG,IAAI,mBAAmB,EAAE,CAAC;CACjF,SAAS,OAAO;EACd,OAAO,uBAAuB,IAAI,OAAO,UAAU;CACrD;AACF;;;;;;;;;AAUA,eAAe,wBACb,IACA,WACA,iBACA,OACiC;CACjC,IAAI;EAKF,MAAM,QAAQ,MAAM,GAAG,UAAU,mBAAmB;GAClD;GACA,GAAI,gBAAgB,SAAS,IAAI,EAAE,gBAAgB,IAAI,CAAC;GACxD,GAAG;EACL,CAAC;EACD,MAAM,WAAmC,CAAC;EAC1C,KAAK,MAAM,QAAQ,OAAO;GAGxB,IAAI,KAAK,gBAAgB,QAAQ,KAAK,aAAa,MAAM;GACzD,MAAM,UAAU,KAAK,MAAM,KAAK,QAAQ;GACxC,IAAI,CAAC,OAAO,SAAS,OAAO,GAAG;GAC/B,MAAM,OAAO,SAAS,KAAK;GAC3B,IAAI,SAAS,KAAA,KAAa,UAAU,MAAM,SAAS,KAAK,eAAe;EACzE;EACA,OAAO;CACT,SAAS,OAAO;EACd,GAAG,OAAO,KAAK;GAAE,KAAK;GAAO;EAAU,GAAG,gDAAgD;EAC1F,OAAO,CAAC;CACV;AACF;;;;;;;AAQA,eAAsB,eAAe,IAAoB,SAAqC;CAC5F,MAAM,EAAE,iBAAiB,IAAI,IAAI,QAAQ,GAAG;CAC5C,MAAM,eAAe,aAAa,IAAI,YAAY,CAAC,EAAE,KAAK;CAI1D,IAAI,QAAiD;CACrD,IAAI,YAA2B;CAC/B,IAAI,kBAA4B,CAAC;CACjC,IAAI,cAAc;EAChB,MAAM,SAAS,WAAW,YAAY;EACtC,IAAI,WAAW,QAAQ,OAAO,OAAO;EACrC,QAAQ;EACR,MAAM,QAAQ,MAAM,eAAe,IAAI,YAAY;EACnD,IAAI,WAAW,OAAO,OAAO,MAAM;EACnC,YAAY,MAAM;EAClB,MAAM,SAAS,MAAM,qBAAqB,IAAI,aAAa,IAAI,mBAAmB,GAAG,SAAS;EAC9F,IAAI,WAAW,QAAQ,OAAO,OAAO;EACrC,kBAAkB,OAAO;CAC3B;CAIA,MAAM,gBACJ,cAAc,QAAQ,UAAU,OAC5B,wBAAwB,IAAI,WAAW,iBAAiB,KAAK,IAC7D,QAAQ,QAAQ,CAAC,CAAC;CAExB,IAAI;CACJ,IAAI;EACF,YAAY,MAAM,GAAG,UAAU,gBAAgB;CACjD,SAAS,OAAO;EACd,OAAO,uBAAuB,IAAI,OAAO,WAAW;CACtD;CAEA,OAAO,aAAa,KAAK;EAIvB,WAAW,UAAU,IAAI,GAAG,IAAI,oBAAoB;EACpD,iBAAiB,MAAM;CACzB,CAAC;AACH;;;;;;;AAQA,eAAsB,kBAAkB,IAAoB,SAAqC;CAC/F,MAAM,EAAE,iBAAiB,IAAI,IAAI,QAAQ,GAAG;CAE5C,MAAM,eAAe,aAAa,IAAI,YAAY,CAAC,EAAE,KAAK;CAC1D,IAAI,CAAC,cACH,OAAO,qBAAqB,gBAAgB,KAAK,wBAAwB;CAG3E,MAAM,QAAQ,WAAW,YAAY;CACrC,IAAI,WAAW,OAAO,OAAO,MAAM;CAEnC,MAAM,QAAQ,MAAM,eAAe,IAAI,YAAY;CACnD,IAAI,WAAW,OAAO,OAAO,MAAM;CACnC,MAAM,EAAE,cAAc;CAEtB,MAAM,SAAS,MAAM,qBAAqB,IAAI,aAAa,IAAI,mBAAmB,GAAG,SAAS;CAC9F,IAAI,WAAW,QAAQ,OAAO,OAAO;CACrC,MAAM,EAAE,oBAAoB;CAE5B,MAAM,aAAa,aAAa,IAAI,aAAa,CAAC,EAAE,KAAK;CAEzD,IAAI;EACF,MAAM,QAAQ,MAAM,GAAG,UAAU,mBAAmB;GAClD;GACA,GAAI,gBAAgB,SAAS,IAAI,EAAE,gBAAgB,IAAI,CAAC;GAGxD,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;GACnC,QAAQ,MAAM;GACd,MAAM,MAAM;EACd,CAAC;EACD,OAAO,aAAa,KAAK,EAAE,OAAO,MAAM,QAAQ,GAAG,IAAI,gBAAgB,EAAE,CAAC;CAC5E,SAAS,OAAO;EACd,OAAO,uBAAuB,IAAI,OAAO,cAAc;CACzD;AACF;;;;;;;;;AAUA,eAAsB,cAAc,IAAoB,SAAqC;CAC3F,MAAM,EAAE,iBAAiB,IAAI,IAAI,QAAQ,GAAG;CAE5C,MAAM,eAAe,aAAa,IAAI,YAAY,CAAC,EAAE,KAAK;CAC1D,IAAI,CAAC,cACH,OAAO,qBAAqB,gBAAgB,KAAK,wBAAwB;CAG3E,MAAM,QAAQ,WAAW,YAAY;CACrC,IAAI,WAAW,OAAO,OAAO,MAAM;CAEnC,MAAM,QAAQ,MAAM,eAAe,IAAI,YAAY;CACnD,IAAI,WAAW,OAAO,OAAO,MAAM;CACnC,MAAM,EAAE,cAAc;CAEtB,MAAM,SAAS,MAAM,qBAAqB,IAAI,aAAa,IAAI,mBAAmB,GAAG,SAAS;CAC9F,IAAI,WAAW,QAAQ,OAAO,OAAO;CACrC,MAAM,EAAE,oBAAoB;CAE5B,MAAM,aAAa,aAAa,IAAI,aAAa,CAAC,EAAE,KAAK;CAEzD,IAAI;EACF,MAAM,OAAO,MAAM,GAAG,UAAU,eAAe;GAC7C,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;GACnC;GAEA,GAAI,gBAAgB,SAAS,IAAI,EAAE,gBAAgB,IAAI,CAAC;GACxD,QAAQ,MAAM;GACd,MAAM,MAAM;EACd,CAAC;EACD,OAAO,aAAa,KAAK,EAAE,MAAM,KAAK,QAAQ,GAAG,IAAI,eAAe,EAAE,CAAC;CACzE,SAAS,OAAO;EACd,OAAO,uBAAuB,IAAI,OAAO,UAAU;CACrD;AACF;;;;;;;;;;;AAYA,eAAsB,cAAc,IAAuC;CACzE,MAAM,MAAM,KAAK,IAAI;CACrB,IAAI;EACF,MAAM,OAAO,MAAM,GAAG,KAAK,gBAAgB,KAAA,GAAW,GAAG;EACzD,MAAM,UAAU,gBAAgB,KAAK,OAAO,GAAG;EAC/C,OAAO,aAAa,KAClB;GAAE;GAAS,OAAO,YAAY,OAAO,OAAO,GAAG,MAAM,KAAK,SAAS,GAAG;EAAE,GAExE,EAAE,SAAS,EAAE,iBAAiB,sBAAsB,EAAE,CACxD;CACF,SAAS,OAAO;EACd,GAAG,OAAO,KAAK,EAAE,KAAK,MAAM,GAAG,qCAAqC;EACpE,OAAO,aAAa,KAClB;GAAE,SAAS;GAAM,OAAO;EAAK,GAC7B,EAAE,SAAS,EAAE,iBAAiB,WAAW,EAAE,CAC7C;CACF;AACF;AAEA,MAAM,uBAAuB;AAE7B,SAAS,SAAS,QAA8B;CAC9C,OAAO,IAAI,aAAa,MAAM;EAAE;EAAQ,SAAS,EAAE,iBAAiB,WAAW;CAAE,CAAC;AACpF;;;AAIA,MAAM,sBAAsB;;;AAI5B,SAAS,QAAQ,WAA2B;CAC1C,MAAM,QAAQ,SAAiB;EAC7B,IAAI,IAAI;EACR,KAAK,IAAI,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK,GAAG;GAC5C,KAAK,UAAU,WAAW,CAAC;GAC3B,IAAI,KAAK,KAAK,GAAG,QAAU;EAC7B;EACA,QAAQ,MAAM,EAAA,CAAG,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CAC/C;CACA,OAAO,IAAI,KAAK,UAAU,IAAI,KAAK,QAAU,EAAE;AACjD;;;;;;AAOA,MAAM,gBAAgB;;AAEtB,MAAM,OAAO;;AAGb,SAAS,iBAAiB,UAAkB,SAAqC;CAE/E,IAAI,SAAS,WAAW,GAAG,KAAK,KAAK,KAAK,QAAQ,KAAK,CAAC,SAAS,SAAS,GAAG,GAAG,OAAO;CACvF,IAAI,cAAc,KAAK,QAAQ,GAAG,OAAO;CACzC,OAAO,QAAQ,MAAM,UAAU;EAC7B,MAAM,UAAU,MAAM,KAAK,CAAC,CAAC,YAAY;EACzC,IAAI,QAAQ,WAAW,IAAI,GAAG;GAC5B,MAAM,SAAS,QAAQ,MAAM,CAAC;GAE9B,OAAO,OAAO,YAAY,GAAG,IAAI,KAAK,SAAS,SAAS,MAAM;EAChE;EACA,OAAO,QAAQ,SAAS,KAAK,aAAa;CAC5C,CAAC;AACH;;;;;;;;;AAUA,SAAS,YAAY,UAAkB,SAA2C;CAChF,IAAI;CACJ,IAAI;EACF,MAAM,IAAI,IAAI,QAAQ;CACxB,QAAQ;EACN,OAAO;CACT;CACA,IAAI,IAAI,aAAa,YAAY,IAAI,SAAS,MAAM,IAAI,aAAa,MAAM,IAAI,aAAa,IAC1F,OAAO;CAET,OAAO,iBAAiB,IAAI,UAAU,OAAO,IAAI,GAAG,IAAI,SAAS,IAAI,aAAa;AACpF;;;;;;AAOA,MAAM,+BAAe,IAAI,IAAI;CAAC;CAAc;CAAa;CAAc;CAAa;AAAY,CAAC;;AAGjG,SAAS,WAAW,QAAsC;CACxD,MAAM,QAAQ,UAAU,GAAA,CAAI,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,YAAY;CAC7D,OAAO,aAAa,IAAI,IAAI,IAAI,OAAO;AACzC;AAEA,SAAS,YAAY,QAAuB,MAAuB;CACjE,IAAI,WAAW,MAAM,OAAO;CAC5B,OAAO,OACJ,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,QAAQ,QAAQ,EAAE,CAAC,CAAC,CAChD,MAAM,UAAU,UAAU,QAAQ,UAAU,GAAG;AACpD;;;;;;;;;;AAWA,eAAsB,YACpB,IACA,SACA,YACmB;CACnB,IAAI,CAAC,kBAAkB,KAAK,UAAU,GAAG,OAAO,SAAS,GAAG;CAE5D,IAAI;CACJ,IAAI;EACF,YAAY,MAAM,GAAG,UAAU,gBAAgB,EAAA,CAAG,MAC/C,aAAa,SAAS,OAAO,UAChC,CAAC,EAAE;CACL,SAAS,OAAO;EACd,GAAG,OAAO,KAAK;GAAE,KAAK;GAAO;EAAW,GAAG,2CAA2C;EACtF,OAAO,SAAS,GAAG;CACrB;CACA,MAAM,YAAY,WACd,YAAY,UAAU,GAAG,QAAQ,eAAe,oBAAoB,IACpE;CACJ,IAAI,CAAC,YAAY,cAAc,MAAM;EACnC,IAAI,UACF,GAAG,OAAO,KAAK,EAAE,WAAW,GAAG,qDAAqD;EACtF,OAAO,SAAS,GAAG;CACrB;CAEA,MAAM,OAAO,QAAQ,SAAS;CAC9B,IAAI,YAAY,QAAQ,QAAQ,IAAI,eAAe,GAAG,IAAI,GACxD,OAAO,IAAI,aAAa,MAAM;EAC5B,QAAQ;EACR,SAAS;GAAE,MAAM;GAAM,iBAAiB;EAAqB;CAC/D,CAAC;CAGH,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,MAAM,UAAU;GAC/B,OAAO;GAGP,UAAU;GACV,QAAQ,YAAY,QAAQ,mBAAmB;EACjD,CAAC;CACH,SAAS,OAAO;EACd,GAAG,OAAO,KAAK;GAAE,KAAK;GAAO;EAAW,GAAG,iCAAiC;EAC5E,OAAO,SAAS,GAAG;CACrB;CACA,MAAM,cAAc,WAAW,SAAS,QAAQ,IAAI,cAAc,CAAC;CAGnE,IAAI,CAAC,SAAS,MAAM,gBAAgB,MAAM;EACxC,IAAI,SAAS,IACX,GAAG,OAAO,KACR;GAAE;GAAY,aAAa,SAAS,QAAQ,IAAI,cAAc;EAAE,GAChE,qCACF;EAEF,MAAM,SAAS,MAAM,OAAO;EAC5B,OAAO,SAAS,GAAG;CACrB;CAEA,OAAO,IAAI,aAAa,SAAS,MAAM;EACrC,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,iBAAiB;GACjB,MAAM;GACN,0BAA0B;GAG1B,2BAA2B;EAC7B;CACF,CAAC;AACH"}