@medalsocial/booking 0.1.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 (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/dist/core/config.d.mts +7 -0
  4. package/dist/core/config.mjs +4 -2
  5. package/dist/core/config.mjs.map +1 -1
  6. package/dist/core/draft-store.d.mts +6 -0
  7. package/dist/core/draft-store.mjs +17 -1
  8. package/dist/core/draft-store.mjs.map +1 -1
  9. package/dist/core/index.d.mts +3 -2
  10. package/dist/core/index.mjs +3 -2
  11. package/dist/core/machine.d.mts +53 -6
  12. package/dist/core/machine.mjs +162 -52
  13. package/dist/core/machine.mjs.map +1 -1
  14. package/dist/core/party-slots.d.mts +9 -2
  15. package/dist/core/party-slots.mjs +18 -12
  16. package/dist/core/party-slots.mjs.map +1 -1
  17. package/dist/core/types.d.mts +4 -0
  18. package/dist/core/visit.d.mts +26 -0
  19. package/dist/core/visit.mjs +40 -0
  20. package/dist/core/visit.mjs.map +1 -0
  21. package/dist/next/catalogue.mjs +25 -7
  22. package/dist/next/catalogue.mjs.map +1 -1
  23. package/dist/next/medal.d.mts +7 -0
  24. package/dist/next/medal.mjs +2 -1
  25. package/dist/next/medal.mjs.map +1 -1
  26. package/dist/next/routes/catalogue-routes.mjs +18 -4
  27. package/dist/next/routes/catalogue-routes.mjs.map +1 -1
  28. package/dist/next/routes/create.mjs +47 -9
  29. package/dist/next/routes/create.mjs.map +1 -1
  30. package/dist/next/routes/manage.mjs.map +1 -1
  31. package/dist/next/routes/shared.mjs +29 -1
  32. package/dist/next/routes/shared.mjs.map +1 -1
  33. package/dist/react/BookingWizard.mjs +32 -11
  34. package/dist/react/BookingWizard.mjs.map +1 -1
  35. package/dist/react/labels/screens.d.mts +1 -1
  36. package/dist/react/labels/screens.mjs +12 -0
  37. package/dist/react/labels/screens.mjs.map +1 -1
  38. package/dist/react/labels/wizard.d.mts +7 -0
  39. package/dist/react/labels/wizard.mjs +2 -0
  40. package/dist/react/labels/wizard.mjs.map +1 -1
  41. package/dist/react/useBooking.d.mts +2 -0
  42. package/dist/react/useBooking.mjs +176 -50
  43. package/dist/react/useBooking.mjs.map +1 -1
  44. package/dist/react/wizard/adapters.mjs +7 -5
  45. package/dist/react/wizard/adapters.mjs.map +1 -1
  46. package/dist/react/wizard-error.mjs +29 -0
  47. package/dist/react/wizard-error.mjs.map +1 -0
  48. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @medalsocial/booking
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#185](https://github.com/Medal-Social/MedalSocial-SDK/pull/185) [`ecf1912`](https://github.com/Medal-Social/MedalSocial-SDK/commit/ecf1912b4029c2faf3aca3e005c4b7f9183f53a1) Thanks [@alioftech](https://github.com/alioftech)! - Several services per person as one visit: the wizard machine holds a person's extra services, the site routes take `extra_service_ids` on availability, schedule and stylists and `extraServiceIds` on create, and `useBooking()` fetches, seats, submits, stashes and confirms the whole visit. New hook actions `toggleServiceFor(index, service)` and `continueFromService()` are ready for a multi-select service screen.
8
+
9
+ `<BookingWizard>` draws meda 3.5's multi-select service step when `party.maxServicesPerPerson` is above `1`: each person ticks up to that many services, the step's own bar shows the visit's total, and «Neste» moves on once everyone has one. The default is `1`, the one-tap step every site has today. The meda peer is now `^3.5.0`.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [[`ecf1912`](https://github.com/Medal-Social/MedalSocial-SDK/commit/ecf1912b4029c2faf3aca3e005c4b7f9183f53a1)]:
14
+ - @medalsocial/sdk@1.13.0
15
+
3
16
  ## 0.1.0
4
17
 
5
18
  ### Minor Changes
package/README.md CHANGED
@@ -73,7 +73,7 @@ its own values in.
73
73
  | `paths` | `/bestill`, `/bestill/administrer`, `/min-side`, `/api/booking`, … | The URLs the site serves |
74
74
  | `query` / `whoValues` | `kategori`, `tjeneste`, `frisor`, `antall`, `hvem`, … | Deep-link query keys and values |
75
75
  | `categories` / `fallbackCategory` | `barn` (child), `annet` (any) | Service groups in display order; unknown Medal categories land on the fallback |
76
- | `party` | `{ maxPeople: 3, allowParallel: true, askWhoFirst: true }` | Step 1's rules |
76
+ | `party` | `{ maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1 }` | Step 1's rules; `maxServicesPerPerson` (1–4) caps one person's visit: `1` is the one-tap service step, above `1` the wizard's service step is multi-select (tick up to that many, then «Neste») |
77
77
  | `window` | `{ rangeDays: 7, prefetchLimit: 4, prefetchCategory: null }` | Bookable window and page prefetch |
78
78
  | `dayparts` | `formiddag` 0–12, `ettermiddag` 12–17, `kveld` 17–24 | Named parts of the day, contiguous from 0 to 24 |
79
79
  | `portal` | disabled; cookie `booking_portal` (+ derived `_next`, `__Host-…_vipps_bind`, `_vipps_link`) | Customer portal switches and cookie names |
@@ -127,7 +127,7 @@ The override ladder, cheapest first, all public API:
127
127
  (see [Labels and text nodes](#labels-and-text-nodes)).
128
128
  3. **`classNames`** — per screen and slot: `classNames={{ who: { chip: '…' }, time: { chip: '…' } }}`.
129
129
  4. **`components`** — card renderers: `components={{ ServiceCard, StylistCard, TimeChip, … }}`.
130
- 5. **`useBooking()`** — the wizard's whole state, fetches and actions, without its markup.
130
+ 5. **`useBooking()`** — the wizard's whole state, fetches and actions, without its markup. Several services per person: `toggleServiceFor(index, service)` ticks or unticks one (never advances) and `continueFromService()` moves on to the time step — for a multi-select service step; `pickService`/`pickServiceFor` keep the one-tap behaviour.
131
131
 
132
132
  `<BookingProvider config labels classNames components>` shares one set of
133
133
  these with every booking piece under it. Server actions stay in the app
@@ -79,6 +79,13 @@ export interface BookingConfig {
79
79
  maxPeople: number;
80
80
  allowParallel: boolean;
81
81
  askWhoFirst: boolean;
82
+ /**
83
+ * How many services one person may book as a single visit (1–4; Medal's
84
+ * cap is 4). `1` — the default — is the one-tap service step: a tap
85
+ * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:
86
+ * each person ticks up to this many, done back to back as one visit.
87
+ */
88
+ maxServicesPerPerson: number;
82
89
  };
83
90
  /** The bookable window and the page's prefetch. */
84
91
  window: {
@@ -60,7 +60,8 @@ function defaults() {
60
60
  party: {
61
61
  maxPeople: 3,
62
62
  allowParallel: true,
63
- askWhoFirst: true
63
+ askWhoFirst: true,
64
+ maxServicesPerPerson: 1
64
65
  },
65
66
  window: {
66
67
  rangeDays: 7,
@@ -238,7 +239,8 @@ const schema = z.object({
238
239
  party: z.object({
239
240
  maxPeople: z.number().int().min(1).max(20),
240
241
  allowParallel: z.boolean(),
241
- askWhoFirst: z.boolean()
242
+ askWhoFirst: z.boolean(),
243
+ maxServicesPerPerson: z.number().int().min(1).max(4)
242
244
  }),
243
245
  window: z.object({
244
246
  rangeDays: z.number().int().min(1).max(62),
@@ -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: { maxPeople: number; allowParallel: boolean; askWhoFirst: boolean };\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 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: { maxPeople: 3, allowParallel: true, askWhoFirst: true },\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 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 }),\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 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 });\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';\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 /** 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 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: { maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1 },\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 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 }),\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 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 });\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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"}
@@ -41,6 +41,12 @@ export declare const DRAFT_MAX_AGE_MS: number;
41
41
  /** One line item, as little of it as a rebuild needs. */
42
42
  export interface WizardDraftItem {
43
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[];
44
50
  bookedForName: string | null;
45
51
  bookedForBirthYear: number | null;
46
52
  /**
@@ -40,6 +40,18 @@ function draftStorageKey(namespace) {
40
40
  * hour in it has very likely gone. A draft dated in the future is refused
41
41
  * outright — see `asDraft`. */
42
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
+ }
43
55
  /** The shape check, done here so nothing downstream has to trust the jar. */
44
56
  function asDraft(value, now) {
45
57
  if (typeof value !== "object" || value === null) return null;
@@ -49,10 +61,14 @@ function asDraft(value, now) {
49
61
  if (age < 0 || age > 18e5) return null;
50
62
  if (!Array.isArray(draft.items) || draft.items.length === 0) return null;
51
63
  const items = [];
64
+ let visitsWhole = true;
52
65
  for (const item of draft.items) {
53
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;
54
69
  items.push({
55
70
  serviceId: item.serviceId,
71
+ ...extraServiceIds === null ? {} : { extraServiceIds },
56
72
  bookedForName: typeof item.bookedForName === "string" ? item.bookedForName : null,
57
73
  bookedForBirthYear: typeof item.bookedForBirthYear === "number" && Number.isFinite(item.bookedForBirthYear) ? item.bookedForBirthYear : null,
58
74
  adult: typeof item.adult === "boolean" ? item.adult : null,
@@ -64,7 +80,7 @@ function asDraft(value, now) {
64
80
  items,
65
81
  resourceId: typeof draft.resourceId === "string" ? draft.resourceId : null,
66
82
  partyMode: draft.partyMode === "parallel" ? "parallel" : "sequential",
67
- startTs: typeof draft.startTs === "number" ? draft.startTs : null,
83
+ startTs: visitsWhole && typeof draft.startTs === "number" ? draft.startTs : null,
68
84
  resolvedResourceId: typeof draft.resolvedResourceId === "string" ? draft.resolvedResourceId : null,
69
85
  partyResourceIds,
70
86
  savedAt: draft.savedAt
@@ -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 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 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 for (const item of draft.items) {\n if (typeof item?.serviceId !== 'string' || item.serviceId.length === 0) return null;\n items.push({\n serviceId: item.serviceId,\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: 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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAgB,gBAAgB,WAA2B;CACzD,OAAO,GAAG,UAAU;AACtB;;;;;AAMA,MAAa,mBAAmB;;AAsChC,SAAS,QAAQ,OAAgB,KAAiC;CAChE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,QAAQ;CAKd,IAAI,OAAO,MAAM,YAAY,UAAU,OAAO;CAC9C,MAAM,MAAM,MAAM,MAAM;CACxB,IAAI,MAAM,KAAK,MAAA,MAAwB,OAAO;CAC9C,IAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,KAAK,MAAM,MAAM,WAAW,GAAG,OAAO;CACpE,MAAM,QAA2B,CAAC;CAClC,KAAK,MAAM,QAAQ,MAAM,OAAO;EAC9B,IAAI,OAAO,MAAM,cAAc,YAAY,KAAK,UAAU,WAAW,GAAG,OAAO;EAC/E,MAAM,KAAK;GACT,WAAW,KAAK;GAChB,eAAe,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB;GAC7E,oBACE,OAAO,KAAK,uBAAuB,YAAY,OAAO,SAAS,KAAK,kBAAkB,IAClF,KAAK,qBACL;GACN,OAAO,OAAO,KAAK,UAAU,YAAY,KAAK,QAAQ;GACtD,UACE,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,SAAS,IAAI,KAAK,WAAW;EACpF,CAAC;CACH;CACA,MAAM,mBACJ,MAAM,QAAQ,MAAM,gBAAgB,KACpC,MAAM,iBAAiB,OAAO,OAAO,OAAO,OAAO,QAAQ,IACvD,MAAM,mBACN;CACN,OAAO;EACL;EACA,YAAY,OAAO,MAAM,eAAe,WAAW,MAAM,aAAa;EACtE,WAAW,MAAM,cAAc,aAAa,aAAa;EACzD,SAAS,OAAO,MAAM,YAAY,WAAW,MAAM,UAAU;EAC7D,oBACE,OAAO,MAAM,uBAAuB,WAAW,MAAM,qBAAqB;EAC5E;EACA,SAAS,MAAM;CACjB;AACF;;AAUA,SAAgB,iBAAiB,WAA+B;CAC9D,MAAM,cAAc,gBAAgB,SAAS;;CAG7C,SAAS,WAAW,OAAqC,MAAc,KAAK,IAAI,GAAS;EACvF,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,QAAQ,aAAa,KAAK,UAAU;IAAE,GAAG;IAAO,SAAS;GAAI,CAAC,CAAC;EACvF,QAAQ,CAGR;CACF;;CAGA,SAAS,UAAU,MAAc,KAAK,IAAI,GAAuB;EAC/D,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,MAAM,OAAO,eAAe,QAAQ,WAAW;GACrD,OAAO,eAAe,WAAW,WAAW;GAC5C,IAAI,QAAQ,MAAM,OAAO;GACzB,OAAO,QAAQ,KAAK,MAAM,GAAG,GAAG,GAAG;EACrC,QAAQ;GAEN,OAAO;EACT;CACF;;CAGA,SAAS,aAAmB;EAC1B,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,WAAW,WAAW;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EAAE,mBAAmB;EAAa;EAAY;EAAW;CAAW;AAC7E"}
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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"}
@@ -16,7 +16,7 @@ import { Ics, IcsEvent, createIcs, icsDataUrl } from "./ics.mjs";
16
16
  import { Money, createMoney } from "./money.mjs";
17
17
  import { earliestOpening, firstOpeningPerResource } from "./next-available.mjs";
18
18
  import { NEXT_FREE_TTL_MS, NextFree, NextFreeClient, createNextFree } from "./next-free.mjs";
19
- import { PartySeat, PartySlot, PartySlots, SlotsByService, createPartySlots, findPartySlots, firstPartyStartPerResource } from "./party-slots.mjs";
19
+ import { PartySeat, PartySlot, PartySlots, SlotsByService, createPartySlots, findPartySlots, firstPartyStartPerResource, slotKeysFor } from "./party-slots.mjs";
20
20
  import { Paths, createPaths } from "./paths.mjs";
21
21
  import { Phone, createPhone, telHref } from "./phone.mjs";
22
22
  import { CookieAttributes, CookieJar, CookieSource } from "./portal/cookies.mjs";
@@ -31,4 +31,5 @@ import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom
31
31
  import { RebookStore, createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
32
32
  import { RESTORE_FALLBACK_MS, RestoreGate, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
33
33
  import { Stores, createStores } from "./stores.mjs";
34
- 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, 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, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitMinutes, visitTailMinutes, withBookingQuery };
34
+ 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 };
@@ -11,10 +11,11 @@ import { DRAFT_MAX_AGE_MS, createDraftStore, draftStorageKey } from "./draft-sto
11
11
  import { RESOURCE_ID_SHAPE, createDto, toBookingDayDto, toBookingSlotDto } from "./dto.mjs";
12
12
  import { createIcs, icsDataUrl } from "./ics.mjs";
13
13
  import { createMoney } from "./money.mjs";
14
+ import { visitKey, visitKeyOfIds, visitOf, visitOfServices, visitPriceOre, visitServicesOf } from "./visit.mjs";
14
15
  import { BOOKED_FOR_NAME_MAX_LENGTH, SELF_KEY, canAdvance, canGoToStep, createWizard, guestChild, initialState, isGuestSeat, itemResourceIds, itemStartTimes, showsPartyMode, visitEndTs, visitMinutes, visitTailMinutes } from "./machine.mjs";
15
16
  import { earliestOpening, firstOpeningPerResource } from "./next-available.mjs";
16
17
  import { NEXT_FREE_TTL_MS, createNextFree } from "./next-free.mjs";
17
- import { createPartySlots, findPartySlots, firstPartyStartPerResource } from "./party-slots.mjs";
18
+ import { createPartySlots, findPartySlots, firstPartyStartPerResource, slotKeysFor } from "./party-slots.mjs";
18
19
  import { createPaths } from "./paths.mjs";
19
20
  import { createPhone, telHref } from "./phone.mjs";
20
21
  import { createPortalDto, monthOrNull, rebookSuggestions, toPortalProfileDto } from "./portal/dto.mjs";
@@ -28,4 +29,4 @@ import { VIPPS_CONFIRM_QUERY, maskedAddress, stripVippsReturn, vippsConfirmFrom
28
29
  import { createRebookStore, rebookStorageKey } from "./rebook-store.mjs";
29
30
  import { RESTORE_FALLBACK_MS, createRestoreGate, restorePendingInStorage } from "./restore-gate.mjs";
30
31
  import { createStores } from "./stores.mjs";
31
- 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, slugify, stripVippsReturn, stylistDisplayName, stylistSlug, telHref, toBookingDayDto, toBookingSlotDto, toPortalProfileDto, upcomingSoonestFirst, verifyLoginInput, vippsConfirmFrom, vippsLinkVerifyPath, visitEndTs, visitMinutes, visitTailMinutes, withBookingQuery };
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 };
@@ -86,7 +86,13 @@ export interface WizardService {
86
86
  ageMaxYears?: number;
87
87
  }
88
88
  export interface WizardItem {
89
+ /** The person's FIRST service. */
89
90
  service: WizardService;
91
+ /**
92
+ * The person's further services, performed back to back after `service` as
93
+ * ONE visit. Absent means one service — never `[]`.
94
+ */
95
+ extraServices?: WizardService[];
90
96
  /** The child this line is for. Asked inline in step 4. */
91
97
  bookedForName?: string;
92
98
  /** Optional fødselsår — powers "Jonas (9)" in the CRM later. */
@@ -117,6 +123,18 @@ export interface WizardState {
117
123
  * stays shut.
118
124
  */
119
125
  choices: Array<WizardService | null>;
126
+ /**
127
+ * Each person's FURTHER services, parallel to `people` and `choices` —
128
+ * `extras[i]` is what `people[i]` has after `choices[i]`, performed back to
129
+ * back as one visit. `[]` is one service, and so is a person whose `choices`
130
+ * entry is still `null`: an extra never stands without a first service.
131
+ *
132
+ * A second array rather than turning `choices` into lists, because meda keeps
133
+ * its own copy of this state's shape and reads `choices` and `item.service`
134
+ * as single services — the multi-service visit is additive, and a screen that
135
+ * has never heard of it keeps working on the first service.
136
+ */
137
+ extras: WizardService[][];
120
138
  items: WizardItem[];
121
139
  /** null = «Første ledige» — the default, and the utilisation-maximising one. */
122
140
  resourceId: string | null;
@@ -191,7 +209,13 @@ export interface WizardState {
191
209
  * else for a grown-up — instead of a guest chair being invented for it.
192
210
  */
193
211
  pendingService: WizardService | null;
194
- error: 'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' | 'unconfigured' | 'upstreamError' |
212
+ error: 'maxParty' |
213
+ /**
214
+ * One person asked for more services than `party.maxServicesPerPerson`
215
+ * allows — `toggleServiceFor` refusing the next one. A code like the rest:
216
+ * the sentence (and the number in it) belongs to whoever shows it.
217
+ */
218
+ 'maxServices' | 'slotTaken' | 'conflict' | 'invalidInput' | 'unconfigured' | 'upstreamError' |
195
219
  /**
196
220
  * The create route's 409 for a submission Medal already holds under its
197
221
  * key: it may well be booked, so the parent is sent to their e-mail
@@ -241,6 +265,25 @@ export type WizardAction =
241
265
  index: number;
242
266
  service: WizardService;
243
267
  resourceServiceIds?: readonly string[];
268
+ } |
269
+ /**
270
+ * The multi-select service card: a person's list gains the service, or loses
271
+ * it if it was already there. The first service a person gets is their
272
+ * `choices` entry, every later one an extra; removing the first promotes the
273
+ * next, and removing the last leaves the person unanswered.
274
+ *
275
+ * Never moves the visitor — unlike the one-tap `pickServiceFor`, a person
276
+ * choosing several services is not done after the first, so «Neste» is theirs
277
+ * (the shell's `goToStep('when')`). Refused with `maxServices` past the
278
+ * site's per-person ceiling and with `maxParty` when more people would take
279
+ * the service than its `maxPerBooking`. `resourceServiceIds` is
280
+ * `pickServiceFor`'s: the named stylist has to do the person's whole list.
281
+ */
282
+ {
283
+ type: 'toggleServiceFor';
284
+ index: number;
285
+ service: WizardService;
286
+ resourceServiceIds?: readonly string[];
244
287
  } | {
245
288
  type: 'pickService';
246
289
  service: WizardService;
@@ -521,11 +564,13 @@ export declare function visitMinutes(items: WizardItem[], partyMode: WizardState
521
564
  /**
522
565
  * How much EARLIER a family has to start than a single first child would.
523
566
  *
524
- * The schedule endpoint answers for one service, so `lastStartTs` is «the last
525
- * start a Gutteklipp fits» — the last minute at which that service's prep,
526
- * duration and cleanup all land inside the day's window. A family needs its
527
- * whole chain to fit, so its own last start is earlier by exactly the part of
528
- * the chain that hangs off the end of the first child's busy span.
567
+ * The schedule endpoint is asked for the FIRST person's whole visit — their
568
+ * service plus its extras — so `lastStartTs` is «the last start that visit
569
+ * fits»: the last minute at which its prep, every service's minutes and its
570
+ * cleanup all land inside the day's window. A family needs its whole chain to
571
+ * fit, so its own last start is earlier by exactly the part of the chain that
572
+ * hangs off the end of the first person's whole busy span — everybody after
573
+ * them.
529
574
  *
530
575
  * NOT `visitMinutes - firstDuration`, which was the first attempt and is wrong
531
576
  * in a way that only shows on a mixed basket: `visitMinutes` deliberately
@@ -581,6 +626,8 @@ export interface Wizard {
581
626
  canAdvance: typeof canAdvance;
582
627
  canGoToStep: typeof canGoToStep;
583
628
  itemPriceOre(service: WizardService, startTs: number | null): number;
629
+ /** One person's whole visit: every service priced on its own, then summed. */
630
+ visitItemPriceOre(item: WizardItem, startTs: number | null): number;
584
631
  totalPriceOre(items: WizardItem[], startTs: number | null): number;
585
632
  itemStartTimes: typeof itemStartTimes;
586
633
  visitMinutes: typeof visitMinutes;