@medalsocial/booking 0.2.1 → 0.3.1

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 (55) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +21 -3
  3. package/dist/core/attempt-store.d.mts +1 -0
  4. package/dist/core/attempt-store.mjs +13 -0
  5. package/dist/core/attempt-store.mjs.map +1 -1
  6. package/dist/core/config.d.mts +37 -0
  7. package/dist/core/config.mjs +29 -0
  8. package/dist/core/config.mjs.map +1 -1
  9. package/dist/core/machine.d.mts +27 -1
  10. package/dist/core/machine.mjs +52 -4
  11. package/dist/core/machine.mjs.map +1 -1
  12. package/dist/next/medal.d.mts +8 -1
  13. package/dist/next/medal.mjs +16 -5
  14. package/dist/next/medal.mjs.map +1 -1
  15. package/dist/next/portal/medal-portal.mjs +1 -62
  16. package/dist/next/portal/medal-portal.mjs.map +1 -1
  17. package/dist/next/redact.mjs +76 -0
  18. package/dist/next/redact.mjs.map +1 -0
  19. package/dist/next/routes/create.mjs +67 -9
  20. package/dist/next/routes/create.mjs.map +1 -1
  21. package/dist/next/routes/shared.mjs +3 -0
  22. package/dist/next/routes/shared.mjs.map +1 -1
  23. package/dist/react/BookingWizard.mjs +143 -11
  24. package/dist/react/BookingWizard.mjs.map +1 -1
  25. package/dist/react/LoginSheet.d.mts +19 -1
  26. package/dist/react/LoginSheet.mjs +15 -1
  27. package/dist/react/LoginSheet.mjs.map +1 -1
  28. package/dist/react/Provider.d.mts +2 -1
  29. package/dist/react/Provider.mjs.map +1 -1
  30. package/dist/react/actions.d.mts +16 -5
  31. package/dist/react/actions.mjs +5 -1
  32. package/dist/react/actions.mjs.map +1 -1
  33. package/dist/react/index.d.mts +2 -2
  34. package/dist/react/kit.d.mts +6 -1
  35. package/dist/react/kit.mjs +4 -3
  36. package/dist/react/kit.mjs.map +1 -1
  37. package/dist/react/labels/login.d.mts +2 -0
  38. package/dist/react/labels/login.mjs +4 -2
  39. package/dist/react/labels/login.mjs.map +1 -1
  40. package/dist/react/labels/screens.mjs +58 -2
  41. package/dist/react/labels/screens.mjs.map +1 -1
  42. package/dist/react/labels/wizard.d.mts +8 -0
  43. package/dist/react/labels/wizard.mjs +6 -0
  44. package/dist/react/labels/wizard.mjs.map +1 -1
  45. package/dist/react/screen-labels.mjs +5 -0
  46. package/dist/react/screen-labels.mjs.map +1 -1
  47. package/dist/react/useBooking.d.mts +19 -1
  48. package/dist/react/useBooking.mjs +269 -42
  49. package/dist/react/useBooking.mjs.map +1 -1
  50. package/dist/react/wizard/adapters.mjs +66 -3
  51. package/dist/react/wizard/adapters.mjs.map +1 -1
  52. package/dist/react/wizard/index.d.mts +2 -2
  53. package/dist/react/wizard/wall-clock.mjs +24 -0
  54. package/dist/react/wizard/wall-clock.mjs.map +1 -0
  55. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"machine.mjs","names":[],"sources":["../../src/core/machine.ts"],"sourcesContent":["/**\n * The wizard's brain: which step is reachable, what the sticky summary bar\n * says, and when submit is allowed. All of it lives here and nowhere else.\n *\n * Deliberately pure — no React, no fetch, no DOM — so every rule is testable\n * without rendering. `medal-client.ts` is `server-only` and must never be\n * imported from this file; the wizard runs in the browser.\n *\n * The step components (meda's `/booking` screens) are meant to be\n * presentational. If one of them would need an `if` about wizard state, that\n * `if` belongs here instead.\n *\n * Most of the machine is config-free and exported as is. `createWizard(config)`\n * binds the parts that are not: the party ceiling (`party.maxPeople`), which\n * category is the kids' path (`categories[].audience`), the weekend on the\n * business's clock, the currency, and the words the summary line uses.\n */\n\nimport { isChildCategory } from './categories';\nimport { createClock } from './clock';\nimport type { BookingConfig } from './config';\nimport { resourceMatches } from './deep-link';\nimport { fill, labelText, resolveLabels } from './labels';\nimport { createMoney } from './money';\nimport { visitKey, visitOf, visitPriceOre, visitServicesOf } from './visit';\n\n/**\n * The three steps the design draws, and the three the machine holds.\n *\n * `when` is one screen and two questions — the stylist and the hour — because\n * that is what a parent answers in one breath and what the frames show as\n * «Frisør og tid». They were separate steps until the redesign, and the pair of\n * them cost a parent a whole extra «Neste» to say a thing they had already\n * decided.\n *\n * There is no `login` step, and there was one. It sat between the hour and\n * «Bekreft» and asked who the parent was — Vipps, an e-mail code, or neither —\n * at the one moment a walk-in parent least wants a decision. The login is an\n * offer in a sheet now (the login sheet), opened from a row\n * on `service` and `details`, and the machine knows nothing about it: a login\n * changes who the contact fields are filled from, never which step is next.\n */\nexport type WizardStep = 'who' | 'service' | 'when' | 'details';\n\n/**\n * «Hvem skal klippes?» — step 1's answer (SP10), one entry per chair.\n *\n * The wizard used to learn who a haircut was for last, in two text fields on\n * «Bekreft», and a logged-in parent's children were a row of name chips there.\n * Asking first is what lets everything after it be ABOUT the child: step 2\n * offers «Samme som sist» for Theo, the ages that filter the menu are Theo's\n * on the appointment day, and the booking goes to Medal as Theo's person id\n * rather than a name a parent typed.\n *\n * `key` identifies the chair across edits and is never shown: `p:<id>` for a\n * saved child, `self` for the logged-in parent, `adult` for a guest adult,\n * `guest:<n>` for a guest's n-th child. Everything else is optional and ABSENT\n * rather than blank, the rule the line items follow — a guest child has no\n * name until «Bekreft» asks for one.\n */\nexport interface WizardPerson {\n key: string;\n /** A grown-up: no child fields on «Bekreft», the full menu on step 2. */\n adult?: boolean;\n /** Medal's id for a saved child — what `booked_for_person_id` carries. */\n personId?: string;\n name?: string;\n birthYear?: number;\n /** 1–12, when the parent gave one. */\n birthMonth?: number;\n}\n\n/** The logged-in parent themselves, on step 1's «Meg selv (voksen)». */\nexport const SELF_KEY = 'self';\n\n/**\n * A seat only a GUEST's step 1 can draw: a count chip's `guest:n`, a child\n * named in the guest sheet (`new:…`), the «Voksen» chip's `adult`. A parent's\n * step 1 draws cards for saved children and «Meg selv» and nothing else — so\n * one of these under a family is a seat nobody can see or untick, counting\n * towards the three.\n */\nexport function isGuestSeat(person: WizardPerson): boolean {\n return person.key.startsWith('guest:') || person.key.startsWith('new:') || person.key === 'adult';\n}\n\nexport interface WizardService {\n id: string;\n name: string;\n category: string;\n durationMinutes: number;\n priceOre: number;\n maxPerBooking: number;\n /** Drives step 3's weekend note and the surcharged price shown on the chip. */\n weekendSurchargePct: number;\n /**\n * The salon's prep and cleanup time around this service, in minutes.\n *\n * Carried because a FAMILY needs them: two children seated back to back on\n * one stylist are separated by the first one's cleanup plus the second one's\n * prep, and `itemStartTimes` cannot honour a gap it does not know about.\n * Availability is asked per service and each opening is judged on its own, so\n * both instants can come back free while the engine refuses the combined\n * submission — a visit offered to the parent and then rejected.\n *\n * Zero is the ordinary value and the wire's own default.\n */\n bufferBeforeMinutes: number;\n bufferAfterMinutes: number;\n /**\n * Who the service is for, in whole years, inclusive (SP10) — «Barnehageklipp»\n * up to 6, say. Used to SUGGEST, never to refuse: Medal does not reject a\n * booking on age, and a parent may know better. Absent is «any age».\n */\n ageMinYears?: number;\n ageMaxYears?: number;\n}\n\nexport interface WizardItem {\n /** The person's FIRST service. */\n service: WizardService;\n /**\n * The person's further services, performed back to back after `service` as\n * ONE visit. Absent means one service — never `[]`.\n */\n extraServices?: WizardService[];\n /** The child this line is for. Asked inline in step 4. */\n bookedForName?: string;\n /** Optional fødselsår — powers \"Jonas (9)\" in the CRM later. */\n bookedForBirthYear?: number;\n /**\n * The saved child this line is for (SP10) — sent as `booked_for_person_id`\n * when the phone rule allows it (see the create route). Never typed: it\n * comes only from a child card on step 1.\n */\n bookedForPersonId?: string;\n /** The child's birth month, for the age on the appointment day. */\n bookedForBirthMonth?: number;\n /** A grown-up's line: «Bekreft» asks nothing about a child for it. */\n adult?: boolean;\n}\n\nexport interface WizardState {\n step: WizardStep;\n /**\n * Who is coming — step 1's answer. One entry per chair, in the order the\n * line items follow: `items[i]` is `people[i]`'s haircut once every chair\n * has a service.\n */\n people: WizardPerson[];\n /**\n * Step 2's answers so far, one per person — `null` where that person has no\n * service yet. `items` is built from these and `people` only once none is\n * `null`, so a family half-way through step 2 has no basket and step 3\n * stays shut.\n */\n choices: Array<WizardService | null>;\n /**\n * Each person's FURTHER services, parallel to `people` and `choices` —\n * `extras[i]` is what `people[i]` has after `choices[i]`, performed back to\n * back as one visit. `[]` is one service, and so is a person whose `choices`\n * entry is still `null`: an extra never stands without a first service.\n *\n * A second array rather than turning `choices` into lists, because meda keeps\n * its own copy of this state's shape and reads `choices` and `item.service`\n * as single services — the multi-service visit is additive, and a screen that\n * has never heard of it keeps working on the first service.\n */\n extras: WizardService[][];\n items: WizardItem[];\n /** null = «Første ledige» — the default, and the utilisation-maximising one. */\n resourceId: string | null;\n /**\n * Whether the stylist question has been ANSWERED, as against left at its\n * default.\n *\n * `resourceId` cannot say. `null` is «Første ledige», which is both the value\n * a fresh wizard starts on and a real choice a parent makes by tapping «Alle»\n * — and while the stylist and the hour were separate steps the difference was\n * legible in `step` alone: a visitor who had answered was on `time`. One\n * screen for both questions took that away, and the thing that noticed is\n * `applyPrefill`, whose whole contract is «fills blanks, never overwrites»:\n * without this a «Bestill igjen» link naming Sara would land on a parent who\n * had just tapped «Alle» and quietly replace their answer.\n *\n * Set by `pickResource` either way, and by the parallel `setPartyMode` — «two\n * stylists at once» is an answer about who cuts the hair even though it\n * clears the preference. Cleared whenever the preference itself is cleared\n * for a reason the parent did not choose: a replaced basket, or a sibling the\n * named stylist cannot take.\n */\n stylistAnswered: boolean;\n partyMode: 'sequential' | 'parallel';\n startTs: number | null;\n resolvedResourceId: string | null;\n /**\n * The stylist each line resolved to, in basket order — the half of a party\n * slot that `resolvedResourceId` cannot hold.\n *\n * `null` for everything except a party slot chosen on step 3, where\n * `resolvedResourceId` still answers for the whole visit. Non-null it is one\n * id per line item, and it is what a parallel party actually needs: two\n * children seen at the same instant are seen by two *different* stylists, so\n * there is no single id that describes the visit.\n *\n * Read through `itemResourceIds` rather than directly, so the submission and\n * the confirmation card cannot disagree about who is cutting whose hair.\n */\n partyResourceIds: string[] | null;\n contact: { phone: string; name: string; email: string };\n notes: string;\n consentTerms: boolean;\n consentMarketing: boolean;\n /**\n * A code, never a sentence. The Norwegian copy for each case belongs to the\n * component that shows it, so the same rule can be a toast in one place and\n * inline text in another without this file knowing either.\n *\n * Wider than what `reduce` can currently produce, on purpose. The submit route\n * answers with `conflict`, `invalidInput`, `unconfigured` and `upstreamError`\n * as well as `slotTaken`, and those have to land somewhere. Somewhere is here:\n * if the union only admitted what this file raises, the details step would grow\n * a second error slot of its own and the sticky bar and the form would start\n * disagreeing about whether anything is wrong.\n *\n * `upstreamError` is the create route's 502 — Medal answered something this\n * site has no mapping for, or did not answer at all. It is an outage rather\n * than a mistake the visitor made, but it arrives with them waiting on a\n * button they have just pressed, so it needs a sentence of its own rather\n * than being folded into `conflict` — which says «prøv en gang til», and\n * trying again is exactly what will not help here.\n */\n /**\n * A service a link named for a parent whose family is known, held until\n * step 1 says who it is for (`holdService`). Each person seated after that\n * starts on it where it suits them — a kids' service for a child, anything\n * else for a grown-up — instead of a guest chair being invented for it.\n */\n pendingService: WizardService | null;\n error:\n | 'maxParty'\n /**\n * One person asked for more services than `party.maxServicesPerPerson`\n * allows — `toggleServiceFor` refusing the next one. A code like the rest:\n * the sentence (and the number in it) belongs to whoever shows it.\n */\n | 'maxServices'\n | 'slotTaken'\n | 'conflict'\n | 'invalidInput'\n | 'unconfigured'\n | 'upstreamError'\n /**\n * The create route's 409 for a submission Medal already holds under its\n * key: it may well be booked, so the parent is sent to their e-mail\n * rather than to the button.\n */\n | 'inProgress'\n | null;\n}\n\nexport type WizardAction =\n /**\n * Back to an empty wizard, for «Bestill ny time» on the confirmation.\n *\n * A parent who has just booked one child and needs a SEPARATE appointment —\n * another service whose `maxPerBooking` is 1, or a sibling who wants a\n * different hour — has to be able to start over. The confirmation is now\n * restored across a reload, so without this the only ways out of it are\n * closing the tab or waiting for the stored attempt to age out.\n */\n | { type: 'startOver' }\n /**\n * Step 1's answer: who is coming. Replaces the party; a person who stays\n * (the same `key`) keeps the service already chosen for them, so going back\n * to add a sibling does not re-ask the first child. `advance` moves on to\n * step 2 — a guest's «2 barn» chip is one tap, a logged-in parent ticks\n * children and presses «Neste».\n */\n | {\n type: 'choosePeople';\n people: WizardPerson[];\n advance?: boolean;\n /**\n * «Samme som sist»: a service to start each person NEW to the party on,\n * aligned with `people` — the child's last haircut, where the shell\n * knows one. A person already in the party keeps their own answer.\n */\n services?: ReadonlyArray<WizardService | null>;\n }\n /**\n * Step 2's answer for ONE person. For a party of one it is the old one-tap\n * `pickService` and moves on; for a family it fills that child's line and\n * waits for the rest. `resourceServiceIds` is `addService`'s: what the named\n * stylist can do, so a service they cannot do releases the preference.\n */\n | {\n type: 'pickServiceFor';\n index: number;\n service: WizardService;\n resourceServiceIds?: readonly string[];\n }\n /**\n * The multi-select service card: a person's list gains the service, or loses\n * it if it was already there. The first service a person gets is their\n * `choices` entry, every later one an extra; removing the first promotes the\n * next, and removing the last leaves the person unanswered.\n *\n * Never moves the visitor — unlike the one-tap `pickServiceFor`, a person\n * choosing several services is not done after the first, so «Neste» is theirs\n * (the shell's `goToStep('when')`). Refused with `maxServices` past the\n * site's per-person ceiling and with `maxParty` when more people would take\n * the service than its `maxPerBooking`. `resourceServiceIds` is\n * `pickServiceFor`'s: the named stylist has to do the person's whole list.\n */\n | {\n type: 'toggleServiceFor';\n index: number;\n service: WizardService;\n resourceServiceIds?: readonly string[];\n }\n | { type: 'pickService'; service: WizardService }\n /**\n * The parent's family has appeared (a login, a guardian read after the\n * first render) or step 1 is shown again under it: the guest seats go —\n * see `isGuestSeat` — and a guest «Voksen» becomes «Meg selv». Only while\n * nothing is held on them (steps 1 and 2); from the hour on they are real\n * chairs with a time, and «Bekreft» names them. A party emptied by it goes\n * back to step 1, where the children are.\n */\n | { type: 'seatFamily' }\n /**\n * One person joins the party as it is WHEN this lands — a child the parent\n * just created in Medal, after the await. Refused at the limit, a no-op for\n * somebody already seated.\n */\n | { type: 'addPerson'; person: WizardPerson }\n /** A link's service, held for the people step 1 is about to seat — see `pendingService`. */\n | { type: 'holdService'; service: WizardService | null }\n /**\n * A sibling, added on step 1 — and the one fact about the stylist catalogue\n * this file is allowed to know.\n *\n * `resourceServiceIds` is what the currently preferred stylist can do. It\n * rides on the action because the alternative was worse in both directions:\n * the machine cannot fetch the catalogue, and a shell that cleared the\n * preference itself would need an action that changes `resourceId` without\n * moving the visitor off step 1, which `pickResource` is not.\n *\n * Absent is «the caller does not know», not «the stylist does nothing». A\n * missing list keeps the preference, because silence is not evidence.\n */\n | { type: 'addService'; service: WizardService; resourceServiceIds?: readonly string[] }\n | { type: 'pickResource'; resourceId: string | null }\n | { type: 'pickSlot'; startTs: number; resourceId: string | null }\n /**\n * How the party is seen — step 2's second question, and the one that decides\n * which search step 3 runs.\n *\n * Its own action rather than a field on `pickResource`, because the two\n * answers are independent: «Sara» and «rett etter hverandre» are one visit,\n * and a parent who changes only the mode has not changed their mind about\n * the stylist.\n */\n | { type: 'setPartyMode'; mode: WizardState['partyMode'] }\n /**\n * A whole family seated in one tap: the instant the visit starts and the\n * stylist each child got.\n *\n * Separate from `pickSlot` because a party slot answers a question\n * `pickSlot` has no room for. `pickSlot` carries one resolved stylist, which\n * is all a single booking ever has; a party in `parallel` has one per child\n * and they are deliberately different people.\n *\n * `resourceIds` is one id per line item, in basket order — never null,\n * because a slot that cannot name the two stylists cannot promise that two\n * children are seen at once.\n *\n * `mode` travels with the slot rather than being read off the state, and it\n * is what makes step 3's «men begge kan tas samtidig kl. 15:00» work: that\n * offer appears while the machine still says `sequential`, and seating a\n * parallel slot sequentially would submit the second child half an hour\n * after a stylist who is free at the hour itself. Carrying it here also\n * removes the ordering trap — `setPartyMode` clears the slot, so a caller\n * that dispatched the two actions the other way round would throw away the\n * time the parent had just accepted.\n */\n | {\n type: 'pickPartySlot';\n startTs: number;\n resourceIds: string[];\n mode: WizardState['partyMode'];\n }\n | { type: 'slotTaken' }\n /**\n * A submission the create route refused, in the vocabulary `WizardState.error`\n * already speaks.\n *\n * Without it the four codes below are a union nothing can produce: the route\n * answers `conflict`, `invalidInput`, `unconfigured` and `upstreamError`, and\n * step 4 has a Norwegian sentence for each, but there was no action that could\n * put one into `state.error` — so the failure would have had to be held beside\n * the machine, in a second error slot the machine's own clearing rules never\n * touch.\n *\n * `slotTaken` and `maxParty` are excluded rather than admitted. Each already\n * has an action that does more than raise a code — `slotTaken` drops the slot\n * and sends the visitor back to step 3, `addService` refuses the child while\n * leaving the ones already chosen alone — and a second spelling of either is\n * how a stolen slot ends up raised without the slot being dropped.\n */\n | {\n type: 'submitFailed';\n error: Exclude<NonNullable<WizardState['error']>, 'slotTaken' | 'maxParty'>;\n }\n | { type: 'goToStep'; step: WizardStep }\n | { type: 'setContact'; field: keyof WizardState['contact']; value: string }\n /**\n * The two answers about the child, written onto the line item they belong to.\n *\n * Indexed rather than keyed by service, because two children in one party can\n * genuinely share a service and the index is the only thing that tells those\n * rows apart — the same reason step 4 keys its fields on it.\n *\n * Split over the two fields rather than taking a `Partial<WizardItem>`, so a\n * caller cannot reach through this action and overwrite `service`, which is\n * step 1's answer and not step 4's to change.\n */\n | { type: 'setItemField'; index: number; field: 'bookedForName'; value: string }\n /** `null` is «cleared», which an emptied year input is and `undefined` would\n * be ambiguous about — a patch that says nothing versus one that says nothing\n * is there. */\n | { type: 'setItemField'; index: number; field: 'bookedForBirthYear'; value: number | null }\n /** A saved child picked for a guest line on «Bekreft» (a family chip); `null` detaches. */\n | { type: 'setItemField'; index: number; field: 'bookedForPersonId'; value: string | null }\n | { type: 'setNotes'; value: string }\n /** One action for both boxes rather than two near-identical ones: they are the\n * same gesture, and the pair that must never be confused — a marketing opt-in\n * standing in for terms — is harder to write by accident when the difference\n * is a required discriminant. */\n | { type: 'setConsent'; which: 'terms' | 'marketing'; accepted: boolean }\n /**\n * A «Bestill igjen» link, applied. The shell dispatches this rather than\n * calling `applyPrefill` on the state it holds, because `useReducer` is the\n * only writer of that state — see `applyPrefill` for the rules.\n */\n | { type: 'prefill'; prefill: WizardPrefill; catalogue: WizardCatalogue };\n\n/**\n * What a `/min-side` «Bestill igjen» link can carry into the wizard:\n * `?service=<id>&stylist=<id>` off the query string, and the child's name\n * from the tab's session store (`rebook-store.ts`) — never from the URL.\n *\n * Every field is optional AND nullable, because `URLSearchParams.get` answers\n * `null` for a missing key and the shell should not have to translate that.\n * An empty string means the same as absent.\n */\nexport interface WizardPrefill {\n serviceId?: string | null;\n /** An id, or — from a «Bestill hos Siv» link — a display name or its slug. */\n resourceId?: string | null;\n who?: string | null;\n /**\n * `?antall=`: how many children. Clamped to the service's `maxPerBooking`\n * and only meaningful for a kids' service, where it pre-creates that many\n * lines; ignored for anything else.\n */\n party?: number | null;\n}\n\n/**\n * What a prefill is checked against — the services step 1 would let the visitor\n * tap, and the stylists step 2 would show for them.\n *\n * The SHELL decides what goes in here, and that is the point: the machine has\n * no `bookableOnline` and no fetched stylist list, so «this id is one the\n * visitor could have chosen» is a fact only the caller can supply. An id absent\n * from the catalogue is ignored, whatever the link said.\n */\nexport interface WizardCatalogue {\n services: readonly WizardService[];\n resources: ReadonlyArray<{ id: string; name?: string; serviceIds: readonly string[] }>;\n}\n\n/**\n * The engine's own bound on `booked_for_name` — `maxLength: 200` in the SDK's\n * OpenAPI document. Step 4's input has no `maxLength`, so this is the one limit\n * that exists, and a prefilled name is cut to it rather than being carried into\n * a body the engine answers 400 to.\n */\nexport const BOOKED_FOR_NAME_MAX_LENGTH = 200;\n\nconst SEPARATOR = ' · ';\n\n/** Stands in for a part the visitor has not chosen yet, so the bar keeps its\n * shape as they fill it in rather than growing under their thumb. */\nconst UNCHOSEN = '—';\n\nexport function initialState(): WizardState {\n return {\n step: 'who',\n people: [],\n choices: [],\n extras: [],\n items: [],\n resourceId: null,\n stylistAnswered: false,\n partyMode: 'sequential',\n startTs: null,\n resolvedResourceId: null,\n partyResourceIds: null,\n contact: { phone: '', name: '', email: '' },\n notes: '',\n consentTerms: false,\n consentMarketing: false,\n pendingService: null,\n error: null,\n };\n}\n\n/**\n * The first service more people would take than its `maxPerBooking` allows, or\n * `null` when every service is within its cap — the engine's party rule, given\n * the lists as they WOULD be after the tap being judged.\n *\n * Per service, not per party. The engine used to cap the whole booking at the\n * strictest service's number, which kept a grown-up's one-at-a-time cut out of\n * any family visit; it now counts, for each service, the people taking it. So\n * a «Skjegg» with `maxPerBooking: 1` rides along with two children's cuts, and\n * two people on that «Skjegg» are still refused. Mirroring the engine rather\n * than inventing a ceiling: a number of our own would be a second, invisible\n * rule that disagreed with the salon's the day they changed it.\n *\n * A service counts at most once per person — a person's list never holds it\n * twice, and if one did it would still be one chair.\n */\nfunction overServiceCap(lists: ReadonlyArray<readonly WizardService[]>): WizardService | null {\n const takers = new Map<string, { service: WizardService; people: number }>();\n for (const list of lists) {\n const once = new Map(list.map((service) => [service.id, service]));\n for (const service of once.values()) {\n const entry = takers.get(service.id) ?? { service, people: 0 };\n entry.people += 1;\n takers.set(service.id, entry);\n }\n }\n for (const { service, people } of takers.values()) {\n if (people > service.maxPerBooking) return service;\n }\n return null;\n}\n\n/**\n * A person's extra services, tolerating a state that predates them. This\n * package's own state always carries `extras` (`initialState`), but meda types\n * the field optional and a custom shell may hold a state saved before 0.3 —\n * reading `state.extras[i]` off that would throw at the first tap.\n */\nfunction extrasAt(state: Pick<WizardState, 'extras'>, index: number): WizardService[] {\n return state.extras?.[index] ?? [];\n}\n\n/** Person `index`'s services in order: their first, then their extras. */\nfunction servicesOf(state: WizardState, index: number): WizardService[] {\n const first = state.choices[index] ?? null;\n return first === null ? [] : [first, ...extrasAt(state, index)];\n}\n\n/** Whether a named stylist can do every service in a list. Absent ids are\n * «the caller does not know», which keeps the preference — see `stylistAfterAdd`. */\nfunction canServeAll(\n resourceServiceIds: readonly string[] | undefined,\n services: readonly WizardService[]\n): boolean {\n return (\n resourceServiceIds === undefined ||\n services.every((service) => resourceServiceIds.includes(service.id))\n );\n}\n\n/** The one stylist a seating chart names throughout, or `null` when it names\n * more than one. An empty chart has nobody, which is also `null`. */\nfunction sharedResourceId(resourceIds: string[]): string | null {\n const [first] = resourceIds;\n if (first === undefined) return null;\n return resourceIds.every((id) => id === first) ? first : null;\n}\n\n/**\n * What `addService` does to the stylist preference: nothing, or release it.\n *\n * A patch rather than a boolean, so the case below spreads one expression and\n * says what it means — and so the empty object is the ordinary answer, which is\n * the point: adding a sibling is not a change of mind about Sara.\n *\n * `resourceServiceIds` absent is «the caller could not say», and it keeps the\n * preference. That direction is deliberate: a wizard whose stylist catalogue has\n * not loaded knows nothing about who does what, and discarding an answer the\n * visitor gave on the strength of that would be a guess dressed as a safeguard.\n * The catalogue is loaded by the time a stylist can be tapped at all, so the\n * case is a caller other than the wizard rather than a race.\n */\nfunction stylistAfterAdd(action: Extract<WizardAction, { type: 'addService' }>): {\n resourceId?: null;\n stylistAnswered?: false;\n} {\n const canServe = canServeAll(action.resourceServiceIds, [action.service]);\n // Releasing the preference un-answers the question with it: the parent chose\n // Sara and the machine took her away, so the next thing that can fill the\n // blank — a «Bestill igjen» link, say — is filling a genuine blank.\n return canServe ? {} : { resourceId: null, stylistAnswered: false };\n}\n\n/**\n * A chosen slot belongs to the question it was chosen under, so every action\n * that changes that question has to drop it — the basket, the seating mode, and\n * the stylist alike. Adding a second child doubles the duration being asked for;\n * swapping the service changes it outright; naming a different stylist points it\n * at somebody else's diary. Either way the time the visitor tapped is no longer\n * a time the salon ever offered them.\n *\n * Left in place this stays invisible until the progress dots become tappable,\n * and then it is a genuinely bad bug rather than an untidy one: `canGoToStep`\n * treats any non-null `startTs` as reason enough to reach `details`, so going\n * back, changing an answer and jumping straight forward submits against a slot\n * that answered the old question — with the summary bar quoting the stale time\n * as if it were confirmed.\n *\n * This spread deliberately does NOT touch `resourceId`, because that is a\n * separate answer and each caller has its own view of it:\n *\n * - `pickResource` writes it — it *is* the new answer.\n * - `pickService` clears it, because the basket was replaced and step 2 only\n * offers stylists who can do every service in it.\n * - `setPartyMode` clears it for `parallel`, which is two stylists by\n * construction.\n * - `addService` keeps it, unless the child being added is one the named stylist\n * cannot take. A parent adding a sibling has not changed their mind about\n * Sara, so clearing unconditionally would re-ask every family in the common\n * case where she can do both — but keeping it unconditionally left step 2 with\n * nothing selected and step 3 filtered to somebody who cannot take the visit,\n * which is «Fullt» on every day of the week at a salon with a free chair.\n * The catalogue that tells the two apart is a fetch result the shell holds and\n * this file cannot reach, so it arrives on the action as\n * `resourceServiceIds`; absent, the preference is kept.\n *\n * `partyResourceIds` is the other half of a chosen slot and goes with it. Left\n * behind it would be a seating chart for a party that no longer has that many\n * children in it, and `itemResourceIds` would hand the submission a stylist for\n * a line item that does not exist.\n */\nconst clearedSlot = { startTs: null, resolvedResourceId: null, partyResourceIds: null };\n\n/** The guest child in chair `n` (1-based) — a chip on step 1, or a sibling a link added. */\nexport function guestChild(n: number): WizardPerson {\n return { key: `guest:${n}` };\n}\n\n/** A person as a line item: the services, and what is known about who sits in the chair. */\nfunction itemFor(\n person: WizardPerson,\n service: WizardService,\n extras: readonly WizardService[]\n): WizardItem {\n return {\n service,\n // Absent rather than `[]`, the rule every optional field here follows: a\n // one-service line has to look exactly as it did before visits had extras.\n ...(extras.length === 0 ? {} : { extraServices: [...extras] }),\n ...(person.name === undefined ? {} : { bookedForName: person.name }),\n ...(person.birthYear === undefined ? {} : { bookedForBirthYear: person.birthYear }),\n ...(person.personId === undefined ? {} : { bookedForPersonId: person.personId }),\n ...(person.birthMonth === undefined ? {} : { bookedForBirthMonth: person.birthMonth }),\n ...(person.adult ? { adult: true } : {}),\n };\n}\n\n/** Every person's whole visit, so an extra added or removed reads as a changed\n * basket — a Vask after the Klipp makes the visit fifteen minutes longer, and\n * the slot chosen for the shorter one is no longer a slot anyone offered. */\nfunction basketOf(items: readonly WizardItem[]): string {\n return items.map((item) => visitKey(item)).join('|');\n}\n\n/**\n * `people`, `choices` and `extras` in, the basket out — the one place `items`\n * is built from them. A basket whose services changed drops the slot, for the\n * reason `clearedSlot` gives; one that only changed WHO (a name typed on\n * «Bekreft», say) keeps it.\n *\n * THE ALIGNMENT RULE: `extras[i]` belongs to `people[i]`, exactly as\n * `choices[i]` does. Every action that rebuilds or reorders `people` either\n * carries each surviving person's extras with them — matched by `key`, the way\n * `choosePeople` and `seatFamily` carry `choices` — or resets them to `[]`\n * where the person's first service is replaced by a one-tap pick: `pickService`\n * replacing the basket clears every extra, `pickServiceFor` clears that\n * person's, because a one-tap card means «this, and only this». A person added\n * by a link or a sibling chip starts on `[]`. Required rather than defaulted, so\n * no caller can drop a person's extras by forgetting to mention them.\n *\n * A person with no first service has no extras either, whatever was passed:\n * an extra never stands alone.\n */\nfunction withParty(\n state: WizardState,\n people: WizardPerson[],\n choices: Array<WizardService | null>,\n extras: ReadonlyArray<readonly WizardService[]>\n): WizardState {\n const aligned = people.map((_, index) => (choices[index] ? [...extras[index]] : []));\n const complete = people.length > 0 && choices.every((choice) => choice !== null);\n const items = complete\n ? people.map((person, index) =>\n itemFor(person, choices[index] as WizardService, aligned[index])\n )\n : [];\n const changed = basketOf(items) !== basketOf(state.items);\n return { ...state, people, choices, extras: aligned, items, ...(changed ? clearedSlot : {}) };\n}\n\n/** The people behind a basket built without step 1 — a link, a restored draft. */\nfunction peopleFor(state: WizardState, count: number): WizardPerson[] {\n return Array.from({ length: count }, (_, index) => state.people[index] ?? guestChild(index + 1));\n}\n\n/**\n * One field of one line item, written the way the engine will have to read it:\n * **absent**, never blank.\n *\n * `booked_for_name` is `z.string().trim().min(1).optional()` upstream, where\n * `''` is a 400 rather than a shrug, and `booked_for_birth_year` is\n * `z.number().optional()`, which refuses the `null` an emptied input serialises\n * to. Storing the emptiness here and coalescing it at the edge would hold\n * exactly until something other than the create route read these items — the\n * confirmation card already does, to write «Gutteklipp for Jonas» — so the\n * emptiness never gets in.\n */\nfunction withItemField(\n item: WizardItem,\n action: Extract<WizardAction, { type: 'setItemField' }>\n): WizardItem {\n const next = { ...item };\n if (action.field === 'bookedForPersonId') {\n if (action.value !== null && action.value !== '') next.bookedForPersonId = action.value;\n else delete next.bookedForPersonId;\n return next;\n }\n if (action.field === 'bookedForName') {\n const trimmed = action.value.trim();\n if (trimmed.length > 0) next.bookedForName = trimmed;\n else delete next.bookedForName;\n return next;\n }\n // `Number.isFinite` rather than a truthiness test: a birth year is never 0,\n // but `NaN` is exactly what parsing «tjue» out of a text field yields, and it\n // passes every looser check on its way to a body the engine rejects.\n if (action.value !== null && Number.isFinite(action.value)) {\n next.bookedForBirthYear = action.value;\n } else {\n delete next.bookedForBirthYear;\n }\n return next;\n}\n\n/** The typed answers about a guest child, carried back onto their person. */\nfunction withPersonFields(person: WizardPerson, item: WizardItem): WizardPerson {\n const { name: _name, birthYear: _year, personId: _id, ...rest } = person;\n return {\n ...rest,\n ...(item.bookedForName === undefined ? {} : { name: item.bookedForName }),\n ...(item.bookedForBirthYear === undefined ? {} : { birthYear: item.bookedForBirthYear }),\n ...(item.bookedForPersonId === undefined ? {} : { personId: item.bookedForPersonId }),\n };\n}\n\n/** `seatFamily` — see the action. */\nfunction seatFamily(state: WizardState): WizardState {\n if (state.step !== 'who' && state.step !== 'service') return state;\n if (!state.people.some(isGuestSeat)) return state;\n const people: WizardPerson[] = [];\n const choices: Array<WizardService | null> = [];\n const extras: WizardService[][] = [];\n state.people.forEach((person, index) => {\n const seat = person.key === 'adult' ? { key: SELF_KEY, adult: true } : person;\n if (isGuestSeat(seat) || people.some((other) => other.key === seat.key)) return;\n people.push(seat);\n choices.push(state.choices[index] ?? null);\n extras.push(extrasAt(state, index));\n });\n return {\n ...withParty(state, people, choices, extras),\n step: people.length === 0 ? 'who' : state.step,\n error: null,\n };\n}\n\n/** `pickServiceFor` — see the action. */\nfunction pickServiceFor(\n state: WizardState,\n action: Extract<WizardAction, { type: 'pickServiceFor' }>\n): WizardState {\n if (!state.people[action.index] && !(action.index === 0 && state.people.length === 0)) {\n return state;\n }\n // A party of one is the old one-tap service card: select and move on.\n if (state.people.length <= 1) {\n const person = state.people[0] ?? guestChild(1);\n return {\n ...withParty(state, [person], [action.service], [[]]),\n ...clearedSlot,\n resourceId: null,\n stylistAnswered: false,\n step: 'when',\n error: null,\n };\n }\n // A family: judged on the lists as they would be after the tap — this person\n // on the one service, everybody else as they are — so a grown-up's\n // one-at-a-time cut is refused only when somebody else already has it.\n const lists = state.people.map((_, index) =>\n index === action.index ? [action.service] : servicesOf(state, index)\n );\n if (overServiceCap(lists) !== null) return { ...state, error: 'maxParty' };\n const choices = state.choices.map((choice, index) =>\n index === action.index ? action.service : choice\n );\n // A one-tap card is «this, and only this»: the person's extras go with the\n // service they followed.\n const extras = state.people.map((_, index) =>\n index === action.index ? [] : extrasAt(state, index)\n );\n const canServe = canServeAll(action.resourceServiceIds, [action.service]);\n return {\n ...withParty(state, state.people, choices, extras),\n ...(canServe ? {} : { resourceId: null, stylistAnswered: false }),\n error: null,\n };\n}\n\n/** The wizard in order. Both `canGoToStep` and the progress dots read the\n * sequence from here rather than restating it, so a fourth step cannot be added\n * to one of them and forgotten in the other. */\nconst STEP_ORDER: readonly WizardStep[] = ['who', 'service', 'when', 'details'];\n\n/**\n * Whether a given step has been answered. One definition, asked two ways —\n * `canAdvance` asks it about the step the visitor is on, `canGoToStep` asks it\n * about the ones behind the step they want. Keeping it in a single function is\n * what stops \"step 3 is done\" from meaning one thing to the Neste button and\n * another to the progress dots.\n */\nfunction isSatisfied(state: WizardState, step: WizardStep): boolean {\n switch (step) {\n case 'who':\n // A basket built without step 1 — a link, a restored draft — has its\n // people too (`peopleFor`), so this is never the thing that shuts it.\n return state.people.length > 0 || state.items.length > 0;\n\n case 'service':\n return state.items.length > 0;\n\n case 'when':\n // The hour, and only the hour. The stylist half of this step has no\n // unanswered state — `null` is «Første ledige», which is both the default\n // and the choice the salon would rather you made — so a visit is settled\n // the moment an instant has been tapped.\n return state.startTs !== null;\n\n case 'details':\n // Trimmed: a phone field holding only the +47 prefix and a space is not a\n // phone number, and it is exactly what an abandoned form leaves behind.\n return state.contact.phone.trim().length > 0 && state.consentTerms;\n }\n}\n\n/**\n * True when the visitor may leave the current step — and on `details`, when they\n * may submit at all. Answers about `state.step` only, so a Neste button can be\n * `disabled={!canAdvance(state)}` and nothing else.\n */\nexport function canAdvance(state: WizardState): boolean {\n return isSatisfied(state, state.step);\n}\n\n/**\n * True when a step may be jumped to directly — the predicate behind whether a\n * progress dot is tappable, and the guard `goToStep` applies to itself.\n *\n * Bounded by what is *behind* the target, never the target itself: reaching\n * `details` requires a service and a time, but obviously not the contact\n * details you are going there to type. Going back is therefore always allowed,\n * because nothing precedes `service` — which is the case that matters, since\n * back is what the dots are mostly for.\n */\nexport function canGoToStep(state: WizardState, step: WizardStep): boolean {\n const index = STEP_ORDER.indexOf(step);\n // A step that is not in the order — a stale caller still naming the old\n // `login` step, say — is unreachable rather than `slice(0, -1)`: that would\n // ask about every step but the last and wave it through.\n if (index === -1) return false;\n return STEP_ORDER.slice(0, index).every((prior) => isSatisfied(state, prior));\n}\n\n/**\n * When each child in the party sits down.\n *\n * `sequential` is the report's default and the ordinary case — one stylist,\n * «rett etter hverandre», so the second child starts when the first is\n * finished. `parallel` is two stylists at once, and every line starts together.\n *\n * The engine takes a `start_ts` per line item rather than one for the visit, so\n * somebody has to do this arithmetic, and it is a rule about the booking rather\n * than about a screen. Step 4 submits with it and the confirmation card reads\n * «15:00 Jonas / 15:30 Emma» off the same function, which is the only way those\n * two can be made to agree.\n *\n * Task 9 owns the slot SEARCH for a party; this is only the seating of a slot\n * already chosen.\n */\nexport function itemStartTimes(\n items: WizardItem[],\n startTs: number,\n partyMode: WizardState['partyMode']\n): number[] {\n let offset = 0;\n return items.map((item, index) => {\n // The gap between two children on one chair is not the first cut's length.\n // It is that length PLUS the salon's cleanup after it PLUS the prep before\n // the next one — the interval the engine actually reserves. Advancing by\n // the duration alone seats the second child inside the first one's buffer,\n // and because availability is asked per service and each opening judged on\n // its own, both instants come back free while the engine refuses the\n // combined submission. The family is offered a visit that cannot be made,\n // and offered it again every time they try.\n //\n // Per VISIT, not per service: a person with Klipp + Vask holds the chair for\n // both, and the cleanup that matters is the one after their LAST service.\n if (partyMode === 'sequential' && index > 0) {\n const previous = visitOf(items[index - 1]);\n offset +=\n (previous.durationMinutes +\n previous.bufferAfterMinutes +\n visitOf(item).bufferBeforeMinutes) *\n 60_000;\n }\n return startTs + offset;\n });\n}\n\n/**\n * How long the family is in the salon — the number the summary bar quotes as\n * «60 min totalt» and the length of the .ics entry.\n *\n * The two modes are two different visits: `sequential` is one chair used twice,\n * so the minutes add up, while `parallel` is two chairs at once and the family\n * leaves when the *longest* cut finishes. Quoting the sum for a parallel party\n * would tell a parent to set aside an hour for a visit that takes half of one,\n * which is the whole reason «To frisører samtidig» is worth offering.\n *\n * `Math.max` seeded with 0 rather than spread over the durations alone:\n * `Math.max()` of nothing is `-Infinity`, and an empty basket is a visit of no\n * length rather than a negative one.\n */\nexport function visitMinutes(items: WizardItem[], partyMode: WizardState['partyMode']): number {\n // Each person's whole visit — every service they chose, back to back.\n const visits = items.map((item) => visitOf(item));\n if (partyMode === 'parallel') {\n return Math.max(0, ...visits.map((visit) => visit.durationMinutes));\n }\n // The inner gaps count, for the same reason `itemStartTimes` applies them: the\n // family is in the salon from the first cut starting to the last one ending,\n // and the second child cannot sit down until the chair is ready. The buffer\n // AFTER the last child is not part of it — they have left by then.\n return visits.reduce(\n (total, visit, index) =>\n total +\n visit.durationMinutes +\n (index === 0 ? 0 : visits[index - 1].bufferAfterMinutes + visit.bufferBeforeMinutes),\n 0\n );\n}\n\n/**\n * How much EARLIER a family has to start than a single first child would.\n *\n * The schedule endpoint is asked for the FIRST person's whole visit — their\n * service plus its extras — so `lastStartTs` is «the last start that visit\n * fits»: the last minute at which its prep, every service's minutes and its\n * cleanup all land inside the day's window. A family needs its whole chain to\n * fit, so its own last start is earlier by exactly the part of the chain that\n * hangs off the end of the first person's whole busy span — everybody after\n * them.\n *\n * NOT `visitMinutes - firstDuration`, which was the first attempt and is wrong\n * in a way that only shows on a mixed basket: `visitMinutes` deliberately\n * excludes the LAST child's cleanup — the family has left by then — so the\n * subtraction silently keeps the FIRST child's cleanup in place of the last\n * one's. Where the later service has the bigger `bufferAfterMinutes` the cutoff\n * comes out too late, which is the error in the direction that matters: it says\n * «Fullt» about an afternoon that is actually over.\n *\n * Sequential is the tail of the chain: every child after the first costs their\n * prep, their duration and their cleanup, and the first child's own cleanup is\n * already inside the number being adjusted. Parallel is one chair each, so what\n * has to fit is the LONGEST busy span rather than the sum.\n */\nexport function visitTailMinutes(items: WizardItem[], partyMode: WizardState['partyMode']): number {\n const first = items[0];\n if (first === undefined || items.length < 2) return 0;\n // A person's busy span is their whole visit: the first service's prep, every\n // service's minutes, the last service's cleanup.\n const busy = (item: WizardItem) => {\n const visit = visitOf(item);\n return visit.bufferBeforeMinutes + visit.durationMinutes + visit.bufferAfterMinutes;\n };\n\n if (partyMode === 'parallel') {\n return Math.max(0, Math.max(...items.map(busy)) - busy(first));\n }\n return items.slice(1).reduce((total, item) => total + busy(item), 0);\n}\n\n/** When the family leaves. The same arithmetic as `visitMinutes`, which is why\n * it is that function and not a second copy of it: an .ics that ended before\n * `visitMinutes` said the visit did would tell a calendar the parent was free\n * while a child was still in the chair. */\nexport function visitEndTs(\n items: WizardItem[],\n startTs: number,\n partyMode: WizardState['partyMode']\n): number {\n return startTs + visitMinutes(items, partyMode) * 60_000;\n}\n\n/**\n * Whether step 2 asks how the party is seen.\n *\n * One child is not a party, and «Samme frisør, rett etter hverandre» is not a\n * choice when there is nobody to be after. The machine answers rather than the\n * component so that the picker and the search cannot disagree about when a\n * booking has become a family visit.\n */\nexport function showsPartyMode(state: WizardState): boolean {\n return state.items.length > 1;\n}\n\n/**\n * Who is cutting whose hair, one answer per line item.\n *\n * The single reader for both shapes the machine can be in: a party slot names a\n * stylist per child, and everything else has one answer for the whole visit —\n * including `null`, which is «Første ledige» still to be resolved by the engine.\n *\n * Step 4's submission and the confirmation card both read it, which is the only\n * way «hos Marcus» on the card can be guaranteed to be the `resource_id` that\n * was actually booked for that child.\n */\nexport function itemResourceIds(state: WizardState): Array<string | null> {\n return state.partyResourceIds ?? state.items.map(() => state.resolvedResourceId);\n}\n\n/**\n * The stylist, as a person rather than as a row id.\n *\n * The machine holds ids — that is what step 2 chose and what the availability\n * query filters on — so naming one takes the catalogue, which is a fetch result\n * and not a rule. Hence the resolver: the caller already has the stylists it\n * rendered on step 2, and an id it cannot name comes back `null` rather than\n * putting `res-7f3a…` under the visitor's thumb.\n *\n * `resolvedResourceId` wins where both are set, because it is who the visitor\n * actually got. That is also what fills this part in for «Første ledige», which\n * is a null preference and stays a dash until a slot resolves it to somebody.\n */\nfunction stylistLabel(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null\n): string | null {\n const resourceId = state.resolvedResourceId ?? state.resourceId;\n return resourceId === null ? null : resolveStylistName(resourceId);\n}\n\nexport type WizardConfig = Pick<\n BookingConfig,\n | 'party'\n | 'categories'\n | 'fallbackCategory'\n | 'timeZone'\n | 'locale'\n | 'currency'\n | 'dayparts'\n | 'labels'\n>;\n\nexport interface Wizard {\n /**\n * The most chairs one visit can take (`party.maxPeople`). The business's own\n * ceiling is per service (`maxPerBooking`), and step 2 still honours it; this\n * is the ceiling of the question on step 1, before any service is known.\n */\n readonly maxPeople: number;\n initialState: typeof initialState;\n reduce(state: WizardState, action: WizardAction): WizardState;\n applyPrefill(state: WizardState, prefill: WizardPrefill, catalogue: WizardCatalogue): WizardState;\n canAdvance: typeof canAdvance;\n canGoToStep: typeof canGoToStep;\n itemPriceOre(service: WizardService, startTs: number | null): number;\n /** One person's whole visit: every service priced on its own, then summed. */\n visitItemPriceOre(item: WizardItem, startTs: number | null): number;\n totalPriceOre(items: WizardItem[], startTs: number | null): number;\n itemStartTimes: typeof itemStartTimes;\n visitMinutes: typeof visitMinutes;\n visitTailMinutes: typeof visitTailMinutes;\n visitEndTs: typeof visitEndTs;\n showsPartyMode: typeof showsPartyMode;\n itemResourceIds: typeof itemResourceIds;\n summaryLine(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now?: number\n ): string;\n guestChild: typeof guestChild;\n isGuestSeat: typeof isGuestSeat;\n}\n\n/** The machine for one site: its party ceiling, kids' categories, clock, currency and words. */\nexport function createWizard(config: WizardConfig): Wizard {\n const maxPeople = config.party.maxPeople;\n const maxServices = config.party.maxServicesPerPerson;\n const clock = createClock(config);\n const money = createMoney(config);\n const labels = resolveLabels(config.locale, config.labels);\n\n /** `choosePeople` — see the action. */\n function choosePeople(\n state: WizardState,\n action: Extract<WizardAction, { type: 'choosePeople' }>\n ): WizardState {\n if (action.people.length > maxPeople) return { ...state, error: 'maxParty' };\n // A person who stays keeps their service; a new one has none yet.\n const before = (person: WizardPerson) =>\n state.people.findIndex((previous) => previous.key === person.key);\n const choices = action.people.map((person, position) => {\n const index = before(person);\n if (index !== -1) return state.choices[index] ?? null;\n // The link the parent followed beats «Samme som sist»: it is what they\n // asked for a moment ago.\n return heldFor(state.pendingService, person) ?? action.services?.[position] ?? null;\n });\n // Extras follow their person by the same key; a new person has none — the\n // held link and «Samme som sist» are one service each.\n const extras = action.people.map((person) => {\n const index = before(person);\n return index === -1 ? [] : extrasAt(state, index);\n });\n // A guest chair that stays keeps what «Bekreft» was told about it: going\n // back and tapping «2 barn» again must not forget the names typed.\n const people = action.people.map((person) => {\n const index = before(person);\n return index === -1 || person.personId !== undefined\n ? person\n : { ...state.people[index], ...person };\n });\n // The stylist preference stays: every service chosen for a NEW person goes\n // through `pickServiceFor`, which releases a stylist who cannot do it —\n // adding a sibling is not a change of mind about Sara.\n return {\n ...withParty(state, people, choices, extras),\n step: action.advance ? 'service' : state.step,\n error: null,\n };\n }\n\n /** The held link service, if it suits this person: kids' cuts for children only. */\n function heldFor(service: WizardService | null, person: WizardPerson): WizardService | null {\n if (service === null) return null;\n const forChildren = isChildCategory(config, service.category);\n return forChildren === !person.adult ? service : null;\n }\n\n /** `addPerson` — see the action. */\n function addPerson(state: WizardState, person: WizardPerson): WizardState {\n if (state.people.some((other) => other.key === person.key)) return state;\n if (state.people.length >= maxPeople) return { ...state, error: 'maxParty' };\n return choosePeople(state, { type: 'choosePeople', people: [...state.people, person] });\n }\n\n /** `toggleServiceFor` — see the action. */\n function toggleServiceFor(\n state: WizardState,\n action: Extract<WizardAction, { type: 'toggleServiceFor' }>\n ): WizardState {\n const seated = state.people[action.index] !== undefined;\n if (!seated && !(action.index === 0 && state.people.length === 0)) return state;\n // Nobody seated yet is the same guest child `pickServiceFor` seats — a\n // service card tapped before step 1 was ever answered.\n const people = seated ? state.people : [guestChild(1)];\n const lists = people.map((_, index) => (seated ? servicesOf(state, index) : []));\n const current = lists[action.index];\n const has = current.some((service) => service.id === action.service.id);\n\n let next: WizardService[];\n if (has) {\n next = current.filter((service) => service.id !== action.service.id);\n } else {\n // Both refusals change nothing but the error, `addService`'s rule: the\n // services already chosen are exactly as the visitor left them.\n if (current.length >= maxServices) return { ...state, error: 'maxServices' };\n next = [...current, action.service];\n const after = lists.map((list, index) => (index === action.index ? next : list));\n if (overServiceCap(after) !== null) return { ...state, error: 'maxParty' };\n }\n\n // The head of the list is the person's `choices` entry — removing the first\n // service promotes the next — and an emptied list is an unanswered person,\n // which empties the basket the way any unanswered person does.\n const choices = people.map((_, index) =>\n index === action.index ? (next[0] ?? null) : (state.choices[index] ?? null)\n );\n const extras = people.map((_, index) =>\n index === action.index ? next.slice(1) : extrasAt(state, index)\n );\n // The named stylist has to do the person's whole list, as `addService` has\n // it for a whole basket; absent ids keep the preference.\n const canServe = canServeAll(action.resourceServiceIds, next);\n return {\n ...withParty(state, people, choices, extras),\n // Always, not only when the basket key changed: a toggle is a change to\n // the question the slot answered, even one that ends where it began.\n ...clearedSlot,\n ...(canServe ? {} : { resourceId: null, stylistAnswered: false }),\n error: null,\n };\n }\n\n function reduce(state: WizardState, action: WizardAction): WizardState {\n switch (action.type) {\n case 'startOver':\n // `initialState()` rather than a hand-written blank: a field added there\n // and forgotten here is one the second booking would silently inherit\n // from the first.\n return initialState();\n\n case 'choosePeople':\n return choosePeople(state, action);\n\n case 'pickServiceFor':\n return pickServiceFor(state, action);\n\n case 'toggleServiceFor':\n return toggleServiceFor(state, action);\n\n case 'seatFamily':\n return seatFamily(state);\n\n case 'addPerson':\n return addPerson(state, action.person);\n\n case 'holdService':\n return { ...state, pendingService: action.service };\n\n case 'pickService':\n // One tap, not two: tapping a card both selects and advances. Picking\n // *replaces* the basket so that changing your mind on step 1 cannot\n // quietly leave the previous service booked alongside the new one.\n //\n // And the stylist goes with it, unlike every other user of `clearedSlot`.\n // Step 2 only offers stylists who can do EVERY service in the basket, so a\n // preference recorded against the old one may name somebody the new list\n // does not contain: the card is gone from the screen, nothing reads as\n // selected, and «Neste» is enabled anyway — because `isSatisfied` treats\n // any answer as an answer. The visitor lands on step 3 with availability\n // filtered to a stylist who cannot do this service, sees «Fullt» on every\n // day of the week, and is handed the telephone by a salon that is open.\n //\n // `null` is «Første ledige», which is both the default and the answer the\n // salon would rather they gave.\n //\n // Every extra goes too: a one-tap card is «this, and only this».\n return {\n ...withParty(state, peopleFor(state, 1), [action.service], [[]]),\n ...clearedSlot,\n resourceId: null,\n stylistAnswered: false,\n step: 'when',\n error: null,\n };\n\n case 'addService':\n // The service being added counts with the basket — a fourth child on a\n // `maxPerBooking: 3` cut is refused at the tap rather than at submit —\n // and so does the party ceiling: with the per-service rule a basket of\n // DIFFERENT services is no longer bounded by any one of their caps, and\n // step 1 would never have seated that many people.\n if (\n state.items.length >= maxPeople ||\n overServiceCap([...state.items.map(visitServicesOf), [action.service]]) !== null\n ) {\n // \"Changes nothing else\" is the whole point: a refused fourth child\n // must leave the three already chosen exactly as they were.\n return { ...state, error: 'maxParty' };\n }\n // No step change — adding a sibling happens on step 1, and yanking the\n // visitor forward mid-basket is how the third child gets lost.\n return {\n ...withParty(\n state,\n peopleFor(state, state.items.length + 1),\n [...state.items.map((item) => item.service), action.service],\n // Everybody already in the basket keeps their visit; the newcomer\n // has the one service they were added with.\n [...state.items.map((item) => item.extraServices ?? []), []]\n ),\n ...clearedSlot,\n // The named stylist has to cover the whole basket, and the basket just\n // grew. A stylist who cannot do the new child's service is one step 2\n // will no longer offer, so keeping them would leave the step showing\n // nothing selected while step 3 filtered every day to their empty diary.\n // Back to «Første ledige», which is where step 2 opens anyway.\n ...stylistAfterAdd(action),\n error: null,\n };\n\n case 'pickResource':\n // Re-affirming the answer already on screen is not a change of mind, and\n // must not cost the visitor the time they picked under it. Tapping the\n // highlighted card is how a parent says «yes, still Sara, carry on» —\n // step 2 keeps its selection when they come back to it, so it is the\n // obvious way forward from there and it happens often.\n if (action.resourceId === state.resourceId) {\n return { ...state, stylistAnswered: true, step: 'when', error: null };\n }\n // Otherwise the slot goes with the stylist, for the same reason it goes\n // with the service. A time chosen from Sara's openings is Sara's time;\n // changing to Marcus leaves an instant nobody ever offered for him — and\n // because `isSatisfied('when')` asks only whether `startTs` is non-null,\n // the surviving one made `details` reachable again. Going back, choosing a\n // different stylist and jumping straight forward would then submit the old\n // slot with the old stylist: somebody the parent did not choose, at a time\n // they were never shown for them.\n //\n // `resourceId` is this action's own answer, written onto the same object\n // after the spread, so `clearedSlot` leaving it alone costs nothing here.\n return {\n ...state,\n ...clearedSlot,\n resourceId: action.resourceId,\n stylistAnswered: true,\n step: 'when',\n error: null,\n };\n\n case 'pickSlot':\n // Two fields rather than one, and both earn their keep today. `resourceId`\n // holds the visitor's *preference* (null = «Første ledige»), which is what\n // step 2 renders as selected on a `goToStep` back and what filters the\n // availability query; `resolvedResourceId` holds the stylist this\n // particular slot turned out to be with, and is what gets submitted.\n // Collapsing them would mean a «Første ledige» booking silently pinning\n // itself to whoever happened to be free first, and a `slotTaken` — which\n // clears the resolution but keeps the preference — having nothing left to\n // re-query with.\n //\n // A family seated side by side cannot take a one-stylist slot: one\n // stylist cannot cut two children at once, and `pickPartySlot` is the\n // action that seats them. The wizard never sends this (a party's time\n // step offers party slots only); a headless caller that does gets the\n // answer every other caller bug gets — the identical object.\n if (state.partyMode === 'parallel' && state.items.length > 1) return state;\n // A regular slot replaces a party slot whole: its seating chart was the\n // other half of THAT slot, and left behind it would submit every child\n // to the stylists seated for a time the visitor has just changed.\n return {\n ...state,\n startTs: action.startTs,\n resolvedResourceId: action.resourceId,\n partyResourceIds: null,\n step: 'details',\n error: null,\n };\n\n case 'setPartyMode': {\n // A site that seats families one after another only (`party.allowParallel`)\n // has no parallel mode to switch to.\n if (action.mode === 'parallel' && !config.party.allowParallel) return state;\n // «To frisører samtidig» and «jeg vil til Sara» cannot both be true — one\n // stylist cannot cut two children at once — so choosing the parallel mode\n // drops the named preference rather than leaving step 2 showing a stylist\n // the search is quietly ignoring. Every slot it could find would be with\n // somebody else.\n //\n // Sequential does not restore it: the machine has nowhere to remember a\n // preference it has already discarded, and «Første ledige» is both the\n // default and the choice the salon would rather the visitor made.\n // «To frisører samtidig» clears the preference and ANSWERS the question at\n // the same time: it is a deliberate statement about who cuts the hair, so\n // a link naming one stylist must not fill the blank it leaves.\n const preference =\n action.mode === 'parallel' ? { resourceId: null, stylistAnswered: true } : {};\n // The slot goes for the ordinary reason: a slot found «rett etter\n // hverandre» seats the second child half an hour after the first, and the\n // same hour in parallel seats them together with a different stylist. The\n // instant may survive the switch; the seating never does.\n return { ...state, ...clearedSlot, ...preference, partyMode: action.mode, error: null };\n }\n\n case 'pickPartySlot':\n if (action.mode === 'parallel' && !config.party.allowParallel) return state;\n // A seating chart that names a different number of children than the\n // basket holds is the caller's bug, and gets the answer `setItemField`\n // gives one: the identical object. Booking on it would submit a stylist\n // for a child who is not coming, or none for one who is.\n if (action.resourceIds.length !== state.items.length) return state;\n return {\n ...state,\n startTs: action.startTs,\n partyResourceIds: action.resourceIds,\n // The mode of the slot that was actually tapped, which is not always\n // the mode the visitor chose on step 2 — accepting step 3's parallel\n // alternative is a change of mind about both at once.\n partyMode: action.mode,\n // The whole visit's stylist where there is one — a sequential party is\n // one person, back to back — and `null` where there is not, which is\n // every parallel party. A parallel visit has two stylists and naming\n // either of them as *the* one would be false on the confirmation card.\n resolvedResourceId: sharedResourceId(action.resourceIds),\n step: 'details',\n error: null,\n };\n\n case 'slotTaken':\n // The resolved stylist belonged to the slot that just vanished. Carrying\n // it forward would submit an assignment nobody ever offered — and for a\n // party that is a whole seating chart, not one id.\n return {\n ...state,\n startTs: null,\n resolvedResourceId: null,\n partyResourceIds: null,\n step: 'when',\n error: 'slotTaken',\n };\n\n case 'submitFailed':\n // Nothing else moves. The visitor is standing on step 4 with a button they\n // have just pressed, and every one of these four is either theirs to fix\n // in the form or ours to fix upstream — neither is a reason to throw away\n // the slot they chose, which is what leaving `details` would do.\n return { ...state, error: action.error };\n\n case 'goToStep':\n // Refused jumps return the *same object*, not a copy: the progress dots\n // decide whether they are tappable with `canGoToStep`, so arriving here\n // with an unreachable step is a bug in the caller rather than something\n // the visitor did, and there is no error copy to show for it. Returning\n // the identical reference also means React re-renders nothing.\n if (!canGoToStep(state, action.step)) return state;\n return { ...state, step: action.step, error: null };\n\n case 'setContact':\n return {\n ...state,\n contact: { ...state.contact, [action.field]: action.value },\n error: null,\n };\n\n case 'setItemField': {\n // An index that names no line item is the caller's bug, and the same\n // answer `goToStep` gives one: the identical object, so nothing re-renders\n // and no phantom item is conjured at index 7 of a party of one.\n if (!state.items[action.index]) return state;\n const item = withItemField(state.items[action.index], action);\n return {\n ...state,\n items: state.items.map((current, index) => (index === action.index ? item : current)),\n // Mirrored onto the person, so a basket rebuilt from `people` — a\n // service changed on step 2 after a name was typed — keeps the name.\n people: state.people.map((person, index) =>\n index === action.index ? withPersonFields(person, item) : person\n ),\n error: null,\n };\n }\n\n case 'setNotes':\n return { ...state, notes: action.value, error: null };\n\n case 'setConsent':\n // Spread of one key or the other, rather than two cases: the two boxes\n // differ only in which flag they set, and the shape below makes it plain\n // that ticking the marketing box cannot touch the terms flag — which is\n // the confusion that would actually matter, since terms gate submit.\n return {\n ...state,\n ...(action.which === 'terms'\n ? { consentTerms: action.accepted }\n : { consentMarketing: action.accepted }),\n error: null,\n };\n\n case 'prefill':\n return applyPrefill(state, action.prefill, action.catalogue);\n }\n // No `default`. The switch is exhaustive over `WizardAction`, so a new action\n // added later fails to compile here instead of silently doing nothing.\n }\n\n /** The children a family link asked for (`?antall=` also applies to a\n * «Bestill igjen» `?service=` link, intentionally), up to what the salon allows and only\n * on a kids' service. Through `addService`, so the party limit is the machine's\n * own and a clamped request raises no error. */\n function withLinkedParty(\n state: WizardState,\n service: WizardService,\n party?: number | null\n ): WizardState {\n if (!party || !isChildCategory(config, service.category)) return state;\n const wanted = Math.min(party, Math.max(1, service.maxPerBooking));\n let next = state;\n while (next.items.length < wanted) {\n const after = reduce(next, { type: 'addService', service });\n // A refusal leaves the basket as it was; stop rather than spin. The\n // service's own limit is already in `wanted`, but the site's party ceiling\n // (`party.maxPeople`) is not: «?antall=3» at a site that seats two stops\n // at two, without an error, because the link asked and nobody tapped.\n if (after.items.length === next.items.length) break;\n next = after;\n }\n return next;\n }\n\n /**\n * A «Bestill igjen» link, folded into the state — so a parent re-booking from\n * `/min-side` opens the wizard with the service, the stylist and the child\n * already answered and only the time left to pick.\n *\n * FILLS BLANKS, NEVER OVERWRITES. Each of the three answers is written only\n * where the visitor has not given one: the service into an empty basket, the\n * name onto a line that has none, the stylist while step 2 is still the open\n * question — nothing answered (`stylistAnswered`, because «Alle» tapped and\n * «Alle» never touched are the same `null`), no slot chosen, and the visitor\n * still ON that step. This is not caution for its own sake. The stylist list is fetched\n * FOR a service, so it does not exist at the moment the link is read; the shell\n * therefore applies the same prefill twice — once with no stylists, once when\n * they land — and in between the visitor may have tapped anything. A second\n * pass that only fills blanks finishes what the first began and cannot undo a\n * tap. It also makes the function idempotent, which is what lets the shell not\n * care whether it has already run.\n *\n * NOTHING IS TRUSTED BEYOND THE CATALOGUE. The link may be a year old: the\n * stylist may have left, the service may have been re-created under a new id.\n * An id the catalogue does not hold is ignored — silently, because there is no\n * error a parent could act on, and the wizard they are left with is the\n * ordinary one. A stylist is accepted only if they can do EVERY service in the\n * basket, which is the rule `StylistStep` filters by; accepting them on the\n * link's word alone would leave step 2 with nothing selected and step 3\n * filtered to a diary that cannot take the visit — «Fullt» on every day of the\n * week at a salon with a free chair, the very failure `pickService` exists to\n * prevent.\n *\n * WHERE IT LANDS is the earliest step with a question still open: nothing\n * applied → wherever the visitor was; a service → step 2, because «who» has\n * not been answered even though «Første ledige» would be a valid default; a\n * stylist too → step 3. Those are the steps the machine's own `pickService` and\n * `pickResource` move to, and this function reaches them by dispatching those\n * actions rather than by writing `step` itself — so a prefilled basket obeys\n * every rule a tapped one does, including the slot-clearing ones.\n *\n * Returns the IDENTICAL object when nothing applies, for the same reason\n * `goToStep` does: React then re-renders nothing, and a test can tell «ignored»\n * from «rewritten to look the same».\n */\n function applyPrefill(\n state: WizardState,\n prefill: WizardPrefill,\n catalogue: WizardCatalogue\n ): WizardState {\n let next = state;\n\n if (next.items.length === 0 && prefill.serviceId) {\n const service = catalogue.services.find((entry) => entry.id === prefill.serviceId);\n if (service !== undefined) {\n next = reduce(next, { type: 'pickService', service });\n next = withLinkedParty(next, service, prefill.party);\n }\n }\n\n // No basket means nothing to name and nobody to book with: the service was\n // unknown, or the link never named one.\n if (next.items.length === 0) return state;\n\n if (prefill.who) {\n // Trimmed first, so the cut falls on the name and not on leading\n // whitespace; `setItemField` trims once more for whatever the cut leaves\n // at the end, and stores nothing at all for a blank.\n const who = prefill.who.trim().slice(0, BOOKED_FOR_NAME_MAX_LENGTH);\n if (who.length > 0 && next.items[0].bookedForName === undefined) {\n next = reduce(next, { type: 'setItemField', index: 0, field: 'bookedForName', value: who });\n }\n }\n\n if (\n prefill.resourceId &&\n next.step === 'when' &&\n !next.stylistAnswered &&\n next.resourceId === null &&\n next.startTs === null\n ) {\n const wantedResource = prefill.resourceId;\n const resource = catalogue.resources.find((entry) => resourceMatches(entry, wantedResource));\n // Every service of every visit, extras included — a stylist who cuts but\n // does not colour cannot take a Klipp + Farge.\n const basket = next.items.flatMap((item) => visitServicesOf(item).map(({ id }) => id));\n if (resource !== undefined && basket.every((id) => resource.serviceIds.includes(id))) {\n next = reduce(next, { type: 'pickResource', resourceId: resource.id });\n }\n }\n\n return next;\n }\n\n /**\n * What ONE line costs, in øre, at the time the visit was booked for.\n *\n * The single definition of «what this child's haircut costs», and the reason\n * `totalPriceOre` below is a sum of it rather than a second copy of the rule:\n * the confirmation card prints a price per child and a total underneath them,\n * and a card whose lines were priced by one rule and whose total was priced by\n * another read «490 kr + 490 kr = 1 078 kr» to the parent about to pay it.\n *\n * The surcharge is per SERVICE — Medal carries `weekend_surcharge_pct` on each\n * one — so it is applied here, per line, and never as one percentage over a\n * basket that may hold two different ones.\n *\n * `clock.isWeekend` and not `getDay()`: a Saturday 00:30 slot is still Saturday\n * to the salon while UTC — and a viewer in London — still calls it Friday, and\n * the surcharge follows the salon's calendar because that is who charges it.\n *\n * A null `startTs` means no slot has been chosen yet, which is the base price\n * rather than a guess at a surcharge.\n *\n * `startTs` is the VISIT's start, not the line's own — the two differ only for\n * a sequential party that crosses midnight into or out of a weekend, which the\n * salon's opening hours do not permit, and pricing every line off the same\n * instant is what makes the lines add up to the total the parent agreed to on\n * step 4.\n */\n function itemPriceOre(service: WizardService, startTs: number | null): number {\n const multiplier =\n startTs !== null && clock.isWeekend(startTs) ? 1 + service.weekendSurchargePct / 100 : 1;\n return Math.round(service.priceOre * multiplier);\n }\n\n /**\n * What ONE person's whole visit costs — every service they chose, each priced\n * by `itemPriceOre`. The line the confirmation card prints per person, and\n * the unit `totalPriceOre` sums, so a line and the total cannot disagree.\n */\n function visitItemPriceOre(item: WizardItem, startTs: number | null): number {\n // Each service on its own, surcharge included, then summed — never one\n // percentage over the visit, since Klipp and Vask may carry different ones.\n return visitPriceOre(visitServicesOf(item), (service) => itemPriceOre(service, startTs));\n }\n\n /**\n * What the visit costs, in øre, at the time it was booked for.\n *\n * Here rather than in either component that shows it, because both of them show\n * it: step 4's «Bekreft time – 539 kr betales i salongen» and the confirmation\n * card's «Totalt» have to be the same number, and the surcharge is exactly the\n * sort of rule that gets applied in one of two places.\n */\n function totalPriceOre(items: WizardItem[], startTs: number | null): number {\n return items.reduce((sum, item) => sum + visitItemPriceOre(item, startTs), 0);\n }\n\n /**\n * What is being booked.\n *\n * One child gets the service by name, which is what they chose and what they\n * will look for. A family gets the report's aggregate — «2 tjenester · 60 min\n * totalt» — because three service names joined by the same separator the bar\n * uses between its own parts is a sentence nobody can parse at the bottom of a\n * phone, and because the minutes are the thing a parent planning the afternoon\n * actually needs.\n */\n function serviceLabel(state: WizardState): string | null {\n const { items } = state;\n if (items.length === 0) return null;\n // `showsPartyMode` and not a second `length > 1`: the bar changing shape and\n // step 2 growing a question are the same event, and two spellings of it is\n // how the bar ends up aggregating a booking the wizard still calls single.\n //\n // One person with several services is still one visit, named — «Klipp +\n // Vask». A plus reads as one booking where the bar's own separator would\n // not, and the family aggregate counts people, of whom there is one.\n if (!showsPartyMode(state)) {\n return visitServicesOf(items[0])\n .map((service) => service.name)\n .join(' + ');\n }\n return `${fill(labels['summary.services'], { count: items.length })}${SEPARATOR}${fill(\n labels['summary.totalMinutes'],\n { minutes: visitMinutes(items, state.partyMode) }\n )}`;\n }\n\n /** «Theo, Emma», «2 barn», «Voksen» — who the visit is for, before what. */\n function peopleLabel(people: readonly WizardPerson[]): string {\n const guests = people.filter((person) => person.key.startsWith('guest:')).length;\n const parts = people\n .filter((person) => !person.key.startsWith('guest:'))\n .map(\n (person) =>\n person.name ??\n labelText(person.key === SELF_KEY ? labels['people.self'] : labels['people.adult'])\n );\n if (guests > 0) parts.unshift(fill(labels['people.children'], { count: guests }));\n return parts.join(', ');\n }\n\n /**\n * `i dag 15:00`, not `15:00`.\n *\n * The bar is read at the bottom of a screen the visitor has been scrolling for\n * a minute, often after tapping between two days on step 3 — and a bare hour\n * there is the one part of the summary they can misread without noticing. The\n * day comes from `salon-clock.ts` along with the hour, so the bar and the chip\n * the visitor tapped read the same; one of them being on the viewer's clock is\n * exactly how they stop.\n */\n function timeLabel(startTs: number | null, now: number): string | null {\n return startTs === null ? null : clock.when(startTs, now);\n }\n\n /**\n * What the visit costs so far.\n *\n * Never a dash: from the moment there is a service there is a price, and it is\n * `totalPriceOre` — the same function step 4's button and the confirmation card\n * read — so the three cannot quote different numbers. It can *rise* when the\n * visitor picks a Saturday, which is the surcharge arriving rather than the bar\n * changing its mind, and is why the number is recomputed here from `startTs`\n * instead of being frozen when the service was chosen.\n */\n function priceLabel(state: WizardState): string | null {\n /* v8 ignore next -- defensive: `summaryLine` asks only once there is a basket */\n if (state.items.length === 0) return null;\n return money.formatMinor(totalPriceOre(state.items, state.startTs));\n }\n\n /**\n * The single source for the sticky bar, so the bar can never disagree with the\n * state it is summarising. Chosen parts are joined; parts still to come render\n * as what is left to do — «Første ledige» for a stylist nobody named, «Velg\n * tid» for the hour — which is what makes the bar a promise of what is left\n * rather than a receipt for what is done. A named stylist the catalogue cannot\n * name is still `—`.\n *\n * `now` is a parameter rather than a `Date.now()` inside `clock.when`, because\n * «i dag» is a claim about when the bar is being *read*: a component that wants\n * one instant for a whole render — the confirmation card already does this — has\n * to be able to say so, and a test that asserts «i dag» has to be able to pin it.\n */\n function summaryLine(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now: number = Date.now()\n ): string {\n /**\n * A family bar is the report's three parts — «2 tjenester · 60 min totalt ·\n * 980 kr» — and drops the stylist and the hour rather than dashing them.\n *\n * Not brevity: a parallel party has two stylists and no single one to name,\n * so that part could only ever be a dash, and a dash that can never be filled\n * in is a promise the bar cannot keep. The when-and-with-whom is on step 3's\n * chip while the visitor is choosing it and on the confirmation card once\n * they have, both of which can say «15:00 (Jonas hos Marcus · Emma hos Sara)»\n * where a one-line sticky bar cannot.\n */\n // Step 1 answered, step 2 not yet: say who, so the bar is already about them.\n if (state.items.length === 0 && state.people.length > 0) return peopleLabel(state.people);\n const party = showsPartyMode(state);\n const service = serviceLabel(state);\n if (party) {\n return [service, priceLabel(state)].filter((part) => part !== null).join(SEPARATOR);\n }\n // Nothing chosen at all: the bar has its own placeholder for step 1, and a\n // row of «Første ledige · Velg tid» with no service in front says nothing.\n if (service === null) return '';\n const unnamedPreference = (state.resolvedResourceId ?? state.resourceId) === null;\n return [\n service,\n stylistLabel(state, resolveStylistName) ??\n (unnamedPreference ? labelText(labels['summary.firstAvailable']) : UNCHOSEN),\n timeLabel(state.startTs, now) ?? labelText(labels['summary.pickTime']),\n // Defensive: a service in the bar means a price.\n /* v8 ignore start */\n priceLabel(state) ?? UNCHOSEN,\n /* v8 ignore stop */\n ].join(SEPARATOR);\n }\n\n return {\n maxPeople,\n initialState,\n reduce,\n applyPrefill,\n canAdvance,\n canGoToStep,\n itemPriceOre,\n visitItemPriceOre,\n totalPriceOre,\n itemStartTimes,\n visitMinutes,\n visitTailMinutes,\n visitEndTs,\n showsPartyMode,\n itemResourceIds,\n summaryLine,\n guestChild,\n isGuestSeat,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"machine.mjs","names":[],"sources":["../../src/core/machine.ts"],"sourcesContent":["/**\n * The wizard's brain: which step is reachable, what the sticky summary bar\n * says, and when submit is allowed. All of it lives here and nowhere else.\n *\n * Deliberately pure — no React, no fetch, no DOM — so every rule is testable\n * without rendering. `medal-client.ts` is `server-only` and must never be\n * imported from this file; the wizard runs in the browser.\n *\n * The step components (meda's `/booking` screens) are meant to be\n * presentational. If one of them would need an `if` about wizard state, that\n * `if` belongs here instead.\n *\n * Most of the machine is config-free and exported as is. `createWizard(config)`\n * binds the parts that are not: the party ceiling (`party.maxPeople`), which\n * category is the kids' path (`categories[].audience`), the weekend on the\n * business's clock, the currency, and the words the summary line uses.\n */\n\nimport { isChildCategory } from './categories';\nimport { createClock } from './clock';\nimport type { BookingConfig } from './config';\nimport { resourceMatches } from './deep-link';\nimport { fill, labelText, resolveLabels } from './labels';\nimport { createMoney } from './money';\nimport { visitKey, visitOf, visitPriceOre, visitServicesOf } from './visit';\n\n/**\n * The three steps the design draws, and the three the machine holds.\n *\n * `when` is one screen and two questions — the stylist and the hour — because\n * that is what a parent answers in one breath and what the frames show as\n * «Frisør og tid». They were separate steps until the redesign, and the pair of\n * them cost a parent a whole extra «Neste» to say a thing they had already\n * decided.\n *\n * There is no `login` step, and there was one. It sat between the hour and\n * «Bekreft» and asked who the parent was — Vipps, an e-mail code, or neither —\n * at the one moment a walk-in parent least wants a decision. The login is an\n * offer in a sheet now (the login sheet), opened from a row\n * on `service` and `details`, and the machine knows nothing about it: a login\n * changes who the contact fields are filled from, never which step is next.\n */\nexport type WizardStep = 'who' | 'service' | 'when' | 'details';\n\n/**\n * «Hvem skal klippes?» — step 1's answer (SP10), one entry per chair.\n *\n * The wizard used to learn who a haircut was for last, in two text fields on\n * «Bekreft», and a logged-in parent's children were a row of name chips there.\n * Asking first is what lets everything after it be ABOUT the child: step 2\n * offers «Samme som sist» for Theo, the ages that filter the menu are Theo's\n * on the appointment day, and the booking goes to Medal as Theo's person id\n * rather than a name a parent typed.\n *\n * `key` identifies the chair across edits and is never shown: `p:<id>` for a\n * saved child, `self` for the logged-in parent, `adult` for a guest adult,\n * `guest:<n>` for a guest's n-th child. Everything else is optional and ABSENT\n * rather than blank, the rule the line items follow — a guest child has no\n * name until «Bekreft» asks for one.\n */\nexport interface WizardPerson {\n key: string;\n /** A grown-up: no child fields on «Bekreft», the full menu on step 2. */\n adult?: boolean;\n /** Medal's id for a saved child — what `booked_for_person_id` carries. */\n personId?: string;\n name?: string;\n birthYear?: number;\n /** 1–12, when the parent gave one. */\n birthMonth?: number;\n}\n\n/** The logged-in parent themselves, on step 1's «Meg selv (voksen)». */\nexport const SELF_KEY = 'self';\n\n/**\n * A seat only a GUEST's step 1 can draw: a count chip's `guest:n`, a child\n * named in the guest sheet (`new:…`), the «Voksen» chip's `adult`. A parent's\n * step 1 draws cards for saved children and «Meg selv» and nothing else — so\n * one of these under a family is a seat nobody can see or untick, counting\n * towards the three.\n */\nexport function isGuestSeat(person: WizardPerson): boolean {\n return person.key.startsWith('guest:') || person.key.startsWith('new:') || person.key === 'adult';\n}\n\nexport interface WizardService {\n id: string;\n name: string;\n category: string;\n durationMinutes: number;\n priceOre: number;\n maxPerBooking: number;\n /** Drives step 3's weekend note and the surcharged price shown on the chip. */\n weekendSurchargePct: number;\n /**\n * The salon's prep and cleanup time around this service, in minutes.\n *\n * Carried because a FAMILY needs them: two children seated back to back on\n * one stylist are separated by the first one's cleanup plus the second one's\n * prep, and `itemStartTimes` cannot honour a gap it does not know about.\n * Availability is asked per service and each opening is judged on its own, so\n * both instants can come back free while the engine refuses the combined\n * submission — a visit offered to the parent and then rejected.\n *\n * Zero is the ordinary value and the wire's own default.\n */\n bufferBeforeMinutes: number;\n bufferAfterMinutes: number;\n /**\n * Who the service is for, in whole years, inclusive (SP10) — «Barnehageklipp»\n * up to 6, say. Used to SUGGEST, never to refuse: Medal does not reject a\n * booking on age, and a parent may know better. Absent is «any age».\n */\n ageMinYears?: number;\n ageMaxYears?: number;\n}\n\nexport interface WizardItem {\n /** The person's FIRST service. */\n service: WizardService;\n /**\n * The person's further services, performed back to back after `service` as\n * ONE visit. Absent means one service — never `[]`.\n */\n extraServices?: WizardService[];\n /** The child this line is for. Asked inline in step 4. */\n bookedForName?: string;\n /** Optional fødselsår — powers \"Jonas (9)\" in the CRM later. */\n bookedForBirthYear?: number;\n /**\n * The saved child this line is for (SP10) — sent as `booked_for_person_id`\n * when the phone rule allows it (see the create route). Never typed: it\n * comes only from a child card on step 1.\n */\n bookedForPersonId?: string;\n /** The child's birth month, for the age on the appointment day. */\n bookedForBirthMonth?: number;\n /** A grown-up's line: «Bekreft» asks nothing about a child for it. */\n adult?: boolean;\n}\n\nexport interface WizardState {\n step: WizardStep;\n /**\n * Who is coming — step 1's answer. One entry per chair, in the order the\n * line items follow: `items[i]` is `people[i]`'s haircut once every chair\n * has a service.\n */\n people: WizardPerson[];\n /**\n * Step 2's answers so far, one per person — `null` where that person has no\n * service yet. `items` is built from these and `people` only once none is\n * `null`, so a family half-way through step 2 has no basket and step 3\n * stays shut.\n */\n choices: Array<WizardService | null>;\n /**\n * Each person's FURTHER services, parallel to `people` and `choices` —\n * `extras[i]` is what `people[i]` has after `choices[i]`, performed back to\n * back as one visit. `[]` is one service, and so is a person whose `choices`\n * entry is still `null`: an extra never stands without a first service.\n *\n * A second array rather than turning `choices` into lists, because meda keeps\n * its own copy of this state's shape and reads `choices` and `item.service`\n * as single services — the multi-service visit is additive, and a screen that\n * has never heard of it keeps working on the first service.\n */\n extras: WizardService[][];\n items: WizardItem[];\n /** null = «Første ledige» — the default, and the utilisation-maximising one. */\n resourceId: string | null;\n /**\n * Whether the stylist question has been ANSWERED, as against left at its\n * default.\n *\n * `resourceId` cannot say. `null` is «Første ledige», which is both the value\n * a fresh wizard starts on and a real choice a parent makes by tapping «Alle»\n * — and while the stylist and the hour were separate steps the difference was\n * legible in `step` alone: a visitor who had answered was on `time`. One\n * screen for both questions took that away, and the thing that noticed is\n * `applyPrefill`, whose whole contract is «fills blanks, never overwrites»:\n * without this a «Bestill igjen» link naming Sara would land on a parent who\n * had just tapped «Alle» and quietly replace their answer.\n *\n * Set by `pickResource` either way, and by the parallel `setPartyMode` — «two\n * stylists at once» is an answer about who cuts the hair even though it\n * clears the preference. Cleared whenever the preference itself is cleared\n * for a reason the parent did not choose: a replaced basket, or a sibling the\n * named stylist cannot take.\n */\n stylistAnswered: boolean;\n partyMode: 'sequential' | 'parallel';\n startTs: number | null;\n resolvedResourceId: string | null;\n /**\n * The stylist each line resolved to, in basket order — the half of a party\n * slot that `resolvedResourceId` cannot hold.\n *\n * `null` for everything except a party slot chosen on step 3, where\n * `resolvedResourceId` still answers for the whole visit. Non-null it is one\n * id per line item, and it is what a parallel party actually needs: two\n * children seen at the same instant are seen by two *different* stylists, so\n * there is no single id that describes the visit.\n *\n * Read through `itemResourceIds` rather than directly, so the submission and\n * the confirmation card cannot disagree about who is cutting whose hair.\n */\n partyResourceIds: string[] | null;\n contact: { phone: string; name: string; email: string };\n notes: string;\n consentTerms: boolean;\n consentMarketing: boolean;\n /**\n * A code, never a sentence. The Norwegian copy for each case belongs to the\n * component that shows it, so the same rule can be a toast in one place and\n * inline text in another without this file knowing either.\n *\n * Wider than what `reduce` can currently produce, on purpose. The submit route\n * answers with `conflict`, `invalidInput`, `unconfigured` and `upstreamError`\n * as well as `slotTaken`, and those have to land somewhere. Somewhere is here:\n * if the union only admitted what this file raises, the details step would grow\n * a second error slot of its own and the sticky bar and the form would start\n * disagreeing about whether anything is wrong.\n *\n * `upstreamError` is the create route's 502 — Medal answered something this\n * site has no mapping for, or did not answer at all. It is an outage rather\n * than a mistake the visitor made, but it arrives with them waiting on a\n * button they have just pressed, so it needs a sentence of its own rather\n * than being folded into `conflict` — which says «prøv en gang til», and\n * trying again is exactly what will not help here.\n */\n /**\n * A service a link named for a parent whose family is known, held until\n * step 1 says who it is for (`holdService`). Each person seated after that\n * starts on it where it suits them — a kids' service for a child, anything\n * else for a grown-up — instead of a guest chair being invented for it.\n */\n pendingService: WizardService | null;\n error:\n | 'maxParty'\n /**\n * One person asked for more services than `party.maxServicesPerPerson`\n * allows — `toggleServiceFor` refusing the next one. A code like the rest:\n * the sentence (and the number in it) belongs to whoever shows it.\n */\n | 'maxServices'\n | 'slotTaken'\n | 'conflict'\n | 'invalidInput'\n | 'unconfigured'\n | 'upstreamError'\n /**\n * The create route's 409 for a submission Medal already holds under its\n * key: it may well be booked, so the parent is sent to their e-mail\n * rather than to the button.\n */\n | 'inProgress'\n | null;\n}\n\nexport type WizardAction =\n /**\n * Back to an empty wizard, for «Bestill ny time» on the confirmation.\n *\n * A parent who has just booked one child and needs a SEPARATE appointment —\n * another service whose `maxPerBooking` is 1, or a sibling who wants a\n * different hour — has to be able to start over. The confirmation is now\n * restored across a reload, so without this the only ways out of it are\n * closing the tab or waiting for the stored attempt to age out.\n */\n | { type: 'startOver' }\n /**\n * Step 1's answer: who is coming. Replaces the party; a person who stays\n * (the same `key`) keeps the service already chosen for them, so going back\n * to add a sibling does not re-ask the first child. `advance` moves on to\n * step 2 — a guest's «2 barn» chip is one tap, a logged-in parent ticks\n * children and presses «Neste».\n */\n | {\n type: 'choosePeople';\n people: WizardPerson[];\n advance?: boolean;\n /**\n * «Samme som sist»: a service to start each person NEW to the party on,\n * aligned with `people` — the child's last haircut, where the shell\n * knows one. A person already in the party keeps their own answer.\n */\n services?: ReadonlyArray<WizardService | null>;\n }\n /**\n * Step 2's answer for ONE person. For a party of one it is the old one-tap\n * `pickService` and moves on; for a family it fills that child's line and\n * waits for the rest. `resourceServiceIds` is `addService`'s: what the named\n * stylist can do, so a service they cannot do releases the preference.\n */\n | {\n type: 'pickServiceFor';\n index: number;\n service: WizardService;\n resourceServiceIds?: readonly string[];\n }\n /**\n * The multi-select service card: a person's list gains the service, or loses\n * it if it was already there. The first service a person gets is their\n * `choices` entry, every later one an extra; removing the first promotes the\n * next, and removing the last leaves the person unanswered.\n *\n * Never moves the visitor — unlike the one-tap `pickServiceFor`, a person\n * choosing several services is not done after the first, so «Neste» is theirs\n * (the shell's `goToStep('when')`). Refused with `maxServices` past the\n * site's per-person ceiling and with `maxParty` when more people would take\n * the service than its `maxPerBooking`. `resourceServiceIds` is\n * `pickServiceFor`'s: the named stylist has to do the person's whole list.\n */\n | {\n type: 'toggleServiceFor';\n index: number;\n service: WizardService;\n resourceServiceIds?: readonly string[];\n }\n | { type: 'pickService'; service: WizardService }\n /**\n * The parent's family has appeared (a login, a guardian read after the\n * first render) or step 1 is shown again under it: the guest seats go —\n * see `isGuestSeat` — and a guest «Voksen» becomes «Meg selv». Only while\n * nothing is held on them (steps 1 and 2); from the hour on they are real\n * chairs with a time, and «Bekreft» names them. A party emptied by it goes\n * back to step 1, where the children are. A guest chair in `keep` stays:\n * one an account switch made of a seat (`unseatPeople`), whose services are\n * the visit the parent is booking, not a guest's count chip.\n */\n | { type: 'seatFamily'; keep?: readonly string[] }\n /**\n * Somebody else has logged in over this party — another account than the\n * one it was seated under: each seat in `keys` (the previous parent's saved\n * children) becomes a guest chair. Its services stay, and so does the hour —\n * the basket is the same visit, only WHO changed — but its name, year and\n * person id go with the parent they belonged to, and «Bekreft» asks again.\n * With `into`, the seat `key` becomes `into[key]` instead — the child of the\n * parent who logged in, found by id — unless that child is seated already.\n */\n | {\n type: 'unseatPeople';\n keys: readonly string[];\n into?: Readonly<Record<string, WizardPerson>>;\n }\n /**\n * One person joins the party as it is WHEN this lands — a child the parent\n * just created in Medal, after the await. Refused at the limit, a no-op for\n * somebody already seated.\n */\n | { type: 'addPerson'; person: WizardPerson }\n /** A link's service, held for the people step 1 is about to seat — see `pendingService`. */\n | { type: 'holdService'; service: WizardService | null }\n /**\n * A sibling, added on step 1 — and the one fact about the stylist catalogue\n * this file is allowed to know.\n *\n * `resourceServiceIds` is what the currently preferred stylist can do. It\n * rides on the action because the alternative was worse in both directions:\n * the machine cannot fetch the catalogue, and a shell that cleared the\n * preference itself would need an action that changes `resourceId` without\n * moving the visitor off step 1, which `pickResource` is not.\n *\n * Absent is «the caller does not know», not «the stylist does nothing». A\n * missing list keeps the preference, because silence is not evidence.\n */\n | { type: 'addService'; service: WizardService; resourceServiceIds?: readonly string[] }\n | { type: 'pickResource'; resourceId: string | null }\n | { type: 'pickSlot'; startTs: number; resourceId: string | null }\n /**\n * How the party is seen — step 2's second question, and the one that decides\n * which search step 3 runs.\n *\n * Its own action rather than a field on `pickResource`, because the two\n * answers are independent: «Sara» and «rett etter hverandre» are one visit,\n * and a parent who changes only the mode has not changed their mind about\n * the stylist.\n */\n | { type: 'setPartyMode'; mode: WizardState['partyMode'] }\n /**\n * A whole family seated in one tap: the instant the visit starts and the\n * stylist each child got.\n *\n * Separate from `pickSlot` because a party slot answers a question\n * `pickSlot` has no room for. `pickSlot` carries one resolved stylist, which\n * is all a single booking ever has; a party in `parallel` has one per child\n * and they are deliberately different people.\n *\n * `resourceIds` is one id per line item, in basket order — never null,\n * because a slot that cannot name the two stylists cannot promise that two\n * children are seen at once.\n *\n * `mode` travels with the slot rather than being read off the state, and it\n * is what makes step 3's «men begge kan tas samtidig kl. 15:00» work: that\n * offer appears while the machine still says `sequential`, and seating a\n * parallel slot sequentially would submit the second child half an hour\n * after a stylist who is free at the hour itself. Carrying it here also\n * removes the ordering trap — `setPartyMode` clears the slot, so a caller\n * that dispatched the two actions the other way round would throw away the\n * time the parent had just accepted.\n */\n | {\n type: 'pickPartySlot';\n startTs: number;\n resourceIds: string[];\n mode: WizardState['partyMode'];\n }\n | { type: 'slotTaken' }\n /**\n * A submission the create route refused, in the vocabulary `WizardState.error`\n * already speaks.\n *\n * Without it the four codes below are a union nothing can produce: the route\n * answers `conflict`, `invalidInput`, `unconfigured` and `upstreamError`, and\n * step 4 has a Norwegian sentence for each, but there was no action that could\n * put one into `state.error` — so the failure would have had to be held beside\n * the machine, in a second error slot the machine's own clearing rules never\n * touch.\n *\n * `slotTaken` and `maxParty` are excluded rather than admitted. Each already\n * has an action that does more than raise a code — `slotTaken` drops the slot\n * and sends the visitor back to step 3, `addService` refuses the child while\n * leaving the ones already chosen alone — and a second spelling of either is\n * how a stolen slot ends up raised without the slot being dropped.\n */\n | {\n type: 'submitFailed';\n error: Exclude<NonNullable<WizardState['error']>, 'slotTaken' | 'maxParty'>;\n }\n | { type: 'goToStep'; step: WizardStep }\n | { type: 'setContact'; field: keyof WizardState['contact']; value: string }\n /**\n * The two answers about the child, written onto the line item they belong to.\n *\n * Indexed rather than keyed by service, because two children in one party can\n * genuinely share a service and the index is the only thing that tells those\n * rows apart — the same reason step 4 keys its fields on it.\n *\n * Split over the two fields rather than taking a `Partial<WizardItem>`, so a\n * caller cannot reach through this action and overwrite `service`, which is\n * step 1's answer and not step 4's to change.\n */\n | { type: 'setItemField'; index: number; field: 'bookedForName'; value: string }\n /** `null` is «cleared», which an emptied year input is and `undefined` would\n * be ambiguous about — a patch that says nothing versus one that says nothing\n * is there. */\n | { type: 'setItemField'; index: number; field: 'bookedForBirthYear'; value: number | null }\n /** A saved child picked for a guest line on «Bekreft» (a family chip); `null` detaches. */\n | { type: 'setItemField'; index: number; field: 'bookedForPersonId'; value: string | null }\n | { type: 'setNotes'; value: string }\n /** One action for both boxes rather than two near-identical ones: they are the\n * same gesture, and the pair that must never be confused — a marketing opt-in\n * standing in for terms — is harder to write by accident when the difference\n * is a required discriminant. */\n | { type: 'setConsent'; which: 'terms' | 'marketing'; accepted: boolean }\n /**\n * A «Bestill igjen» link, applied. The shell dispatches this rather than\n * calling `applyPrefill` on the state it holds, because `useReducer` is the\n * only writer of that state — see `applyPrefill` for the rules.\n */\n | { type: 'prefill'; prefill: WizardPrefill; catalogue: WizardCatalogue };\n\n/**\n * What a `/min-side` «Bestill igjen» link can carry into the wizard:\n * `?service=<id>&stylist=<id>` off the query string, and the child's name\n * from the tab's session store (`rebook-store.ts`) — never from the URL.\n *\n * Every field is optional AND nullable, because `URLSearchParams.get` answers\n * `null` for a missing key and the shell should not have to translate that.\n * An empty string means the same as absent.\n */\nexport interface WizardPrefill {\n serviceId?: string | null;\n /** An id, or — from a «Bestill hos Siv» link — a display name or its slug. */\n resourceId?: string | null;\n who?: string | null;\n /**\n * `?antall=`: how many children. Clamped to the service's `maxPerBooking`\n * and only meaningful for a kids' service, where it pre-creates that many\n * lines; ignored for anything else.\n */\n party?: number | null;\n}\n\n/**\n * What a prefill is checked against — the services step 1 would let the visitor\n * tap, and the stylists step 2 would show for them.\n *\n * The SHELL decides what goes in here, and that is the point: the machine has\n * no `bookableOnline` and no fetched stylist list, so «this id is one the\n * visitor could have chosen» is a fact only the caller can supply. An id absent\n * from the catalogue is ignored, whatever the link said.\n */\nexport interface WizardCatalogue {\n services: readonly WizardService[];\n resources: ReadonlyArray<{ id: string; name?: string; serviceIds: readonly string[] }>;\n}\n\n/**\n * The engine's own bound on `booked_for_name` — `maxLength: 200` in the SDK's\n * OpenAPI document. Step 4's input has no `maxLength`, so this is the one limit\n * that exists, and a prefilled name is cut to it rather than being carried into\n * a body the engine answers 400 to.\n */\nexport const BOOKED_FOR_NAME_MAX_LENGTH = 200;\n\nconst SEPARATOR = ' · ';\n\n/** Stands in for a part the visitor has not chosen yet, so the bar keeps its\n * shape as they fill it in rather than growing under their thumb. */\nconst UNCHOSEN = '—';\n\nexport function initialState(): WizardState {\n return {\n step: 'who',\n people: [],\n choices: [],\n extras: [],\n items: [],\n resourceId: null,\n stylistAnswered: false,\n partyMode: 'sequential',\n startTs: null,\n resolvedResourceId: null,\n partyResourceIds: null,\n contact: { phone: '', name: '', email: '' },\n notes: '',\n consentTerms: false,\n consentMarketing: false,\n pendingService: null,\n error: null,\n };\n}\n\n/**\n * The first service more people would take than its `maxPerBooking` allows, or\n * `null` when every service is within its cap — the engine's party rule, given\n * the lists as they WOULD be after the tap being judged.\n *\n * Per service, not per party. The engine used to cap the whole booking at the\n * strictest service's number, which kept a grown-up's one-at-a-time cut out of\n * any family visit; it now counts, for each service, the people taking it. So\n * a «Skjegg» with `maxPerBooking: 1` rides along with two children's cuts, and\n * two people on that «Skjegg» are still refused. Mirroring the engine rather\n * than inventing a ceiling: a number of our own would be a second, invisible\n * rule that disagreed with the salon's the day they changed it.\n *\n * A service counts at most once per person — a person's list never holds it\n * twice, and if one did it would still be one chair.\n */\nfunction overServiceCap(lists: ReadonlyArray<readonly WizardService[]>): WizardService | null {\n const takers = new Map<string, { service: WizardService; people: number }>();\n for (const list of lists) {\n const once = new Map(list.map((service) => [service.id, service]));\n for (const service of once.values()) {\n const entry = takers.get(service.id) ?? { service, people: 0 };\n entry.people += 1;\n takers.set(service.id, entry);\n }\n }\n for (const { service, people } of takers.values()) {\n if (people > service.maxPerBooking) return service;\n }\n return null;\n}\n\n/**\n * A person's extra services, tolerating a state that predates them. This\n * package's own state always carries `extras` (`initialState`), but meda types\n * the field optional and a custom shell may hold a state saved before 0.3 —\n * reading `state.extras[i]` off that would throw at the first tap.\n */\nfunction extrasAt(state: Pick<WizardState, 'extras'>, index: number): WizardService[] {\n return state.extras?.[index] ?? [];\n}\n\n/** Person `index`'s services in order: their first, then their extras. */\nfunction servicesOf(state: WizardState, index: number): WizardService[] {\n const first = state.choices[index] ?? null;\n return first === null ? [] : [first, ...extrasAt(state, index)];\n}\n\n/** Whether a named stylist can do every service in a list. Absent ids are\n * «the caller does not know», which keeps the preference — see `stylistAfterAdd`. */\nfunction canServeAll(\n resourceServiceIds: readonly string[] | undefined,\n services: readonly WizardService[]\n): boolean {\n return (\n resourceServiceIds === undefined ||\n services.every((service) => resourceServiceIds.includes(service.id))\n );\n}\n\n/** The one stylist a seating chart names throughout, or `null` when it names\n * more than one. An empty chart has nobody, which is also `null`. */\nfunction sharedResourceId(resourceIds: string[]): string | null {\n const [first] = resourceIds;\n if (first === undefined) return null;\n return resourceIds.every((id) => id === first) ? first : null;\n}\n\n/**\n * What `addService` does to the stylist preference: nothing, or release it.\n *\n * A patch rather than a boolean, so the case below spreads one expression and\n * says what it means — and so the empty object is the ordinary answer, which is\n * the point: adding a sibling is not a change of mind about Sara.\n *\n * `resourceServiceIds` absent is «the caller could not say», and it keeps the\n * preference. That direction is deliberate: a wizard whose stylist catalogue has\n * not loaded knows nothing about who does what, and discarding an answer the\n * visitor gave on the strength of that would be a guess dressed as a safeguard.\n * The catalogue is loaded by the time a stylist can be tapped at all, so the\n * case is a caller other than the wizard rather than a race.\n */\nfunction stylistAfterAdd(action: Extract<WizardAction, { type: 'addService' }>): {\n resourceId?: null;\n stylistAnswered?: false;\n} {\n const canServe = canServeAll(action.resourceServiceIds, [action.service]);\n // Releasing the preference un-answers the question with it: the parent chose\n // Sara and the machine took her away, so the next thing that can fill the\n // blank — a «Bestill igjen» link, say — is filling a genuine blank.\n return canServe ? {} : { resourceId: null, stylistAnswered: false };\n}\n\n/**\n * A chosen slot belongs to the question it was chosen under, so every action\n * that changes that question has to drop it — the basket, the seating mode, and\n * the stylist alike. Adding a second child doubles the duration being asked for;\n * swapping the service changes it outright; naming a different stylist points it\n * at somebody else's diary. Either way the time the visitor tapped is no longer\n * a time the salon ever offered them.\n *\n * Left in place this stays invisible until the progress dots become tappable,\n * and then it is a genuinely bad bug rather than an untidy one: `canGoToStep`\n * treats any non-null `startTs` as reason enough to reach `details`, so going\n * back, changing an answer and jumping straight forward submits against a slot\n * that answered the old question — with the summary bar quoting the stale time\n * as if it were confirmed.\n *\n * This spread deliberately does NOT touch `resourceId`, because that is a\n * separate answer and each caller has its own view of it:\n *\n * - `pickResource` writes it — it *is* the new answer.\n * - `pickService` clears it, because the basket was replaced and step 2 only\n * offers stylists who can do every service in it.\n * - `setPartyMode` clears it for `parallel`, which is two stylists by\n * construction.\n * - `addService` keeps it, unless the child being added is one the named stylist\n * cannot take. A parent adding a sibling has not changed their mind about\n * Sara, so clearing unconditionally would re-ask every family in the common\n * case where she can do both — but keeping it unconditionally left step 2 with\n * nothing selected and step 3 filtered to somebody who cannot take the visit,\n * which is «Fullt» on every day of the week at a salon with a free chair.\n * The catalogue that tells the two apart is a fetch result the shell holds and\n * this file cannot reach, so it arrives on the action as\n * `resourceServiceIds`; absent, the preference is kept.\n *\n * `partyResourceIds` is the other half of a chosen slot and goes with it. Left\n * behind it would be a seating chart for a party that no longer has that many\n * children in it, and `itemResourceIds` would hand the submission a stylist for\n * a line item that does not exist.\n */\nconst clearedSlot = { startTs: null, resolvedResourceId: null, partyResourceIds: null };\n\n/** The guest child in chair `n` (1-based) — a chip on step 1, or a sibling a link added. */\nexport function guestChild(n: number): WizardPerson {\n return { key: `guest:${n}` };\n}\n\n/** A person as a line item: the services, and what is known about who sits in the chair. */\nfunction itemFor(\n person: WizardPerson,\n service: WizardService,\n extras: readonly WizardService[]\n): WizardItem {\n return {\n service,\n // Absent rather than `[]`, the rule every optional field here follows: a\n // one-service line has to look exactly as it did before visits had extras.\n ...(extras.length === 0 ? {} : { extraServices: [...extras] }),\n ...(person.name === undefined ? {} : { bookedForName: person.name }),\n ...(person.birthYear === undefined ? {} : { bookedForBirthYear: person.birthYear }),\n ...(person.personId === undefined ? {} : { bookedForPersonId: person.personId }),\n ...(person.birthMonth === undefined ? {} : { bookedForBirthMonth: person.birthMonth }),\n ...(person.adult ? { adult: true } : {}),\n };\n}\n\n/** Every person's whole visit, so an extra added or removed reads as a changed\n * basket — a Vask after the Klipp makes the visit fifteen minutes longer, and\n * the slot chosen for the shorter one is no longer a slot anyone offered. */\nfunction basketOf(items: readonly WizardItem[]): string {\n return items.map((item) => visitKey(item)).join('|');\n}\n\n/**\n * `people`, `choices` and `extras` in, the basket out — the one place `items`\n * is built from them. A basket whose services changed drops the slot, for the\n * reason `clearedSlot` gives; one that only changed WHO (a name typed on\n * «Bekreft», say) keeps it.\n *\n * THE ALIGNMENT RULE: `extras[i]` belongs to `people[i]`, exactly as\n * `choices[i]` does. Every action that rebuilds or reorders `people` either\n * carries each surviving person's extras with them — matched by `key`, the way\n * `choosePeople` and `seatFamily` carry `choices` — or resets them to `[]`\n * where the person's first service is replaced by a one-tap pick: `pickService`\n * replacing the basket clears every extra, `pickServiceFor` clears that\n * person's, because a one-tap card means «this, and only this». A person added\n * by a link or a sibling chip starts on `[]`. Required rather than defaulted, so\n * no caller can drop a person's extras by forgetting to mention them.\n *\n * A person with no first service has no extras either, whatever was passed:\n * an extra never stands alone.\n */\nfunction withParty(\n state: WizardState,\n people: WizardPerson[],\n choices: Array<WizardService | null>,\n extras: ReadonlyArray<readonly WizardService[]>\n): WizardState {\n const aligned = people.map((_, index) => (choices[index] ? [...extras[index]] : []));\n const complete = people.length > 0 && choices.every((choice) => choice !== null);\n const items = complete\n ? people.map((person, index) =>\n itemFor(person, choices[index] as WizardService, aligned[index])\n )\n : [];\n const changed = basketOf(items) !== basketOf(state.items);\n return { ...state, people, choices, extras: aligned, items, ...(changed ? clearedSlot : {}) };\n}\n\n/** The people behind a basket built without step 1 — a link, a restored draft. */\nfunction peopleFor(state: WizardState, count: number): WizardPerson[] {\n return Array.from({ length: count }, (_, index) => state.people[index] ?? guestChild(index + 1));\n}\n\n/**\n * One field of one line item, written the way the engine will have to read it:\n * **absent**, never blank.\n *\n * `booked_for_name` is `z.string().trim().min(1).optional()` upstream, where\n * `''` is a 400 rather than a shrug, and `booked_for_birth_year` is\n * `z.number().optional()`, which refuses the `null` an emptied input serialises\n * to. Storing the emptiness here and coalescing it at the edge would hold\n * exactly until something other than the create route read these items — the\n * confirmation card already does, to write «Gutteklipp for Jonas» — so the\n * emptiness never gets in.\n */\nfunction withItemField(\n item: WizardItem,\n action: Extract<WizardAction, { type: 'setItemField' }>\n): WizardItem {\n const next = { ...item };\n if (action.field === 'bookedForPersonId') {\n if (action.value !== null && action.value !== '') next.bookedForPersonId = action.value;\n else delete next.bookedForPersonId;\n return next;\n }\n if (action.field === 'bookedForName') {\n const trimmed = action.value.trim();\n if (trimmed.length > 0) next.bookedForName = trimmed;\n else delete next.bookedForName;\n return next;\n }\n // `Number.isFinite` rather than a truthiness test: a birth year is never 0,\n // but `NaN` is exactly what parsing «tjue» out of a text field yields, and it\n // passes every looser check on its way to a body the engine rejects.\n if (action.value !== null && Number.isFinite(action.value)) {\n next.bookedForBirthYear = action.value;\n } else {\n delete next.bookedForBirthYear;\n }\n return next;\n}\n\n/** The typed answers about a guest child, carried back onto their person. */\nfunction withPersonFields(person: WizardPerson, item: WizardItem): WizardPerson {\n const { name: _name, birthYear: _year, personId: _id, ...rest } = person;\n return {\n ...rest,\n ...(item.bookedForName === undefined ? {} : { name: item.bookedForName }),\n ...(item.bookedForBirthYear === undefined ? {} : { birthYear: item.bookedForBirthYear }),\n ...(item.bookedForPersonId === undefined ? {} : { personId: item.bookedForPersonId }),\n };\n}\n\n/** `seatFamily` — see the action. */\nfunction seatFamily(state: WizardState, keep: readonly string[] = []): WizardState {\n const drops = (person: WizardPerson) => isGuestSeat(person) && !keep.includes(person.key);\n if (state.step !== 'who' && state.step !== 'service') return state;\n if (!state.people.some(drops)) return state;\n const people: WizardPerson[] = [];\n const choices: Array<WizardService | null> = [];\n const extras: WizardService[][] = [];\n state.people.forEach((person, index) => {\n const seat = person.key === 'adult' ? { key: SELF_KEY, adult: true } : person;\n if (drops(seat) || people.some((other) => other.key === seat.key)) return;\n people.push(seat);\n choices.push(state.choices[index] ?? null);\n extras.push(extrasAt(state, index));\n });\n return {\n ...withParty(state, people, choices, extras),\n step: people.length === 0 ? 'who' : state.step,\n error: null,\n };\n}\n\n/** `unseatPeople` — see the action. */\nfunction unseatPeople(\n state: WizardState,\n keys: readonly string[],\n into: Readonly<Record<string, WizardPerson>> = {}\n): WizardState {\n const leaving = state.people.map((person) => keys.includes(person.key));\n if (!leaving.includes(true)) return state;\n const taken = new Set(state.people.map((person) => person.key));\n let n = 0;\n const people = state.people.map((person, index) => {\n if (!leaving[index]) return person;\n const own = into[person.key];\n if (own !== undefined && !taken.has(own.key)) {\n taken.add(own.key);\n return own;\n }\n let seat = guestChild(++n);\n while (taken.has(seat.key)) seat = guestChild(++n);\n taken.add(seat.key);\n return seat;\n });\n // The lines that stay keep what «Bekreft» was already told about them; the\n // unseated ones are rebuilt from their new seat, with the same services.\n // A party without a whole basket yet has no lines to rebuild.\n const items = state.items.map((item, index) =>\n leaving[index] ? itemFor(people[index], item.service, item.extraServices ?? []) : item\n );\n return { ...state, people, items, error: null };\n}\n\n/** `pickServiceFor` — see the action. */\nfunction pickServiceFor(\n state: WizardState,\n action: Extract<WizardAction, { type: 'pickServiceFor' }>\n): WizardState {\n if (!state.people[action.index] && !(action.index === 0 && state.people.length === 0)) {\n return state;\n }\n // A party of one is the old one-tap service card: select and move on.\n if (state.people.length <= 1) {\n const person = state.people[0] ?? guestChild(1);\n return {\n ...withParty(state, [person], [action.service], [[]]),\n ...clearedSlot,\n resourceId: null,\n stylistAnswered: false,\n step: 'when',\n error: null,\n };\n }\n // A family: judged on the lists as they would be after the tap — this person\n // on the one service, everybody else as they are — so a grown-up's\n // one-at-a-time cut is refused only when somebody else already has it.\n const lists = state.people.map((_, index) =>\n index === action.index ? [action.service] : servicesOf(state, index)\n );\n if (overServiceCap(lists) !== null) return { ...state, error: 'maxParty' };\n const choices = state.choices.map((choice, index) =>\n index === action.index ? action.service : choice\n );\n // A one-tap card is «this, and only this»: the person's extras go with the\n // service they followed.\n const extras = state.people.map((_, index) =>\n index === action.index ? [] : extrasAt(state, index)\n );\n const canServe = canServeAll(action.resourceServiceIds, [action.service]);\n return {\n ...withParty(state, state.people, choices, extras),\n ...(canServe ? {} : { resourceId: null, stylistAnswered: false }),\n error: null,\n };\n}\n\n/** The wizard in order. Both `canGoToStep` and the progress dots read the\n * sequence from here rather than restating it, so a fourth step cannot be added\n * to one of them and forgotten in the other. */\nconst STEP_ORDER: readonly WizardStep[] = ['who', 'service', 'when', 'details'];\n\n/**\n * Whether a given step has been answered. One definition, asked two ways —\n * `canAdvance` asks it about the step the visitor is on, `canGoToStep` asks it\n * about the ones behind the step they want. Keeping it in a single function is\n * what stops \"step 3 is done\" from meaning one thing to the Neste button and\n * another to the progress dots.\n */\nfunction isSatisfied(state: WizardState, step: WizardStep): boolean {\n switch (step) {\n case 'who':\n // A basket built without step 1 — a link, a restored draft — has its\n // people too (`peopleFor`), so this is never the thing that shuts it.\n return state.people.length > 0 || state.items.length > 0;\n\n case 'service':\n return state.items.length > 0;\n\n case 'when':\n // The hour, and only the hour. The stylist half of this step has no\n // unanswered state — `null` is «Første ledige», which is both the default\n // and the choice the salon would rather you made — so a visit is settled\n // the moment an instant has been tapped.\n return state.startTs !== null;\n\n case 'details':\n // Trimmed: a phone field holding only the +47 prefix and a space is not a\n // phone number, and it is exactly what an abandoned form leaves behind.\n return state.contact.phone.trim().length > 0 && state.consentTerms;\n }\n}\n\n/**\n * True when the visitor may leave the current step — and on `details`, when they\n * may submit at all. Answers about `state.step` only, so a Neste button can be\n * `disabled={!canAdvance(state)}` and nothing else.\n */\nexport function canAdvance(state: WizardState): boolean {\n return isSatisfied(state, state.step);\n}\n\n/**\n * True when a step may be jumped to directly — the predicate behind whether a\n * progress dot is tappable, and the guard `goToStep` applies to itself.\n *\n * Bounded by what is *behind* the target, never the target itself: reaching\n * `details` requires a service and a time, but obviously not the contact\n * details you are going there to type. Going back is therefore always allowed,\n * because nothing precedes `service` — which is the case that matters, since\n * back is what the dots are mostly for.\n */\nexport function canGoToStep(state: WizardState, step: WizardStep): boolean {\n const index = STEP_ORDER.indexOf(step);\n // A step that is not in the order — a stale caller still naming the old\n // `login` step, say — is unreachable rather than `slice(0, -1)`: that would\n // ask about every step but the last and wave it through.\n if (index === -1) return false;\n return STEP_ORDER.slice(0, index).every((prior) => isSatisfied(state, prior));\n}\n\n/**\n * When each child in the party sits down.\n *\n * `sequential` is the report's default and the ordinary case — one stylist,\n * «rett etter hverandre», so the second child starts when the first is\n * finished. `parallel` is two stylists at once, and every line starts together.\n *\n * The engine takes a `start_ts` per line item rather than one for the visit, so\n * somebody has to do this arithmetic, and it is a rule about the booking rather\n * than about a screen. Step 4 submits with it and the confirmation card reads\n * «15:00 Jonas / 15:30 Emma» off the same function, which is the only way those\n * two can be made to agree.\n *\n * Task 9 owns the slot SEARCH for a party; this is only the seating of a slot\n * already chosen.\n */\nexport function itemStartTimes(\n items: WizardItem[],\n startTs: number,\n partyMode: WizardState['partyMode']\n): number[] {\n let offset = 0;\n return items.map((item, index) => {\n // The gap between two children on one chair is not the first cut's length.\n // It is that length PLUS the salon's cleanup after it PLUS the prep before\n // the next one — the interval the engine actually reserves. Advancing by\n // the duration alone seats the second child inside the first one's buffer,\n // and because availability is asked per service and each opening judged on\n // its own, both instants come back free while the engine refuses the\n // combined submission. The family is offered a visit that cannot be made,\n // and offered it again every time they try.\n //\n // Per VISIT, not per service: a person with Klipp + Vask holds the chair for\n // both, and the cleanup that matters is the one after their LAST service.\n if (partyMode === 'sequential' && index > 0) {\n const previous = visitOf(items[index - 1]);\n offset +=\n (previous.durationMinutes +\n previous.bufferAfterMinutes +\n visitOf(item).bufferBeforeMinutes) *\n 60_000;\n }\n return startTs + offset;\n });\n}\n\n/**\n * How long the family is in the salon — the number the summary bar quotes as\n * «60 min totalt» and the length of the .ics entry.\n *\n * The two modes are two different visits: `sequential` is one chair used twice,\n * so the minutes add up, while `parallel` is two chairs at once and the family\n * leaves when the *longest* cut finishes. Quoting the sum for a parallel party\n * would tell a parent to set aside an hour for a visit that takes half of one,\n * which is the whole reason «To frisører samtidig» is worth offering.\n *\n * `Math.max` seeded with 0 rather than spread over the durations alone:\n * `Math.max()` of nothing is `-Infinity`, and an empty basket is a visit of no\n * length rather than a negative one.\n */\nexport function visitMinutes(items: WizardItem[], partyMode: WizardState['partyMode']): number {\n // Each person's whole visit — every service they chose, back to back.\n const visits = items.map((item) => visitOf(item));\n if (partyMode === 'parallel') {\n return Math.max(0, ...visits.map((visit) => visit.durationMinutes));\n }\n // The inner gaps count, for the same reason `itemStartTimes` applies them: the\n // family is in the salon from the first cut starting to the last one ending,\n // and the second child cannot sit down until the chair is ready. The buffer\n // AFTER the last child is not part of it — they have left by then.\n return visits.reduce(\n (total, visit, index) =>\n total +\n visit.durationMinutes +\n (index === 0 ? 0 : visits[index - 1].bufferAfterMinutes + visit.bufferBeforeMinutes),\n 0\n );\n}\n\n/**\n * How much EARLIER a family has to start than a single first child would.\n *\n * The schedule endpoint is asked for the FIRST person's whole visit — their\n * service plus its extras — so `lastStartTs` is «the last start that visit\n * fits»: the last minute at which its prep, every service's minutes and its\n * cleanup all land inside the day's window. A family needs its whole chain to\n * fit, so its own last start is earlier by exactly the part of the chain that\n * hangs off the end of the first person's whole busy span — everybody after\n * them.\n *\n * NOT `visitMinutes - firstDuration`, which was the first attempt and is wrong\n * in a way that only shows on a mixed basket: `visitMinutes` deliberately\n * excludes the LAST child's cleanup — the family has left by then — so the\n * subtraction silently keeps the FIRST child's cleanup in place of the last\n * one's. Where the later service has the bigger `bufferAfterMinutes` the cutoff\n * comes out too late, which is the error in the direction that matters: it says\n * «Fullt» about an afternoon that is actually over.\n *\n * Sequential is the tail of the chain: every child after the first costs their\n * prep, their duration and their cleanup, and the first child's own cleanup is\n * already inside the number being adjusted. Parallel is one chair each, so what\n * has to fit is the LONGEST busy span rather than the sum.\n */\nexport function visitTailMinutes(items: WizardItem[], partyMode: WizardState['partyMode']): number {\n const first = items[0];\n if (first === undefined || items.length < 2) return 0;\n // A person's busy span is their whole visit: the first service's prep, every\n // service's minutes, the last service's cleanup.\n const busy = (item: WizardItem) => {\n const visit = visitOf(item);\n return visit.bufferBeforeMinutes + visit.durationMinutes + visit.bufferAfterMinutes;\n };\n\n if (partyMode === 'parallel') {\n return Math.max(0, Math.max(...items.map(busy)) - busy(first));\n }\n return items.slice(1).reduce((total, item) => total + busy(item), 0);\n}\n\n/** When the family leaves. The same arithmetic as `visitMinutes`, which is why\n * it is that function and not a second copy of it: an .ics that ended before\n * `visitMinutes` said the visit did would tell a calendar the parent was free\n * while a child was still in the chair. */\nexport function visitEndTs(\n items: WizardItem[],\n startTs: number,\n partyMode: WizardState['partyMode']\n): number {\n return startTs + visitMinutes(items, partyMode) * 60_000;\n}\n\n/**\n * Whether step 2 asks how the party is seen.\n *\n * One child is not a party, and «Samme frisør, rett etter hverandre» is not a\n * choice when there is nobody to be after. The machine answers rather than the\n * component so that the picker and the search cannot disagree about when a\n * booking has become a family visit.\n */\nexport function showsPartyMode(state: WizardState): boolean {\n return state.items.length > 1;\n}\n\n/**\n * Who is cutting whose hair, one answer per line item.\n *\n * The single reader for both shapes the machine can be in: a party slot names a\n * stylist per child, and everything else has one answer for the whole visit —\n * including `null`, which is «Første ledige» still to be resolved by the engine.\n *\n * Step 4's submission and the confirmation card both read it, which is the only\n * way «hos Marcus» on the card can be guaranteed to be the `resource_id` that\n * was actually booked for that child.\n */\nexport function itemResourceIds(state: WizardState): Array<string | null> {\n return state.partyResourceIds ?? state.items.map(() => state.resolvedResourceId);\n}\n\n/**\n * The stylist, as a person rather than as a row id.\n *\n * The machine holds ids — that is what step 2 chose and what the availability\n * query filters on — so naming one takes the catalogue, which is a fetch result\n * and not a rule. Hence the resolver: the caller already has the stylists it\n * rendered on step 2, and an id it cannot name comes back `null` rather than\n * putting `res-7f3a…` under the visitor's thumb.\n *\n * `resolvedResourceId` wins where both are set, because it is who the visitor\n * actually got. That is also what fills this part in for «Første ledige», which\n * is a null preference and stays a dash until a slot resolves it to somebody.\n */\nfunction stylistLabel(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null\n): string | null {\n const resourceId = state.resolvedResourceId ?? state.resourceId;\n return resourceId === null ? null : resolveStylistName(resourceId);\n}\n\nexport type WizardConfig = Pick<\n BookingConfig,\n | 'party'\n | 'categories'\n | 'fallbackCategory'\n | 'timeZone'\n | 'locale'\n | 'currency'\n | 'dayparts'\n | 'labels'\n>;\n\nexport interface Wizard {\n /**\n * The most chairs one visit can take (`party.maxPeople`). The business's own\n * ceiling is per service (`maxPerBooking`), and step 2 still honours it; this\n * is the ceiling of the question on step 1, before any service is known.\n */\n readonly maxPeople: number;\n initialState: typeof initialState;\n reduce(state: WizardState, action: WizardAction): WizardState;\n applyPrefill(state: WizardState, prefill: WizardPrefill, catalogue: WizardCatalogue): WizardState;\n canAdvance: typeof canAdvance;\n canGoToStep: typeof canGoToStep;\n itemPriceOre(service: WizardService, startTs: number | null): number;\n /** One person's whole visit: every service priced on its own, then summed. */\n visitItemPriceOre(item: WizardItem, startTs: number | null): number;\n totalPriceOre(items: WizardItem[], startTs: number | null): number;\n itemStartTimes: typeof itemStartTimes;\n visitMinutes: typeof visitMinutes;\n visitTailMinutes: typeof visitTailMinutes;\n visitEndTs: typeof visitEndTs;\n showsPartyMode: typeof showsPartyMode;\n itemResourceIds: typeof itemResourceIds;\n summaryLine(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now?: number\n ): string;\n /**\n * `summaryLine`'s words in two parts, for a bar with a second line: `line`\n * is what (and with whom), `detail` when and for how much. The same parts\n * as the one-line form, so the two can never disagree.\n */\n summaryParts(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now?: number\n ): { line: string; detail: string };\n guestChild: typeof guestChild;\n isGuestSeat: typeof isGuestSeat;\n}\n\n/** The machine for one site: its party ceiling, kids' categories, clock, currency and words. */\nexport function createWizard(config: WizardConfig): Wizard {\n const maxPeople = config.party.maxPeople;\n const maxServices = config.party.maxServicesPerPerson;\n const clock = createClock(config);\n const money = createMoney(config);\n const labels = resolveLabels(config.locale, config.labels);\n\n /** `choosePeople` — see the action. */\n function choosePeople(\n state: WizardState,\n action: Extract<WizardAction, { type: 'choosePeople' }>\n ): WizardState {\n if (action.people.length > maxPeople) return { ...state, error: 'maxParty' };\n // A person who stays keeps their service; a new one has none yet.\n const before = (person: WizardPerson) =>\n state.people.findIndex((previous) => previous.key === person.key);\n const choices = action.people.map((person, position) => {\n const index = before(person);\n if (index !== -1) return state.choices[index] ?? null;\n // The link the parent followed beats «Samme som sist»: it is what they\n // asked for a moment ago.\n return heldFor(state.pendingService, person) ?? action.services?.[position] ?? null;\n });\n // Extras follow their person by the same key; a new person has none — the\n // held link and «Samme som sist» are one service each.\n const extras = action.people.map((person) => {\n const index = before(person);\n return index === -1 ? [] : extrasAt(state, index);\n });\n // A guest chair that stays keeps what «Bekreft» was told about it: going\n // back and tapping «2 barn» again must not forget the names typed.\n const people = action.people.map((person) => {\n const index = before(person);\n return index === -1 || person.personId !== undefined\n ? person\n : { ...state.people[index], ...person };\n });\n // The stylist preference stays: every service chosen for a NEW person goes\n // through `pickServiceFor`, which releases a stylist who cannot do it —\n // adding a sibling is not a change of mind about Sara.\n return {\n ...withParty(state, people, choices, extras),\n step: action.advance ? 'service' : state.step,\n error: null,\n };\n }\n\n /** The held link service, if it suits this person: kids' cuts for children only. */\n function heldFor(service: WizardService | null, person: WizardPerson): WizardService | null {\n if (service === null) return null;\n const forChildren = isChildCategory(config, service.category);\n return forChildren === !person.adult ? service : null;\n }\n\n /** `addPerson` — see the action. */\n function addPerson(state: WizardState, person: WizardPerson): WizardState {\n if (state.people.some((other) => other.key === person.key)) return state;\n if (state.people.length >= maxPeople) return { ...state, error: 'maxParty' };\n return choosePeople(state, { type: 'choosePeople', people: [...state.people, person] });\n }\n\n /** `toggleServiceFor` — see the action. */\n function toggleServiceFor(\n state: WizardState,\n action: Extract<WizardAction, { type: 'toggleServiceFor' }>\n ): WizardState {\n const seated = state.people[action.index] !== undefined;\n if (!seated && !(action.index === 0 && state.people.length === 0)) return state;\n // Nobody seated yet is the same guest child `pickServiceFor` seats — a\n // service card tapped before step 1 was ever answered.\n const people = seated ? state.people : [guestChild(1)];\n const lists = people.map((_, index) => (seated ? servicesOf(state, index) : []));\n const current = lists[action.index];\n const has = current.some((service) => service.id === action.service.id);\n\n let next: WizardService[];\n if (has) {\n next = current.filter((service) => service.id !== action.service.id);\n } else {\n // Both refusals change nothing but the error, `addService`'s rule: the\n // services already chosen are exactly as the visitor left them.\n if (current.length >= maxServices) return { ...state, error: 'maxServices' };\n next = [...current, action.service];\n const after = lists.map((list, index) => (index === action.index ? next : list));\n if (overServiceCap(after) !== null) return { ...state, error: 'maxParty' };\n }\n\n // The head of the list is the person's `choices` entry — removing the first\n // service promotes the next — and an emptied list is an unanswered person,\n // which empties the basket the way any unanswered person does.\n const choices = people.map((_, index) =>\n index === action.index ? (next[0] ?? null) : (state.choices[index] ?? null)\n );\n const extras = people.map((_, index) =>\n index === action.index ? next.slice(1) : extrasAt(state, index)\n );\n // The named stylist has to do the person's whole list, as `addService` has\n // it for a whole basket; absent ids keep the preference.\n const canServe = canServeAll(action.resourceServiceIds, next);\n return {\n ...withParty(state, people, choices, extras),\n // Always, not only when the basket key changed: a toggle is a change to\n // the question the slot answered, even one that ends where it began.\n ...clearedSlot,\n ...(canServe ? {} : { resourceId: null, stylistAnswered: false }),\n error: null,\n };\n }\n\n function reduce(state: WizardState, action: WizardAction): WizardState {\n switch (action.type) {\n case 'startOver':\n // `initialState()` rather than a hand-written blank: a field added there\n // and forgotten here is one the second booking would silently inherit\n // from the first.\n return initialState();\n\n case 'choosePeople':\n return choosePeople(state, action);\n\n case 'pickServiceFor':\n return pickServiceFor(state, action);\n\n case 'toggleServiceFor':\n return toggleServiceFor(state, action);\n\n case 'seatFamily':\n return seatFamily(state, action.keep);\n\n case 'unseatPeople':\n return unseatPeople(state, action.keys, action.into);\n\n case 'addPerson':\n return addPerson(state, action.person);\n\n case 'holdService':\n return { ...state, pendingService: action.service };\n\n case 'pickService':\n // One tap, not two: tapping a card both selects and advances. Picking\n // *replaces* the basket so that changing your mind on step 1 cannot\n // quietly leave the previous service booked alongside the new one.\n //\n // And the stylist goes with it, unlike every other user of `clearedSlot`.\n // Step 2 only offers stylists who can do EVERY service in the basket, so a\n // preference recorded against the old one may name somebody the new list\n // does not contain: the card is gone from the screen, nothing reads as\n // selected, and «Neste» is enabled anyway — because `isSatisfied` treats\n // any answer as an answer. The visitor lands on step 3 with availability\n // filtered to a stylist who cannot do this service, sees «Fullt» on every\n // day of the week, and is handed the telephone by a salon that is open.\n //\n // `null` is «Første ledige», which is both the default and the answer the\n // salon would rather they gave.\n //\n // Every extra goes too: a one-tap card is «this, and only this».\n return {\n ...withParty(state, peopleFor(state, 1), [action.service], [[]]),\n ...clearedSlot,\n resourceId: null,\n stylistAnswered: false,\n step: 'when',\n error: null,\n };\n\n case 'addService':\n // The service being added counts with the basket — a fourth child on a\n // `maxPerBooking: 3` cut is refused at the tap rather than at submit —\n // and so does the party ceiling: with the per-service rule a basket of\n // DIFFERENT services is no longer bounded by any one of their caps, and\n // step 1 would never have seated that many people.\n if (\n state.items.length >= maxPeople ||\n overServiceCap([...state.items.map(visitServicesOf), [action.service]]) !== null\n ) {\n // \"Changes nothing else\" is the whole point: a refused fourth child\n // must leave the three already chosen exactly as they were.\n return { ...state, error: 'maxParty' };\n }\n // No step change — adding a sibling happens on step 1, and yanking the\n // visitor forward mid-basket is how the third child gets lost.\n return {\n ...withParty(\n state,\n peopleFor(state, state.items.length + 1),\n [...state.items.map((item) => item.service), action.service],\n // Everybody already in the basket keeps their visit; the newcomer\n // has the one service they were added with.\n [...state.items.map((item) => item.extraServices ?? []), []]\n ),\n ...clearedSlot,\n // The named stylist has to cover the whole basket, and the basket just\n // grew. A stylist who cannot do the new child's service is one step 2\n // will no longer offer, so keeping them would leave the step showing\n // nothing selected while step 3 filtered every day to their empty diary.\n // Back to «Første ledige», which is where step 2 opens anyway.\n ...stylistAfterAdd(action),\n error: null,\n };\n\n case 'pickResource':\n // Re-affirming the answer already on screen is not a change of mind, and\n // must not cost the visitor the time they picked under it. Tapping the\n // highlighted card is how a parent says «yes, still Sara, carry on» —\n // step 2 keeps its selection when they come back to it, so it is the\n // obvious way forward from there and it happens often.\n if (action.resourceId === state.resourceId) {\n return { ...state, stylistAnswered: true, step: 'when', error: null };\n }\n // Otherwise the slot goes with the stylist, for the same reason it goes\n // with the service. A time chosen from Sara's openings is Sara's time;\n // changing to Marcus leaves an instant nobody ever offered for him — and\n // because `isSatisfied('when')` asks only whether `startTs` is non-null,\n // the surviving one made `details` reachable again. Going back, choosing a\n // different stylist and jumping straight forward would then submit the old\n // slot with the old stylist: somebody the parent did not choose, at a time\n // they were never shown for them.\n //\n // `resourceId` is this action's own answer, written onto the same object\n // after the spread, so `clearedSlot` leaving it alone costs nothing here.\n return {\n ...state,\n ...clearedSlot,\n resourceId: action.resourceId,\n stylistAnswered: true,\n step: 'when',\n error: null,\n };\n\n case 'pickSlot':\n // Two fields rather than one, and both earn their keep today. `resourceId`\n // holds the visitor's *preference* (null = «Første ledige»), which is what\n // step 2 renders as selected on a `goToStep` back and what filters the\n // availability query; `resolvedResourceId` holds the stylist this\n // particular slot turned out to be with, and is what gets submitted.\n // Collapsing them would mean a «Første ledige» booking silently pinning\n // itself to whoever happened to be free first, and a `slotTaken` — which\n // clears the resolution but keeps the preference — having nothing left to\n // re-query with.\n //\n // A family seated side by side cannot take a one-stylist slot: one\n // stylist cannot cut two children at once, and `pickPartySlot` is the\n // action that seats them. The wizard never sends this (a party's time\n // step offers party slots only); a headless caller that does gets the\n // answer every other caller bug gets — the identical object.\n if (state.partyMode === 'parallel' && state.items.length > 1) return state;\n // A regular slot replaces a party slot whole: its seating chart was the\n // other half of THAT slot, and left behind it would submit every child\n // to the stylists seated for a time the visitor has just changed.\n return {\n ...state,\n startTs: action.startTs,\n resolvedResourceId: action.resourceId,\n partyResourceIds: null,\n step: 'details',\n error: null,\n };\n\n case 'setPartyMode': {\n // A site that seats families one after another only (`party.allowParallel`)\n // has no parallel mode to switch to.\n if (action.mode === 'parallel' && !config.party.allowParallel) return state;\n // «To frisører samtidig» and «jeg vil til Sara» cannot both be true — one\n // stylist cannot cut two children at once — so choosing the parallel mode\n // drops the named preference rather than leaving step 2 showing a stylist\n // the search is quietly ignoring. Every slot it could find would be with\n // somebody else.\n //\n // Sequential does not restore it: the machine has nowhere to remember a\n // preference it has already discarded, and «Første ledige» is both the\n // default and the choice the salon would rather the visitor made.\n // «To frisører samtidig» clears the preference and ANSWERS the question at\n // the same time: it is a deliberate statement about who cuts the hair, so\n // a link naming one stylist must not fill the blank it leaves.\n const preference =\n action.mode === 'parallel' ? { resourceId: null, stylistAnswered: true } : {};\n // The slot goes for the ordinary reason: a slot found «rett etter\n // hverandre» seats the second child half an hour after the first, and the\n // same hour in parallel seats them together with a different stylist. The\n // instant may survive the switch; the seating never does.\n return { ...state, ...clearedSlot, ...preference, partyMode: action.mode, error: null };\n }\n\n case 'pickPartySlot':\n if (action.mode === 'parallel' && !config.party.allowParallel) return state;\n // A seating chart that names a different number of children than the\n // basket holds is the caller's bug, and gets the answer `setItemField`\n // gives one: the identical object. Booking on it would submit a stylist\n // for a child who is not coming, or none for one who is.\n if (action.resourceIds.length !== state.items.length) return state;\n return {\n ...state,\n startTs: action.startTs,\n partyResourceIds: action.resourceIds,\n // The mode of the slot that was actually tapped, which is not always\n // the mode the visitor chose on step 2 — accepting step 3's parallel\n // alternative is a change of mind about both at once.\n partyMode: action.mode,\n // The whole visit's stylist where there is one — a sequential party is\n // one person, back to back — and `null` where there is not, which is\n // every parallel party. A parallel visit has two stylists and naming\n // either of them as *the* one would be false on the confirmation card.\n resolvedResourceId: sharedResourceId(action.resourceIds),\n step: 'details',\n error: null,\n };\n\n case 'slotTaken':\n // The resolved stylist belonged to the slot that just vanished. Carrying\n // it forward would submit an assignment nobody ever offered — and for a\n // party that is a whole seating chart, not one id.\n return {\n ...state,\n startTs: null,\n resolvedResourceId: null,\n partyResourceIds: null,\n step: 'when',\n error: 'slotTaken',\n };\n\n case 'submitFailed':\n // Nothing else moves. The visitor is standing on step 4 with a button they\n // have just pressed, and every one of these four is either theirs to fix\n // in the form or ours to fix upstream — neither is a reason to throw away\n // the slot they chose, which is what leaving `details` would do.\n return { ...state, error: action.error };\n\n case 'goToStep':\n // Refused jumps return the *same object*, not a copy: the progress dots\n // decide whether they are tappable with `canGoToStep`, so arriving here\n // with an unreachable step is a bug in the caller rather than something\n // the visitor did, and there is no error copy to show for it. Returning\n // the identical reference also means React re-renders nothing.\n if (!canGoToStep(state, action.step)) return state;\n return { ...state, step: action.step, error: null };\n\n case 'setContact':\n return {\n ...state,\n contact: { ...state.contact, [action.field]: action.value },\n error: null,\n };\n\n case 'setItemField': {\n // An index that names no line item is the caller's bug, and the same\n // answer `goToStep` gives one: the identical object, so nothing re-renders\n // and no phantom item is conjured at index 7 of a party of one.\n if (!state.items[action.index]) return state;\n const item = withItemField(state.items[action.index], action);\n return {\n ...state,\n items: state.items.map((current, index) => (index === action.index ? item : current)),\n // Mirrored onto the person, so a basket rebuilt from `people` — a\n // service changed on step 2 after a name was typed — keeps the name.\n people: state.people.map((person, index) =>\n index === action.index ? withPersonFields(person, item) : person\n ),\n error: null,\n };\n }\n\n case 'setNotes':\n return { ...state, notes: action.value, error: null };\n\n case 'setConsent':\n // Spread of one key or the other, rather than two cases: the two boxes\n // differ only in which flag they set, and the shape below makes it plain\n // that ticking the marketing box cannot touch the terms flag — which is\n // the confusion that would actually matter, since terms gate submit.\n return {\n ...state,\n ...(action.which === 'terms'\n ? { consentTerms: action.accepted }\n : { consentMarketing: action.accepted }),\n error: null,\n };\n\n case 'prefill':\n return applyPrefill(state, action.prefill, action.catalogue);\n }\n // No `default`. The switch is exhaustive over `WizardAction`, so a new action\n // added later fails to compile here instead of silently doing nothing.\n }\n\n /** The children a family link asked for (`?antall=` also applies to a\n * «Bestill igjen» `?service=` link, intentionally), up to what the salon allows and only\n * on a kids' service. Through `addService`, so the party limit is the machine's\n * own and a clamped request raises no error. */\n function withLinkedParty(\n state: WizardState,\n service: WizardService,\n party?: number | null\n ): WizardState {\n if (!party || !isChildCategory(config, service.category)) return state;\n const wanted = Math.min(party, Math.max(1, service.maxPerBooking));\n let next = state;\n while (next.items.length < wanted) {\n const after = reduce(next, { type: 'addService', service });\n // A refusal leaves the basket as it was; stop rather than spin. The\n // service's own limit is already in `wanted`, but the site's party ceiling\n // (`party.maxPeople`) is not: «?antall=3» at a site that seats two stops\n // at two, without an error, because the link asked and nobody tapped.\n if (after.items.length === next.items.length) break;\n next = after;\n }\n return next;\n }\n\n /**\n * A «Bestill igjen» link, folded into the state — so a parent re-booking from\n * `/min-side` opens the wizard with the service, the stylist and the child\n * already answered and only the time left to pick.\n *\n * FILLS BLANKS, NEVER OVERWRITES. Each of the three answers is written only\n * where the visitor has not given one: the service into an empty basket, the\n * name onto a line that has none, the stylist while step 2 is still the open\n * question — nothing answered (`stylistAnswered`, because «Alle» tapped and\n * «Alle» never touched are the same `null`), no slot chosen, and the visitor\n * still ON that step. This is not caution for its own sake. The stylist list is fetched\n * FOR a service, so it does not exist at the moment the link is read; the shell\n * therefore applies the same prefill twice — once with no stylists, once when\n * they land — and in between the visitor may have tapped anything. A second\n * pass that only fills blanks finishes what the first began and cannot undo a\n * tap. It also makes the function idempotent, which is what lets the shell not\n * care whether it has already run.\n *\n * NOTHING IS TRUSTED BEYOND THE CATALOGUE. The link may be a year old: the\n * stylist may have left, the service may have been re-created under a new id.\n * An id the catalogue does not hold is ignored — silently, because there is no\n * error a parent could act on, and the wizard they are left with is the\n * ordinary one. A stylist is accepted only if they can do EVERY service in the\n * basket, which is the rule `StylistStep` filters by; accepting them on the\n * link's word alone would leave step 2 with nothing selected and step 3\n * filtered to a diary that cannot take the visit — «Fullt» on every day of the\n * week at a salon with a free chair, the very failure `pickService` exists to\n * prevent.\n *\n * WHERE IT LANDS is the earliest step with a question still open: nothing\n * applied → wherever the visitor was; a service → step 2, because «who» has\n * not been answered even though «Første ledige» would be a valid default; a\n * stylist too → step 3. Those are the steps the machine's own `pickService` and\n * `pickResource` move to, and this function reaches them by dispatching those\n * actions rather than by writing `step` itself — so a prefilled basket obeys\n * every rule a tapped one does, including the slot-clearing ones.\n *\n * Returns the IDENTICAL object when nothing applies, for the same reason\n * `goToStep` does: React then re-renders nothing, and a test can tell «ignored»\n * from «rewritten to look the same».\n */\n function applyPrefill(\n state: WizardState,\n prefill: WizardPrefill,\n catalogue: WizardCatalogue\n ): WizardState {\n let next = state;\n\n if (next.items.length === 0 && prefill.serviceId) {\n const service = catalogue.services.find((entry) => entry.id === prefill.serviceId);\n if (service !== undefined) {\n next = reduce(next, { type: 'pickService', service });\n next = withLinkedParty(next, service, prefill.party);\n }\n }\n\n // No basket means nothing to name and nobody to book with: the service was\n // unknown, or the link never named one.\n if (next.items.length === 0) return state;\n\n if (prefill.who) {\n // Trimmed first, so the cut falls on the name and not on leading\n // whitespace; `setItemField` trims once more for whatever the cut leaves\n // at the end, and stores nothing at all for a blank.\n const who = prefill.who.trim().slice(0, BOOKED_FOR_NAME_MAX_LENGTH);\n if (who.length > 0 && next.items[0].bookedForName === undefined) {\n next = reduce(next, { type: 'setItemField', index: 0, field: 'bookedForName', value: who });\n }\n }\n\n if (\n prefill.resourceId &&\n next.step === 'when' &&\n !next.stylistAnswered &&\n next.resourceId === null &&\n next.startTs === null\n ) {\n const wantedResource = prefill.resourceId;\n const resource = catalogue.resources.find((entry) => resourceMatches(entry, wantedResource));\n // Every service of every visit, extras included — a stylist who cuts but\n // does not colour cannot take a Klipp + Farge.\n const basket = next.items.flatMap((item) => visitServicesOf(item).map(({ id }) => id));\n if (resource !== undefined && basket.every((id) => resource.serviceIds.includes(id))) {\n next = reduce(next, { type: 'pickResource', resourceId: resource.id });\n }\n }\n\n return next;\n }\n\n /**\n * What ONE line costs, in øre, at the time the visit was booked for.\n *\n * The single definition of «what this child's haircut costs», and the reason\n * `totalPriceOre` below is a sum of it rather than a second copy of the rule:\n * the confirmation card prints a price per child and a total underneath them,\n * and a card whose lines were priced by one rule and whose total was priced by\n * another read «490 kr + 490 kr = 1 078 kr» to the parent about to pay it.\n *\n * The surcharge is per SERVICE — Medal carries `weekend_surcharge_pct` on each\n * one — so it is applied here, per line, and never as one percentage over a\n * basket that may hold two different ones.\n *\n * `clock.isWeekend` and not `getDay()`: a Saturday 00:30 slot is still Saturday\n * to the salon while UTC — and a viewer in London — still calls it Friday, and\n * the surcharge follows the salon's calendar because that is who charges it.\n *\n * A null `startTs` means no slot has been chosen yet, which is the base price\n * rather than a guess at a surcharge.\n *\n * `startTs` is the VISIT's start, not the line's own — the two differ only for\n * a sequential party that crosses midnight into or out of a weekend, which the\n * salon's opening hours do not permit, and pricing every line off the same\n * instant is what makes the lines add up to the total the parent agreed to on\n * step 4.\n */\n function itemPriceOre(service: WizardService, startTs: number | null): number {\n const multiplier =\n startTs !== null && clock.isWeekend(startTs) ? 1 + service.weekendSurchargePct / 100 : 1;\n return Math.round(service.priceOre * multiplier);\n }\n\n /**\n * What ONE person's whole visit costs — every service they chose, each priced\n * by `itemPriceOre`. The line the confirmation card prints per person, and\n * the unit `totalPriceOre` sums, so a line and the total cannot disagree.\n */\n function visitItemPriceOre(item: WizardItem, startTs: number | null): number {\n // Each service on its own, surcharge included, then summed — never one\n // percentage over the visit, since Klipp and Vask may carry different ones.\n return visitPriceOre(visitServicesOf(item), (service) => itemPriceOre(service, startTs));\n }\n\n /**\n * What the visit costs, in øre, at the time it was booked for.\n *\n * Here rather than in either component that shows it, because both of them show\n * it: step 4's «Bekreft time – 539 kr betales i salongen» and the confirmation\n * card's «Totalt» have to be the same number, and the surcharge is exactly the\n * sort of rule that gets applied in one of two places.\n */\n function totalPriceOre(items: WizardItem[], startTs: number | null): number {\n return items.reduce((sum, item) => sum + visitItemPriceOre(item, startTs), 0);\n }\n\n /**\n * What is being booked.\n *\n * One child gets the service by name, which is what they chose and what they\n * will look for. A family gets the report's aggregate — «2 tjenester · 60 min\n * totalt» — because three service names joined by the same separator the bar\n * uses between its own parts is a sentence nobody can parse at the bottom of a\n * phone, and because the minutes are the thing a parent planning the afternoon\n * actually needs.\n */\n function serviceLabel(state: WizardState): string | null {\n const { items } = state;\n if (items.length === 0) return null;\n // `showsPartyMode` and not a second `length > 1`: the bar changing shape and\n // step 2 growing a question are the same event, and two spellings of it is\n // how the bar ends up aggregating a booking the wizard still calls single.\n //\n // One person with several services is still one visit, named — «Klipp +\n // Vask». A plus reads as one booking where the bar's own separator would\n // not, and the family aggregate counts people, of whom there is one.\n if (!showsPartyMode(state)) {\n return visitServicesOf(items[0])\n .map((service) => service.name)\n .join(' + ');\n }\n return `${fill(labels['summary.services'], { count: items.length })}${SEPARATOR}${fill(\n labels['summary.totalMinutes'],\n { minutes: visitMinutes(items, state.partyMode) }\n )}`;\n }\n\n /** «Theo, Emma», «2 barn», «Voksen» — who the visit is for, before what. */\n function peopleLabel(people: readonly WizardPerson[]): string {\n const guests = people.filter((person) => person.key.startsWith('guest:')).length;\n const parts = people\n .filter((person) => !person.key.startsWith('guest:'))\n .map(\n (person) =>\n person.name ??\n labelText(person.key === SELF_KEY ? labels['people.self'] : labels['people.adult'])\n );\n if (guests > 0) parts.unshift(fill(labels['people.children'], { count: guests }));\n return parts.join(', ');\n }\n\n /**\n * `i dag 15:00`, not `15:00`.\n *\n * The bar is read at the bottom of a screen the visitor has been scrolling for\n * a minute, often after tapping between two days on step 3 — and a bare hour\n * there is the one part of the summary they can misread without noticing. The\n * day comes from `salon-clock.ts` along with the hour, so the bar and the chip\n * the visitor tapped read the same; one of them being on the viewer's clock is\n * exactly how they stop.\n */\n function timeLabel(startTs: number | null, now: number): string | null {\n return startTs === null ? null : clock.when(startTs, now);\n }\n\n /**\n * What the visit costs so far.\n *\n * Never a dash: from the moment there is a service there is a price, and it is\n * `totalPriceOre` — the same function step 4's button and the confirmation card\n * read — so the three cannot quote different numbers. It can *rise* when the\n * visitor picks a Saturday, which is the surcharge arriving rather than the bar\n * changing its mind, and is why the number is recomputed here from `startTs`\n * instead of being frozen when the service was chosen.\n */\n function priceLabel(state: WizardState): string | null {\n /* v8 ignore next -- defensive: `summaryLine` asks only once there is a basket */\n if (state.items.length === 0) return null;\n return money.formatMinor(totalPriceOre(state.items, state.startTs));\n }\n\n /**\n * The single source for the sticky bar, so the bar can never disagree with the\n * state it is summarising. Chosen parts are joined; parts still to come render\n * as what is left to do — «Første ledige» for a stylist nobody named, «Velg\n * tid» for the hour — which is what makes the bar a promise of what is left\n * rather than a receipt for what is done. A named stylist the catalogue cannot\n * name is still `—`.\n *\n * `now` is a parameter rather than a `Date.now()` inside `clock.when`, because\n * «i dag» is a claim about when the bar is being *read*: a component that wants\n * one instant for a whole render — the confirmation card already does this — has\n * to be able to say so, and a test that asserts «i dag» has to be able to pin it.\n */\n function summaryLine(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now: number = Date.now()\n ): string {\n /**\n * A family bar is the report's three parts — «2 tjenester · 60 min totalt ·\n * 980 kr» — and drops the stylist and the hour rather than dashing them.\n *\n * Not brevity: a parallel party has two stylists and no single one to name,\n * so that part could only ever be a dash, and a dash that can never be filled\n * in is a promise the bar cannot keep. The when-and-with-whom is on step 3's\n * chip while the visitor is choosing it and on the confirmation card once\n * they have, both of which can say «15:00 (Jonas hos Marcus · Emma hos Sara)»\n * where a one-line sticky bar cannot.\n */\n // Step 1 answered, step 2 not yet: say who, so the bar is already about them.\n if (state.items.length === 0 && state.people.length > 0) return peopleLabel(state.people);\n const party = showsPartyMode(state);\n const service = serviceLabel(state);\n if (party) {\n return [service, priceLabel(state)].filter((part) => part !== null).join(SEPARATOR);\n }\n // Nothing chosen at all: the bar has its own placeholder for step 1, and a\n // row of «Første ledige · Velg tid» with no service in front says nothing.\n if (service === null) return '';\n const unnamedPreference = (state.resolvedResourceId ?? state.resourceId) === null;\n return [\n service,\n stylistLabel(state, resolveStylistName) ??\n (unnamedPreference ? labelText(labels['summary.firstAvailable']) : UNCHOSEN),\n timeLabel(state.startTs, now) ?? labelText(labels['summary.pickTime']),\n // Defensive: a service in the bar means a price.\n /* v8 ignore start */\n priceLabel(state) ?? UNCHOSEN,\n /* v8 ignore stop */\n ].join(SEPARATOR);\n }\n\n function summaryParts(\n state: WizardState,\n resolveStylistName: (resourceId: string) => string | null,\n now: number = Date.now()\n ): { line: string; detail: string } {\n if (state.items.length === 0) {\n return { line: state.people.length > 0 ? peopleLabel(state.people) : '', detail: '' };\n }\n // A basket always has a service and a price; the bar names both.\n const service = serviceLabel(state) as string;\n const price = money.formatMinor(totalPriceOre(state.items, state.startTs));\n if (showsPartyMode(state)) return { line: service, detail: price };\n const unnamedPreference = (state.resolvedResourceId ?? state.resourceId) === null;\n const stylist =\n stylistLabel(state, resolveStylistName) ??\n (unnamedPreference ? labelText(labels['summary.firstAvailable']) : UNCHOSEN);\n const when = timeLabel(state.startTs, now) ?? labelText(labels['summary.pickTime']);\n return {\n line: [service, stylist].join(SEPARATOR),\n detail: [when, price].join(SEPARATOR),\n };\n }\n\n return {\n maxPeople,\n initialState,\n reduce,\n applyPrefill,\n canAdvance,\n canGoToStep,\n itemPriceOre,\n visitItemPriceOre,\n totalPriceOre,\n itemStartTimes,\n visitMinutes,\n visitTailMinutes,\n visitEndTs,\n showsPartyMode,\n itemResourceIds,\n summaryLine,\n summaryParts,\n guestChild,\n isGuestSeat,\n };\n}\n"],"mappings":"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"}
@@ -93,7 +93,14 @@ export interface MedalSeam {
93
93
  listResources(): Promise<MedalResource[]>;
94
94
  listAvailability(args: RangeArgs): Promise<MedalSlot[]>;
95
95
  listSchedule(args: RangeArgs): Promise<MedalScheduleDay[]>;
96
- createBooking(body: CreateBookingBody, idempotencyKey: string): Promise<CreateBookingResult>;
96
+ /**
97
+ * `opts.portalSession` — the logged-in parent's portal session, sent as
98
+ * `X-Portal-Session` so Medal books on THAT contact. Never logged: a throw
99
+ * is scrubbed of it before it leaves.
100
+ */
101
+ createBooking(body: CreateBookingBody, idempotencyKey: string, opts?: {
102
+ portalSession?: string;
103
+ }): Promise<CreateBookingResult>;
97
104
  recordConsent(body: RecordConsentBody): Promise<unknown>;
98
105
  getManage(token: string): Promise<MedalManageSummary>;
99
106
  cancelManage(token: string, reason?: string, idempotencyKey?: string): Promise<unknown>;
@@ -1,3 +1,4 @@
1
+ import { redactSession } from "./redact.mjs";
1
2
  import { Medal, MedalApiError as MedalApiError$1 } from "@medalsocial/sdk";
2
3
  //#region src/next/medal.ts
3
4
  var MedalConfigError = class extends Error {
@@ -116,11 +117,21 @@ function createMedalSeam(options) {
116
117
  async listSchedule(args) {
117
118
  return unwrap(await requireMedal().bookings.schedule(rangeParams(args)), "/api/v1/bookings/schedule");
118
119
  },
119
- async createBooking(body, idempotencyKey) {
120
- return { bookings: unwrap(await requireMedal().bookings.create({
121
- ...body,
122
- created_via: SITE_PROVENANCE
123
- }, { idempotencyKey }), "/api/v1/bookings").bookings };
120
+ async createBooking(body, idempotencyKey, opts) {
121
+ const portalSession = opts?.portalSession;
122
+ let response;
123
+ try {
124
+ response = await requireMedal().bookings.create({
125
+ ...body,
126
+ created_via: SITE_PROVENANCE
127
+ }, portalSession ? {
128
+ idempotencyKey,
129
+ portalSession
130
+ } : { idempotencyKey });
131
+ } catch (error) {
132
+ throw portalSession ? redactSession(error, portalSession) : error;
133
+ }
134
+ return { bookings: unwrap(response, "/api/v1/bookings").bookings };
124
135
  },
125
136
  async recordConsent(body) {
126
137
  return unwrap(await requireMedal().gdpr.recordConsent(body), "/api/v1/gdpr/consent");