@medalsocial/booking 0.4.0 → 0.5.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 (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/dist/core/config.d.mts +3 -0
  4. package/dist/core/config.mjs +5 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +2 -0
  7. package/dist/core/draft-store.mjs +1 -0
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +2 -1
  10. package/dist/core/index.mjs +2 -1
  11. package/dist/core/machine.d.mts +16 -0
  12. package/dist/core/machine.mjs +59 -3
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +6 -1
  15. package/dist/core/party-slots.mjs +6 -5
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/range.d.mts +30 -0
  18. package/dist/core/range.mjs +32 -0
  19. package/dist/core/range.mjs.map +1 -0
  20. package/dist/next/catalogue.mjs +4 -5
  21. package/dist/next/catalogue.mjs.map +1 -1
  22. package/dist/next/handler.mjs +63 -5
  23. package/dist/next/handler.mjs.map +1 -1
  24. package/dist/next/medal.mjs +47 -15
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/create.mjs +3 -6
  27. package/dist/next/routes/create.mjs.map +1 -1
  28. package/dist/next/routes/manage.mjs +5 -8
  29. package/dist/next/routes/manage.mjs.map +1 -1
  30. package/dist/next/routes/shared.mjs +57 -14
  31. package/dist/next/routes/shared.mjs.map +1 -1
  32. package/dist/react/BookingWizard.d.mts +7 -1
  33. package/dist/react/BookingWizard.mjs +20 -2
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/index.d.mts +2 -2
  36. package/dist/react/labels/screens.mjs +4 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/useBooking.d.mts +11 -1
  39. package/dist/react/useBooking.mjs +152 -31
  40. package/dist/react/useBooking.mjs.map +1 -1
  41. package/dist/react/wizard/adapters.mjs +33 -1
  42. package/dist/react/wizard/adapters.mjs.map +1 -1
  43. package/dist/react/wizard/index.d.mts +2 -2
  44. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @medalsocial/booking
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#222](https://github.com/Medal-Social/MedalSocial-SDK/pull/222) [`685a4fb`](https://github.com/Medal-Social/MedalSocial-SDK/commit/685a4fb45914f3d8ce9a02c6092faa98b7455d7b) Thanks [@adaadev](https://github.com/adaadev)! - Config `screens.seatStylists` (default `false`): when a family is seen at once, each person can ask for their own stylist («who cuts whom»), and the search seats everyone else with whoever is free at that minute. New `WizardState.seatResourceIds` and `pickSeatResource` action, kept in drafts and dropped when the basket changes. `findPartySlots` takes per-seat preferences. Built-in nb/en `stylist.party.seat*` words.
8
+
9
+ Requires `@medalsocial/meda` ^3.9.0.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#222](https://github.com/Medal-Social/MedalSocial-SDK/pull/222) [`685a4fb`](https://github.com/Medal-Social/MedalSocial-SDK/commit/685a4fb45914f3d8ce9a02c6092faa98b7455d7b) Thanks [@adaadev](https://github.com/adaadev)! - A person who replaces someone on step 1 no longer inherits that person's «who cuts whom» stylist. A choice only moves to a new seat key when a login renames the same person.
14
+
15
+ - [#222](https://github.com/Medal-Social/MedalSocial-SDK/pull/222) [`685a4fb`](https://github.com/Medal-Social/MedalSocial-SDK/commit/685a4fb45914f3d8ce9a02c6092faa98b7455d7b) Thanks [@adaadev](https://github.com/adaadev)! - The stylist step's «who cuts whom» seats are passed only while the party is seen at once. One after another is one stylist for the whole visit, so the wizard no longer depends on the screen hiding them.
16
+
17
+ ## 0.4.1
18
+
19
+ ### Patch Changes
20
+
21
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - Outage-hardening follow-ups: every failed visit in a party's batch reaches `onEvent` as its own `read_error` (the controller's `readError` is now `readErrors`, a short queue). An overnight «Prøv igjen» shows the stylists loading until the new window answers. Portal refusals are logged with their `reason` code.
22
+
23
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - Outage hardening after a production incident where the DST hour pushed the widest booking window past the routes' 62 × 24 h range guard, so every unseeded service answered 400 between 00:00 and 01:00 for eight weeks of nights.
24
+
25
+ - The read routes admit the widest window a valid config can build, DST hour included. One module (`core/range.ts`, exporting `MAX_RANGE_DAYS` and `MAX_RANGE_MS`) owns the bound for the config schema, `parseRange` and the catalogue cache horizon.
26
+ - «Prøv igjen» re-reads the openings, the stylists and the hours, seeded ones included. If the business day has moved on, it starts a fresh window from now. A party keeps the visits that answered and asks only for the ones that failed.
27
+ - Medal's `in_past`, `outside_horizon` and `off_grid` refusals take the slot-taken path (fresh openings, back to the time step) rather than an endless «Prøv en gang til».
28
+ - Every answer ≥ 400 is logged through the site's logger with the route (a fixed label for malformed or unmatched paths), method, status and error code. The message is never logged, and neither are the rate limiter's own 429s. `onEvent` gains `{ type: 'read_error', endpoint, status }`, once per failure.
29
+ - Catalogue and calendar reads give up after 4 s per attempt and 8 s in total, instead of the SDK's 30 s × 3 with an uncapped `Retry-After`. The deadline aborts the SDK call itself, so nothing more is sent upstream.
30
+ - A booking that happened is never answered as a failure because a cache could not be expired.
31
+ - A pending booking resumes on browsers without `AbortSignal.timeout` (Safari < 16).
32
+
33
+ - [#214](https://github.com/Medal-Social/MedalSocial-SDK/pull/214) [`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e) Thanks [@alioftech](https://github.com/alioftech)! - `isSlotTaken` reads Medal's «pick another time» refusals defensively: any 409 (not only code `CONFLICT`), `details.reason` in any case, and the message prefix when there is no reason. A change of spelling on the engine side now degrades to fresh openings, never to a retry loop.
34
+ - Updated dependencies [[`6afacff`](https://github.com/Medal-Social/MedalSocial-SDK/commit/6afacffc461213b95a02951f15638d3d42c1975e)]:
35
+ - @medalsocial/sdk@1.15.0
36
+
3
37
  ## 0.4.0
4
38
 
5
39
  ### Minor Changes
package/README.md CHANGED
@@ -100,6 +100,7 @@ built-in packs carry their words; override them with `labels`.
100
100
  | `guestParty` | A guest picks a number of children and «me too» instead of the chips, so a parent and child book together without an account; the step opens with one child seated (also in the server render) (`who.party.*`) |
101
101
  | `childMenuFirst` | A child's service list leads with the children's and anyone's groups; grown-ups' groups move below the «usually not for this age» divider |
102
102
  | `wideLayouts` | The stylist step keeps its row of faces at every width, with the chosen stylist's bio under it; the time step puts the days beside the times on an iPad and shows the week as a grid of times on a desktop (`time.week.*` labels) |
103
+ | `seatStylists` | A family seen at once can ask for a stylist per person («who cuts whom», `stylist.party.seat*` labels); the rest are seated with anyone free at that minute |
103
104
 
104
105
  A site moving onto the package keeps its live sessions, drafts and cache
105
106
  entries by passing the paths, cookie names and storage namespace it already
@@ -161,6 +161,8 @@ export interface BookingConfig {
161
161
  * (the chosen stylist's bio under it), and the time step goes
162
162
  * `layout="adaptive"`: days beside the times on an iPad, the whole week as
163
163
  * a grid of times on a desktop. Needs the `time.week.*` labels.
164
+ * - `seatStylists` — a family seen at once can ask for a stylist per person
165
+ * («who cuts whom»); the rest are seated with anyone free at that minute.
164
166
  */
165
167
  screens: {
166
168
  recap: boolean;
@@ -173,6 +175,7 @@ export interface BookingConfig {
173
175
  guestParty: boolean;
174
176
  childMenuFirst: boolean;
175
177
  wideLayouts: boolean;
178
+ seatStylists: boolean;
176
179
  };
177
180
  consent: {
178
181
  termsUrl: string | null;
@@ -1,3 +1,4 @@
1
+ import "./range.mjs";
1
2
  import { z } from "zod";
2
3
  //#region src/core/config.ts
3
4
  /**
@@ -108,7 +109,8 @@ function defaults() {
108
109
  stylistEdgeFade: false,
109
110
  guestParty: false,
110
111
  childMenuFirst: false,
111
- wideLayouts: false
112
+ wideLayouts: false,
113
+ seatStylists: false
112
114
  },
113
115
  consent: {
114
116
  termsUrl: null,
@@ -296,7 +298,8 @@ const schema = z.object({
296
298
  stylistEdgeFade: z.boolean(),
297
299
  guestParty: z.boolean(),
298
300
  childMenuFirst: z.boolean(),
299
- wideLayouts: z.boolean()
301
+ wideLayouts: z.boolean(),
302
+ seatStylists: z.boolean()
300
303
  }),
301
304
  consent: z.object({
302
305
  termsUrl: z.string().min(1).nullable(),
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n /**\n * A party with a grown-up in it starts «Samtidig» (`partyMode:\n * 'parallel'`): the child in one chair, the parent in the next, done\n * together. Only until the parent picks a mode, or a stylist link names\n * one stylist (one stylist cannot cut two people at once). Needs\n * `allowParallel`. Off by default: everyone else keeps «Rett etter\n * hverandre».\n */\n adultTogether: boolean;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n * - `wideLayouts` — the stylist step keeps its row of faces at every width\n * (the chosen stylist's bio under it), and the time step goes\n * `layout=\"adaptive\"`: days beside the times on an iPad, the whole week as\n * a grid of times on a desktop. Needs the `time.week.*` labels.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n wideLayouts: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: {\n maxPeople: 3,\n allowParallel: true,\n askWhoFirst: true,\n maxServicesPerPerson: 1,\n adultTogether: false,\n },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n wideLayouts: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n adultTogether: z.boolean(),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(62),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n wideLayouts: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\nimport { MAX_RANGE_DAYS } from './range';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n /**\n * A party with a grown-up in it starts «Samtidig» (`partyMode:\n * 'parallel'`): the child in one chair, the parent in the next, done\n * together. Only until the parent picks a mode, or a stylist link names\n * one stylist (one stylist cannot cut two people at once). Needs\n * `allowParallel`. Off by default: everyone else keeps «Rett etter\n * hverandre».\n */\n adultTogether: boolean;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n * - `wideLayouts` — the stylist step keeps its row of faces at every width\n * (the chosen stylist's bio under it), and the time step goes\n * `layout=\"adaptive\"`: days beside the times on an iPad, the whole week as\n * a grid of times on a desktop. Needs the `time.week.*` labels.\n * - `seatStylists` — a family seen at once can ask for a stylist per person\n * («who cuts whom»); the rest are seated with anyone free at that minute.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n wideLayouts: boolean;\n seatStylists: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: {\n maxPeople: 3,\n allowParallel: true,\n askWhoFirst: true,\n maxServicesPerPerson: 1,\n adultTogether: false,\n },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n wideLayouts: false,\n seatStylists: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n adultTogether: z.boolean(),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(MAX_RANGE_DAYS),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n wideLayouts: z.boolean(),\n seatStylists: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
@@ -74,6 +74,8 @@ export interface WizardDraft {
74
74
  * draft written before the field existed, which restores as chosen.
75
75
  */
76
76
  partyModeChosen?: boolean;
77
+ /** Seen at once: each line's own stylist, by line index (`null` = anyone). Absent before the field existed. */
78
+ seatResourceIds?: Array<string | null>;
77
79
  startTs: number | null;
78
80
  /** Who the chosen slot resolved to, which is what gets submitted. */
79
81
  resolvedResourceId: string | null;
@@ -81,6 +81,7 @@ function asDraft(value, now) {
81
81
  resourceId: typeof draft.resourceId === "string" ? draft.resourceId : null,
82
82
  partyMode: draft.partyMode === "parallel" ? "parallel" : "sequential",
83
83
  ...typeof draft.partyModeChosen === "boolean" ? { partyModeChosen: draft.partyModeChosen } : {},
84
+ ...Array.isArray(draft.seatResourceIds) && draft.seatResourceIds.length === items.length && draft.seatResourceIds.every((id) => id === null || typeof id === "string") ? { seatResourceIds: draft.seatResourceIds } : {},
84
85
  startTs: visitsWhole && typeof draft.startTs === "number" ? draft.startTs : null,
85
86
  resolvedResourceId: typeof draft.resolvedResourceId === "string" ? draft.resolvedResourceId : null,
86
87
  partyResourceIds,
@@ -1 +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 /**\n * The parent picked `partyMode` (not `party.adultTogether`). Absent in a\n * draft written before the field existed, which restores as chosen.\n */\n partyModeChosen?: boolean;\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 ...(typeof draft.partyModeChosen === 'boolean'\n ? { partyModeChosen: draft.partyModeChosen }\n : {}),\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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"}
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 /**\n * The parent picked `partyMode` (not `party.adultTogether`). Absent in a\n * draft written before the field existed, which restores as chosen.\n */\n partyModeChosen?: boolean;\n /** Seen at once: each line's own stylist, by line index (`null` = anyone). Absent before the field existed. */\n seatResourceIds?: Array<string | null>;\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 ...(typeof draft.partyModeChosen === 'boolean'\n ? { partyModeChosen: draft.partyModeChosen }\n : {}),\n ...(Array.isArray(draft.seatResourceIds) &&\n draft.seatResourceIds.length === items.length &&\n draft.seatResourceIds.every((id) => id === null || typeof id === 'string')\n ? { seatResourceIds: draft.seatResourceIds }\n : {}),\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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"}
@@ -28,8 +28,9 @@ import { ReturnPathGuard, createReturnPath } from "./portal/return-path.mjs";
28
28
  import { SessionCookie, createSessionCookie, isPortalSessionToken } from "./portal/session-cookie.mjs";
29
29
  import { VippsLinkCookies, createVippsLinkCookies, isVippsToken, mintBrowserBinding, vippsLinkVerifyPath } from "./portal/vipps-link.mjs";
30
30
  import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom } from "./portal/vipps-return.mjs";
31
+ import { MAX_RANGE_DAYS, MAX_RANGE_MS } from "./range.mjs";
31
32
  import { RebookStore, createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
32
33
  import { RESTORE_FALLBACK_MS, RestoreGate, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
33
34
  import { Stores, createStores } from "./stores.mjs";
34
35
  import { VisitTiming, visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf } from "./visit.mjs";
35
- export { ATTEMPT_TTL_MS, Age, AgeBounds, AgeRange, AttemptStore, BOOKED_FOR_NAME_MAX_LENGTH, BookingAttempt, BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDayDto, BookingDaypart, BookingFamilyMember, BookingGuardian, BookingLabel, BookingLabels, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem, CONSENT_SOURCE, Clock, ClockConfig, ConfirmedBooking, CookieAttributes, CookieJar, CookieSource, CoreLabels, DRAFT_MAX_AGE_MS, DeepLinks, DraftStore, Dto, FamilyKey, Ics, IcsEvent, KeyedLabels, LABEL_PACKS, MarketingConsent, MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot, Money, NEXT_FREE_TTL_MS, NextFree, NextFreeClient, PartySeat, PartySlot, PartySlots, Paths, Phone, PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalNextPath, PortalProfileDto, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, RebookStore, RebookSuggestion, RestoreGate, ReturnPathGuard, SELF_KEY, SessionCookie, SlotsByService, Stores, SubmittedVisit, VIPPS_CONFIRM_QUERY, VerifyLoginFailure, VippsLinkCookies, VisitTiming, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardDraft, WizardDraftItem, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
36
+ export { ATTEMPT_TTL_MS, Age, AgeBounds, AgeRange, AttemptStore, BOOKED_FOR_NAME_MAX_LENGTH, BookingAttempt, BookingCategory, BookingConfig, BookingConfigError, BookingConfigInput, BookingDayDto, BookingDaypart, BookingFamilyMember, BookingGuardian, BookingLabel, BookingLabels, BookingLastVisit, BookingManageDto, BookingResourceDto, BookingServiceDto, BookingSlotDto, BookingSubmission, BookingSubmissionItem, CONSENT_SOURCE, Clock, ClockConfig, ConfirmedBooking, CookieAttributes, CookieJar, CookieSource, CoreLabels, DRAFT_MAX_AGE_MS, DeepLinks, DraftStore, Dto, FamilyKey, Ics, IcsEvent, KeyedLabels, LABEL_PACKS, MAX_RANGE_DAYS, MAX_RANGE_MS, MarketingConsent, MedalManageSummary, MedalResource, MedalScheduleDay, MedalService, MedalSlot, Money, NEXT_FREE_TTL_MS, NextFree, NextFreeClient, PartySeat, PartySlot, PartySlots, Paths, Phone, PortalBookingDto, PortalDto, PortalFamilyMemberDto, PortalLabels, PortalNextPath, PortalProfileDto, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, RebookStore, RebookSuggestion, RestoreGate, ReturnPathGuard, SELF_KEY, SessionCookie, SlotsByService, Stores, SubmittedVisit, VIPPS_CONFIRM_QUERY, VerifyLoginFailure, VippsLinkCookies, VisitTiming, Wizard, WizardAction, WizardCatalogue, WizardConfig, WizardDraft, WizardDraftItem, WizardItem, WizardPerson, WizardPrefill, WizardService, WizardState, WizardStep, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
@@ -3,6 +3,7 @@ import { LABEL_PACKS, fill, fillParts, isBookingLabel, labelPackFor, labelText,
3
3
  import { ageOnDay, createAge, fitsAge } from "./age.mjs";
4
4
  import { ATTEMPT_TTL_MS, attemptStorageKey, createAttemptStore } from "./attempt-store.mjs";
5
5
  import { createClock } from "./clock.mjs";
6
+ import { MAX_RANGE_DAYS, MAX_RANGE_MS } from "./range.mjs";
6
7
  import { BookingConfigError, resolveBookingConfig } from "./config.mjs";
7
8
  import { CONSENT_SOURCE, marketingConsent } from "./consent.mjs";
8
9
  import { initialsOf, stylistDisplayName } from "./display-name.mjs";
@@ -29,4 +30,4 @@ import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom
29
30
  import { createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
30
31
  import { RESTORE_FALLBACK_MS, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
31
32
  import { createStores } from "./stores.mjs";
32
- export { ATTEMPT_TTL_MS, BOOKED_FOR_NAME_MAX_LENGTH, BookingConfigError, CONSENT_SOURCE, DRAFT_MAX_AGE_MS, LABEL_PACKS, NEXT_FREE_TTL_MS, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, SELF_KEY, VIPPS_CONFIRM_QUERY, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
33
+ export { ATTEMPT_TTL_MS, BOOKED_FOR_NAME_MAX_LENGTH, BookingConfigError, CONSENT_SOURCE, DRAFT_MAX_AGE_MS, LABEL_PACKS, MAX_RANGE_DAYS, MAX_RANGE_MS, NEXT_FREE_TTL_MS, RESOURCE_ID_SHAPE, RESTORE_FALLBACK_MS, SELF_KEY, VIPPS_CONFIRM_QUERY, adultCategoryFor, adultEquivalent, ageOnDay, attemptStorageKey, canAdvance, canGoToStep, categoryOrder, childCategory, codeField, completedNewestFirst, createAge, createAttemptStore, createClock, createDeepLinks, createDraftStore, createDto, createIcs, createMoney, createNextFree, createPartySlots, createPaths, createPhone, createPortalDto, createPortalNextPath, createRebookStore, createRestoreGate, createReturnPath, createSessionCookie, createStores, createVippsLinkCookies, createWizard, draftStorageKey, earliestOpening, emailField, fill, fillParts, findPartySlots, firstBookingFor, firstOpeningPerResource, firstPartyStartPerResource, fitsAge, guestChild, icsDataUrl, initialState, initialsOf, isBookingLabel, isChildCategory, isGuestSeat, isPortalSessionToken, isVippsToken, itemResourceIds, itemStartTimes, labelPackFor, labelText, marketingConsent, maskedAddress, mintBrowserBinding, monthOrNull, normaliseCategory, parseParty, rebookStorageKey, rebookSuggestions, resolveBookingConfig, resolveLabels, resourceMatches, restorePendingInStorage, serviceMatches, showsPartyMode, slotKeysFor, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitKey, visitKeyOfIds, visitMinutes, visitOf, visitOfServices, visitPriceOre, visitServicesOf, visitTailMinutes, withBookingQuery };
@@ -181,6 +181,12 @@ export interface WizardState {
181
181
  * the confirmation card cannot disagree about who is cutting whose hair.
182
182
  */
183
183
  partyResourceIds: string[] | null;
184
+ /**
185
+ * Seen at once: the stylist a person asked for, by their seat key («who cuts
186
+ * whom»). A person not in it is seated with anyone free. Dropped whenever the
187
+ * basket changes, as the stylist was chosen for that visit. Absent = none.
188
+ */
189
+ seatResourceIds?: Readonly<Record<string, string>>;
184
190
  contact: {
185
191
  phone: string;
186
192
  name: string;
@@ -374,6 +380,16 @@ export type WizardAction =
374
380
  type: 'setPartyMode';
375
381
  mode: WizardState['partyMode'];
376
382
  } |
383
+ /**
384
+ * Seen at once: the person at `index`'s own stylist (`null` = anyone). Refused for a
385
+ * stylist already asked for by somebody else in the party — one stylist
386
+ * cannot cut two people at once. The slot goes, as with `pickResource`.
387
+ */
388
+ {
389
+ type: 'pickSeatResource';
390
+ index: number;
391
+ resourceId: string | null;
392
+ } |
377
393
  /**
378
394
  * A whole family seated in one tap: the instant the visit starts and the
379
395
  * stylist each child got.
@@ -235,14 +235,21 @@ function basketOf(items) {
235
235
  function withParty(state, people, choices, extras) {
236
236
  const aligned = people.map((_, index) => choices[index] ? [...extras[index]] : []);
237
237
  const items = people.length > 0 && choices.every((choice) => choice !== null) ? people.map((person, index) => itemFor(person, choices[index], aligned[index])) : [];
238
- const changed = basketOf(items) !== basketOf(state.items);
239
- return {
238
+ if (!(basketOf(items) !== basketOf(state.items))) return {
240
239
  ...state,
241
240
  people,
242
241
  choices,
243
242
  extras: aligned,
243
+ items
244
+ };
245
+ const { seatResourceIds: _released, ...rest } = state;
246
+ return {
247
+ ...rest,
248
+ people,
249
+ choices,
250
+ extras: aligned,
244
251
  items,
245
- ...changed ? clearedSlot : {}
252
+ ...clearedSlot
246
253
  };
247
254
  }
248
255
  /** The people behind a basket built without step 1 — a link, a restored draft. */
@@ -644,6 +651,37 @@ function createWizard(config) {
644
651
  };
645
652
  }
646
653
  function reduce(state, action) {
654
+ const renames = action.type === "unseatPeople" || action.type === "seatFamily";
655
+ return carrySeatResources(state, reduceStep(state, action), renames);
656
+ }
657
+ /**
658
+ * «Who cuts whom» follows the person, not the key. A login re-keys seats
659
+ * (`unseatPeople`, `seatFamily`: a guest chair becomes the family's child,
660
+ * «adult» becomes «self») without the basket changing, and a choice left under
661
+ * the old key would vanish from the selector and the search yet still block
662
+ * that stylist for everyone. So after every step: a key that survives keeps
663
+ * its choice, a seat a login renamed in place takes the old key's choice,
664
+ * and anything else is dropped — a newcomer on step 1 never inherits one.
665
+ */
666
+ function carrySeatResources(before, after, renames) {
667
+ const wishes = after.seatResourceIds;
668
+ if (wishes === void 0 || after.people === before.people) return after;
669
+ const current = new Set(after.people.map((person) => person.key));
670
+ const carried = {};
671
+ after.people.forEach((person, index) => {
672
+ const own = wishes[person.key];
673
+ const previous = before.people[index]?.key;
674
+ const renamed = renames && previous !== void 0 && previous !== person.key && !current.has(previous) ? wishes[previous] : void 0;
675
+ const wish = own ?? renamed;
676
+ if (wish !== void 0) carried[person.key] = wish;
677
+ });
678
+ const { seatResourceIds: _stale, ...rest } = after;
679
+ return Object.keys(carried).length > 0 ? {
680
+ ...rest,
681
+ seatResourceIds: carried
682
+ } : rest;
683
+ }
684
+ function reduceStep(state, action) {
647
685
  switch (action.type) {
648
686
  case "startOver": return initialState();
649
687
  case "choosePeople": return choosePeople(state, action);
@@ -715,6 +753,24 @@ function createWizard(config) {
715
753
  error: null
716
754
  };
717
755
  }
756
+ case "pickSeatResource": {
757
+ const key = state.people[action.index]?.key;
758
+ if (key === void 0) return state;
759
+ const seated = new Set(state.people.map((person) => person.key));
760
+ const current = Object.fromEntries(Object.entries(state.seatResourceIds ?? {}).filter(([other]) => seated.has(other)));
761
+ if (action.resourceId === null) delete current[key];
762
+ else {
763
+ if (Object.entries(current).find(([other, id]) => id === action.resourceId && other !== key)) return state;
764
+ current[key] = action.resourceId;
765
+ }
766
+ const { seatResourceIds: _previous, ...rest } = state;
767
+ return {
768
+ ...rest,
769
+ ...clearedSlot,
770
+ ...Object.keys(current).length > 0 ? { seatResourceIds: current } : {},
771
+ error: null
772
+ };
773
+ }
718
774
  case "pickPartySlot":
719
775
  if (action.mode === "parallel" && !config.party.allowParallel) return state;
720
776
  if (action.resourceIds.length !== state.items.length) return state;