@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,14 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ //#region src/core/consent.d.ts
3
+ /** How the record says where the consent came from — the same word the CRM
4
+ * files the contact itself under. */
5
+ export declare const CONSENT_SOURCE = "booking";
6
+ export interface MarketingConsent {
7
+ text: string;
8
+ version: string;
9
+ source: typeof CONSENT_SOURCE;
10
+ }
11
+ /** The site's marketing consent, or `null` when it offers no marketing box. */
12
+ export declare function marketingConsent(config: Pick<BookingConfig, 'consent'>): MarketingConsent | null;
13
+ //#endregion
14
+ //# sourceMappingURL=consent.d.mts.map
@@ -0,0 +1,17 @@
1
+ //#region src/core/consent.ts
2
+ /** How the record says where the consent came from — the same word the CRM
3
+ * files the contact itself under. */
4
+ const CONSENT_SOURCE = "booking";
5
+ /** The site's marketing consent, or `null` when it offers no marketing box. */
6
+ function marketingConsent(config) {
7
+ const marketing = config.consent.marketing;
8
+ return marketing === null ? null : {
9
+ text: marketing.text,
10
+ version: marketing.version,
11
+ source: CONSENT_SOURCE
12
+ };
13
+ }
14
+ //#endregion
15
+ export { CONSENT_SOURCE, marketingConsent };
16
+
17
+ //# sourceMappingURL=consent.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consent.mjs","names":[],"sources":["../../src/core/consent.ts"],"sourcesContent":["/**\n * The marketing opt-in on the details step, in one place because two ends need\n * the same one: the checkbox that renders it and the route handler that files\n * it.\n *\n * A consent is not a boolean column. It is a record of a sentence somebody\n * agreed to, on a day, in a wording — which is why the engine's endpoint takes\n * `consent_text` and `version` alongside `granted`, and why the sentence cannot\n * live only inside the component. Two copies would drift on the first rewrite,\n * and the drift would be silent: the box would say one thing and the record\n * would keep another.\n *\n * The sentence and its version are the SITE's (`config.consent.marketing`) —\n * it names the business, and only the business can say what it promises. The\n * version must be bumped whenever the sentence changes: the point of storing it\n * is to answer, years later, *which* wording a given customer agreed to.\n *\n * The box is unticked by default wherever it is rendered. A pre-ticked box is\n * not a consent under GDPR, however much traffic it would buy.\n *\n * Deliberately free of React so a `server-only` route can import it.\n */\n\nimport type { BookingConfig } from './config';\n\n/** How the record says where the consent came from — the same word the CRM\n * files the contact itself under. */\nexport const CONSENT_SOURCE = 'booking';\n\nexport interface MarketingConsent {\n text: string;\n version: string;\n source: typeof CONSENT_SOURCE;\n}\n\n/** The site's marketing consent, or `null` when it offers no marketing box. */\nexport function marketingConsent(config: Pick<BookingConfig, 'consent'>): MarketingConsent | null {\n const marketing = config.consent.marketing;\n return marketing === null\n ? null\n : { text: marketing.text, version: marketing.version, source: CONSENT_SOURCE };\n}\n"],"mappings":";;;AA2BA,MAAa,iBAAiB;;AAS9B,SAAgB,iBAAiB,QAAiE;CAChG,MAAM,YAAY,OAAO,QAAQ;CACjC,OAAO,cAAc,OACjB,OACA;EAAE,MAAM,UAAU;EAAM,SAAS,UAAU;EAAS,QAAQ;CAAe;AACjF"}
@@ -0,0 +1,40 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ //#region src/core/deep-link.d.ts
3
+ type DeepLinkConfig = Pick<BookingConfig, 'query' | 'whoValues' | 'categories' | 'fallbackCategory' | 'paths'>;
4
+ /** `Farge dame` → `farge-dame`, `Bjarne (Salong Demo)` → `bjarne`. */
5
+ export declare function slugify(value: string): string;
6
+ /** The slug form of a stylist's name, as a «Bestill hos …» link writes it. */
7
+ export declare function stylistSlug(name: string): string;
8
+ /** Whether `wanted` names this service, by id or by name slug. */
9
+ export declare function serviceMatches(service: {
10
+ id: string;
11
+ name: string;
12
+ }, wanted: string): boolean;
13
+ /** Whether `wanted` names this stylist: id, display name (any case) or its slug. */
14
+ export declare function resourceMatches(resource: {
15
+ id: string;
16
+ name?: string;
17
+ }, wanted: string): boolean;
18
+ /** `?antall=` as a whole number ≥ 1, or `null` for anything else. Not yet
19
+ * clamped to a service: the limit is the service's, and unknown here. */
20
+ export declare function parseParty(value: string | null | undefined): number | null;
21
+ /** The `?…` a card appends, or the base untouched when it cannot be extended. */
22
+ export declare function withBookingQuery(base: string, query: string | undefined): string;
23
+ export interface DeepLinks {
24
+ /** `?kategori=` as a configured category key, or `null`. */
25
+ parseCategory(value: string | null | undefined): string | null;
26
+ /** `?hvem=` as one of `config.whoValues`, or `null`. */
27
+ parseWho(value: string | null | undefined): string | null;
28
+ parseParty: typeof parseParty;
29
+ impliedParty(query: URLSearchParams | null): {
30
+ children: number;
31
+ } | {
32
+ adult: true;
33
+ } | null;
34
+ hasDeepLink(query: URLSearchParams | null): boolean;
35
+ resumePath(query: URLSearchParams | null): string;
36
+ }
37
+ /** The link readers for this site's query keys, categories and booking path. */
38
+ export declare function createDeepLinks(config: DeepLinkConfig): DeepLinks;
39
+ //#endregion
40
+ //# sourceMappingURL=deep-link.d.mts.map
@@ -0,0 +1,160 @@
1
+ import { isChildCategory } from "./categories.mjs";
2
+ import { stylistDisplayName } from "./display-name.mjs";
3
+ //#region src/core/deep-link.ts
4
+ /**
5
+ * What a marketing link can ask `/bestill` for, and how the page and the wizard
6
+ * read it — one place, so the server's prefetch and the client's first render
7
+ * cannot disagree about which service `?tjeneste=farge-dame` means.
8
+ *
9
+ * `?kategori=barn` opens that category on step 1
10
+ * `?tjeneste=<id|slug>` picks the service (and advances, like a tap)
11
+ * `?frisor=<id|navn>` picks the stylist, when they can do the service
12
+ * `?antall=2` the party size (children, for a kids' service)
13
+ * `?hvem=barn|voksen` who is being cut, answering step 1 («Hvem skal klippes?»)
14
+ *
15
+ * A link that says who is coming skips step 1: `?kategori=barn` is one child
16
+ * unless `?antall=` says more, `?hvem=voksen` is one grown-up. Step 1 is one
17
+ * «Tilbake» away for the parent who meant something else.
18
+ *
19
+ * Every value is untrusted: an unknown one is ignored, never an error, because
20
+ * the link may be a year old and there is nothing a parent could do about it.
21
+ *
22
+ * The key names above are the defaults; a site renames them through
23
+ * `config.query`, the `?hvem=` values through `config.whoValues`, the
24
+ * categories through `config.categories` and the page through `paths.booking`.
25
+ * `createDeepLinks(config)` binds the ones that read any of those.
26
+ */
27
+ /** `Farge dame` → `farge-dame`, `Bjarne (Salong Demo)` → `bjarne`. */
28
+ function slugify(value) {
29
+ return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
30
+ }
31
+ /** The slug form of a stylist's name, as a «Bestill hos …» link writes it. */
32
+ function stylistSlug(name) {
33
+ return slugify(stylistDisplayName(name));
34
+ }
35
+ /** Whether `wanted` names this service, by id or by name slug. */
36
+ function serviceMatches(service, wanted) {
37
+ const value = wanted.trim().toLowerCase();
38
+ return value !== "" && (service.id.toLowerCase() === value || slugify(service.name) === value);
39
+ }
40
+ /** Whether `wanted` names this stylist: id, display name (any case) or its slug. */
41
+ function resourceMatches(resource, wanted) {
42
+ const value = wanted.trim().toLowerCase();
43
+ if (value === "") return false;
44
+ if (resource.id.toLowerCase() === value) return true;
45
+ if (!resource.name) return false;
46
+ const display = stylistDisplayName(resource.name).toLowerCase();
47
+ return display === value || slugify(display) === slugify(value);
48
+ }
49
+ /** `?kategori=` as a category the wizard has, or `null`. */
50
+ function parseCategory(config, value) {
51
+ const wanted = value?.trim().toLowerCase();
52
+ return config.categories.some((category) => category.key === wanted) ? wanted : null;
53
+ }
54
+ /** `?hvem=` as the answer to step 1, or `null`. */
55
+ function parseWho(config, value) {
56
+ const wanted = value?.trim().toLowerCase();
57
+ return wanted === config.whoValues.child || wanted === config.whoValues.adult ? wanted : null;
58
+ }
59
+ /**
60
+ * Who a link has already answered step 1 with: `n` children, one grown-up, or
61
+ * nothing. `?antall=` wins; otherwise the kids' menu (`?kategori=barn`) or
62
+ * `?hvem=barn` is one child — the common visit, and the one the home page's
63
+ * «Bestill for barn» card means — and `?hvem=voksen` is one adult.
64
+ */
65
+ function impliedParty(config, query) {
66
+ const party = parseParty(query?.get(config.query.party));
67
+ if (party !== null) return { children: party };
68
+ const who = parseWho(config, query?.get(config.query.who));
69
+ if (who === config.whoValues.adult) return { adult: true };
70
+ const category = parseCategory(config, query?.get(config.query.category));
71
+ if (who === config.whoValues.child || category !== null && isChildCategory(config, category)) return { children: 1 };
72
+ return null;
73
+ }
74
+ /** `?antall=` as a whole number ≥ 1, or `null` for anything else. Not yet
75
+ * clamped to a service: the limit is the service's, and unknown here. */
76
+ function parseParty(value) {
77
+ if (!value || !/^\d{1,3}$/.test(value.trim())) return null;
78
+ const count = Number.parseInt(value, 10);
79
+ return count >= 1 ? count : null;
80
+ }
81
+ /** The `?…` a card appends, or the base untouched when it cannot be extended. */
82
+ function withBookingQuery(base, query) {
83
+ if (!query || base.includes("?") || base.includes("#") || /^https?:\/\//i.test(base)) return base;
84
+ return `${base}?${query}`;
85
+ }
86
+ /**
87
+ * The parameters that make a visit a DEEP-LINKED one, whatever else is in the
88
+ * address. `?service=` / `?stylist=` («Bestill igjen») are read by the
89
+ * wizard's own prefill; these are the marketing pages' words.
90
+ */
91
+ function deepLinkKeys(config) {
92
+ const { category, stylist, party, service, who } = config.query;
93
+ return [
94
+ category,
95
+ stylist,
96
+ party,
97
+ service,
98
+ who
99
+ ];
100
+ }
101
+ /**
102
+ * Whether the address asks the wizard for anything — a category, a service, a
103
+ * stylist or a party size — even something a stale catalogue can no longer
104
+ * honour. The restore gate and the mount effect both ask it, so an old
105
+ * confirmation this tab still remembers never swallows a link the parent has
106
+ * just followed: they came to book, and «Bestill ny time» would throw the link
107
+ * away.
108
+ */
109
+ function hasDeepLink(config, query) {
110
+ return deepLinkKeys(config).some((key) => (query?.get(key)?.trim() ?? "") !== "");
111
+ }
112
+ /** The parts of a deep link that still mean something after a Vipps round
113
+ * trip. `tjeneste` is not one: the service is in the draft by then, or was
114
+ * never chosen. */
115
+ function resumableKeys(config) {
116
+ const { category, stylist, party, who } = config.query;
117
+ return [
118
+ category,
119
+ stylist,
120
+ party,
121
+ who
122
+ ];
123
+ }
124
+ /** Long enough for any stylist slug; short enough that three of them keep the
125
+ * path well inside `safeReturnPath`'s 512 characters. */
126
+ const RESUMABLE_VALUE_MAX = 64;
127
+ /**
128
+ * Where a Vipps login started from the wizard comes back to:
129
+ * `/bestill?resume=1`, plus whichever of `kategori`, `frisor` and `antall`
130
+ * this visit arrived with.
131
+ *
132
+ * The draft carries the answers the parent GAVE; this carries what the link
133
+ * asked for and they have not answered yet — a parent who opened «Bestill hos
134
+ * Siv» on step 1 and logged in before tapping a service should come back to
135
+ * Siv, not to a plain wizard. The path passes `safeReturnPath` (an exact
136
+ * `/bestill` with its query kept), and nothing in it is personal.
137
+ */
138
+ function resumePath(config, query) {
139
+ const out = new URLSearchParams({ [config.query.resume]: "1" });
140
+ for (const key of resumableKeys(config)) {
141
+ const value = query?.get(key)?.trim();
142
+ if (value && value.length <= RESUMABLE_VALUE_MAX) out.set(key, value);
143
+ }
144
+ return `${config.paths.booking}?${out.toString()}`;
145
+ }
146
+ /** The link readers for this site's query keys, categories and booking path. */
147
+ function createDeepLinks(config) {
148
+ return {
149
+ parseCategory: (value) => parseCategory(config, value),
150
+ parseWho: (value) => parseWho(config, value),
151
+ parseParty,
152
+ impliedParty: (query) => impliedParty(config, query),
153
+ hasDeepLink: (query) => hasDeepLink(config, query),
154
+ resumePath: (query) => resumePath(config, query)
155
+ };
156
+ }
157
+ //#endregion
158
+ export { createDeepLinks, parseParty, resourceMatches, serviceMatches, slugify, stylistSlug, withBookingQuery };
159
+
160
+ //# sourceMappingURL=deep-link.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-link.mjs","names":[],"sources":["../../src/core/deep-link.ts"],"sourcesContent":["/**\n * What a marketing link can ask `/bestill` for, and how the page and the wizard\n * read it — one place, so the server's prefetch and the client's first render\n * cannot disagree about which service `?tjeneste=farge-dame` means.\n *\n * `?kategori=barn` opens that category on step 1\n * `?tjeneste=<id|slug>` picks the service (and advances, like a tap)\n * `?frisor=<id|navn>` picks the stylist, when they can do the service\n * `?antall=2` the party size (children, for a kids' service)\n * `?hvem=barn|voksen` who is being cut, answering step 1 («Hvem skal klippes?»)\n *\n * A link that says who is coming skips step 1: `?kategori=barn` is one child\n * unless `?antall=` says more, `?hvem=voksen` is one grown-up. Step 1 is one\n * «Tilbake» away for the parent who meant something else.\n *\n * Every value is untrusted: an unknown one is ignored, never an error, because\n * the link may be a year old and there is nothing a parent could do about it.\n *\n * The key names above are the defaults; a site renames them through\n * `config.query`, the `?hvem=` values through `config.whoValues`, the\n * categories through `config.categories` and the page through `paths.booking`.\n * `createDeepLinks(config)` binds the ones that read any of those.\n */\nimport { isChildCategory } from './categories';\nimport type { BookingConfig } from './config';\nimport { stylistDisplayName } from './display-name';\n\ntype DeepLinkConfig = Pick<\n BookingConfig,\n 'query' | 'whoValues' | 'categories' | 'fallbackCategory' | 'paths'\n>;\n\n/** `Farge dame` → `farge-dame`, `Bjarne (Salong Demo)` → `bjarne`. */\nexport function slugify(value: string): string {\n return value\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}]+/gu, '-')\n .replace(/^-+|-+$/g, '');\n}\n\n/** The slug form of a stylist's name, as a «Bestill hos …» link writes it. */\nexport function stylistSlug(name: string): string {\n return slugify(stylistDisplayName(name));\n}\n\n/** Whether `wanted` names this service, by id or by name slug. */\nexport function serviceMatches(service: { id: string; name: string }, wanted: string): boolean {\n const value = wanted.trim().toLowerCase();\n return value !== '' && (service.id.toLowerCase() === value || slugify(service.name) === value);\n}\n\n/** Whether `wanted` names this stylist: id, display name (any case) or its slug. */\nexport function resourceMatches(resource: { id: string; name?: string }, wanted: string): boolean {\n const value = wanted.trim().toLowerCase();\n if (value === '') return false;\n if (resource.id.toLowerCase() === value) return true;\n if (!resource.name) return false;\n const display = stylistDisplayName(resource.name).toLowerCase();\n return display === value || slugify(display) === slugify(value);\n}\n\n/** `?kategori=` as a category the wizard has, or `null`. */\nfunction parseCategory(config: DeepLinkConfig, value: string | null | undefined): string | null {\n const wanted = value?.trim().toLowerCase();\n return config.categories.some((category) => category.key === wanted) ? (wanted as string) : null;\n}\n\n/** `?hvem=` as the answer to step 1, or `null`. */\nfunction parseWho(config: DeepLinkConfig, value: string | null | undefined): string | null {\n const wanted = value?.trim().toLowerCase();\n return wanted === config.whoValues.child || wanted === config.whoValues.adult ? wanted : null;\n}\n\n/**\n * Who a link has already answered step 1 with: `n` children, one grown-up, or\n * nothing. `?antall=` wins; otherwise the kids' menu (`?kategori=barn`) or\n * `?hvem=barn` is one child — the common visit, and the one the home page's\n * «Bestill for barn» card means — and `?hvem=voksen` is one adult.\n */\nfunction impliedParty(\n config: DeepLinkConfig,\n query: URLSearchParams | null\n): { children: number } | { adult: true } | null {\n const party = parseParty(query?.get(config.query.party));\n if (party !== null) return { children: party };\n const who = parseWho(config, query?.get(config.query.who));\n if (who === config.whoValues.adult) return { adult: true };\n const category = parseCategory(config, query?.get(config.query.category));\n if (who === config.whoValues.child || (category !== null && isChildCategory(config, category))) {\n return { children: 1 };\n }\n return null;\n}\n\n/** `?antall=` as a whole number ≥ 1, or `null` for anything else. Not yet\n * clamped to a service: the limit is the service's, and unknown here. */\nexport function parseParty(value: string | null | undefined): number | null {\n if (!value || !/^\\d{1,3}$/.test(value.trim())) return null;\n const count = Number.parseInt(value, 10);\n return count >= 1 ? count : null;\n}\n\n/** The `?…` a card appends, or the base untouched when it cannot be extended. */\nexport function withBookingQuery(base: string, query: string | undefined): string {\n if (!query || base.includes('?') || base.includes('#') || /^https?:\\/\\//i.test(base)) return base;\n return `${base}?${query}`;\n}\n\n/**\n * The parameters that make a visit a DEEP-LINKED one, whatever else is in the\n * address. `?service=` / `?stylist=` («Bestill igjen») are read by the\n * wizard's own prefill; these are the marketing pages' words.\n */\nfunction deepLinkKeys(config: DeepLinkConfig): string[] {\n const { category, stylist, party, service, who } = config.query;\n return [category, stylist, party, service, who];\n}\n\n/**\n * Whether the address asks the wizard for anything — a category, a service, a\n * stylist or a party size — even something a stale catalogue can no longer\n * honour. The restore gate and the mount effect both ask it, so an old\n * confirmation this tab still remembers never swallows a link the parent has\n * just followed: they came to book, and «Bestill ny time» would throw the link\n * away.\n */\nfunction hasDeepLink(config: DeepLinkConfig, query: URLSearchParams | null): boolean {\n return deepLinkKeys(config).some((key) => (query?.get(key)?.trim() ?? '') !== '');\n}\n\n/** The parts of a deep link that still mean something after a Vipps round\n * trip. `tjeneste` is not one: the service is in the draft by then, or was\n * never chosen. */\nfunction resumableKeys(config: DeepLinkConfig): string[] {\n const { category, stylist, party, who } = config.query;\n return [category, stylist, party, who];\n}\n\n/** Long enough for any stylist slug; short enough that three of them keep the\n * path well inside `safeReturnPath`'s 512 characters. */\nconst RESUMABLE_VALUE_MAX = 64;\n\n/**\n * Where a Vipps login started from the wizard comes back to:\n * `/bestill?resume=1`, plus whichever of `kategori`, `frisor` and `antall`\n * this visit arrived with.\n *\n * The draft carries the answers the parent GAVE; this carries what the link\n * asked for and they have not answered yet — a parent who opened «Bestill hos\n * Siv» on step 1 and logged in before tapping a service should come back to\n * Siv, not to a plain wizard. The path passes `safeReturnPath` (an exact\n * `/bestill` with its query kept), and nothing in it is personal.\n */\nfunction resumePath(config: DeepLinkConfig, query: URLSearchParams | null): string {\n const out = new URLSearchParams({ [config.query.resume]: '1' });\n for (const key of resumableKeys(config)) {\n const value = query?.get(key)?.trim();\n if (value && value.length <= RESUMABLE_VALUE_MAX) out.set(key, value);\n }\n return `${config.paths.booking}?${out.toString()}`;\n}\n\nexport interface DeepLinks {\n /** `?kategori=` as a configured category key, or `null`. */\n parseCategory(value: string | null | undefined): string | null;\n /** `?hvem=` as one of `config.whoValues`, or `null`. */\n parseWho(value: string | null | undefined): string | null;\n parseParty: typeof parseParty;\n impliedParty(query: URLSearchParams | null): { children: number } | { adult: true } | null;\n hasDeepLink(query: URLSearchParams | null): boolean;\n resumePath(query: URLSearchParams | null): string;\n}\n\n/** The link readers for this site's query keys, categories and booking path. */\nexport function createDeepLinks(config: DeepLinkConfig): DeepLinks {\n return {\n parseCategory: (value) => parseCategory(config, value),\n parseWho: (value) => parseWho(config, value),\n parseParty,\n impliedParty: (query) => impliedParty(config, query),\n hasDeepLink: (query) => hasDeepLink(config, query),\n resumePath: (query) => resumePath(config, query),\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ //#region src/core/display-name.d.ts
2
+ /**
3
+ * A stylist's name as a parent should read it.
4
+ *
5
+ * Medal's resource names are the salon's admin labels — «siv», «Bjarne
6
+ * (Salong Demo)» — and every screen that names a stylist (step 2, the summary
7
+ * bar, the confirmation, the manage page) goes through this rather than
8
+ * printing them raw. Idempotent, so a name already cleaned upstream, or
9
+ * restored from a stored confirmation, can pass through again safely.
10
+ */
11
+ export declare function stylistDisplayName(name: string): string;
12
+ /**
13
+ * Up to two initials, from the first LETTER of the first two words that have
14
+ * one — so «Bjarne (Salong Demo)» is «B», never «B(», and «Åse Ødegård» is «ÅØ».
15
+ */
16
+ export declare function initialsOf(name: string): string;
17
+ //#endregion
18
+ //# sourceMappingURL=display-name.d.mts.map
@@ -0,0 +1,30 @@
1
+ //#region src/core/display-name.ts
2
+ /**
3
+ * A stylist's name as a parent should read it.
4
+ *
5
+ * Medal's resource names are the salon's admin labels — «siv», «Bjarne
6
+ * (Salong Demo)» — and every screen that names a stylist (step 2, the summary
7
+ * bar, the confirmation, the manage page) goes through this rather than
8
+ * printing them raw. Idempotent, so a name already cleaned upstream, or
9
+ * restored from a stored confirmation, can pass through again safely.
10
+ */
11
+ /** Every trailing «(…)» group — the admin's notes, «(Salong Demo) (vikar)» — so
12
+ * cleaning an already-cleaned name changes nothing. */
13
+ const TRAILING_SUFFIX = /(?:\s*\([^()]*\))+\s*$/u;
14
+ function stylistDisplayName(name) {
15
+ const trimmed = name.trim();
16
+ const stripped = trimmed.replace(TRAILING_SUFFIX, "").trim() || trimmed;
17
+ if (stripped === "") return "";
18
+ return `${stripped.charAt(0).toLocaleUpperCase("nb-NO")}${stripped.slice(1)}`;
19
+ }
20
+ /**
21
+ * Up to two initials, from the first LETTER of the first two words that have
22
+ * one — so «Bjarne (Salong Demo)» is «B», never «B(», and «Åse Ødegård» is «ÅØ».
23
+ */
24
+ function initialsOf(name) {
25
+ return stylistDisplayName(name).split(/\s+/u).map((word) => word.match(/\p{L}/u)?.[0]).filter((letter) => letter !== void 0).slice(0, 2).join("").toLocaleUpperCase("nb-NO");
26
+ }
27
+ //#endregion
28
+ export { initialsOf, stylistDisplayName };
29
+
30
+ //# sourceMappingURL=display-name.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"display-name.mjs","names":[],"sources":["../../src/core/display-name.ts"],"sourcesContent":["/**\n * A stylist's name as a parent should read it.\n *\n * Medal's resource names are the salon's admin labels — «siv», «Bjarne\n * (Salong Demo)» — and every screen that names a stylist (step 2, the summary\n * bar, the confirmation, the manage page) goes through this rather than\n * printing them raw. Idempotent, so a name already cleaned upstream, or\n * restored from a stored confirmation, can pass through again safely.\n */\n\n/** Every trailing «(…)» group — the admin's notes, «(Salong Demo) (vikar)» — so\n * cleaning an already-cleaned name changes nothing. */\nconst TRAILING_SUFFIX = /(?:\\s*\\([^()]*\\))+\\s*$/u;\n\nexport function stylistDisplayName(name: string): string {\n const trimmed = name.trim();\n // A name that is nothing BUT a suffix keeps it: a blank card is worse than\n // an odd one, and the salon can see and fix the odd one.\n const stripped = trimmed.replace(TRAILING_SUFFIX, '').trim() || trimmed;\n if (stripped === '') return '';\n return `${stripped.charAt(0).toLocaleUpperCase('nb-NO')}${stripped.slice(1)}`;\n}\n\n/**\n * Up to two initials, from the first LETTER of the first two words that have\n * one — so «Bjarne (Salong Demo)» is «B», never «B(», and «Åse Ødegård» is «ÅØ».\n */\nexport function initialsOf(name: string): string {\n return stylistDisplayName(name)\n .split(/\\s+/u)\n .map((word) => word.match(/\\p{L}/u)?.[0])\n .filter((letter): letter is string => letter !== undefined)\n .slice(0, 2)\n .join('')\n .toLocaleUpperCase('nb-NO');\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAM,kBAAkB;AAExB,SAAgB,mBAAmB,MAAsB;CACvD,MAAM,UAAU,KAAK,KAAK;CAG1B,MAAM,WAAW,QAAQ,QAAQ,iBAAiB,EAAE,CAAC,CAAC,KAAK,KAAK;CAChE,IAAI,aAAa,IAAI,OAAO;CAC5B,OAAO,GAAG,SAAS,OAAO,CAAC,CAAC,CAAC,kBAAkB,OAAO,IAAI,SAAS,MAAM,CAAC;AAC5E;;;;;AAMA,SAAgB,WAAW,MAAsB;CAC/C,OAAO,mBAAmB,IAAI,CAAC,CAC5B,MAAM,MAAM,CAAC,CACb,KAAK,SAAS,KAAK,MAAM,QAAQ,CAAC,GAAG,EAAE,CAAC,CACxC,QAAQ,WAA6B,WAAW,KAAA,CAAS,CAAC,CAC1D,MAAM,GAAG,CAAC,CAAC,CACX,KAAK,EAAE,CAAC,CACR,kBAAkB,OAAO;AAC9B"}
@@ -0,0 +1,89 @@
1
+ //#region src/core/draft-store.d.ts
2
+ /**
3
+ * The half-built booking a Vipps login leaves behind, kept for the length of
4
+ * one round trip.
5
+ *
6
+ * The login sheet offers «Fortsett med Vipps», and Vipps is a whole navigation away:
7
+ * the browser goes to Medal, then to Vipps, then back to `/min-side/vipps`,
8
+ * which hands it on to wherever the login said it started. Every answer the
9
+ * parent has given by then — the service, the stylist, the hour — lives in a
10
+ * `useReducer` in a component that no longer exists. Without this they come
11
+ * back logged in to an empty step 1, which is a worse experience than never
12
+ * having been offered the login.
13
+ *
14
+ * `sessionStorage`, for the same reasons `rebook-store.ts` uses it: one tab,
15
+ * gone with it, and never in a URL. The draft carries a child's NAME, which is
16
+ * personal data about a minor — a query string is copied into browser history,
17
+ * the edge access log and every analytics pageview that records `location`, and
18
+ * this is not.
19
+ *
20
+ * Read-once, like the rebook stash and for the same reason: a draft that
21
+ * survived its login would rebuild an abandoned booking on the parent's next
22
+ * visit to `/bestill` in the same tab.
23
+ *
24
+ * IDS AND INSTANTS ONLY. Nothing here is trusted on the way back in: the
25
+ * service ids are looked up in the catalogue the page just fetched, and the
26
+ * restore runs through the machine's own actions, so a draft naming a service
27
+ * the salon has since retired rebuilds nothing rather than a basket the wizard
28
+ * could not have produced by tapping.
29
+ *
30
+ * Every path falls back to «no draft» rather than throwing — no storage during
31
+ * the server render, Safari private mode, storage turned off — because the
32
+ * worst case is a parent answering three questions they had answered once.
33
+ */
34
+ /** `<namespace>:booking:draft`. A storage key, not a credential. */
35
+ export declare function draftStorageKey(namespace: string): string;
36
+ /** How long a draft is worth restoring. A Vipps login is a phone unlock and a
37
+ * confirmation; anything older than this is a tab left open over lunch, and the
38
+ * hour in it has very likely gone. A draft dated in the future is refused
39
+ * outright — see `asDraft`. */
40
+ export declare const DRAFT_MAX_AGE_MS: number;
41
+ /** One line item, as little of it as a rebuild needs. */
42
+ export interface WizardDraftItem {
43
+ serviceId: string;
44
+ /**
45
+ * The rest of the person's visit after `serviceId`, in order. Absent for a
46
+ * one-service line (and in a draft written before visits had extras), never
47
+ * `[]`. Ids only, like `serviceId`: a restore looks each one up again.
48
+ */
49
+ extraServiceIds?: string[];
50
+ bookedForName: string | null;
51
+ bookedForBirthYear: number | null;
52
+ /**
53
+ * A grown-up's line (`true`) or a child's (`false`); `null` in a draft
54
+ * written before the field existed. A restore reseats a guest from it, so a
55
+ * parent who changed «Voksen» to «1 barn» comes back from Vipps as they left,
56
+ * not as the link they arrived on said.
57
+ */
58
+ adult?: boolean | null;
59
+ /**
60
+ * The saved child the line was for (`bookedForPersonId`); `null` for a guest
61
+ * or a draft written before the field existed. Never trusted on the way
62
+ * back: a restore reseats it only when the logged-in parent's own family has
63
+ * a child with this id.
64
+ */
65
+ personId?: string | null;
66
+ }
67
+ export interface WizardDraft {
68
+ items: WizardDraftItem[];
69
+ /** The visitor's preference — `null` is «Første ledige». */
70
+ resourceId: string | null;
71
+ partyMode: 'sequential' | 'parallel';
72
+ startTs: number | null;
73
+ /** Who the chosen slot resolved to, which is what gets submitted. */
74
+ resolvedResourceId: string | null;
75
+ /** One stylist per line item, for a party; `null` for everything else. */
76
+ partyResourceIds: string[] | null;
77
+ /** When it was written, so a stale tab does not resurrect last week's hour. */
78
+ savedAt: number;
79
+ }
80
+ export interface DraftStore {
81
+ readonly DRAFT_STORAGE_KEY: string;
82
+ stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now?: number): void;
83
+ takeDraft(now?: number): WizardDraft | null;
84
+ clearDraft(): void;
85
+ }
86
+ /** The draft store under `<namespace>:booking:draft`. */
87
+ export declare function createDraftStore(namespace: string): DraftStore;
88
+ //#endregion
89
+ //# sourceMappingURL=draft-store.d.mts.map
@@ -0,0 +1,131 @@
1
+ //#region src/core/draft-store.ts
2
+ /**
3
+ * The half-built booking a Vipps login leaves behind, kept for the length of
4
+ * one round trip.
5
+ *
6
+ * The login sheet offers «Fortsett med Vipps», and Vipps is a whole navigation away:
7
+ * the browser goes to Medal, then to Vipps, then back to `/min-side/vipps`,
8
+ * which hands it on to wherever the login said it started. Every answer the
9
+ * parent has given by then — the service, the stylist, the hour — lives in a
10
+ * `useReducer` in a component that no longer exists. Without this they come
11
+ * back logged in to an empty step 1, which is a worse experience than never
12
+ * having been offered the login.
13
+ *
14
+ * `sessionStorage`, for the same reasons `rebook-store.ts` uses it: one tab,
15
+ * gone with it, and never in a URL. The draft carries a child's NAME, which is
16
+ * personal data about a minor — a query string is copied into browser history,
17
+ * the edge access log and every analytics pageview that records `location`, and
18
+ * this is not.
19
+ *
20
+ * Read-once, like the rebook stash and for the same reason: a draft that
21
+ * survived its login would rebuild an abandoned booking on the parent's next
22
+ * visit to `/bestill` in the same tab.
23
+ *
24
+ * IDS AND INSTANTS ONLY. Nothing here is trusted on the way back in: the
25
+ * service ids are looked up in the catalogue the page just fetched, and the
26
+ * restore runs through the machine's own actions, so a draft naming a service
27
+ * the salon has since retired rebuilds nothing rather than a basket the wizard
28
+ * could not have produced by tapping.
29
+ *
30
+ * Every path falls back to «no draft» rather than throwing — no storage during
31
+ * the server render, Safari private mode, storage turned off — because the
32
+ * worst case is a parent answering three questions they had answered once.
33
+ */
34
+ /** `<namespace>:booking:draft`. A storage key, not a credential. */
35
+ function draftStorageKey(namespace) {
36
+ return `${namespace}:booking:draft`;
37
+ }
38
+ /** How long a draft is worth restoring. A Vipps login is a phone unlock and a
39
+ * confirmation; anything older than this is a tab left open over lunch, and the
40
+ * hour in it has very likely gone. A draft dated in the future is refused
41
+ * outright — see `asDraft`. */
42
+ const DRAFT_MAX_AGE_MS = 18e5;
43
+ /** The most extras a line can carry — the engine's own ceiling. */
44
+ const MAX_DRAFT_EXTRAS = 3;
45
+ /**
46
+ * A line's extras, or `null` to drop the field: anything but a short list of
47
+ * non-empty strings is a jar somebody edited, and the line is still worth
48
+ * restoring as its one service — without the hour, which was chosen for the
49
+ * longer visit (see `asDraft`).
50
+ */
51
+ function asExtraServiceIds(value) {
52
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DRAFT_EXTRAS) return null;
53
+ return value.every((id) => typeof id === "string" && id.length > 0) ? [...value] : null;
54
+ }
55
+ /** The shape check, done here so nothing downstream has to trust the jar. */
56
+ function asDraft(value, now) {
57
+ if (typeof value !== "object" || value === null) return null;
58
+ const draft = value;
59
+ if (typeof draft.savedAt !== "number") return null;
60
+ const age = now - draft.savedAt;
61
+ if (age < 0 || age > 18e5) return null;
62
+ if (!Array.isArray(draft.items) || draft.items.length === 0) return null;
63
+ const items = [];
64
+ let visitsWhole = true;
65
+ for (const item of draft.items) {
66
+ if (typeof item?.serviceId !== "string" || item.serviceId.length === 0) return null;
67
+ const extraServiceIds = asExtraServiceIds(item.extraServiceIds);
68
+ if (item.extraServiceIds !== void 0 && extraServiceIds === null) visitsWhole = false;
69
+ items.push({
70
+ serviceId: item.serviceId,
71
+ ...extraServiceIds === null ? {} : { extraServiceIds },
72
+ bookedForName: typeof item.bookedForName === "string" ? item.bookedForName : null,
73
+ bookedForBirthYear: typeof item.bookedForBirthYear === "number" && Number.isFinite(item.bookedForBirthYear) ? item.bookedForBirthYear : null,
74
+ adult: typeof item.adult === "boolean" ? item.adult : null,
75
+ personId: typeof item.personId === "string" && item.personId.length > 0 ? item.personId : null
76
+ });
77
+ }
78
+ const partyResourceIds = Array.isArray(draft.partyResourceIds) && draft.partyResourceIds.every((id) => typeof id === "string") ? draft.partyResourceIds : null;
79
+ return {
80
+ items,
81
+ resourceId: typeof draft.resourceId === "string" ? draft.resourceId : null,
82
+ partyMode: draft.partyMode === "parallel" ? "parallel" : "sequential",
83
+ startTs: visitsWhole && typeof draft.startTs === "number" ? draft.startTs : null,
84
+ resolvedResourceId: typeof draft.resolvedResourceId === "string" ? draft.resolvedResourceId : null,
85
+ partyResourceIds,
86
+ savedAt: draft.savedAt
87
+ };
88
+ }
89
+ /** The draft store under `<namespace>:booking:draft`. */
90
+ function createDraftStore(namespace) {
91
+ const STORAGE_KEY = draftStorageKey(namespace);
92
+ /** Write the draft, replacing whatever was there. One tab holds one booking. */
93
+ function stashDraft(draft, now = Date.now()) {
94
+ if (typeof window === "undefined") return;
95
+ try {
96
+ window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
97
+ ...draft,
98
+ savedAt: now
99
+ }));
100
+ } catch {}
101
+ }
102
+ /** The draft, and it is gone afterwards. */
103
+ function takeDraft(now = Date.now()) {
104
+ if (typeof window === "undefined") return null;
105
+ try {
106
+ const raw = window.sessionStorage.getItem(STORAGE_KEY);
107
+ window.sessionStorage.removeItem(STORAGE_KEY);
108
+ if (raw === null) return null;
109
+ return asDraft(JSON.parse(raw), now);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ /** Forget any draft, for a booking that has been made or abandoned. */
115
+ function clearDraft() {
116
+ if (typeof window === "undefined") return;
117
+ try {
118
+ window.sessionStorage.removeItem(STORAGE_KEY);
119
+ } catch {}
120
+ }
121
+ return {
122
+ DRAFT_STORAGE_KEY: STORAGE_KEY,
123
+ stashDraft,
124
+ takeDraft,
125
+ clearDraft
126
+ };
127
+ }
128
+ //#endregion
129
+ export { DRAFT_MAX_AGE_MS, createDraftStore, draftStorageKey };
130
+
131
+ //# sourceMappingURL=draft-store.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draft-store.mjs","names":[],"sources":["../../src/core/draft-store.ts"],"sourcesContent":["/**\n * The half-built booking a Vipps login leaves behind, kept for the length of\n * one round trip.\n *\n * The login sheet offers «Fortsett med Vipps», and Vipps is a whole navigation away:\n * the browser goes to Medal, then to Vipps, then back to `/min-side/vipps`,\n * which hands it on to wherever the login said it started. Every answer the\n * parent has given by then — the service, the stylist, the hour — lives in a\n * `useReducer` in a component that no longer exists. Without this they come\n * back logged in to an empty step 1, which is a worse experience than never\n * having been offered the login.\n *\n * `sessionStorage`, for the same reasons `rebook-store.ts` uses it: one tab,\n * gone with it, and never in a URL. The draft carries a child's NAME, which is\n * personal data about a minor — a query string is copied into browser history,\n * the edge access log and every analytics pageview that records `location`, and\n * this is not.\n *\n * Read-once, like the rebook stash and for the same reason: a draft that\n * survived its login would rebuild an abandoned booking on the parent's next\n * visit to `/bestill` in the same tab.\n *\n * IDS AND INSTANTS ONLY. Nothing here is trusted on the way back in: the\n * service ids are looked up in the catalogue the page just fetched, and the\n * restore runs through the machine's own actions, so a draft naming a service\n * the salon has since retired rebuilds nothing rather than a basket the wizard\n * could not have produced by tapping.\n *\n * Every path falls back to «no draft» rather than throwing — no storage during\n * the server render, Safari private mode, storage turned off — because the\n * worst case is a parent answering three questions they had answered once.\n */\n\n/** `<namespace>:booking:draft`. A storage key, not a credential. */\nexport function draftStorageKey(namespace: string): string {\n return `${namespace}:booking:draft`;\n}\n\n/** How long a draft is worth restoring. A Vipps login is a phone unlock and a\n * confirmation; anything older than this is a tab left open over lunch, and the\n * hour in it has very likely gone. A draft dated in the future is refused\n * outright — see `asDraft`. */\nexport const DRAFT_MAX_AGE_MS = 30 * 60 * 1000;\n\n/** One line item, as little of it as a rebuild needs. */\nexport interface WizardDraftItem {\n serviceId: string;\n /**\n * The rest of the person's visit after `serviceId`, in order. Absent for a\n * one-service line (and in a draft written before visits had extras), never\n * `[]`. Ids only, like `serviceId`: a restore looks each one up again.\n */\n extraServiceIds?: string[];\n bookedForName: string | null;\n bookedForBirthYear: number | null;\n /**\n * A grown-up's line (`true`) or a child's (`false`); `null` in a draft\n * written before the field existed. A restore reseats a guest from it, so a\n * parent who changed «Voksen» to «1 barn» comes back from Vipps as they left,\n * not as the link they arrived on said.\n */\n adult?: boolean | null;\n /**\n * The saved child the line was for (`bookedForPersonId`); `null` for a guest\n * or a draft written before the field existed. Never trusted on the way\n * back: a restore reseats it only when the logged-in parent's own family has\n * a child with this id.\n */\n personId?: string | null;\n}\n\nexport interface WizardDraft {\n items: WizardDraftItem[];\n /** The visitor's preference — `null` is «Første ledige». */\n resourceId: string | null;\n partyMode: 'sequential' | 'parallel';\n startTs: number | null;\n /** Who the chosen slot resolved to, which is what gets submitted. */\n resolvedResourceId: string | null;\n /** One stylist per line item, for a party; `null` for everything else. */\n partyResourceIds: string[] | null;\n /** When it was written, so a stale tab does not resurrect last week's hour. */\n savedAt: number;\n}\n\n/** The most extras a line can carry — the engine's own ceiling. */\nconst MAX_DRAFT_EXTRAS = 3;\n\n/**\n * A line's extras, or `null` to drop the field: anything but a short list of\n * non-empty strings is a jar somebody edited, and the line is still worth\n * restoring as its one service — without the hour, which was chosen for the\n * longer visit (see `asDraft`).\n */\nfunction asExtraServiceIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DRAFT_EXTRAS) return null;\n return value.every((id) => typeof id === 'string' && id.length > 0) ? [...value] : null;\n}\n\n/** The shape check, done here so nothing downstream has to trust the jar. */\nfunction asDraft(value: unknown, now: number): WizardDraft | null {\n if (typeof value !== 'object' || value === null) return null;\n const draft = value as Partial<WizardDraft>;\n // Both ends of the window. A draft older than the lifetime is a tab left open\n // over lunch — and one stamped in the FUTURE is a clock that has since been\n // corrected backwards, whose negative age would pass an «older than» check\n // indefinitely and resurrect an obsolete hour hours later.\n if (typeof draft.savedAt !== 'number') return null;\n const age = now - draft.savedAt;\n if (age < 0 || age > DRAFT_MAX_AGE_MS) return null;\n if (!Array.isArray(draft.items) || draft.items.length === 0) return null;\n const items: WizardDraftItem[] = [];\n // Whether every line's visit survived the check whole. A dropped extra makes\n // the visit shorter than the one the stored hour was chosen for.\n let visitsWhole = true;\n for (const item of draft.items) {\n if (typeof item?.serviceId !== 'string' || item.serviceId.length === 0) return null;\n const extraServiceIds = asExtraServiceIds(item.extraServiceIds);\n if (item.extraServiceIds !== undefined && extraServiceIds === null) visitsWhole = false;\n items.push({\n serviceId: item.serviceId,\n ...(extraServiceIds === null ? {} : { extraServiceIds }),\n bookedForName: typeof item.bookedForName === 'string' ? item.bookedForName : null,\n bookedForBirthYear:\n typeof item.bookedForBirthYear === 'number' && Number.isFinite(item.bookedForBirthYear)\n ? item.bookedForBirthYear\n : null,\n adult: typeof item.adult === 'boolean' ? item.adult : null,\n personId:\n typeof item.personId === 'string' && item.personId.length > 0 ? item.personId : null,\n });\n }\n const partyResourceIds =\n Array.isArray(draft.partyResourceIds) &&\n draft.partyResourceIds.every((id) => typeof id === 'string')\n ? draft.partyResourceIds\n : null;\n return {\n items,\n resourceId: typeof draft.resourceId === 'string' ? draft.resourceId : null,\n partyMode: draft.partyMode === 'parallel' ? 'parallel' : 'sequential',\n startTs: visitsWhole && typeof draft.startTs === 'number' ? draft.startTs : null,\n resolvedResourceId:\n typeof draft.resolvedResourceId === 'string' ? draft.resolvedResourceId : null,\n partyResourceIds,\n savedAt: draft.savedAt,\n };\n}\n\nexport interface DraftStore {\n readonly DRAFT_STORAGE_KEY: string;\n stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now?: number): void;\n takeDraft(now?: number): WizardDraft | null;\n clearDraft(): void;\n}\n\n/** The draft store under `<namespace>:booking:draft`. */\nexport function createDraftStore(namespace: string): DraftStore {\n const STORAGE_KEY = draftStorageKey(namespace);\n\n /** Write the draft, replacing whatever was there. One tab holds one booking. */\n function stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now: number = Date.now()): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...draft, savedAt: now }));\n } catch {\n // The parent answers the three questions again. Nothing is lost that was\n // not already only in a browser tab.\n }\n }\n\n /** The draft, and it is gone afterwards. */\n function takeDraft(now: number = Date.now()): WizardDraft | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n window.sessionStorage.removeItem(STORAGE_KEY);\n if (raw === null) return null;\n return asDraft(JSON.parse(raw), now);\n } catch {\n // A malformed value has already been removed above where it could be.\n return null;\n }\n }\n\n /** Forget any draft, for a booking that has been made or abandoned. */\n function clearDraft(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do: a draft that cannot be removed also could not be written.\n }\n }\n\n return { DRAFT_STORAGE_KEY: STORAGE_KEY, stashDraft, takeDraft, clearDraft };\n}\n"],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ import { BookingConfig } from "./config.mjs";
2
+ import { BookingDayDto, BookingResourceDto, BookingServiceDto, BookingSlotDto } from "./types.mjs";
3
+ import { MedalResource, MedalScheduleDay, MedalService, MedalSlot } from "./wire.mjs";
4
+ //#region src/core/dto.d.ts
5
+ type DtoConfig = Pick<BookingConfig, 'categories' | 'fallbackCategory' | 'paths'>;
6
+ /** What a resource id may look like before the avatar route will look it up
7
+ * — and before this module will build a URL out of it. */
8
+ export declare const RESOURCE_ID_SHAPE: RegExp;
9
+ /**
10
+ * Zero or one slot, so a timeless one is dropped rather than emitted.
11
+ *
12
+ * `start_ts` is nullable on the wire like every other timestamp. A `NaN` carried
13
+ * through would render as an «Invalid Date» chip that books nothing, and would
14
+ * reach `createBooking` as a `start_ts` the engine rejects.
15
+ */
16
+ export declare function toBookingSlotDto(slot: MedalSlot): BookingSlotDto[];
17
+ /**
18
+ * Zero or one open day, so a date the wire could not describe is dropped rather
19
+ * than emitted.
20
+ *
21
+ * Dropping is the SAFE failure here, and it is worth being explicit about why,
22
+ * because the two directions are not symmetric. A date missing from this list
23
+ * reads downstream as «the salon keeps no hours then», which at worst shows
24
+ * «Stengt» beside a day that had none of its slots taken — visible, and it
25
+ * costs the visitor a tap on another day. A date emitted with a nonsense
26
+ * `opensTs` reads as open, and `NaN` compares false against every clock check
27
+ * the step makes, so the day would silently fall through to «Fullt» — the exact
28
+ * false claim about the business this whole path exists to stop.
29
+ *
30
+ * `lastStartTs` is the one field allowed to be null after this: there, null is
31
+ * an ANSWER (open on paper, shut on the day) rather than an unreadable value.
32
+ */
33
+ export declare function toBookingDayDto(day: MedalScheduleDay): BookingDayDto[];
34
+ export interface Dto {
35
+ /** A free-string Medal category as one of the configured keys, or the fallback. */
36
+ normaliseServiceCategory(category: string): string;
37
+ toBookingServiceDto(service: MedalService): BookingServiceDto;
38
+ /** This site's stable avatar URL for a stylist, or `null` for an id the route would refuse. */
39
+ avatarPath(resourceId: string): string | null;
40
+ toBookingResourceDto(resource: MedalResource): BookingResourceDto;
41
+ toBookingSlotDto: typeof toBookingSlotDto;
42
+ toBookingDayDto: typeof toBookingDayDto;
43
+ }
44
+ export declare function createDto(config: DtoConfig): Dto;
45
+ //#endregion
46
+ //# sourceMappingURL=dto.d.mts.map